PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.13.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.13.0
5.12.0 5.13.0 5.13.1 5.11.0 5.10.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 All 47 releases
← All changes | src/Admin/AdminRestController.php +321 -343 5.1.6 → 5.13.0 View file →
@@ -14,8 +14,12 @@
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;
21 +use Forge12\DoubleOptIn\Subscription\SubscriptionGroups;
18 22 use Forge12\Shared\LoggerInterface;
19 23
20 24 if ( ! defined( 'ABSPATH' ) ) {
21 25 exit;
@@ -29,8 +33,14 @@
29 33 class AdminRestController {
30 34
31 35 const API_NAMESPACE = 'f12-doi/v1';
32 36
37 + /**
38 + * SQL form of OptIn::isOptedOut(): not confirmed, withdrawal IP and time
39 + * recorded. A re-opt-in clears the time, so the row counts as confirmed again.
40 + */
41 + 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'))";
42 +
33 43 private LoggerInterface $logger;
34 44 private FormSettingsService $formService;
35 45 private FormSettingsValidator $formValidator;
36 46
@@ -104,8 +114,19 @@
104 114 'permission_callback' => array( $this, 'checkPermission' ),
105 115 )
106 116 );
107 117
118 + // ── Subscription groups (read-only; managed by an add-on) ──
119 + register_rest_route(
120 + self::API_NAMESPACE,
121 + '/subscription-groups',
122 + array(
123 + 'methods' => \WP_REST_Server::READABLE,
124 + 'callback' => array( $this, 'getSubscriptionGroups' ),
125 + 'permission_callback' => array( $this, 'checkPermission' ),
126 + )
127 + );
128 +
108 129 // ── Opt-Ins ────────────────────────────────────────────────
109 130 register_rest_route(
110 131 self::API_NAMESPACE,
111 132 '/optins',
@@ -508,19 +529,8 @@
508 529 'permission_callback' => array( $this, 'checkPermission' ),
509 530 )
510 531 );
511 532
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 533 // ── Addons manifest (UI mount-point system, plan §9) ────────
524 534 register_rest_route(
525 535 self::API_NAMESPACE,
526 536 '/addons',
@@ -625,9 +635,10 @@
625 635 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
626 636
627 637 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
628 638 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
629 - $pending = $total - $confirmed;
639 + $revoked = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE " . self::REVOKED_SQL ); // phpcs:ignore WordPress.DB.PreparedSQL -- fixed SQL, no input.
640 + $pending = max( 0, $total - $confirmed - $revoked );
630 641 $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0;
631 642
632 643 // Recent opt-ins (raw activity feed — not analytics).
633 644 // Time-bucketed activity, top-forms breakdown and the big
@@ -633,9 +644,9 @@
633 644 // Time-bucketed activity, top-forms breakdown and the big
634 645 // conversion-rate card moved into addon-analytics, which
635 646 // renders them at the `dashboard.widget` mount point.
636 647 $recent = $wpdb->get_results(
637 - "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5",
648 + "SELECT id, email, cf_form_id, doubleoptin, createtime, ipaddr_optout, optouttime FROM {$table} ORDER BY id DESC LIMIT 5",
638 649 ARRAY_A
639 650 );
640 651
641 652 foreach ( $recent as &$row ) {
@@ -641,14 +652,18 @@
641 652 foreach ( $recent as &$row ) {
642 653 $post = get_post( (int) $row['cf_form_id'] );
643 654 $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] );
644 655 $row['confirmed'] = (int) $row['doubleoptin'] === 1;
656 + $row['revoked'] = self::isRevokedRow( $row );
657 + unset( $row['ipaddr_optout'], $row['optouttime'] );
645 658 }
659 + unset( $row );
646 660
647 661 $data = array(
648 662 'totalOptins' => $total,
649 663 'confirmed' => $confirmed,
650 664 'pending' => $pending,
665 + 'revoked' => $revoked,
651 666 'conversionRate' => $rate,
652 667 'recentOptins' => $recent ?: array(),
653 668 );
654 669
@@ -700,8 +715,35 @@
700 715 // ═══════════════════════════════════════════════════════════════
701 716 // OPT-INS
702 717 // ═══════════════════════════════════════════════════════════════
703 718
719 + /**
720 + * Subscription groups for the filter in the opt-in list. `available`
721 + * is false when no add-on provides groups, so the SPA hides the
722 + * controls instead of showing an empty filter.
723 + */
724 + public function getSubscriptionGroups( \WP_REST_Request $request ): \WP_REST_Response {
725 + $groups = array();
726 + foreach ( SubscriptionGroups::resolver()->groups() as $group ) {
727 + $groups[] = array(
728 + 'key' => $group->getKey(),
729 + 'label' => $group->getLabel(),
730 + 'memberCount' => count( $group->getMembers() ),
731 + );
732 + }
733 +
734 + return new \WP_REST_Response(
735 + array(
736 + 'success' => true,
737 + 'data' => array(
738 + 'available' => SubscriptionGroups::isAvailable(),
739 + 'groups' => $groups,
740 + ),
741 + ),
742 + 200
743 + );
744 + }
745 +
704 746 public function getOptins( \WP_REST_Request $request ): \WP_REST_Response {
705 747 $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
706 748 $perPage = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ?: 20 ) );
707 749 $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' );
@@ -729,8 +771,11 @@
729 771 if ( $status === 'confirmed' ) {
730 772 $where[] = 'doubleoptin = 1';
731 773 } elseif ( $status === 'pending' ) {
732 774 $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)';
775 + $where[] = 'NOT ' . self::REVOKED_SQL;
776 + } elseif ( $status === 'revoked' ) {
777 + $where[] = self::REVOKED_SQL;
733 778 }
734 779
735 780 if ( $formId !== null && $formId !== '' ) {
736 781 $where[] = 'cf_form_id = %d';
@@ -736,8 +781,38 @@
736 781 $where[] = 'cf_form_id = %d';
737 782 $params[] = (int) $formId;
738 783 }
739 784
785 + // Subscription group (5.12.0). An unknown key selects nothing, so a
786 + // stale link can never widen into the unfiltered list.
787 + $groupKey = sanitize_text_field( (string) ( $request->get_param( 'group' ) ?? '' ) );
788 + if ( $groupKey !== '' ) {
789 + $group = SubscriptionGroups::resolver()->findGroup( $groupKey );
790 + if ( $group === null ) {
791 + $where[] = '1 = 0';
792 + } else {
793 + list( $groupSql, $groupParams ) = $group->toSqlCondition();
794 + $where[] = $groupSql;
795 + $params = array_merge( $params, $groupParams );
796 + }
797 + }
798 +
799 + // Opt-ins whose confirmation mail could not be sent (5.8.0).
800 + if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) {
801 + $where[] = 'mail_status = %s';
802 + $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED;
803 + }
804 +
805 + // Confirmed opt-ins whose follow-up actions failed or have an
806 + // unknown outcome — the admin's "needs attention" list.
807 + if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
808 + $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
809 + $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic();
810 + $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN ("
811 + . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))';
812 + $params = array_merge( $params, $problems );
813 + }
814 +
740 815 $whereClause = implode( ' AND ', $where );
741 816
742 817 // Count
743 818 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -790,8 +865,12 @@
790 865 }
791 866
792 867 $data = $this->formatOptinRow( $row, true );
793 868
869 + // Other sign-ups of the same address in the same subscription
870 + // group (5.12.0). The records stay separate; this only links them.
871 + $data['linkedOptIns'] = $this->linkedOptIns( $row );
872 +
794 873 // Dev-mode UI hint: surface whether the reset-confirmation
795 874 // endpoint is reachable for this request, so the React detail
796 875 // page can show/hide the "Reset to pending" button without
797 876 // having to probe the endpoint and handle a 403. Mirrors
@@ -832,9 +911,9 @@
832 911 // Full row (id, hash, content, files, cf_form_id) so the
833 912 // pre-delete cascade hook from pre-doi-data-retention Step 1
834 913 // can fire with a payload that lets listeners reach into
835 914 // integration storage. ARRAY_A — listener-friendly.
836 - $row = $wpdb->get_row(
915 + $row = $wpdb->get_row(
837 916 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
838 917 ARRAY_A
839 918 );
840 919 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -892,8 +971,32 @@
892 971 200
893 972 );
894 973 }
895 974
975 + /**
976 + * The admin's answer for a resend that did not go out.
977 + */
978 + private static function resendRefusal( string $reason ): \WP_REST_Response {
979 + $map = array(
980 + ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ),
981 + ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ),
982 + ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ),
983 + ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ),
984 + ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ),
985 + );
986 + $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 );
987 + $message = $entry[0];
988 + $status = $entry[1];
989 +
990 + return new \WP_REST_Response(
991 + array(
992 + 'success' => false,
993 + 'message' => $message,
994 + ),
995 + $status
996 + );
997 + }
998 +
896 999 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
897 1000 global $wpdb;
898 1001 $id = (int) $request->get_param( 'id' );
899 1002 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -933,96 +1036,23 @@
933 1036 */
934 1037 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
935 1038
936 1039 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 - }
1040 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
1041 + ->get( ConfirmationMailResender::class )
1042 + ->resend( $id );
967 1043
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 - }
1044 + if ( ! $outcome->isSent() ) {
1045 + return self::resendRefusal( $outcome->getReason() );
997 1046 }
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 );
1047 + $result = true;
1048 + } else {
1049 + // An extension sent it; record the outcome all the same.
1050 + do_action( 'f12_doi_optin_mail_result', $id, (bool) $result, '' );
1015 1051 }
1016 1052
1017 1053 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 - );
1054 + return self::resendRefusal( ResendResult::SEND_FAILED );
1025 1055 }
1026 1056
1027 1057 AuditLogger::log(
1028 1058 AuditLogger::TYPE_EMAIL,
@@ -1439,8 +1469,13 @@
1439 1469
1440 1470 public function getSettings( \WP_REST_Request $request ): \WP_REST_Response {
1441 1471 $defaults = array(
1442 1472 'telemetry' => 1,
1473 + // Optional "Double Opt-In by Forge12" credit on the confirmation
1474 + // page. Defaults to 0 and must stay that way: wordpress.org
1475 + // guideline 10 requires credit links to be off unless the site
1476 + // owner explicitly turns them on.
1477 + 'credit_link' => 0,
1443 1478 'delete' => 12,
1444 1479 'delete_unconfirmed' => 7,
1445 1480 'delete_period' => 'months',
1446 1481 'delete_unconfirmed_period' => 'months',
@@ -1515,8 +1550,13 @@
1515 1550 'type' => 'int',
1516 1551 'min' => 0,
1517 1552 'max' => 1,
1518 1553 ),
1554 + 'credit_link' => array(
1555 + 'type' => 'int',
1556 + 'min' => 0,
1557 + 'max' => 1,
1558 + ),
1519 1559 'privacy_policy_page' => array(
1520 1560 'type' => 'int',
1521 1561 'min' => 0,
1522 1562 ),
@@ -1909,22 +1949,41 @@
1909 1949 );
1910 1950 }
1911 1951
1912 1952 // ═══════════════════════════════════════════════════════════════
1913 - // PRO-EXTENSIBLE STUBS
1914 - // These return minimal responses; Pro overrides via filters or
1915 - // registers its own REST routes that take precedence.
1953 + // ADD-ON ROUTES
1954 + // Core owns the route; the data comes from the add-on through a
1955 + // filter. Without a handler the answer is ADDON_INACTIVE. Core
1956 + // itself never checks a licence here (wordpress.org guideline 5):
1957 + // the functionality lives in the add-on, which only hooks in when
1958 + // it runs licensed.
1916 1959 // ═══════════════════════════════════════════════════════════════
1917 1960
1961 + /**
1962 + * Answer for a route whose add-on is not running.
1963 + *
1964 + * 404 with `code` so the SPA can tell it from an unknown route
1965 + * (`rest_no_route`); `ApiError` reads `body.code`.
1966 + */
1967 + private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response {
1968 + return new \WP_REST_Response(
1969 + array(
1970 + 'success' => false,
1971 + 'code' => 'ADDON_INACTIVE',
1972 + 'addon' => $addonId,
1973 + 'message' => sprintf(
1974 + /* translators: %s: add-on name */
1975 + __( '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' ),
1976 + $addonName
1977 + ),
1978 + ),
1979 + 404
1980 + );
1981 + }
1982 +
1918 1983 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1919 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1920 - return new \WP_REST_Response(
1921 - array(
1922 - 'success' => false,
1923 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1924 - ),
1925 - 403
1926 - );
1984 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1985 + return $this->addonInactive( 'analytics', 'Analytics' );
1927 1986 }
1928 1987
1929 1988 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1930 1989
@@ -1937,16 +1996,10 @@
1937 1996 );
1938 1997 }
1939 1998
1940 1999 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1941 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1942 - return new \WP_REST_Response(
1943 - array(
1944 - 'success' => false,
1945 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1946 - ),
1947 - 403
1948 - );
2000 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
2001 + return $this->addonInactive( 'analytics', 'Analytics' );
1949 2002 }
1950 2003
1951 2004 $formId = (int) $request->get_param( 'form_id' );
1952 2005 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1960,16 +2013,10 @@
1960 2013 );
1961 2014 }
1962 2015
1963 2016 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1964 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1965 - return new \WP_REST_Response(
1966 - array(
1967 - 'success' => false,
1968 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1969 - ),
1970 - 403
1971 - );
2017 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
2018 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1972 2019 }
1973 2020
1974 2021 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1975 2022
@@ -1982,16 +2029,10 @@
1982 2029 );
1983 2030 }
1984 2031
1985 2032 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1986 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1987 - return new \WP_REST_Response(
1988 - array(
1989 - 'success' => false,
1990 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1991 - ),
1992 - 403
1993 - );
2033 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
2034 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1994 2035 }
1995 2036
1996 2037 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
1997 2038
@@ -2006,140 +2047,32 @@
2006 2047
2007 2048 /**
2008 2049 * POST /f12-doi/v1/optout/page/generate
2009 2050 *
2010 - * One-click generator for the opt-out landing page. Eliminates the
2011 - * onboarding-friction loop where the user has to manually create a
2012 - * page and paste the shortcodes before opt-out works at all.
2051 + * One-click generator for the opt-out landing page. The logic lives in
2052 + * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through
2053 + * the filter below; Core only owns the route.
2013 2054 *
2014 - * Algorithm:
2015 - * 1. Idempotent fast-path — scan `published` pages for the list
2016 - * shortcode. If one already exists, return its ID untouched
2017 - * (no duplicate creation, no content overwrite).
2018 - * 2. Title-collision safety — if a page named "Opt-Out" exists
2019 - * but WITHOUT the list shortcode, refuse to auto-modify. The
2020 - * user might have intentionally repurposed that title; we'd
2021 - * rather show a 409 with a clear message than clobber.
2022 - * 3. Insert a fresh page with both shortcodes (form + list) so
2023 - * the page is functional end-to-end out of the box.
2024 - *
2025 - * Response shape (always 200 unless error):
2026 - * { page_id, page_title, edit_url, view_url, created: bool }
2027 - *
2028 2055 * @return \WP_REST_Response
2029 2056 */
2030 2057 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2031 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2032 - return new \WP_REST_Response(
2033 - array(
2034 - 'success' => false,
2035 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2036 - ),
2037 - 403
2038 - );
2058 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
2059 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2039 2060 }
2040 2061
2041 - if ( ! current_user_can( 'publish_pages' ) ) {
2042 - return new \WP_REST_Response(
2043 - array(
2044 - 'success' => false,
2045 - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2046 - ),
2047 - 403
2048 - );
2049 - }
2062 + /**
2063 + * Filter: answer the opt-out page generator request.
2064 + *
2065 + * @param \WP_REST_Response|null $response Null until a handler answers.
2066 + * @param \WP_REST_Request $request The request.
2067 + *
2068 + * @since 5.8.0
2069 + */
2070 + $response = apply_filters( 'f12_doi_rest_optout_generate_page', null, $request );
2050 2071
2051 - $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2052 - $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2053 -
2054 - // 1. Idempotent fast-path — first page with the list shortcode wins.
2055 - $existing = get_posts(
2056 - array(
2057 - 'post_type' => 'page',
2058 - 'post_status' => 'publish',
2059 - 'posts_per_page' => 1,
2060 - 's' => $listShortcode,
2061 - 'fields' => 'ids',
2062 - 'no_found_rows' => true,
2063 - )
2064 - );
2065 - if ( ! empty( $existing ) ) {
2066 - $pageId = (int) $existing[0];
2067 - return new \WP_REST_Response(
2068 - array(
2069 - 'success' => true,
2070 - 'created' => false,
2071 - 'page_id' => $pageId,
2072 - 'page_title' => get_the_title( $pageId ),
2073 - 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2074 - 'view_url' => get_permalink( $pageId ),
2075 - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2076 - ),
2077 - 200
2078 - );
2079 - }
2080 -
2081 - // 2. Title collision — a page literally titled "Opt-Out" but
2082 - // without the shortcode is the user's own content. Refuse
2083 - // to silently modify it.
2084 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2085 - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2086 - // Plain null check, not instanceof: this replaces `?->ID`, which only
2087 - // short-circuits on null and does not care about the concrete class.
2088 - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2089 - if ( $collisionId > 0 ) {
2090 - return new \WP_REST_Response(
2091 - array(
2092 - 'success' => false,
2093 - 'code' => 'TITLE_COLLISION',
2094 - 'page_id' => $collisionId,
2095 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2096 - 'message' => sprintf(
2097 - /* translators: %s = page title */
2098 - __( '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' ),
2099 - $desiredTitle
2100 - ),
2101 - ),
2102 - 409
2103 - );
2104 - }
2105 -
2106 - // 3. Insert.
2107 - $pageId = wp_insert_post(
2108 - array(
2109 - 'post_type' => 'page',
2110 - 'post_status' => 'publish',
2111 - 'post_title' => $desiredTitle,
2112 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2113 - 'post_author' => get_current_user_id(),
2114 - 'comment_status' => 'closed',
2115 - 'ping_status' => 'closed',
2116 - ),
2117 - true
2118 - );
2119 -
2120 - if ( is_wp_error( $pageId ) ) {
2121 - return new \WP_REST_Response(
2122 - array(
2123 - 'success' => false,
2124 - 'message' => $pageId->get_error_message(),
2125 - ),
2126 - 500
2127 - );
2128 - }
2129 -
2130 - return new \WP_REST_Response(
2131 - array(
2132 - 'success' => true,
2133 - 'created' => true,
2134 - 'page_id' => (int) $pageId,
2135 - 'page_title' => $desiredTitle,
2136 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2137 - 'view_url' => get_permalink( (int) $pageId ),
2138 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2139 - ),
2140 - 200
2141 - );
2072 + return $response instanceof \WP_REST_Response
2073 + ? $response
2074 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2142 2075 }
2143 2076
2144 2077 /**
2145 2078 * License gate for the User Creation endpoints.
@@ -2159,15 +2092,9 @@
2159 2092 }
2160 2093
2161 2094 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2162 2095 if ( ! $this->userCreationAuthorized() ) {
2163 - return new \WP_REST_Response(
2164 - array(
2165 - 'success' => false,
2166 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2167 - ),
2168 - 403
2169 - );
2096 + return $this->addonInactive( 'user-registration', 'User Registration' );
2170 2097 }
2171 2098
2172 2099 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2173 2100
@@ -2181,15 +2108,9 @@
2181 2108 }
2182 2109
2183 2110 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2184 2111 if ( ! $this->userCreationAuthorized() ) {
2185 - return new \WP_REST_Response(
2186 - array(
2187 - 'success' => false,
2188 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2189 - ),
2190 - 403
2191 - );
2112 + return $this->addonInactive( 'user-registration', 'User Registration' );
2192 2113 }
2193 2114
2194 2115 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2195 2116
@@ -2202,16 +2123,10 @@
2202 2123 );
2203 2124 }
2204 2125
2205 2126 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2206 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2207 - return new \WP_REST_Response(
2208 - array(
2209 - 'success' => false,
2210 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2211 - ),
2212 - 403
2213 - );
2127 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2128 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2214 2129 }
2215 2130
2216 2131 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2217 2132
@@ -2224,16 +2139,10 @@
2224 2139 );
2225 2140 }
2226 2141
2227 2142 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2228 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2229 - return new \WP_REST_Response(
2230 - array(
2231 - 'success' => false,
2232 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2233 - ),
2234 - 403
2235 - );
2143 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2144 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2236 2145 }
2237 2146
2238 2147 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2239 2148
@@ -2327,44 +2236,25 @@
2327 2236
2328 2237 return new \WP_REST_Response( $result, $status );
2329 2238 }
2330 2239
2331 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2332 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2333 - return new \WP_REST_Response(
2334 - array(
2335 - 'success' => false,
2336 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2337 - ),
2338 - 403
2339 - );
2340 - }
2240 + // ═══════════════════════════════════════════════════════════════
2241 + // HELPERS
2242 + // ═══════════════════════════════════════════════════════════════
2341 2243
2342 - $input = $request->get_json_params();
2244 + /**
2245 + * PHP form of REVOKED_SQL for a raw table row.
2246 + *
2247 + * @param array<string, mixed> $row The database row.
2248 + */
2249 + private static function isRevokedRow( array $row ): bool {
2250 + $optOutTime = (string) ( $row['optouttime'] ?? '' );
2343 2251
2344 - /**
2345 - * Filter to let Pro handle database export.
2346 - *
2347 - * @param array $result Result.
2348 - * @param array $input Export parameters.
2349 - * @since 4.2.0
2350 - */
2351 - $result = apply_filters(
2352 - 'f12_doi_rest_database_export',
2353 - array(
2354 - 'success' => false,
2355 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2356 - ),
2357 - $input
2358 - );
2359 -
2360 - return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 );
2252 + return (int) ( $row['doubleoptin'] ?? 0 ) !== 1
2253 + && (string) ( $row['ipaddr_optout'] ?? '' ) !== ''
2254 + && $optOutTime !== '' && $optOutTime !== '0';
2361 2255 }
2362 2256
2363 - // ═══════════════════════════════════════════════════════════════
2364 - // HELPERS
2365 - // ═══════════════════════════════════════════════════════════════
2366 -
2367 2257 /**
2368 2258 * Format an opt-in database row for the API response.
2369 2259 *
2370 2260 * @param array $row The database row.
@@ -2371,8 +2261,74 @@
2371 2261 * @param bool $detailed Whether to include full detail (content, mail data).
2372 2262 *
2373 2263 * @return array Formatted data.
2374 2264 */
2265 + /**
2266 + * @param array<string, mixed> $row A database row.
2267 + *
2268 + * @return array{key:string, label:string}|null
2269 + */
2270 + private function subscriptionOfRow( array $row ): ?array {
2271 + $group = SubscriptionGroups::resolver()->groupForForm(
2272 + (int) ( $row['cf_form_id'] ?? 0 ),
2273 + (string) ( $row['form_ref'] ?? '' )
2274 + );
2275 +
2276 + return $group === null ? null : array(
2277 + 'key' => $group->getKey(),
2278 + 'label' => $group->getLabel(),
2279 + );
2280 + }
2281 +
2282 + /**
2283 + * The other records of the same address that belong to the same
2284 + * subscription group as the given record.
2285 + *
2286 + * @param array<string, mixed> $row A database row.
2287 + *
2288 + * @return array<int, array<string, mixed>>
2289 + */
2290 + private function linkedOptIns( array $row ): array {
2291 + $group = SubscriptionGroups::resolver()->groupForForm(
2292 + (int) ( $row['cf_form_id'] ?? 0 ),
2293 + (string) ( $row['form_ref'] ?? '' )
2294 + );
2295 + $email = (string) ( $row['email'] ?? '' );
2296 + if ( $group === null || $email === '' ) {
2297 + return array();
2298 + }
2299 +
2300 + global $wpdb;
2301 + $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
2302 + list( $groupSql, $groupParams ) = $group->toSqlCondition();
2303 +
2304 + // The group condition is built from fixed column names and `%d`/`%s`
2305 + // placeholders only; every value travels in the parameter list.
2306 + $found = $wpdb->get_results(
2307 + $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders come from SubscriptionGroup::toSqlCondition().
2308 + "SELECT * FROM {$table} WHERE email = %s AND id <> %d AND {$groupSql} ORDER BY id DESC LIMIT 50", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2309 + array_merge( array( $email, (int) $row['id'] ), $groupParams )
2310 + ),
2311 + ARRAY_A
2312 + );
2313 +
2314 + $linked = array();
2315 + foreach ( (array) $found as $other ) {
2316 + $data = $this->formatOptinRow( $other );
2317 + $linked[] = array(
2318 + 'id' => $data['id'],
2319 + 'formId' => $data['formId'],
2320 + 'formName' => $data['formName'],
2321 + 'formRef' => $data['formRef'],
2322 + 'confirmed' => $data['confirmed'],
2323 + 'revoked' => $data['revoked'],
2324 + 'createtime' => $data['createtime'],
2325 + );
2326 + }
2327 +
2328 + return $linked;
2329 + }
2330 +
2375 2331 private function formatOptinRow( array $row, bool $detailed = false ): array {
2376 2332 $post = get_post( (int) $row['cf_form_id'] );
2377 2333
2378 2334 $data = array(
@@ -2382,12 +2338,22 @@
2382 2338 'formId' => (int) $row['cf_form_id'],
2383 2339 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2384 2340 'category' => (int) $row['category'],
2385 2341 'confirmed' => (int) $row['doubleoptin'] === 1,
2342 + // Consent withdrawn via the opt-out (5.9.0). Not "pending".
2343 + 'revoked' => self::isRevokedRow( $row ),
2344 + // Confirmation mail: 'sent' (handed to the mail server), 'failed',
2345 + // or '' (recorded before 5.8.0). Since 5.8.0.
2346 + 'mailStatus' => (string) ( $row['mail_status'] ?? '' ),
2386 2347 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2387 2348 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2388 2349 );
2389 2350
2351 + // Instance inside the form id, e.g. the Elementor widget (5.12.0).
2352 + $data['formRef'] = (string) ( $row['form_ref'] ?? '' );
2353 + // Subscription group of the record, or null (5.12.0).
2354 + $data['subscription'] = $this->subscriptionOfRow( $row );
2355 +
2390 2356 if ( $detailed ) {
2391 2357 $data['ipRegister'] = $row['ipaddr_register'];
2392 2358 $data['ipConfirmation'] = $row['ipaddr_confirmation'];
2393 2359 $data['ipOptout'] = $row['ipaddr_optout'];
@@ -2394,8 +2360,10 @@
2394 2360 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2395 2361 $data['consentText'] = $row['consent_text'];
2396 2362 $data['consentField'] = $row['consent_field'] ?? '';
2397 2363 $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] );
2364 + $data['mailError'] = (string) ( $row['mail_error'] ?? '' );
2365 + $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) );
2398 2366
2399 2367 // Category name
2400 2368 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2401 2369 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2408,28 +2376,31 @@
2408 2376 // configured, look up the value the user actually submitted.
2409 2377 // Truthy = explicit acknowledgment captured. Falsy = either
2410 2378 // gate wasn't enforced or this is a legacy record.
2411 2379 //
2412 - // Storage shape varies per integration:
2413 - // - CF7 / WPForms / GF (default path) store fields flat
2414 - // at the top level: $content[fieldName] = value.
2415 - // - Avada wraps fields under a `data` sub-key alongside
2416 - // metadata (field_labels, field_types, form_parameter)
2417 - // — its OnSubmit overrides the flat content set by
2418 - // createOptIn(). For Avada records, $content[fieldName]
2419 - // is undefined; the value lives at $content['data'][fieldName].
2380 + // Where that value sits differs per integration, and this
2381 + // reader got the list wrong twice:
2420 2382 //
2421 - // Pre-2026-05-01 we only checked the flat shape, so every
2422 - // Avada opt-in showed "User acknowledged: ✗ No" even when
2423 - // the user explicitly checked the GDPR box. The fallback
2424 - // below recognises the Avada shape too — adding a third
2425 - // shape would be the next addition.
2426 - $data['consentAcknowledged'] = ! empty( $data['consentField'] )
2427 - && is_array( $content )
2428 - && (
2429 - ! empty( $content[ $data['consentField'] ] )
2430 - || ! empty( $content['data'][ $data['consentField'] ] ?? null )
2431 - );
2383 + // 2026-05-01 Avada wraps its fields under `data`, so the
2384 + // flat lookup missed and every Avada opt-in
2385 + // showed "User acknowledged: ✗ No" even with
2386 + // the GDPR box explicitly checked.
2387 + // 2026-08-27 Elementor stores the whole $_POST parameter
2388 + // dict, so its fields sit under `form_fields`
2389 + // — the same symptom, one integration further
2390 + // on. The docblock added after the Avada fix
2391 + // had predicted exactly this ("adding a third
2392 + // shape would be the next addition").
2393 + //
2394 + // The shape list now lives in SubmittedContent, shared with
2395 + // OptInFrontend::addPlaceholders() — the other consumer that
2396 + // already knew all of them. A fourth integration with a
2397 + // fourth layout is taught to both at once.
2398 + //
2399 + // The lookup also tolerates a consent_field that the
2400 + // pre-5.3.2 sanitize_key() lowercased, so installations
2401 + // recover from the update without re-saving every form.
2402 + $data['consentAcknowledged'] = SubmittedContent::hasValue( $content, (string) $data['consentField'] );
2432 2403
2433 2404 // Parse mail_optin
2434 2405 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2435 2406 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
@@ -2612,10 +2583,10 @@
2612 2583 // First pass: every registered addon gets an entry, even if
2613 2584 // it contributes no UI. That lets the client show per-addon
2614 2585 // licensing/boot state without a second round-trip.
2615 2586 foreach ( $registered as $id => $addon ) {
2616 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2617 - $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2587 + $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2588 + $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2618 2589 unset( $fragments[ $id ] );
2619 2590 }
2620 2591
2621 2592 // Second pass: fragments for addons NOT in the registry
@@ -2853,11 +2824,18 @@
2853 2824 }
2854 2825
2855 2826 $activateUrl = null;
2856 2827 if ( $installed && ! $active ) {
2857 - $activateUrl = wp_nonce_url(
2858 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2859 - 'activate-plugin_' . $pluginFile
2828 + // Not wp_nonce_url(): it HTML-escapes & to &amp;, and this URL
2829 + // goes as JSON into an href — "plugin" and "_wpnonce" then
2830 + // arrived as "amp;plugin" and the activation failed.
2831 + $activateUrl = add_query_arg(
2832 + array(
2833 + 'action' => 'activate',
2834 + 'plugin' => rawurlencode( $pluginFile ),
2835 + '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ),
2836 + ),
2837 + self_admin_url( 'plugins.php' )
2860 2838 );
2861 2839 }
2862 2840
2863 2841 $registeredAddon = $registered[ $id ] ?? null;