PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.12.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.12.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 +278 -324 5.6.3 → 5.12.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,28 @@
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 +
741 805 // Confirmed opt-ins whose follow-up actions failed or have an
742 806 // unknown outcome — the admin's "needs attention" list.
743 807 if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
744 808 $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
@@ -801,8 +865,12 @@
801 865 }
802 866
803 867 $data = $this->formatOptinRow( $row, true );
804 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 +
805 873 // Dev-mode UI hint: surface whether the reset-confirmation
806 874 // endpoint is reachable for this request, so the React detail
807 875 // page can show/hide the "Reset to pending" button without
808 876 // having to probe the endpoint and handle a 403. Mirrors
@@ -843,9 +911,9 @@
843 911 // Full row (id, hash, content, files, cf_form_id) so the
844 912 // pre-delete cascade hook from pre-doi-data-retention Step 1
845 913 // can fire with a payload that lets listeners reach into
846 914 // integration storage. ARRAY_A — listener-friendly.
847 - $row = $wpdb->get_row(
915 + $row = $wpdb->get_row(
848 916 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
849 917 ARRAY_A
850 918 );
851 919 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -903,8 +971,32 @@
903 971 200
904 972 );
905 973 }
906 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 +
907 999 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
908 1000 global $wpdb;
909 1001 $id = (int) $request->get_param( 'id' );
910 1002 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -944,96 +1036,23 @@
944 1036 */
945 1037 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
946 1038
947 1039 if ( $result === null ) {
948 - // Default resend logic: use stored mail data.
949 - //
950 - // `mail_optin` is shipped by every integration via
951 - // {@see \forge12\contactform7\CF7DoubleOptIn\OptIn::set_mail_optin()}.
952 - // That method takes a STRING (the rendered HTML body) — the
953 - // admin opt-in-detail UI reads it as-is for the body
954 - // preview. Earlier versions of this handler expected a
955 - // serialized `['to' => ..., 'subject' => ..., 'body' => ...]`
956 - // array and bailed with "Email data is incomplete" whenever
957 - // the stored value was the (correct) plain body string —
958 - // which is the production case for every free-version
959 - // integration (CF7 / Avada / WPForms / Gravity / Elementor).
960 - // User-reported 2026-05-13: clicking Resend yielded that
961 - // error 100 % of the time.
962 - //
963 - // Both shapes are accepted now: the array form for Pro and
964 - // any future caller that stores structured payloads, the
965 - // plain string for the free-version integrations whose
966 - // contract is documented in
967 - // {@see \Forge12\DoubleOptIn\Wpforms\Tests\Unit\Integration\WPFormsSettingsApplyTest}.
968 - $mailOptin = $row['mail_optin'] ?? '';
969 - if ( empty( $mailOptin ) ) {
970 - return new \WP_REST_Response(
971 - array(
972 - 'success' => false,
973 - 'message' => __( 'No email data available for resend.', 'double-opt-in' ),
974 - ),
975 - 400
976 - );
977 - }
1040 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
1041 + ->get( ConfirmationMailResender::class )
1042 + ->resend( $id );
978 1043
979 - $unserialized = maybe_unserialize( $mailOptin );
980 -
981 - if ( is_array( $unserialized ) ) {
982 - // Structured payload (Pro / future writers).
983 - $to = $unserialized['to'] ?? '';
984 - $subject = $unserialized['subject'] ?? '';
985 - $body = $unserialized['body'] ?? '';
986 - $from = $unserialized['from'] ?? '';
987 - } else {
988 - // Plain body string — the production case. Reconstruct
989 - // `to` from the OptIn record's own `email` column and
990 - // `subject` from the form's central settings.
991 - $to = $row['email'] ?? '';
992 - $body = is_string( $unserialized ) ? $unserialized : (string) $mailOptin;
993 - $subject = '';
994 - $from = '';
995 -
996 - $formId = isset( $row['cf_form_id'] ) ? (int) $row['cf_form_id'] : 0;
997 - if ( $formId > 0 && class_exists( '\\forge12\\contactform7\\CF7DoubleOptIn\\CF7DoubleOptIn' ) ) {
998 - $formParam = \forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn::getInstance()->getParameter( $formId );
999 - $subject = (string) ( $formParam['subject'] ?? '' );
1000 - $senderEmail = (string) ( $formParam['sender'] ?? '' );
1001 - $senderName = (string) ( $formParam['sender_name'] ?? '' );
1002 - if ( $senderEmail !== '' ) {
1003 - $from = $senderName !== ''
1004 - ? $senderName . ' <' . $senderEmail . '>'
1005 - : $senderEmail;
1006 - }
1007 - }
1044 + if ( ! $outcome->isSent() ) {
1045 + return self::resendRefusal( $outcome->getReason() );
1008 1046 }
1009 -
1010 - if ( empty( $to ) || empty( $body ) ) {
1011 - return new \WP_REST_Response(
1012 - array(
1013 - 'success' => false,
1014 - 'message' => __( 'Email data is incomplete.', 'double-opt-in' ),
1015 - ),
1016 - 400
1017 - );
1018 - }
1019 -
1020 - $headers = array( 'Content-Type: text/html; charset=UTF-8' );
1021 - if ( ! empty( $from ) ) {
1022 - $headers[] = 'From: ' . $from;
1023 - }
1024 -
1025 - $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, '' );
1026 1051 }
1027 1052
1028 1053 if ( ! $result ) {
1029 - return new \WP_REST_Response(
1030 - array(
1031 - 'success' => false,
1032 - 'message' => __( 'Failed to send email.', 'double-opt-in' ),
1033 - ),
1034 - 500
1035 - );
1054 + return self::resendRefusal( ResendResult::SEND_FAILED );
1036 1055 }
1037 1056
1038 1057 AuditLogger::log(
1039 1058 AuditLogger::TYPE_EMAIL,
@@ -1930,22 +1949,41 @@
1930 1949 );
1931 1950 }
1932 1951
1933 1952 // ═══════════════════════════════════════════════════════════════
1934 - // PRO-EXTENSIBLE STUBS
1935 - // These return minimal responses; Pro overrides via filters or
1936 - // 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.
1937 1959 // ═══════════════════════════════════════════════════════════════
1938 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 +
1939 1983 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1940 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1941 - return new \WP_REST_Response(
1942 - array(
1943 - 'success' => false,
1944 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1945 - ),
1946 - 403
1947 - );
1984 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1985 + return $this->addonInactive( 'analytics', 'Analytics' );
1948 1986 }
1949 1987
1950 1988 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1951 1989
@@ -1958,16 +1996,10 @@
1958 1996 );
1959 1997 }
1960 1998
1961 1999 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1962 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1963 - return new \WP_REST_Response(
1964 - array(
1965 - 'success' => false,
1966 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1967 - ),
1968 - 403
1969 - );
2000 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
2001 + return $this->addonInactive( 'analytics', 'Analytics' );
1970 2002 }
1971 2003
1972 2004 $formId = (int) $request->get_param( 'form_id' );
1973 2005 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1981,16 +2013,10 @@
1981 2013 );
1982 2014 }
1983 2015
1984 2016 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1985 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1986 - return new \WP_REST_Response(
1987 - array(
1988 - 'success' => false,
1989 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1990 - ),
1991 - 403
1992 - );
2017 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
2018 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1993 2019 }
1994 2020
1995 2021 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1996 2022
@@ -2003,16 +2029,10 @@
2003 2029 );
2004 2030 }
2005 2031
2006 2032 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
2007 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2008 - return new \WP_REST_Response(
2009 - array(
2010 - 'success' => false,
2011 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2012 - ),
2013 - 403
2014 - );
2033 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
2034 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2015 2035 }
2016 2036
2017 2037 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2018 2038
@@ -2027,140 +2047,32 @@
2027 2047
2028 2048 /**
2029 2049 * POST /f12-doi/v1/optout/page/generate
2030 2050 *
2031 - * One-click generator for the opt-out landing page. Eliminates the
2032 - * onboarding-friction loop where the user has to manually create a
2033 - * 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.
2034 2054 *
2035 - * Algorithm:
2036 - * 1. Idempotent fast-path — scan `published` pages for the list
2037 - * shortcode. If one already exists, return its ID untouched
2038 - * (no duplicate creation, no content overwrite).
2039 - * 2. Title-collision safety — if a page named "Opt-Out" exists
2040 - * but WITHOUT the list shortcode, refuse to auto-modify. The
2041 - * user might have intentionally repurposed that title; we'd
2042 - * rather show a 409 with a clear message than clobber.
2043 - * 3. Insert a fresh page with both shortcodes (form + list) so
2044 - * the page is functional end-to-end out of the box.
2045 - *
2046 - * Response shape (always 200 unless error):
2047 - * { page_id, page_title, edit_url, view_url, created: bool }
2048 - *
2049 2055 * @return \WP_REST_Response
2050 2056 */
2051 2057 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2052 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2053 - return new \WP_REST_Response(
2054 - array(
2055 - 'success' => false,
2056 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2057 - ),
2058 - 403
2059 - );
2058 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
2059 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2060 2060 }
2061 2061
2062 - if ( ! current_user_can( 'publish_pages' ) ) {
2063 - return new \WP_REST_Response(
2064 - array(
2065 - 'success' => false,
2066 - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2067 - ),
2068 - 403
2069 - );
2070 - }
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 );
2071 2071
2072 - $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2073 - $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2074 -
2075 - // 1. Idempotent fast-path — first page with the list shortcode wins.
2076 - $existing = get_posts(
2077 - array(
2078 - 'post_type' => 'page',
2079 - 'post_status' => 'publish',
2080 - 'posts_per_page' => 1,
2081 - 's' => $listShortcode,
2082 - 'fields' => 'ids',
2083 - 'no_found_rows' => true,
2084 - )
2085 - );
2086 - if ( ! empty( $existing ) ) {
2087 - $pageId = (int) $existing[0];
2088 - return new \WP_REST_Response(
2089 - array(
2090 - 'success' => true,
2091 - 'created' => false,
2092 - 'page_id' => $pageId,
2093 - 'page_title' => get_the_title( $pageId ),
2094 - 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2095 - 'view_url' => get_permalink( $pageId ),
2096 - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2097 - ),
2098 - 200
2099 - );
2100 - }
2101 -
2102 - // 2. Title collision — a page literally titled "Opt-Out" but
2103 - // without the shortcode is the user's own content. Refuse
2104 - // to silently modify it.
2105 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2106 - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2107 - // Plain null check, not instanceof: this replaces `?->ID`, which only
2108 - // short-circuits on null and does not care about the concrete class.
2109 - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2110 - if ( $collisionId > 0 ) {
2111 - return new \WP_REST_Response(
2112 - array(
2113 - 'success' => false,
2114 - 'code' => 'TITLE_COLLISION',
2115 - 'page_id' => $collisionId,
2116 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2117 - 'message' => sprintf(
2118 - /* translators: %s = page title */
2119 - __( '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' ),
2120 - $desiredTitle
2121 - ),
2122 - ),
2123 - 409
2124 - );
2125 - }
2126 -
2127 - // 3. Insert.
2128 - $pageId = wp_insert_post(
2129 - array(
2130 - 'post_type' => 'page',
2131 - 'post_status' => 'publish',
2132 - 'post_title' => $desiredTitle,
2133 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2134 - 'post_author' => get_current_user_id(),
2135 - 'comment_status' => 'closed',
2136 - 'ping_status' => 'closed',
2137 - ),
2138 - true
2139 - );
2140 -
2141 - if ( is_wp_error( $pageId ) ) {
2142 - return new \WP_REST_Response(
2143 - array(
2144 - 'success' => false,
2145 - 'message' => $pageId->get_error_message(),
2146 - ),
2147 - 500
2148 - );
2149 - }
2150 -
2151 - return new \WP_REST_Response(
2152 - array(
2153 - 'success' => true,
2154 - 'created' => true,
2155 - 'page_id' => (int) $pageId,
2156 - 'page_title' => $desiredTitle,
2157 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2158 - 'view_url' => get_permalink( (int) $pageId ),
2159 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2160 - ),
2161 - 200
2162 - );
2072 + return $response instanceof \WP_REST_Response
2073 + ? $response
2074 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2163 2075 }
2164 2076
2165 2077 /**
2166 2078 * License gate for the User Creation endpoints.
@@ -2180,15 +2092,9 @@
2180 2092 }
2181 2093
2182 2094 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2183 2095 if ( ! $this->userCreationAuthorized() ) {
2184 - return new \WP_REST_Response(
2185 - array(
2186 - 'success' => false,
2187 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2188 - ),
2189 - 403
2190 - );
2096 + return $this->addonInactive( 'user-registration', 'User Registration' );
2191 2097 }
2192 2098
2193 2099 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2194 2100
@@ -2202,15 +2108,9 @@
2202 2108 }
2203 2109
2204 2110 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2205 2111 if ( ! $this->userCreationAuthorized() ) {
2206 - return new \WP_REST_Response(
2207 - array(
2208 - 'success' => false,
2209 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2210 - ),
2211 - 403
2212 - );
2112 + return $this->addonInactive( 'user-registration', 'User Registration' );
2213 2113 }
2214 2114
2215 2115 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2216 2116
@@ -2223,16 +2123,10 @@
2223 2123 );
2224 2124 }
2225 2125
2226 2126 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2227 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2228 - return new \WP_REST_Response(
2229 - array(
2230 - 'success' => false,
2231 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2232 - ),
2233 - 403
2234 - );
2127 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2128 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2235 2129 }
2236 2130
2237 2131 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2238 2132
@@ -2245,16 +2139,10 @@
2245 2139 );
2246 2140 }
2247 2141
2248 2142 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2249 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2250 - return new \WP_REST_Response(
2251 - array(
2252 - 'success' => false,
2253 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2254 - ),
2255 - 403
2256 - );
2143 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2144 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2257 2145 }
2258 2146
2259 2147 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2260 2148
@@ -2348,44 +2236,25 @@
2348 2236
2349 2237 return new \WP_REST_Response( $result, $status );
2350 2238 }
2351 2239
2352 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2353 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2354 - return new \WP_REST_Response(
2355 - array(
2356 - 'success' => false,
2357 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2358 - ),
2359 - 403
2360 - );
2361 - }
2240 + // ═══════════════════════════════════════════════════════════════
2241 + // HELPERS
2242 + // ═══════════════════════════════════════════════════════════════
2362 2243
2363 - $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'] ?? '' );
2364 2251
2365 - /**
2366 - * Filter to let Pro handle database export.
2367 - *
2368 - * @param array $result Result.
2369 - * @param array $input Export parameters.
2370 - * @since 4.2.0
2371 - */
2372 - $result = apply_filters(
2373 - 'f12_doi_rest_database_export',
2374 - array(
2375 - 'success' => false,
2376 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2377 - ),
2378 - $input
2379 - );
2380 -
2381 - 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';
2382 2255 }
2383 2256
2384 - // ═══════════════════════════════════════════════════════════════
2385 - // HELPERS
2386 - // ═══════════════════════════════════════════════════════════════
2387 -
2388 2257 /**
2389 2258 * Format an opt-in database row for the API response.
2390 2259 *
2391 2260 * @param array $row The database row.
@@ -2392,8 +2261,74 @@
2392 2261 * @param bool $detailed Whether to include full detail (content, mail data).
2393 2262 *
2394 2263 * @return array Formatted data.
2395 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 +
2396 2331 private function formatOptinRow( array $row, bool $detailed = false ): array {
2397 2332 $post = get_post( (int) $row['cf_form_id'] );
2398 2333
2399 2334 $data = array(
@@ -2403,12 +2338,22 @@
2403 2338 'formId' => (int) $row['cf_form_id'],
2404 2339 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2405 2340 'category' => (int) $row['category'],
2406 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'] ?? '' ),
2407 2347 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2408 2348 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2409 2349 );
2410 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 +
2411 2356 if ( $detailed ) {
2412 2357 $data['ipRegister'] = $row['ipaddr_register'];
2413 2358 $data['ipConfirmation'] = $row['ipaddr_confirmation'];
2414 2359 $data['ipOptout'] = $row['ipaddr_optout'];
@@ -2415,8 +2360,10 @@
2415 2360 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2416 2361 $data['consentText'] = $row['consent_text'];
2417 2362 $data['consentField'] = $row['consent_field'] ?? '';
2418 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'] ?? '' ) );
2419 2366
2420 2367 // Category name
2421 2368 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2422 2369 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2636,10 +2583,10 @@
2636 2583 // First pass: every registered addon gets an entry, even if
2637 2584 // it contributes no UI. That lets the client show per-addon
2638 2585 // licensing/boot state without a second round-trip.
2639 2586 foreach ( $registered as $id => $addon ) {
2640 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2641 - $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 );
2642 2589 unset( $fragments[ $id ] );
2643 2590 }
2644 2591
2645 2592 // Second pass: fragments for addons NOT in the registry
@@ -2877,11 +2824,18 @@
2877 2824 }
2878 2825
2879 2826 $activateUrl = null;
2880 2827 if ( $installed && ! $active ) {
2881 - $activateUrl = wp_nonce_url(
2882 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2883 - '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' )
2884 2838 );
2885 2839 }
2886 2840
2887 2841 $registeredAddon = $registered[ $id ] ?? null;