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 +183 -343 5.3.1 → 5.9.0 View file →
@@ -14,8 +14,11 @@
14 14 use Forge12\DoubleOptIn\Audit\AuditLogger;
15 15 use Forge12\DoubleOptIn\FormSettings\FormSettingsDTO;
16 16 use Forge12\DoubleOptIn\FormSettings\FormSettingsService;
17 17 use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator;
18 +use Forge12\DoubleOptIn\Integration\SubmittedContent;
19 +use Forge12\DoubleOptIn\Service\ConfirmationMailResender;
20 +use Forge12\DoubleOptIn\Service\ResendResult;
18 21 use Forge12\Shared\LoggerInterface;
19 22
20 23 if ( ! defined( 'ABSPATH' ) ) {
21 24 exit;
@@ -29,8 +32,14 @@
29 32 class AdminRestController {
30 33
31 34 const API_NAMESPACE = 'f12-doi/v1';
32 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 +
33 42 private LoggerInterface $logger;
34 43 private FormSettingsService $formService;
35 44 private FormSettingsValidator $formValidator;
36 45
@@ -508,19 +517,8 @@
508 517 'permission_callback' => array( $this, 'checkPermission' ),
509 518 )
510 519 );
511 520
512 - // ── Database Export (Pro-extensible) ────────────────────────
513 - register_rest_route(
514 - self::API_NAMESPACE,
515 - '/database/export',
516 - array(
517 - 'methods' => \WP_REST_Server::CREATABLE,
518 - 'callback' => array( $this, 'exportDatabase' ),
519 - 'permission_callback' => array( $this, 'checkPermission' ),
520 - )
521 - );
522 -
523 521 // ── Addons manifest (UI mount-point system, plan §9) ────────
524 522 register_rest_route(
525 523 self::API_NAMESPACE,
526 524 '/addons',
@@ -625,9 +623,10 @@
625 623 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
626 624
627 625 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
628 626 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
629 - $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 );
630 629 $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0;
631 630
632 631 // Recent opt-ins (raw activity feed — not analytics).
633 632 // Time-bucketed activity, top-forms breakdown and the big
@@ -633,9 +632,9 @@
633 632 // Time-bucketed activity, top-forms breakdown and the big
634 633 // conversion-rate card moved into addon-analytics, which
635 634 // renders them at the `dashboard.widget` mount point.
636 635 $recent = $wpdb->get_results(
637 - "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",
638 637 ARRAY_A
639 638 );
640 639
641 640 foreach ( $recent as &$row ) {
@@ -641,14 +640,18 @@
641 640 foreach ( $recent as &$row ) {
642 641 $post = get_post( (int) $row['cf_form_id'] );
643 642 $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] );
644 643 $row['confirmed'] = (int) $row['doubleoptin'] === 1;
644 + $row['revoked'] = self::isRevokedRow( $row );
645 + unset( $row['ipaddr_optout'], $row['optouttime'] );
645 646 }
647 + unset( $row );
646 648
647 649 $data = array(
648 650 'totalOptins' => $total,
649 651 'confirmed' => $confirmed,
650 652 'pending' => $pending,
653 + 'revoked' => $revoked,
651 654 'conversionRate' => $rate,
652 655 'recentOptins' => $recent ?: array(),
653 656 );
654 657
@@ -729,8 +732,11 @@
729 732 if ( $status === 'confirmed' ) {
730 733 $where[] = 'doubleoptin = 1';
731 734 } elseif ( $status === 'pending' ) {
732 735 $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)';
736 + $where[] = 'NOT ' . self::REVOKED_SQL;
737 + } elseif ( $status === 'revoked' ) {
738 + $where[] = self::REVOKED_SQL;
733 739 }
734 740
735 741 if ( $formId !== null && $formId !== '' ) {
736 742 $where[] = 'cf_form_id = %d';
@@ -736,8 +742,24 @@
736 742 $where[] = 'cf_form_id = %d';
737 743 $params[] = (int) $formId;
738 744 }
739 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 +
752 + // Confirmed opt-ins whose follow-up actions failed or have an
753 + // unknown outcome — the admin's "needs attention" list.
754 + if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
755 + $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
756 + $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic();
757 + $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN ("
758 + . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))';
759 + $params = array_merge( $params, $problems );
760 + }
761 +
740 762 $whereClause = implode( ' AND ', $where );
741 763
742 764 // Count
743 765 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -832,9 +854,9 @@
832 854 // Full row (id, hash, content, files, cf_form_id) so the
833 855 // pre-delete cascade hook from pre-doi-data-retention Step 1
834 856 // can fire with a payload that lets listeners reach into
835 857 // integration storage. ARRAY_A — listener-friendly.
836 - $row = $wpdb->get_row(
858 + $row = $wpdb->get_row(
837 859 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
838 860 ARRAY_A
839 861 );
840 862 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -892,8 +914,32 @@
892 914 200
893 915 );
894 916 }
895 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 +
896 942 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
897 943 global $wpdb;
898 944 $id = (int) $request->get_param( 'id' );
899 945 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -933,96 +979,23 @@
933 979 */
934 980 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
935 981
936 982 if ( $result === null ) {
937 - // Default resend logic: use stored mail data.
938 - //
939 - // `mail_optin` is shipped by every integration via
940 - // {@see \forge12\contactform7\CF7DoubleOptIn\OptIn::set_mail_optin()}.
941 - // That method takes a STRING (the rendered HTML body) — the
942 - // admin opt-in-detail UI reads it as-is for the body
943 - // preview. Earlier versions of this handler expected a
944 - // serialized `['to' => ..., 'subject' => ..., 'body' => ...]`
945 - // array and bailed with "Email data is incomplete" whenever
946 - // the stored value was the (correct) plain body string —
947 - // which is the production case for every free-version
948 - // integration (CF7 / Avada / WPForms / Gravity / Elementor).
949 - // User-reported 2026-05-13: clicking Resend yielded that
950 - // error 100 % of the time.
951 - //
952 - // Both shapes are accepted now: the array form for Pro and
953 - // any future caller that stores structured payloads, the
954 - // plain string for the free-version integrations whose
955 - // contract is documented in
956 - // {@see \Forge12\DoubleOptIn\Wpforms\Tests\Unit\Integration\WPFormsSettingsApplyTest}.
957 - $mailOptin = $row['mail_optin'] ?? '';
958 - if ( empty( $mailOptin ) ) {
959 - return new \WP_REST_Response(
960 - array(
961 - 'success' => false,
962 - 'message' => __( 'No email data available for resend.', 'double-opt-in' ),
963 - ),
964 - 400
965 - );
966 - }
983 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
984 + ->get( ConfirmationMailResender::class )
985 + ->resend( $id );
967 986
968 - $unserialized = maybe_unserialize( $mailOptin );
969 -
970 - if ( is_array( $unserialized ) ) {
971 - // Structured payload (Pro / future writers).
972 - $to = $unserialized['to'] ?? '';
973 - $subject = $unserialized['subject'] ?? '';
974 - $body = $unserialized['body'] ?? '';
975 - $from = $unserialized['from'] ?? '';
976 - } else {
977 - // Plain body string — the production case. Reconstruct
978 - // `to` from the OptIn record's own `email` column and
979 - // `subject` from the form's central settings.
980 - $to = $row['email'] ?? '';
981 - $body = is_string( $unserialized ) ? $unserialized : (string) $mailOptin;
982 - $subject = '';
983 - $from = '';
984 -
985 - $formId = isset( $row['cf_form_id'] ) ? (int) $row['cf_form_id'] : 0;
986 - if ( $formId > 0 && class_exists( '\\forge12\\contactform7\\CF7DoubleOptIn\\CF7DoubleOptIn' ) ) {
987 - $formParam = \forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn::getInstance()->getParameter( $formId );
988 - $subject = (string) ( $formParam['subject'] ?? '' );
989 - $senderEmail = (string) ( $formParam['sender'] ?? '' );
990 - $senderName = (string) ( $formParam['sender_name'] ?? '' );
991 - if ( $senderEmail !== '' ) {
992 - $from = $senderName !== ''
993 - ? $senderName . ' <' . $senderEmail . '>'
994 - : $senderEmail;
995 - }
996 - }
987 + if ( ! $outcome->isSent() ) {
988 + return self::resendRefusal( $outcome->getReason() );
997 989 }
998 -
999 - if ( empty( $to ) || empty( $body ) ) {
1000 - return new \WP_REST_Response(
1001 - array(
1002 - 'success' => false,
1003 - 'message' => __( 'Email data is incomplete.', 'double-opt-in' ),
1004 - ),
1005 - 400
1006 - );
1007 - }
1008 -
1009 - $headers = array( 'Content-Type: text/html; charset=UTF-8' );
1010 - if ( ! empty( $from ) ) {
1011 - $headers[] = 'From: ' . $from;
1012 - }
1013 -
1014 - $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, '' );
1015 994 }
1016 995
1017 996 if ( ! $result ) {
1018 - return new \WP_REST_Response(
1019 - array(
1020 - 'success' => false,
1021 - 'message' => __( 'Failed to send email.', 'double-opt-in' ),
1022 - ),
1023 - 500
1024 - );
997 + return self::resendRefusal( ResendResult::SEND_FAILED );
1025 998 }
1026 999
1027 1000 AuditLogger::log(
1028 1001 AuditLogger::TYPE_EMAIL,
@@ -1919,22 +1892,41 @@
1919 1892 );
1920 1893 }
1921 1894
1922 1895 // ═══════════════════════════════════════════════════════════════
1923 - // PRO-EXTENSIBLE STUBS
1924 - // These return minimal responses; Pro overrides via filters or
1925 - // 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.
1926 1902 // ═══════════════════════════════════════════════════════════════
1927 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 +
1928 1926 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1929 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1930 - return new \WP_REST_Response(
1931 - array(
1932 - 'success' => false,
1933 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1934 - ),
1935 - 403
1936 - );
1927 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1928 + return $this->addonInactive( 'analytics', 'Analytics' );
1937 1929 }
1938 1930
1939 1931 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1940 1932
@@ -1947,16 +1939,10 @@
1947 1939 );
1948 1940 }
1949 1941
1950 1942 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1951 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1952 - return new \WP_REST_Response(
1953 - array(
1954 - 'success' => false,
1955 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1956 - ),
1957 - 403
1958 - );
1943 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1944 + return $this->addonInactive( 'analytics', 'Analytics' );
1959 1945 }
1960 1946
1961 1947 $formId = (int) $request->get_param( 'form_id' );
1962 1948 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1970,16 +1956,10 @@
1970 1956 );
1971 1957 }
1972 1958
1973 1959 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1974 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1975 - return new \WP_REST_Response(
1976 - array(
1977 - 'success' => false,
1978 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1979 - ),
1980 - 403
1981 - );
1960 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1961 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1982 1962 }
1983 1963
1984 1964 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1985 1965
@@ -1992,16 +1972,10 @@
1992 1972 );
1993 1973 }
1994 1974
1995 1975 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1996 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1997 - return new \WP_REST_Response(
1998 - array(
1999 - 'success' => false,
2000 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2001 - ),
2002 - 403
2003 - );
1976 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1977 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2004 1978 }
2005 1979
2006 1980 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2007 1981
@@ -2016,140 +1990,32 @@
2016 1990
2017 1991 /**
2018 1992 * POST /f12-doi/v1/optout/page/generate
2019 1993 *
2020 - * One-click generator for the opt-out landing page. Eliminates the
2021 - * onboarding-friction loop where the user has to manually create a
2022 - * 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.
2023 1997 *
2024 - * Algorithm:
2025 - * 1. Idempotent fast-path — scan `published` pages for the list
2026 - * shortcode. If one already exists, return its ID untouched
2027 - * (no duplicate creation, no content overwrite).
2028 - * 2. Title-collision safety — if a page named "Opt-Out" exists
2029 - * but WITHOUT the list shortcode, refuse to auto-modify. The
2030 - * user might have intentionally repurposed that title; we'd
2031 - * rather show a 409 with a clear message than clobber.
2032 - * 3. Insert a fresh page with both shortcodes (form + list) so
2033 - * the page is functional end-to-end out of the box.
2034 - *
2035 - * Response shape (always 200 unless error):
2036 - * { page_id, page_title, edit_url, view_url, created: bool }
2037 - *
2038 1998 * @return \WP_REST_Response
2039 1999 */
2040 2000 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2041 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2042 - return new \WP_REST_Response(
2043 - array(
2044 - 'success' => false,
2045 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2046 - ),
2047 - 403
2048 - );
2001 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
2002 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2049 2003 }
2050 2004
2051 - if ( ! current_user_can( 'publish_pages' ) ) {
2052 - return new \WP_REST_Response(
2053 - array(
2054 - 'success' => false,
2055 - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2056 - ),
2057 - 403
2058 - );
2059 - }
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 );
2060 2014
2061 - $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2062 - $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2063 -
2064 - // 1. Idempotent fast-path — first page with the list shortcode wins.
2065 - $existing = get_posts(
2066 - array(
2067 - 'post_type' => 'page',
2068 - 'post_status' => 'publish',
2069 - 'posts_per_page' => 1,
2070 - 's' => $listShortcode,
2071 - 'fields' => 'ids',
2072 - 'no_found_rows' => true,
2073 - )
2074 - );
2075 - if ( ! empty( $existing ) ) {
2076 - $pageId = (int) $existing[0];
2077 - return new \WP_REST_Response(
2078 - array(
2079 - 'success' => true,
2080 - 'created' => false,
2081 - 'page_id' => $pageId,
2082 - 'page_title' => get_the_title( $pageId ),
2083 - 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2084 - 'view_url' => get_permalink( $pageId ),
2085 - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2086 - ),
2087 - 200
2088 - );
2089 - }
2090 -
2091 - // 2. Title collision — a page literally titled "Opt-Out" but
2092 - // without the shortcode is the user's own content. Refuse
2093 - // to silently modify it.
2094 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2095 - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2096 - // Plain null check, not instanceof: this replaces `?->ID`, which only
2097 - // short-circuits on null and does not care about the concrete class.
2098 - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2099 - if ( $collisionId > 0 ) {
2100 - return new \WP_REST_Response(
2101 - array(
2102 - 'success' => false,
2103 - 'code' => 'TITLE_COLLISION',
2104 - 'page_id' => $collisionId,
2105 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2106 - 'message' => sprintf(
2107 - /* translators: %s = page title */
2108 - __( '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' ),
2109 - $desiredTitle
2110 - ),
2111 - ),
2112 - 409
2113 - );
2114 - }
2115 -
2116 - // 3. Insert.
2117 - $pageId = wp_insert_post(
2118 - array(
2119 - 'post_type' => 'page',
2120 - 'post_status' => 'publish',
2121 - 'post_title' => $desiredTitle,
2122 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2123 - 'post_author' => get_current_user_id(),
2124 - 'comment_status' => 'closed',
2125 - 'ping_status' => 'closed',
2126 - ),
2127 - true
2128 - );
2129 -
2130 - if ( is_wp_error( $pageId ) ) {
2131 - return new \WP_REST_Response(
2132 - array(
2133 - 'success' => false,
2134 - 'message' => $pageId->get_error_message(),
2135 - ),
2136 - 500
2137 - );
2138 - }
2139 -
2140 - return new \WP_REST_Response(
2141 - array(
2142 - 'success' => true,
2143 - 'created' => true,
2144 - 'page_id' => (int) $pageId,
2145 - 'page_title' => $desiredTitle,
2146 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2147 - 'view_url' => get_permalink( (int) $pageId ),
2148 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2149 - ),
2150 - 200
2151 - );
2015 + return $response instanceof \WP_REST_Response
2016 + ? $response
2017 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2152 2018 }
2153 2019
2154 2020 /**
2155 2021 * License gate for the User Creation endpoints.
@@ -2169,15 +2035,9 @@
2169 2035 }
2170 2036
2171 2037 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2172 2038 if ( ! $this->userCreationAuthorized() ) {
2173 - return new \WP_REST_Response(
2174 - array(
2175 - 'success' => false,
2176 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2177 - ),
2178 - 403
2179 - );
2039 + return $this->addonInactive( 'user-registration', 'User Registration' );
2180 2040 }
2181 2041
2182 2042 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2183 2043
@@ -2191,15 +2051,9 @@
2191 2051 }
2192 2052
2193 2053 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2194 2054 if ( ! $this->userCreationAuthorized() ) {
2195 - return new \WP_REST_Response(
2196 - array(
2197 - 'success' => false,
2198 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2199 - ),
2200 - 403
2201 - );
2055 + return $this->addonInactive( 'user-registration', 'User Registration' );
2202 2056 }
2203 2057
2204 2058 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2205 2059
@@ -2212,16 +2066,10 @@
2212 2066 );
2213 2067 }
2214 2068
2215 2069 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2216 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2217 - return new \WP_REST_Response(
2218 - array(
2219 - 'success' => false,
2220 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2221 - ),
2222 - 403
2223 - );
2070 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2071 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2224 2072 }
2225 2073
2226 2074 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2227 2075
@@ -2234,16 +2082,10 @@
2234 2082 );
2235 2083 }
2236 2084
2237 2085 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2238 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2239 - return new \WP_REST_Response(
2240 - array(
2241 - 'success' => false,
2242 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2243 - ),
2244 - 403
2245 - );
2086 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2087 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2246 2088 }
2247 2089
2248 2090 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2249 2091
@@ -2337,44 +2179,25 @@
2337 2179
2338 2180 return new \WP_REST_Response( $result, $status );
2339 2181 }
2340 2182
2341 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2342 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2343 - return new \WP_REST_Response(
2344 - array(
2345 - 'success' => false,
2346 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2347 - ),
2348 - 403
2349 - );
2350 - }
2183 + // ═══════════════════════════════════════════════════════════════
2184 + // HELPERS
2185 + // ═══════════════════════════════════════════════════════════════
2351 2186
2352 - $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'] ?? '' );
2353 2194
2354 - /**
2355 - * Filter to let Pro handle database export.
2356 - *
2357 - * @param array $result Result.
2358 - * @param array $input Export parameters.
2359 - * @since 4.2.0
2360 - */
2361 - $result = apply_filters(
2362 - 'f12_doi_rest_database_export',
2363 - array(
2364 - 'success' => false,
2365 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2366 - ),
2367 - $input
2368 - );
2369 -
2370 - 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';
2371 2198 }
2372 2199
2373 - // ═══════════════════════════════════════════════════════════════
2374 - // HELPERS
2375 - // ═══════════════════════════════════════════════════════════════
2376 -
2377 2200 /**
2378 2201 * Format an opt-in database row for the API response.
2379 2202 *
2380 2203 * @param array $row The database row.
@@ -2392,8 +2215,13 @@
2392 2215 'formId' => (int) $row['cf_form_id'],
2393 2216 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2394 2217 'category' => (int) $row['category'],
2395 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'] ?? '' ),
2396 2224 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2397 2225 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2398 2226 );
2399 2227
@@ -2404,8 +2232,10 @@
2404 2232 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2405 2233 $data['consentText'] = $row['consent_text'];
2406 2234 $data['consentField'] = $row['consent_field'] ?? '';
2407 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'] ?? '' ) );
2408 2238
2409 2239 // Category name
2410 2240 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2411 2241 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2418,28 +2248,31 @@
2418 2248 // configured, look up the value the user actually submitted.
2419 2249 // Truthy = explicit acknowledgment captured. Falsy = either
2420 2250 // gate wasn't enforced or this is a legacy record.
2421 2251 //
2422 - // Storage shape varies per integration:
2423 - // - CF7 / WPForms / GF (default path) store fields flat
2424 - // at the top level: $content[fieldName] = value.
2425 - // - Avada wraps fields under a `data` sub-key alongside
2426 - // metadata (field_labels, field_types, form_parameter)
2427 - // — its OnSubmit overrides the flat content set by
2428 - // createOptIn(). For Avada records, $content[fieldName]
2429 - // is undefined; the value lives at $content['data'][fieldName].
2252 + // Where that value sits differs per integration, and this
2253 + // reader got the list wrong twice:
2430 2254 //
2431 - // Pre-2026-05-01 we only checked the flat shape, so every
2432 - // Avada opt-in showed "User acknowledged: ✗ No" even when
2433 - // the user explicitly checked the GDPR box. The fallback
2434 - // below recognises the Avada shape too — adding a third
2435 - // shape would be the next addition.
2436 - $data['consentAcknowledged'] = ! empty( $data['consentField'] )
2437 - && is_array( $content )
2438 - && (
2439 - ! empty( $content[ $data['consentField'] ] )
2440 - || ! empty( $content['data'][ $data['consentField'] ] ?? null )
2441 - );
2255 + // 2026-05-01 Avada wraps its fields under `data`, so the
2256 + // flat lookup missed and every Avada opt-in
2257 + // showed "User acknowledged: ✗ No" even with
2258 + // the GDPR box explicitly checked.
2259 + // 2026-08-27 Elementor stores the whole $_POST parameter
2260 + // dict, so its fields sit under `form_fields`
2261 + // — the same symptom, one integration further
2262 + // on. The docblock added after the Avada fix
2263 + // had predicted exactly this ("adding a third
2264 + // shape would be the next addition").
2265 + //
2266 + // The shape list now lives in SubmittedContent, shared with
2267 + // OptInFrontend::addPlaceholders() — the other consumer that
2268 + // already knew all of them. A fourth integration with a
2269 + // fourth layout is taught to both at once.
2270 + //
2271 + // The lookup also tolerates a consent_field that the
2272 + // pre-5.3.2 sanitize_key() lowercased, so installations
2273 + // recover from the update without re-saving every form.
2274 + $data['consentAcknowledged'] = SubmittedContent::hasValue( $content, (string) $data['consentField'] );
2442 2275
2443 2276 // Parse mail_optin
2444 2277 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2445 2278 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
@@ -2622,10 +2455,10 @@
2622 2455 // First pass: every registered addon gets an entry, even if
2623 2456 // it contributes no UI. That lets the client show per-addon
2624 2457 // licensing/boot state without a second round-trip.
2625 2458 foreach ( $registered as $id => $addon ) {
2626 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2627 - $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 );
2628 2461 unset( $fragments[ $id ] );
2629 2462 }
2630 2463
2631 2464 // Second pass: fragments for addons NOT in the registry
@@ -2863,11 +2696,18 @@
2863 2696 }
2864 2697
2865 2698 $activateUrl = null;
2866 2699 if ( $installed && ! $active ) {
2867 - $activateUrl = wp_nonce_url(
2868 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2869 - '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' )
2870 2710 );
2871 2711 }
2872 2712
2873 2713 $registeredAddon = $registered[ $id ] ?? null;