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 +288 -324 5.3.2 → 5.13.0 View file →
@@ -15,8 +15,11 @@
15 15 use Forge12\DoubleOptIn\FormSettings\FormSettingsDTO;
16 16 use Forge12\DoubleOptIn\FormSettings\FormSettingsService;
17 17 use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator;
18 18 use Forge12\DoubleOptIn\Integration\SubmittedContent;
19 +use Forge12\DoubleOptIn\Service\ConfirmationMailResender;
20 +use Forge12\DoubleOptIn\Service\ResendResult;
21 +use Forge12\DoubleOptIn\Subscription\SubscriptionGroups;
19 22 use Forge12\Shared\LoggerInterface;
20 23
21 24 if ( ! defined( 'ABSPATH' ) ) {
22 25 exit;
@@ -30,8 +33,14 @@
30 33 class AdminRestController {
31 34
32 35 const API_NAMESPACE = 'f12-doi/v1';
33 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 +
34 43 private LoggerInterface $logger;
35 44 private FormSettingsService $formService;
36 45 private FormSettingsValidator $formValidator;
37 46
@@ -105,8 +114,19 @@
105 114 'permission_callback' => array( $this, 'checkPermission' ),
106 115 )
107 116 );
108 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 +
109 129 // ── Opt-Ins ────────────────────────────────────────────────
110 130 register_rest_route(
111 131 self::API_NAMESPACE,
112 132 '/optins',
@@ -509,19 +529,8 @@
509 529 'permission_callback' => array( $this, 'checkPermission' ),
510 530 )
511 531 );
512 532
513 - // ── Database Export (Pro-extensible) ────────────────────────
514 - register_rest_route(
515 - self::API_NAMESPACE,
516 - '/database/export',
517 - array(
518 - 'methods' => \WP_REST_Server::CREATABLE,
519 - 'callback' => array( $this, 'exportDatabase' ),
520 - 'permission_callback' => array( $this, 'checkPermission' ),
521 - )
522 - );
523 -
524 533 // ── Addons manifest (UI mount-point system, plan §9) ────────
525 534 register_rest_route(
526 535 self::API_NAMESPACE,
527 536 '/addons',
@@ -626,9 +635,10 @@
626 635 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
627 636
628 637 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
629 638 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
630 - $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 );
631 641 $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0;
632 642
633 643 // Recent opt-ins (raw activity feed — not analytics).
634 644 // Time-bucketed activity, top-forms breakdown and the big
@@ -634,9 +644,9 @@
634 644 // Time-bucketed activity, top-forms breakdown and the big
635 645 // conversion-rate card moved into addon-analytics, which
636 646 // renders them at the `dashboard.widget` mount point.
637 647 $recent = $wpdb->get_results(
638 - "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",
639 649 ARRAY_A
640 650 );
641 651
642 652 foreach ( $recent as &$row ) {
@@ -642,14 +652,18 @@
642 652 foreach ( $recent as &$row ) {
643 653 $post = get_post( (int) $row['cf_form_id'] );
644 654 $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] );
645 655 $row['confirmed'] = (int) $row['doubleoptin'] === 1;
656 + $row['revoked'] = self::isRevokedRow( $row );
657 + unset( $row['ipaddr_optout'], $row['optouttime'] );
646 658 }
659 + unset( $row );
647 660
648 661 $data = array(
649 662 'totalOptins' => $total,
650 663 'confirmed' => $confirmed,
651 664 'pending' => $pending,
665 + 'revoked' => $revoked,
652 666 'conversionRate' => $rate,
653 667 'recentOptins' => $recent ?: array(),
654 668 );
655 669
@@ -701,8 +715,35 @@
701 715 // ═══════════════════════════════════════════════════════════════
702 716 // OPT-INS
703 717 // ═══════════════════════════════════════════════════════════════
704 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 +
705 746 public function getOptins( \WP_REST_Request $request ): \WP_REST_Response {
706 747 $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
707 748 $perPage = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ?: 20 ) );
708 749 $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' );
@@ -730,8 +771,11 @@
730 771 if ( $status === 'confirmed' ) {
731 772 $where[] = 'doubleoptin = 1';
732 773 } elseif ( $status === 'pending' ) {
733 774 $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)';
775 + $where[] = 'NOT ' . self::REVOKED_SQL;
776 + } elseif ( $status === 'revoked' ) {
777 + $where[] = self::REVOKED_SQL;
734 778 }
735 779
736 780 if ( $formId !== null && $formId !== '' ) {
737 781 $where[] = 'cf_form_id = %d';
@@ -737,8 +781,38 @@
737 781 $where[] = 'cf_form_id = %d';
738 782 $params[] = (int) $formId;
739 783 }
740 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 +
741 815 $whereClause = implode( ' AND ', $where );
742 816
743 817 // Count
744 818 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -791,8 +865,12 @@
791 865 }
792 866
793 867 $data = $this->formatOptinRow( $row, true );
794 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 +
795 873 // Dev-mode UI hint: surface whether the reset-confirmation
796 874 // endpoint is reachable for this request, so the React detail
797 875 // page can show/hide the "Reset to pending" button without
798 876 // having to probe the endpoint and handle a 403. Mirrors
@@ -833,9 +911,9 @@
833 911 // Full row (id, hash, content, files, cf_form_id) so the
834 912 // pre-delete cascade hook from pre-doi-data-retention Step 1
835 913 // can fire with a payload that lets listeners reach into
836 914 // integration storage. ARRAY_A — listener-friendly.
837 - $row = $wpdb->get_row(
915 + $row = $wpdb->get_row(
838 916 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
839 917 ARRAY_A
840 918 );
841 919 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -893,8 +971,32 @@
893 971 200
894 972 );
895 973 }
896 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 +
897 999 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
898 1000 global $wpdb;
899 1001 $id = (int) $request->get_param( 'id' );
900 1002 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -934,96 +1036,23 @@
934 1036 */
935 1037 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
936 1038
937 1039 if ( $result === null ) {
938 - // Default resend logic: use stored mail data.
939 - //
940 - // `mail_optin` is shipped by every integration via
941 - // {@see \forge12\contactform7\CF7DoubleOptIn\OptIn::set_mail_optin()}.
942 - // That method takes a STRING (the rendered HTML body) — the
943 - // admin opt-in-detail UI reads it as-is for the body
944 - // preview. Earlier versions of this handler expected a
945 - // serialized `['to' => ..., 'subject' => ..., 'body' => ...]`
946 - // array and bailed with "Email data is incomplete" whenever
947 - // the stored value was the (correct) plain body string —
948 - // which is the production case for every free-version
949 - // integration (CF7 / Avada / WPForms / Gravity / Elementor).
950 - // User-reported 2026-05-13: clicking Resend yielded that
951 - // error 100 % of the time.
952 - //
953 - // Both shapes are accepted now: the array form for Pro and
954 - // any future caller that stores structured payloads, the
955 - // plain string for the free-version integrations whose
956 - // contract is documented in
957 - // {@see \Forge12\DoubleOptIn\Wpforms\Tests\Unit\Integration\WPFormsSettingsApplyTest}.
958 - $mailOptin = $row['mail_optin'] ?? '';
959 - if ( empty( $mailOptin ) ) {
960 - return new \WP_REST_Response(
961 - array(
962 - 'success' => false,
963 - 'message' => __( 'No email data available for resend.', 'double-opt-in' ),
964 - ),
965 - 400
966 - );
967 - }
1040 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
1041 + ->get( ConfirmationMailResender::class )
1042 + ->resend( $id );
968 1043
969 - $unserialized = maybe_unserialize( $mailOptin );
970 -
971 - if ( is_array( $unserialized ) ) {
972 - // Structured payload (Pro / future writers).
973 - $to = $unserialized['to'] ?? '';
974 - $subject = $unserialized['subject'] ?? '';
975 - $body = $unserialized['body'] ?? '';
976 - $from = $unserialized['from'] ?? '';
977 - } else {
978 - // Plain body string — the production case. Reconstruct
979 - // `to` from the OptIn record's own `email` column and
980 - // `subject` from the form's central settings.
981 - $to = $row['email'] ?? '';
982 - $body = is_string( $unserialized ) ? $unserialized : (string) $mailOptin;
983 - $subject = '';
984 - $from = '';
985 -
986 - $formId = isset( $row['cf_form_id'] ) ? (int) $row['cf_form_id'] : 0;
987 - if ( $formId > 0 && class_exists( '\\forge12\\contactform7\\CF7DoubleOptIn\\CF7DoubleOptIn' ) ) {
988 - $formParam = \forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn::getInstance()->getParameter( $formId );
989 - $subject = (string) ( $formParam['subject'] ?? '' );
990 - $senderEmail = (string) ( $formParam['sender'] ?? '' );
991 - $senderName = (string) ( $formParam['sender_name'] ?? '' );
992 - if ( $senderEmail !== '' ) {
993 - $from = $senderName !== ''
994 - ? $senderName . ' <' . $senderEmail . '>'
995 - : $senderEmail;
996 - }
997 - }
1044 + if ( ! $outcome->isSent() ) {
1045 + return self::resendRefusal( $outcome->getReason() );
998 1046 }
999 -
1000 - if ( empty( $to ) || empty( $body ) ) {
1001 - return new \WP_REST_Response(
1002 - array(
1003 - 'success' => false,
1004 - 'message' => __( 'Email data is incomplete.', 'double-opt-in' ),
1005 - ),
1006 - 400
1007 - );
1008 - }
1009 -
1010 - $headers = array( 'Content-Type: text/html; charset=UTF-8' );
1011 - if ( ! empty( $from ) ) {
1012 - $headers[] = 'From: ' . $from;
1013 - }
1014 -
1015 - $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, '' );
1016 1051 }
1017 1052
1018 1053 if ( ! $result ) {
1019 - return new \WP_REST_Response(
1020 - array(
1021 - 'success' => false,
1022 - 'message' => __( 'Failed to send email.', 'double-opt-in' ),
1023 - ),
1024 - 500
1025 - );
1054 + return self::resendRefusal( ResendResult::SEND_FAILED );
1026 1055 }
1027 1056
1028 1057 AuditLogger::log(
1029 1058 AuditLogger::TYPE_EMAIL,
@@ -1920,22 +1949,41 @@
1920 1949 );
1921 1950 }
1922 1951
1923 1952 // ═══════════════════════════════════════════════════════════════
1924 - // PRO-EXTENSIBLE STUBS
1925 - // These return minimal responses; Pro overrides via filters or
1926 - // 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.
1927 1959 // ═══════════════════════════════════════════════════════════════
1928 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 +
1929 1983 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1930 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1931 - return new \WP_REST_Response(
1932 - array(
1933 - 'success' => false,
1934 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1935 - ),
1936 - 403
1937 - );
1984 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1985 + return $this->addonInactive( 'analytics', 'Analytics' );
1938 1986 }
1939 1987
1940 1988 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1941 1989
@@ -1948,16 +1996,10 @@
1948 1996 );
1949 1997 }
1950 1998
1951 1999 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1952 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1953 - return new \WP_REST_Response(
1954 - array(
1955 - 'success' => false,
1956 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1957 - ),
1958 - 403
1959 - );
2000 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
2001 + return $this->addonInactive( 'analytics', 'Analytics' );
1960 2002 }
1961 2003
1962 2004 $formId = (int) $request->get_param( 'form_id' );
1963 2005 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1971,16 +2013,10 @@
1971 2013 );
1972 2014 }
1973 2015
1974 2016 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1975 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1976 - return new \WP_REST_Response(
1977 - array(
1978 - 'success' => false,
1979 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1980 - ),
1981 - 403
1982 - );
2017 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
2018 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1983 2019 }
1984 2020
1985 2021 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1986 2022
@@ -1993,16 +2029,10 @@
1993 2029 );
1994 2030 }
1995 2031
1996 2032 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1997 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1998 - return new \WP_REST_Response(
1999 - array(
2000 - 'success' => false,
2001 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2002 - ),
2003 - 403
2004 - );
2033 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
2034 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2005 2035 }
2006 2036
2007 2037 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2008 2038
@@ -2017,140 +2047,32 @@
2017 2047
2018 2048 /**
2019 2049 * POST /f12-doi/v1/optout/page/generate
2020 2050 *
2021 - * One-click generator for the opt-out landing page. Eliminates the
2022 - * onboarding-friction loop where the user has to manually create a
2023 - * 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.
2024 2054 *
2025 - * Algorithm:
2026 - * 1. Idempotent fast-path — scan `published` pages for the list
2027 - * shortcode. If one already exists, return its ID untouched
2028 - * (no duplicate creation, no content overwrite).
2029 - * 2. Title-collision safety — if a page named "Opt-Out" exists
2030 - * but WITHOUT the list shortcode, refuse to auto-modify. The
2031 - * user might have intentionally repurposed that title; we'd
2032 - * rather show a 409 with a clear message than clobber.
2033 - * 3. Insert a fresh page with both shortcodes (form + list) so
2034 - * the page is functional end-to-end out of the box.
2035 - *
2036 - * Response shape (always 200 unless error):
2037 - * { page_id, page_title, edit_url, view_url, created: bool }
2038 - *
2039 2055 * @return \WP_REST_Response
2040 2056 */
2041 2057 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2042 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2043 - return new \WP_REST_Response(
2044 - array(
2045 - 'success' => false,
2046 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2047 - ),
2048 - 403
2049 - );
2058 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
2059 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2050 2060 }
2051 2061
2052 - if ( ! current_user_can( 'publish_pages' ) ) {
2053 - return new \WP_REST_Response(
2054 - array(
2055 - 'success' => false,
2056 - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2057 - ),
2058 - 403
2059 - );
2060 - }
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 );
2061 2071
2062 - $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2063 - $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2064 -
2065 - // 1. Idempotent fast-path — first page with the list shortcode wins.
2066 - $existing = get_posts(
2067 - array(
2068 - 'post_type' => 'page',
2069 - 'post_status' => 'publish',
2070 - 'posts_per_page' => 1,
2071 - 's' => $listShortcode,
2072 - 'fields' => 'ids',
2073 - 'no_found_rows' => true,
2074 - )
2075 - );
2076 - if ( ! empty( $existing ) ) {
2077 - $pageId = (int) $existing[0];
2078 - return new \WP_REST_Response(
2079 - array(
2080 - 'success' => true,
2081 - 'created' => false,
2082 - 'page_id' => $pageId,
2083 - 'page_title' => get_the_title( $pageId ),
2084 - 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2085 - 'view_url' => get_permalink( $pageId ),
2086 - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2087 - ),
2088 - 200
2089 - );
2090 - }
2091 -
2092 - // 2. Title collision — a page literally titled "Opt-Out" but
2093 - // without the shortcode is the user's own content. Refuse
2094 - // to silently modify it.
2095 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2096 - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2097 - // Plain null check, not instanceof: this replaces `?->ID`, which only
2098 - // short-circuits on null and does not care about the concrete class.
2099 - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2100 - if ( $collisionId > 0 ) {
2101 - return new \WP_REST_Response(
2102 - array(
2103 - 'success' => false,
2104 - 'code' => 'TITLE_COLLISION',
2105 - 'page_id' => $collisionId,
2106 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2107 - 'message' => sprintf(
2108 - /* translators: %s = page title */
2109 - __( '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' ),
2110 - $desiredTitle
2111 - ),
2112 - ),
2113 - 409
2114 - );
2115 - }
2116 -
2117 - // 3. Insert.
2118 - $pageId = wp_insert_post(
2119 - array(
2120 - 'post_type' => 'page',
2121 - 'post_status' => 'publish',
2122 - 'post_title' => $desiredTitle,
2123 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2124 - 'post_author' => get_current_user_id(),
2125 - 'comment_status' => 'closed',
2126 - 'ping_status' => 'closed',
2127 - ),
2128 - true
2129 - );
2130 -
2131 - if ( is_wp_error( $pageId ) ) {
2132 - return new \WP_REST_Response(
2133 - array(
2134 - 'success' => false,
2135 - 'message' => $pageId->get_error_message(),
2136 - ),
2137 - 500
2138 - );
2139 - }
2140 -
2141 - return new \WP_REST_Response(
2142 - array(
2143 - 'success' => true,
2144 - 'created' => true,
2145 - 'page_id' => (int) $pageId,
2146 - 'page_title' => $desiredTitle,
2147 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2148 - 'view_url' => get_permalink( (int) $pageId ),
2149 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2150 - ),
2151 - 200
2152 - );
2072 + return $response instanceof \WP_REST_Response
2073 + ? $response
2074 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2153 2075 }
2154 2076
2155 2077 /**
2156 2078 * License gate for the User Creation endpoints.
@@ -2170,15 +2092,9 @@
2170 2092 }
2171 2093
2172 2094 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2173 2095 if ( ! $this->userCreationAuthorized() ) {
2174 - return new \WP_REST_Response(
2175 - array(
2176 - 'success' => false,
2177 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2178 - ),
2179 - 403
2180 - );
2096 + return $this->addonInactive( 'user-registration', 'User Registration' );
2181 2097 }
2182 2098
2183 2099 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2184 2100
@@ -2192,15 +2108,9 @@
2192 2108 }
2193 2109
2194 2110 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2195 2111 if ( ! $this->userCreationAuthorized() ) {
2196 - return new \WP_REST_Response(
2197 - array(
2198 - 'success' => false,
2199 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2200 - ),
2201 - 403
2202 - );
2112 + return $this->addonInactive( 'user-registration', 'User Registration' );
2203 2113 }
2204 2114
2205 2115 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2206 2116
@@ -2213,16 +2123,10 @@
2213 2123 );
2214 2124 }
2215 2125
2216 2126 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2217 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2218 - return new \WP_REST_Response(
2219 - array(
2220 - 'success' => false,
2221 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2222 - ),
2223 - 403
2224 - );
2127 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2128 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2225 2129 }
2226 2130
2227 2131 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2228 2132
@@ -2235,16 +2139,10 @@
2235 2139 );
2236 2140 }
2237 2141
2238 2142 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2239 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2240 - return new \WP_REST_Response(
2241 - array(
2242 - 'success' => false,
2243 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2244 - ),
2245 - 403
2246 - );
2143 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2144 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2247 2145 }
2248 2146
2249 2147 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2250 2148
@@ -2338,44 +2236,25 @@
2338 2236
2339 2237 return new \WP_REST_Response( $result, $status );
2340 2238 }
2341 2239
2342 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2343 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2344 - return new \WP_REST_Response(
2345 - array(
2346 - 'success' => false,
2347 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2348 - ),
2349 - 403
2350 - );
2351 - }
2240 + // ═══════════════════════════════════════════════════════════════
2241 + // HELPERS
2242 + // ═══════════════════════════════════════════════════════════════
2352 2243
2353 - $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'] ?? '' );
2354 2251
2355 - /**
2356 - * Filter to let Pro handle database export.
2357 - *
2358 - * @param array $result Result.
2359 - * @param array $input Export parameters.
2360 - * @since 4.2.0
2361 - */
2362 - $result = apply_filters(
2363 - 'f12_doi_rest_database_export',
2364 - array(
2365 - 'success' => false,
2366 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2367 - ),
2368 - $input
2369 - );
2370 -
2371 - 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';
2372 2255 }
2373 2256
2374 - // ═══════════════════════════════════════════════════════════════
2375 - // HELPERS
2376 - // ═══════════════════════════════════════════════════════════════
2377 -
2378 2257 /**
2379 2258 * Format an opt-in database row for the API response.
2380 2259 *
2381 2260 * @param array $row The database row.
@@ -2382,8 +2261,74 @@
2382 2261 * @param bool $detailed Whether to include full detail (content, mail data).
2383 2262 *
2384 2263 * @return array Formatted data.
2385 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 +
2386 2331 private function formatOptinRow( array $row, bool $detailed = false ): array {
2387 2332 $post = get_post( (int) $row['cf_form_id'] );
2388 2333
2389 2334 $data = array(
@@ -2393,12 +2338,22 @@
2393 2338 'formId' => (int) $row['cf_form_id'],
2394 2339 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2395 2340 'category' => (int) $row['category'],
2396 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'] ?? '' ),
2397 2347 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2398 2348 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2399 2349 );
2400 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 +
2401 2356 if ( $detailed ) {
2402 2357 $data['ipRegister'] = $row['ipaddr_register'];
2403 2358 $data['ipConfirmation'] = $row['ipaddr_confirmation'];
2404 2359 $data['ipOptout'] = $row['ipaddr_optout'];
@@ -2405,8 +2360,10 @@
2405 2360 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2406 2361 $data['consentText'] = $row['consent_text'];
2407 2362 $data['consentField'] = $row['consent_field'] ?? '';
2408 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'] ?? '' ) );
2409 2366
2410 2367 // Category name
2411 2368 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2412 2369 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2626,10 +2583,10 @@
2626 2583 // First pass: every registered addon gets an entry, even if
2627 2584 // it contributes no UI. That lets the client show per-addon
2628 2585 // licensing/boot state without a second round-trip.
2629 2586 foreach ( $registered as $id => $addon ) {
2630 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2631 - $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 );
2632 2589 unset( $fragments[ $id ] );
2633 2590 }
2634 2591
2635 2592 // Second pass: fragments for addons NOT in the registry
@@ -2867,11 +2824,18 @@
2867 2824 }
2868 2825
2869 2826 $activateUrl = null;
2870 2827 if ( $installed && ! $active ) {
2871 - $activateUrl = wp_nonce_url(
2872 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2873 - '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' )
2874 2838 );
2875 2839 }
2876 2840
2877 2841 $registeredAddon = $registered[ $id ] ?? null;