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 -340 5.1.5 → 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,137 +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 - $collisionId = (int) get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' )?->ID;
2086 - if ( $collisionId > 0 ) {
2087 - return new \WP_REST_Response(
2088 - array(
2089 - 'success' => false,
2090 - 'code' => 'TITLE_COLLISION',
2091 - 'page_id' => $collisionId,
2092 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2093 - 'message' => sprintf(
2094 - /* translators: %s = page title */
2095 - __( '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' ),
2096 - $desiredTitle
2097 - ),
2098 - ),
2099 - 409
2100 - );
2101 - }
2102 -
2103 - // 3. Insert.
2104 - $pageId = wp_insert_post(
2105 - array(
2106 - 'post_type' => 'page',
2107 - 'post_status' => 'publish',
2108 - 'post_title' => $desiredTitle,
2109 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2110 - 'post_author' => get_current_user_id(),
2111 - 'comment_status' => 'closed',
2112 - 'ping_status' => 'closed',
2113 - ),
2114 - true
2115 - );
2116 -
2117 - if ( is_wp_error( $pageId ) ) {
2118 - return new \WP_REST_Response(
2119 - array(
2120 - 'success' => false,
2121 - 'message' => $pageId->get_error_message(),
2122 - ),
2123 - 500
2124 - );
2125 - }
2126 -
2127 - return new \WP_REST_Response(
2128 - array(
2129 - 'success' => true,
2130 - 'created' => true,
2131 - 'page_id' => (int) $pageId,
2132 - 'page_title' => $desiredTitle,
2133 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2134 - 'view_url' => get_permalink( (int) $pageId ),
2135 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2136 - ),
2137 - 200
2138 - );
2072 + return $response instanceof \WP_REST_Response
2073 + ? $response
2074 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2139 2075 }
2140 2076
2141 2077 /**
2142 2078 * License gate for the User Creation endpoints.
@@ -2156,15 +2092,9 @@
2156 2092 }
2157 2093
2158 2094 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2159 2095 if ( ! $this->userCreationAuthorized() ) {
2160 - return new \WP_REST_Response(
2161 - array(
2162 - 'success' => false,
2163 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2164 - ),
2165 - 403
2166 - );
2096 + return $this->addonInactive( 'user-registration', 'User Registration' );
2167 2097 }
2168 2098
2169 2099 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2170 2100
@@ -2178,15 +2108,9 @@
2178 2108 }
2179 2109
2180 2110 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2181 2111 if ( ! $this->userCreationAuthorized() ) {
2182 - return new \WP_REST_Response(
2183 - array(
2184 - 'success' => false,
2185 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2186 - ),
2187 - 403
2188 - );
2112 + return $this->addonInactive( 'user-registration', 'User Registration' );
2189 2113 }
2190 2114
2191 2115 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2192 2116
@@ -2199,16 +2123,10 @@
2199 2123 );
2200 2124 }
2201 2125
2202 2126 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2203 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2204 - return new \WP_REST_Response(
2205 - array(
2206 - 'success' => false,
2207 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2208 - ),
2209 - 403
2210 - );
2127 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2128 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2211 2129 }
2212 2130
2213 2131 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2214 2132
@@ -2221,16 +2139,10 @@
2221 2139 );
2222 2140 }
2223 2141
2224 2142 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2225 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2226 - return new \WP_REST_Response(
2227 - array(
2228 - 'success' => false,
2229 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2230 - ),
2231 - 403
2232 - );
2143 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2144 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2233 2145 }
2234 2146
2235 2147 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2236 2148
@@ -2324,44 +2236,25 @@
2324 2236
2325 2237 return new \WP_REST_Response( $result, $status );
2326 2238 }
2327 2239
2328 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2329 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2330 - return new \WP_REST_Response(
2331 - array(
2332 - 'success' => false,
2333 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2334 - ),
2335 - 403
2336 - );
2337 - }
2240 + // ═══════════════════════════════════════════════════════════════
2241 + // HELPERS
2242 + // ═══════════════════════════════════════════════════════════════
2338 2243
2339 - $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'] ?? '' );
2340 2251
2341 - /**
2342 - * Filter to let Pro handle database export.
2343 - *
2344 - * @param array $result Result.
2345 - * @param array $input Export parameters.
2346 - * @since 4.2.0
2347 - */
2348 - $result = apply_filters(
2349 - 'f12_doi_rest_database_export',
2350 - array(
2351 - 'success' => false,
2352 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2353 - ),
2354 - $input
2355 - );
2356 -
2357 - 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';
2358 2255 }
2359 2256
2360 - // ═══════════════════════════════════════════════════════════════
2361 - // HELPERS
2362 - // ═══════════════════════════════════════════════════════════════
2363 -
2364 2257 /**
2365 2258 * Format an opt-in database row for the API response.
2366 2259 *
2367 2260 * @param array $row The database row.
@@ -2368,8 +2261,74 @@
2368 2261 * @param bool $detailed Whether to include full detail (content, mail data).
2369 2262 *
2370 2263 * @return array Formatted data.
2371 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 +
2372 2331 private function formatOptinRow( array $row, bool $detailed = false ): array {
2373 2332 $post = get_post( (int) $row['cf_form_id'] );
2374 2333
2375 2334 $data = array(
@@ -2379,12 +2338,22 @@
2379 2338 'formId' => (int) $row['cf_form_id'],
2380 2339 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2381 2340 'category' => (int) $row['category'],
2382 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'] ?? '' ),
2383 2347 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2384 2348 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2385 2349 );
2386 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 +
2387 2356 if ( $detailed ) {
2388 2357 $data['ipRegister'] = $row['ipaddr_register'];
2389 2358 $data['ipConfirmation'] = $row['ipaddr_confirmation'];
2390 2359 $data['ipOptout'] = $row['ipaddr_optout'];
@@ -2391,8 +2360,10 @@
2391 2360 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2392 2361 $data['consentText'] = $row['consent_text'];
2393 2362 $data['consentField'] = $row['consent_field'] ?? '';
2394 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'] ?? '' ) );
2395 2366
2396 2367 // Category name
2397 2368 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2398 2369 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2405,28 +2376,31 @@
2405 2376 // configured, look up the value the user actually submitted.
2406 2377 // Truthy = explicit acknowledgment captured. Falsy = either
2407 2378 // gate wasn't enforced or this is a legacy record.
2408 2379 //
2409 - // Storage shape varies per integration:
2410 - // - CF7 / WPForms / GF (default path) store fields flat
2411 - // at the top level: $content[fieldName] = value.
2412 - // - Avada wraps fields under a `data` sub-key alongside
2413 - // metadata (field_labels, field_types, form_parameter)
2414 - // — its OnSubmit overrides the flat content set by
2415 - // createOptIn(). For Avada records, $content[fieldName]
2416 - // 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:
2417 2382 //
2418 - // Pre-2026-05-01 we only checked the flat shape, so every
2419 - // Avada opt-in showed "User acknowledged: ✗ No" even when
2420 - // the user explicitly checked the GDPR box. The fallback
2421 - // below recognises the Avada shape too — adding a third
2422 - // shape would be the next addition.
2423 - $data['consentAcknowledged'] = ! empty( $data['consentField'] )
2424 - && is_array( $content )
2425 - && (
2426 - ! empty( $content[ $data['consentField'] ] )
2427 - || ! empty( $content['data'][ $data['consentField'] ] ?? null )
2428 - );
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'] );
2429 2403
2430 2404 // Parse mail_optin
2431 2405 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2432 2406 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
@@ -2609,10 +2583,10 @@
2609 2583 // First pass: every registered addon gets an entry, even if
2610 2584 // it contributes no UI. That lets the client show per-addon
2611 2585 // licensing/boot state without a second round-trip.
2612 2586 foreach ( $registered as $id => $addon ) {
2613 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2614 - $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 );
2615 2589 unset( $fragments[ $id ] );
2616 2590 }
2617 2591
2618 2592 // Second pass: fragments for addons NOT in the registry
@@ -2850,11 +2824,18 @@
2850 2824 }
2851 2825
2852 2826 $activateUrl = null;
2853 2827 if ( $installed && ! $active ) {
2854 - $activateUrl = wp_nonce_url(
2855 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2856 - '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' )
2857 2838 );
2858 2839 }
2859 2840
2860 2841 $registeredAddon = $registered[ $id ] ?? null;