PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.9.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.9.0
5.9.0 5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 All 42 releases
← All changes | src/Admin/AdminRestController.php +150 -324 5.6.3 → 5.9.0 View file →
@@ -15,8 +15,10 @@
15 15 use Forge12\DoubleOptIn\FormSettings\FormSettingsDTO;
16 16 use Forge12\DoubleOptIn\FormSettings\FormSettingsService;
17 17 use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator;
18 18 use Forge12\DoubleOptIn\Integration\SubmittedContent;
19 +use Forge12\DoubleOptIn\Service\ConfirmationMailResender;
20 +use Forge12\DoubleOptIn\Service\ResendResult;
19 21 use Forge12\Shared\LoggerInterface;
20 22
21 23 if ( ! defined( 'ABSPATH' ) ) {
22 24 exit;
@@ -30,8 +32,14 @@
30 32 class AdminRestController {
31 33
32 34 const API_NAMESPACE = 'f12-doi/v1';
33 35
36 + /**
37 + * SQL form of OptIn::isOptedOut(): not confirmed, withdrawal IP and time
38 + * recorded. A re-opt-in clears the time, so the row counts as confirmed again.
39 + */
40 + private const REVOKED_SQL = "(doubleoptin = 0 AND ipaddr_optout IS NOT NULL AND ipaddr_optout <> '' AND optouttime IS NOT NULL AND optouttime NOT IN ('', '0'))";
41 +
34 42 private LoggerInterface $logger;
35 43 private FormSettingsService $formService;
36 44 private FormSettingsValidator $formValidator;
37 45
@@ -509,19 +517,8 @@
509 517 'permission_callback' => array( $this, 'checkPermission' ),
510 518 )
511 519 );
512 520
513 - // ── Database Export (Pro-extensible) ────────────────────────
514 - register_rest_route(
515 - self::API_NAMESPACE,
516 - '/database/export',
517 - array(
518 - 'methods' => \WP_REST_Server::CREATABLE,
519 - 'callback' => array( $this, 'exportDatabase' ),
520 - 'permission_callback' => array( $this, 'checkPermission' ),
521 - )
522 - );
523 -
524 521 // ── Addons manifest (UI mount-point system, plan §9) ────────
525 522 register_rest_route(
526 523 self::API_NAMESPACE,
527 524 '/addons',
@@ -626,9 +623,10 @@
626 623 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
627 624
628 625 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
629 626 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
630 - $pending = $total - $confirmed;
627 + $revoked = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE " . self::REVOKED_SQL ); // phpcs:ignore WordPress.DB.PreparedSQL -- fixed SQL, no input.
628 + $pending = max( 0, $total - $confirmed - $revoked );
631 629 $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0;
632 630
633 631 // Recent opt-ins (raw activity feed — not analytics).
634 632 // Time-bucketed activity, top-forms breakdown and the big
@@ -634,9 +632,9 @@
634 632 // Time-bucketed activity, top-forms breakdown and the big
635 633 // conversion-rate card moved into addon-analytics, which
636 634 // renders them at the `dashboard.widget` mount point.
637 635 $recent = $wpdb->get_results(
638 - "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5",
636 + "SELECT id, email, cf_form_id, doubleoptin, createtime, ipaddr_optout, optouttime FROM {$table} ORDER BY id DESC LIMIT 5",
639 637 ARRAY_A
640 638 );
641 639
642 640 foreach ( $recent as &$row ) {
@@ -642,14 +640,18 @@
642 640 foreach ( $recent as &$row ) {
643 641 $post = get_post( (int) $row['cf_form_id'] );
644 642 $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] );
645 643 $row['confirmed'] = (int) $row['doubleoptin'] === 1;
644 + $row['revoked'] = self::isRevokedRow( $row );
645 + unset( $row['ipaddr_optout'], $row['optouttime'] );
646 646 }
647 + unset( $row );
647 648
648 649 $data = array(
649 650 'totalOptins' => $total,
650 651 'confirmed' => $confirmed,
651 652 'pending' => $pending,
653 + 'revoked' => $revoked,
652 654 'conversionRate' => $rate,
653 655 'recentOptins' => $recent ?: array(),
654 656 );
655 657
@@ -730,8 +732,11 @@
730 732 if ( $status === 'confirmed' ) {
731 733 $where[] = 'doubleoptin = 1';
732 734 } elseif ( $status === 'pending' ) {
733 735 $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)';
736 + $where[] = 'NOT ' . self::REVOKED_SQL;
737 + } elseif ( $status === 'revoked' ) {
738 + $where[] = self::REVOKED_SQL;
734 739 }
735 740
736 741 if ( $formId !== null && $formId !== '' ) {
737 742 $where[] = 'cf_form_id = %d';
@@ -737,8 +742,14 @@
737 742 $where[] = 'cf_form_id = %d';
738 743 $params[] = (int) $formId;
739 744 }
740 745
746 + // Opt-ins whose confirmation mail could not be sent (5.8.0).
747 + if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) {
748 + $where[] = 'mail_status = %s';
749 + $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED;
750 + }
751 +
741 752 // Confirmed opt-ins whose follow-up actions failed or have an
742 753 // unknown outcome — the admin's "needs attention" list.
743 754 if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
744 755 $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
@@ -843,9 +854,9 @@
843 854 // Full row (id, hash, content, files, cf_form_id) so the
844 855 // pre-delete cascade hook from pre-doi-data-retention Step 1
845 856 // can fire with a payload that lets listeners reach into
846 857 // integration storage. ARRAY_A — listener-friendly.
847 - $row = $wpdb->get_row(
858 + $row = $wpdb->get_row(
848 859 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
849 860 ARRAY_A
850 861 );
851 862 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -903,8 +914,32 @@
903 914 200
904 915 );
905 916 }
906 917
918 + /**
919 + * The admin's answer for a resend that did not go out.
920 + */
921 + private static function resendRefusal( string $reason ): \WP_REST_Response {
922 + $map = array(
923 + ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ),
924 + ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ),
925 + ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ),
926 + ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ),
927 + ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ),
928 + );
929 + $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 );
930 + $message = $entry[0];
931 + $status = $entry[1];
932 +
933 + return new \WP_REST_Response(
934 + array(
935 + 'success' => false,
936 + 'message' => $message,
937 + ),
938 + $status
939 + );
940 + }
941 +
907 942 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
908 943 global $wpdb;
909 944 $id = (int) $request->get_param( 'id' );
910 945 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -944,96 +979,23 @@
944 979 */
945 980 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
946 981
947 982 if ( $result === null ) {
948 - // Default resend logic: use stored mail data.
949 - //
950 - // `mail_optin` is shipped by every integration via
951 - // {@see \forge12\contactform7\CF7DoubleOptIn\OptIn::set_mail_optin()}.
952 - // That method takes a STRING (the rendered HTML body) — the
953 - // admin opt-in-detail UI reads it as-is for the body
954 - // preview. Earlier versions of this handler expected a
955 - // serialized `['to' => ..., 'subject' => ..., 'body' => ...]`
956 - // array and bailed with "Email data is incomplete" whenever
957 - // the stored value was the (correct) plain body string —
958 - // which is the production case for every free-version
959 - // integration (CF7 / Avada / WPForms / Gravity / Elementor).
960 - // User-reported 2026-05-13: clicking Resend yielded that
961 - // error 100 % of the time.
962 - //
963 - // Both shapes are accepted now: the array form for Pro and
964 - // any future caller that stores structured payloads, the
965 - // plain string for the free-version integrations whose
966 - // contract is documented in
967 - // {@see \Forge12\DoubleOptIn\Wpforms\Tests\Unit\Integration\WPFormsSettingsApplyTest}.
968 - $mailOptin = $row['mail_optin'] ?? '';
969 - if ( empty( $mailOptin ) ) {
970 - return new \WP_REST_Response(
971 - array(
972 - 'success' => false,
973 - 'message' => __( 'No email data available for resend.', 'double-opt-in' ),
974 - ),
975 - 400
976 - );
977 - }
983 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
984 + ->get( ConfirmationMailResender::class )
985 + ->resend( $id );
978 986
979 - $unserialized = maybe_unserialize( $mailOptin );
980 -
981 - if ( is_array( $unserialized ) ) {
982 - // Structured payload (Pro / future writers).
983 - $to = $unserialized['to'] ?? '';
984 - $subject = $unserialized['subject'] ?? '';
985 - $body = $unserialized['body'] ?? '';
986 - $from = $unserialized['from'] ?? '';
987 - } else {
988 - // Plain body string — the production case. Reconstruct
989 - // `to` from the OptIn record's own `email` column and
990 - // `subject` from the form's central settings.
991 - $to = $row['email'] ?? '';
992 - $body = is_string( $unserialized ) ? $unserialized : (string) $mailOptin;
993 - $subject = '';
994 - $from = '';
995 -
996 - $formId = isset( $row['cf_form_id'] ) ? (int) $row['cf_form_id'] : 0;
997 - if ( $formId > 0 && class_exists( '\\forge12\\contactform7\\CF7DoubleOptIn\\CF7DoubleOptIn' ) ) {
998 - $formParam = \forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn::getInstance()->getParameter( $formId );
999 - $subject = (string) ( $formParam['subject'] ?? '' );
1000 - $senderEmail = (string) ( $formParam['sender'] ?? '' );
1001 - $senderName = (string) ( $formParam['sender_name'] ?? '' );
1002 - if ( $senderEmail !== '' ) {
1003 - $from = $senderName !== ''
1004 - ? $senderName . ' <' . $senderEmail . '>'
1005 - : $senderEmail;
1006 - }
1007 - }
987 + if ( ! $outcome->isSent() ) {
988 + return self::resendRefusal( $outcome->getReason() );
1008 989 }
1009 -
1010 - if ( empty( $to ) || empty( $body ) ) {
1011 - return new \WP_REST_Response(
1012 - array(
1013 - 'success' => false,
1014 - 'message' => __( 'Email data is incomplete.', 'double-opt-in' ),
1015 - ),
1016 - 400
1017 - );
1018 - }
1019 -
1020 - $headers = array( 'Content-Type: text/html; charset=UTF-8' );
1021 - if ( ! empty( $from ) ) {
1022 - $headers[] = 'From: ' . $from;
1023 - }
1024 -
1025 - $result = wp_mail( $to, $subject !== '' ? $subject : __( 'Confirmation Email (resent)', 'double-opt-in' ), $body, $headers );
990 + $result = true;
991 + } else {
992 + // An extension sent it; record the outcome all the same.
993 + do_action( 'f12_doi_optin_mail_result', $id, (bool) $result, '' );
1026 994 }
1027 995
1028 996 if ( ! $result ) {
1029 - return new \WP_REST_Response(
1030 - array(
1031 - 'success' => false,
1032 - 'message' => __( 'Failed to send email.', 'double-opt-in' ),
1033 - ),
1034 - 500
1035 - );
997 + return self::resendRefusal( ResendResult::SEND_FAILED );
1036 998 }
1037 999
1038 1000 AuditLogger::log(
1039 1001 AuditLogger::TYPE_EMAIL,
@@ -1930,22 +1892,41 @@
1930 1892 );
1931 1893 }
1932 1894
1933 1895 // ═══════════════════════════════════════════════════════════════
1934 - // PRO-EXTENSIBLE STUBS
1935 - // These return minimal responses; Pro overrides via filters or
1936 - // registers its own REST routes that take precedence.
1896 + // ADD-ON ROUTES
1897 + // Core owns the route; the data comes from the add-on through a
1898 + // filter. Without a handler the answer is ADDON_INACTIVE. Core
1899 + // itself never checks a licence here (wordpress.org guideline 5):
1900 + // the functionality lives in the add-on, which only hooks in when
1901 + // it runs licensed.
1937 1902 // ═══════════════════════════════════════════════════════════════
1938 1903
1904 + /**
1905 + * Answer for a route whose add-on is not running.
1906 + *
1907 + * 404 with `code` so the SPA can tell it from an unknown route
1908 + * (`rest_no_route`); `ApiError` reads `body.code`.
1909 + */
1910 + private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response {
1911 + return new \WP_REST_Response(
1912 + array(
1913 + 'success' => false,
1914 + 'code' => 'ADDON_INACTIVE',
1915 + 'addon' => $addonId,
1916 + 'message' => sprintf(
1917 + /* translators: %s: add-on name */
1918 + __( 'This feature is provided by the %s add-on. Install and activate the add-on with a valid license to use it.', 'double-opt-in' ),
1919 + $addonName
1920 + ),
1921 + ),
1922 + 404
1923 + );
1924 + }
1925 +
1939 1926 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1940 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1941 - return new \WP_REST_Response(
1942 - array(
1943 - 'success' => false,
1944 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1945 - ),
1946 - 403
1947 - );
1927 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1928 + return $this->addonInactive( 'analytics', 'Analytics' );
1948 1929 }
1949 1930
1950 1931 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1951 1932
@@ -1958,16 +1939,10 @@
1958 1939 );
1959 1940 }
1960 1941
1961 1942 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1962 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1963 - return new \WP_REST_Response(
1964 - array(
1965 - 'success' => false,
1966 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1967 - ),
1968 - 403
1969 - );
1943 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1944 + return $this->addonInactive( 'analytics', 'Analytics' );
1970 1945 }
1971 1946
1972 1947 $formId = (int) $request->get_param( 'form_id' );
1973 1948 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1981,16 +1956,10 @@
1981 1956 );
1982 1957 }
1983 1958
1984 1959 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1985 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1986 - return new \WP_REST_Response(
1987 - array(
1988 - 'success' => false,
1989 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1990 - ),
1991 - 403
1992 - );
1960 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1961 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1993 1962 }
1994 1963
1995 1964 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1996 1965
@@ -2003,16 +1972,10 @@
2003 1972 );
2004 1973 }
2005 1974
2006 1975 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
2007 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2008 - return new \WP_REST_Response(
2009 - array(
2010 - 'success' => false,
2011 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2012 - ),
2013 - 403
2014 - );
1976 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1977 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2015 1978 }
2016 1979
2017 1980 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2018 1981
@@ -2027,140 +1990,32 @@
2027 1990
2028 1991 /**
2029 1992 * POST /f12-doi/v1/optout/page/generate
2030 1993 *
2031 - * One-click generator for the opt-out landing page. Eliminates the
2032 - * onboarding-friction loop where the user has to manually create a
2033 - * page and paste the shortcodes before opt-out works at all.
1994 + * One-click generator for the opt-out landing page. The logic lives in
1995 + * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through
1996 + * the filter below; Core only owns the route.
2034 1997 *
2035 - * Algorithm:
2036 - * 1. Idempotent fast-path — scan `published` pages for the list
2037 - * shortcode. If one already exists, return its ID untouched
2038 - * (no duplicate creation, no content overwrite).
2039 - * 2. Title-collision safety — if a page named "Opt-Out" exists
2040 - * but WITHOUT the list shortcode, refuse to auto-modify. The
2041 - * user might have intentionally repurposed that title; we'd
2042 - * rather show a 409 with a clear message than clobber.
2043 - * 3. Insert a fresh page with both shortcodes (form + list) so
2044 - * the page is functional end-to-end out of the box.
2045 - *
2046 - * Response shape (always 200 unless error):
2047 - * { page_id, page_title, edit_url, view_url, created: bool }
2048 - *
2049 1998 * @return \WP_REST_Response
2050 1999 */
2051 2000 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2052 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2053 - return new \WP_REST_Response(
2054 - array(
2055 - 'success' => false,
2056 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2057 - ),
2058 - 403
2059 - );
2001 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
2002 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2060 2003 }
2061 2004
2062 - if ( ! current_user_can( 'publish_pages' ) ) {
2063 - return new \WP_REST_Response(
2064 - array(
2065 - 'success' => false,
2066 - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2067 - ),
2068 - 403
2069 - );
2070 - }
2005 + /**
2006 + * Filter: answer the opt-out page generator request.
2007 + *
2008 + * @param \WP_REST_Response|null $response Null until a handler answers.
2009 + * @param \WP_REST_Request $request The request.
2010 + *
2011 + * @since 5.8.0
2012 + */
2013 + $response = apply_filters( 'f12_doi_rest_optout_generate_page', null, $request );
2071 2014
2072 - $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2073 - $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2074 -
2075 - // 1. Idempotent fast-path — first page with the list shortcode wins.
2076 - $existing = get_posts(
2077 - array(
2078 - 'post_type' => 'page',
2079 - 'post_status' => 'publish',
2080 - 'posts_per_page' => 1,
2081 - 's' => $listShortcode,
2082 - 'fields' => 'ids',
2083 - 'no_found_rows' => true,
2084 - )
2085 - );
2086 - if ( ! empty( $existing ) ) {
2087 - $pageId = (int) $existing[0];
2088 - return new \WP_REST_Response(
2089 - array(
2090 - 'success' => true,
2091 - 'created' => false,
2092 - 'page_id' => $pageId,
2093 - 'page_title' => get_the_title( $pageId ),
2094 - 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2095 - 'view_url' => get_permalink( $pageId ),
2096 - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2097 - ),
2098 - 200
2099 - );
2100 - }
2101 -
2102 - // 2. Title collision — a page literally titled "Opt-Out" but
2103 - // without the shortcode is the user's own content. Refuse
2104 - // to silently modify it.
2105 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2106 - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2107 - // Plain null check, not instanceof: this replaces `?->ID`, which only
2108 - // short-circuits on null and does not care about the concrete class.
2109 - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2110 - if ( $collisionId > 0 ) {
2111 - return new \WP_REST_Response(
2112 - array(
2113 - 'success' => false,
2114 - 'code' => 'TITLE_COLLISION',
2115 - 'page_id' => $collisionId,
2116 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2117 - 'message' => sprintf(
2118 - /* translators: %s = page title */
2119 - __( 'A page titled "%s" already exists but doesn\'t contain the opt-out shortcode. Add the shortcode manually, or rename the page, then try again.', 'double-opt-in' ),
2120 - $desiredTitle
2121 - ),
2122 - ),
2123 - 409
2124 - );
2125 - }
2126 -
2127 - // 3. Insert.
2128 - $pageId = wp_insert_post(
2129 - array(
2130 - 'post_type' => 'page',
2131 - 'post_status' => 'publish',
2132 - 'post_title' => $desiredTitle,
2133 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2134 - 'post_author' => get_current_user_id(),
2135 - 'comment_status' => 'closed',
2136 - 'ping_status' => 'closed',
2137 - ),
2138 - true
2139 - );
2140 -
2141 - if ( is_wp_error( $pageId ) ) {
2142 - return new \WP_REST_Response(
2143 - array(
2144 - 'success' => false,
2145 - 'message' => $pageId->get_error_message(),
2146 - ),
2147 - 500
2148 - );
2149 - }
2150 -
2151 - return new \WP_REST_Response(
2152 - array(
2153 - 'success' => true,
2154 - 'created' => true,
2155 - 'page_id' => (int) $pageId,
2156 - 'page_title' => $desiredTitle,
2157 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2158 - 'view_url' => get_permalink( (int) $pageId ),
2159 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2160 - ),
2161 - 200
2162 - );
2015 + return $response instanceof \WP_REST_Response
2016 + ? $response
2017 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2163 2018 }
2164 2019
2165 2020 /**
2166 2021 * License gate for the User Creation endpoints.
@@ -2180,15 +2035,9 @@
2180 2035 }
2181 2036
2182 2037 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2183 2038 if ( ! $this->userCreationAuthorized() ) {
2184 - return new \WP_REST_Response(
2185 - array(
2186 - 'success' => false,
2187 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2188 - ),
2189 - 403
2190 - );
2039 + return $this->addonInactive( 'user-registration', 'User Registration' );
2191 2040 }
2192 2041
2193 2042 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2194 2043
@@ -2202,15 +2051,9 @@
2202 2051 }
2203 2052
2204 2053 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2205 2054 if ( ! $this->userCreationAuthorized() ) {
2206 - return new \WP_REST_Response(
2207 - array(
2208 - 'success' => false,
2209 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2210 - ),
2211 - 403
2212 - );
2055 + return $this->addonInactive( 'user-registration', 'User Registration' );
2213 2056 }
2214 2057
2215 2058 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2216 2059
@@ -2223,16 +2066,10 @@
2223 2066 );
2224 2067 }
2225 2068
2226 2069 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2227 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2228 - return new \WP_REST_Response(
2229 - array(
2230 - 'success' => false,
2231 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2232 - ),
2233 - 403
2234 - );
2070 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2071 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2235 2072 }
2236 2073
2237 2074 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2238 2075
@@ -2245,16 +2082,10 @@
2245 2082 );
2246 2083 }
2247 2084
2248 2085 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2249 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2250 - return new \WP_REST_Response(
2251 - array(
2252 - 'success' => false,
2253 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2254 - ),
2255 - 403
2256 - );
2086 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2087 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2257 2088 }
2258 2089
2259 2090 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2260 2091
@@ -2348,44 +2179,25 @@
2348 2179
2349 2180 return new \WP_REST_Response( $result, $status );
2350 2181 }
2351 2182
2352 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2353 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2354 - return new \WP_REST_Response(
2355 - array(
2356 - 'success' => false,
2357 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2358 - ),
2359 - 403
2360 - );
2361 - }
2183 + // ═══════════════════════════════════════════════════════════════
2184 + // HELPERS
2185 + // ═══════════════════════════════════════════════════════════════
2362 2186
2363 - $input = $request->get_json_params();
2187 + /**
2188 + * PHP form of REVOKED_SQL for a raw table row.
2189 + *
2190 + * @param array<string, mixed> $row The database row.
2191 + */
2192 + private static function isRevokedRow( array $row ): bool {
2193 + $optOutTime = (string) ( $row['optouttime'] ?? '' );
2364 2194
2365 - /**
2366 - * Filter to let Pro handle database export.
2367 - *
2368 - * @param array $result Result.
2369 - * @param array $input Export parameters.
2370 - * @since 4.2.0
2371 - */
2372 - $result = apply_filters(
2373 - 'f12_doi_rest_database_export',
2374 - array(
2375 - 'success' => false,
2376 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2377 - ),
2378 - $input
2379 - );
2380 -
2381 - return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 );
2195 + return (int) ( $row['doubleoptin'] ?? 0 ) !== 1
2196 + && (string) ( $row['ipaddr_optout'] ?? '' ) !== ''
2197 + && $optOutTime !== '' && $optOutTime !== '0';
2382 2198 }
2383 2199
2384 - // ═══════════════════════════════════════════════════════════════
2385 - // HELPERS
2386 - // ═══════════════════════════════════════════════════════════════
2387 -
2388 2200 /**
2389 2201 * Format an opt-in database row for the API response.
2390 2202 *
2391 2203 * @param array $row The database row.
@@ -2403,8 +2215,13 @@
2403 2215 'formId' => (int) $row['cf_form_id'],
2404 2216 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2405 2217 'category' => (int) $row['category'],
2406 2218 'confirmed' => (int) $row['doubleoptin'] === 1,
2219 + // Consent withdrawn via the opt-out (5.9.0). Not "pending".
2220 + 'revoked' => self::isRevokedRow( $row ),
2221 + // Confirmation mail: 'sent' (handed to the mail server), 'failed',
2222 + // or '' (recorded before 5.8.0). Since 5.8.0.
2223 + 'mailStatus' => (string) ( $row['mail_status'] ?? '' ),
2407 2224 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2408 2225 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2409 2226 );
2410 2227
@@ -2415,8 +2232,10 @@
2415 2232 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2416 2233 $data['consentText'] = $row['consent_text'];
2417 2234 $data['consentField'] = $row['consent_field'] ?? '';
2418 2235 $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] );
2236 + $data['mailError'] = (string) ( $row['mail_error'] ?? '' );
2237 + $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) );
2419 2238
2420 2239 // Category name
2421 2240 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2422 2241 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2636,10 +2455,10 @@
2636 2455 // First pass: every registered addon gets an entry, even if
2637 2456 // it contributes no UI. That lets the client show per-addon
2638 2457 // licensing/boot state without a second round-trip.
2639 2458 foreach ( $registered as $id => $addon ) {
2640 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2641 - $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2459 + $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2460 + $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2642 2461 unset( $fragments[ $id ] );
2643 2462 }
2644 2463
2645 2464 // Second pass: fragments for addons NOT in the registry
@@ -2877,11 +2696,18 @@
2877 2696 }
2878 2697
2879 2698 $activateUrl = null;
2880 2699 if ( $installed && ! $active ) {
2881 - $activateUrl = wp_nonce_url(
2882 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2883 - 'activate-plugin_' . $pluginFile
2700 + // Not wp_nonce_url(): it HTML-escapes & to &amp;, and this URL
2701 + // goes as JSON into an href — "plugin" and "_wpnonce" then
2702 + // arrived as "amp;plugin" and the activation failed.
2703 + $activateUrl = add_query_arg(
2704 + array(
2705 + 'action' => 'activate',
2706 + 'plugin' => rawurlencode( $pluginFile ),
2707 + '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ),
2708 + ),
2709 + self_admin_url( 'plugins.php' )
2884 2710 );
2885 2711 }
2886 2712
2887 2713 $registeredAddon = $registered[ $id ] ?? null;