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 +311 -343 5.3.1 → 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,
@@ -1919,22 +1949,41 @@
1919 1949 );
1920 1950 }
1921 1951
1922 1952 // ═══════════════════════════════════════════════════════════════
1923 - // PRO-EXTENSIBLE STUBS
1924 - // These return minimal responses; Pro overrides via filters or
1925 - // 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.
1926 1959 // ═══════════════════════════════════════════════════════════════
1927 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 +
1928 1983 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 - );
1984 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1985 + return $this->addonInactive( 'analytics', 'Analytics' );
1937 1986 }
1938 1987
1939 1988 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1940 1989
@@ -1947,16 +1996,10 @@
1947 1996 );
1948 1997 }
1949 1998
1950 1999 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 - );
2000 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
2001 + return $this->addonInactive( 'analytics', 'Analytics' );
1959 2002 }
1960 2003
1961 2004 $formId = (int) $request->get_param( 'form_id' );
1962 2005 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1970,16 +2013,10 @@
1970 2013 );
1971 2014 }
1972 2015
1973 2016 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 - );
2017 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
2018 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1982 2019 }
1983 2020
1984 2021 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1985 2022
@@ -1992,16 +2029,10 @@
1992 2029 );
1993 2030 }
1994 2031
1995 2032 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 - );
2033 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
2034 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2004 2035 }
2005 2036
2006 2037 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2007 2038
@@ -2016,140 +2047,32 @@
2016 2047
2017 2048 /**
2018 2049 * POST /f12-doi/v1/optout/page/generate
2019 2050 *
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.
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.
2023 2054 *
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 2055 * @return \WP_REST_Response
2039 2056 */
2040 2057 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 - );
2058 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
2059 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2049 2060 }
2050 2061
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 - }
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 );
2060 2071
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 - );
2072 + return $response instanceof \WP_REST_Response
2073 + ? $response
2074 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2152 2075 }
2153 2076
2154 2077 /**
2155 2078 * License gate for the User Creation endpoints.
@@ -2169,15 +2092,9 @@
2169 2092 }
2170 2093
2171 2094 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2172 2095 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 - );
2096 + return $this->addonInactive( 'user-registration', 'User Registration' );
2180 2097 }
2181 2098
2182 2099 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2183 2100
@@ -2191,15 +2108,9 @@
2191 2108 }
2192 2109
2193 2110 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2194 2111 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 - );
2112 + return $this->addonInactive( 'user-registration', 'User Registration' );
2202 2113 }
2203 2114
2204 2115 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2205 2116
@@ -2212,16 +2123,10 @@
2212 2123 );
2213 2124 }
2214 2125
2215 2126 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 - );
2127 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2128 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2224 2129 }
2225 2130
2226 2131 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2227 2132
@@ -2234,16 +2139,10 @@
2234 2139 );
2235 2140 }
2236 2141
2237 2142 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 - );
2143 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2144 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2246 2145 }
2247 2146
2248 2147 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2249 2148
@@ -2337,44 +2236,25 @@
2337 2236
2338 2237 return new \WP_REST_Response( $result, $status );
2339 2238 }
2340 2239
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 - }
2240 + // ═══════════════════════════════════════════════════════════════
2241 + // HELPERS
2242 + // ═══════════════════════════════════════════════════════════════
2351 2243
2352 - $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'] ?? '' );
2353 2251
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 );
2252 + return (int) ( $row['doubleoptin'] ?? 0 ) !== 1
2253 + && (string) ( $row['ipaddr_optout'] ?? '' ) !== ''
2254 + && $optOutTime !== '' && $optOutTime !== '0';
2371 2255 }
2372 2256
2373 - // ═══════════════════════════════════════════════════════════════
2374 - // HELPERS
2375 - // ═══════════════════════════════════════════════════════════════
2376 -
2377 2257 /**
2378 2258 * Format an opt-in database row for the API response.
2379 2259 *
2380 2260 * @param array $row The database row.
@@ -2381,8 +2261,74 @@
2381 2261 * @param bool $detailed Whether to include full detail (content, mail data).
2382 2262 *
2383 2263 * @return array Formatted data.
2384 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 +
2385 2331 private function formatOptinRow( array $row, bool $detailed = false ): array {
2386 2332 $post = get_post( (int) $row['cf_form_id'] );
2387 2333
2388 2334 $data = array(
@@ -2392,12 +2338,22 @@
2392 2338 'formId' => (int) $row['cf_form_id'],
2393 2339 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2394 2340 'category' => (int) $row['category'],
2395 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'] ?? '' ),
2396 2347 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2397 2348 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2398 2349 );
2399 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 +
2400 2356 if ( $detailed ) {
2401 2357 $data['ipRegister'] = $row['ipaddr_register'];
2402 2358 $data['ipConfirmation'] = $row['ipaddr_confirmation'];
2403 2359 $data['ipOptout'] = $row['ipaddr_optout'];
@@ -2404,8 +2360,10 @@
2404 2360 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2405 2361 $data['consentText'] = $row['consent_text'];
2406 2362 $data['consentField'] = $row['consent_field'] ?? '';
2407 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'] ?? '' ) );
2408 2366
2409 2367 // Category name
2410 2368 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2411 2369 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2418,28 +2376,31 @@
2418 2376 // configured, look up the value the user actually submitted.
2419 2377 // Truthy = explicit acknowledgment captured. Falsy = either
2420 2378 // gate wasn't enforced or this is a legacy record.
2421 2379 //
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].
2380 + // Where that value sits differs per integration, and this
2381 + // reader got the list wrong twice:
2430 2382 //
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 - );
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'] );
2442 2403
2443 2404 // Parse mail_optin
2444 2405 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2445 2406 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
@@ -2622,10 +2583,10 @@
2622 2583 // First pass: every registered addon gets an entry, even if
2623 2584 // it contributes no UI. That lets the client show per-addon
2624 2585 // licensing/boot state without a second round-trip.
2625 2586 foreach ( $registered as $id => $addon ) {
2626 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2627 - $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 );
2628 2589 unset( $fragments[ $id ] );
2629 2590 }
2630 2591
2631 2592 // Second pass: fragments for addons NOT in the registry
@@ -2863,11 +2824,18 @@
2863 2824 }
2864 2825
2865 2826 $activateUrl = null;
2866 2827 if ( $installed && ! $active ) {
2867 - $activateUrl = wp_nonce_url(
2868 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2869 - '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' )
2870 2838 );
2871 2839 }
2872 2840
2873 2841 $registeredAddon = $registered[ $id ] ?? null;