PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.9.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.9.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 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 All 42 releases
← All changes | src/Admin/AdminRestController.php +193 -343 5.1.6 → 5.9.0 View file →
@@ -14,8 +14,11 @@
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;
18 21 use Forge12\Shared\LoggerInterface;
19 22
20 23 if ( ! defined( 'ABSPATH' ) ) {
21 24 exit;
@@ -29,8 +32,14 @@
29 32 class AdminRestController {
30 33
31 34 const API_NAMESPACE = 'f12-doi/v1';
32 35
36 + /**
37 + * SQL form of OptIn::isOptedOut(): not confirmed, withdrawal IP and time
38 + * recorded. A re-opt-in clears the time, so the row counts as confirmed again.
39 + */
40 + 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'))";
41 +
33 42 private LoggerInterface $logger;
34 43 private FormSettingsService $formService;
35 44 private FormSettingsValidator $formValidator;
36 45
@@ -508,19 +517,8 @@
508 517 'permission_callback' => array( $this, 'checkPermission' ),
509 518 )
510 519 );
511 520
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 521 // ── Addons manifest (UI mount-point system, plan §9) ────────
524 522 register_rest_route(
525 523 self::API_NAMESPACE,
526 524 '/addons',
@@ -625,9 +623,10 @@
625 623 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
626 624
627 625 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
628 626 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
629 - $pending = $total - $confirmed;
627 + $revoked = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE " . self::REVOKED_SQL ); // phpcs:ignore WordPress.DB.PreparedSQL -- fixed SQL, no input.
628 + $pending = max( 0, $total - $confirmed - $revoked );
630 629 $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0;
631 630
632 631 // Recent opt-ins (raw activity feed — not analytics).
633 632 // Time-bucketed activity, top-forms breakdown and the big
@@ -633,9 +632,9 @@
633 632 // Time-bucketed activity, top-forms breakdown and the big
634 633 // conversion-rate card moved into addon-analytics, which
635 634 // renders them at the `dashboard.widget` mount point.
636 635 $recent = $wpdb->get_results(
637 - "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5",
636 + "SELECT id, email, cf_form_id, doubleoptin, createtime, ipaddr_optout, optouttime FROM {$table} ORDER BY id DESC LIMIT 5",
638 637 ARRAY_A
639 638 );
640 639
641 640 foreach ( $recent as &$row ) {
@@ -641,14 +640,18 @@
641 640 foreach ( $recent as &$row ) {
642 641 $post = get_post( (int) $row['cf_form_id'] );
643 642 $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] );
644 643 $row['confirmed'] = (int) $row['doubleoptin'] === 1;
644 + $row['revoked'] = self::isRevokedRow( $row );
645 + unset( $row['ipaddr_optout'], $row['optouttime'] );
645 646 }
647 + unset( $row );
646 648
647 649 $data = array(
648 650 'totalOptins' => $total,
649 651 'confirmed' => $confirmed,
650 652 'pending' => $pending,
653 + 'revoked' => $revoked,
651 654 'conversionRate' => $rate,
652 655 'recentOptins' => $recent ?: array(),
653 656 );
654 657
@@ -729,8 +732,11 @@
729 732 if ( $status === 'confirmed' ) {
730 733 $where[] = 'doubleoptin = 1';
731 734 } elseif ( $status === 'pending' ) {
732 735 $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)';
736 + $where[] = 'NOT ' . self::REVOKED_SQL;
737 + } elseif ( $status === 'revoked' ) {
738 + $where[] = self::REVOKED_SQL;
733 739 }
734 740
735 741 if ( $formId !== null && $formId !== '' ) {
736 742 $where[] = 'cf_form_id = %d';
@@ -736,8 +742,24 @@
736 742 $where[] = 'cf_form_id = %d';
737 743 $params[] = (int) $formId;
738 744 }
739 745
746 + // Opt-ins whose confirmation mail could not be sent (5.8.0).
747 + if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) {
748 + $where[] = 'mail_status = %s';
749 + $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED;
750 + }
751 +
752 + // Confirmed opt-ins whose follow-up actions failed or have an
753 + // unknown outcome — the admin's "needs attention" list.
754 + if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
755 + $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
756 + $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic();
757 + $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN ("
758 + . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))';
759 + $params = array_merge( $params, $problems );
760 + }
761 +
740 762 $whereClause = implode( ' AND ', $where );
741 763
742 764 // Count
743 765 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -832,9 +854,9 @@
832 854 // Full row (id, hash, content, files, cf_form_id) so the
833 855 // pre-delete cascade hook from pre-doi-data-retention Step 1
834 856 // can fire with a payload that lets listeners reach into
835 857 // integration storage. ARRAY_A — listener-friendly.
836 - $row = $wpdb->get_row(
858 + $row = $wpdb->get_row(
837 859 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
838 860 ARRAY_A
839 861 );
840 862 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -892,8 +914,32 @@
892 914 200
893 915 );
894 916 }
895 917
918 + /**
919 + * The admin's answer for a resend that did not go out.
920 + */
921 + private static function resendRefusal( string $reason ): \WP_REST_Response {
922 + $map = array(
923 + ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ),
924 + ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ),
925 + ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ),
926 + ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ),
927 + ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ),
928 + );
929 + $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 );
930 + $message = $entry[0];
931 + $status = $entry[1];
932 +
933 + return new \WP_REST_Response(
934 + array(
935 + 'success' => false,
936 + 'message' => $message,
937 + ),
938 + $status
939 + );
940 + }
941 +
896 942 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
897 943 global $wpdb;
898 944 $id = (int) $request->get_param( 'id' );
899 945 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -933,96 +979,23 @@
933 979 */
934 980 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
935 981
936 982 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 - }
983 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
984 + ->get( ConfirmationMailResender::class )
985 + ->resend( $id );
967 986
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 - }
987 + if ( ! $outcome->isSent() ) {
988 + return self::resendRefusal( $outcome->getReason() );
997 989 }
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 );
990 + $result = true;
991 + } else {
992 + // An extension sent it; record the outcome all the same.
993 + do_action( 'f12_doi_optin_mail_result', $id, (bool) $result, '' );
1015 994 }
1016 995
1017 996 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 - );
997 + return self::resendRefusal( ResendResult::SEND_FAILED );
1025 998 }
1026 999
1027 1000 AuditLogger::log(
1028 1001 AuditLogger::TYPE_EMAIL,
@@ -1439,8 +1412,13 @@
1439 1412
1440 1413 public function getSettings( \WP_REST_Request $request ): \WP_REST_Response {
1441 1414 $defaults = array(
1442 1415 'telemetry' => 1,
1416 + // Optional "Double Opt-In by Forge12" credit on the confirmation
1417 + // page. Defaults to 0 and must stay that way: wordpress.org
1418 + // guideline 10 requires credit links to be off unless the site
1419 + // owner explicitly turns them on.
1420 + 'credit_link' => 0,
1443 1421 'delete' => 12,
1444 1422 'delete_unconfirmed' => 7,
1445 1423 'delete_period' => 'months',
1446 1424 'delete_unconfirmed_period' => 'months',
@@ -1515,8 +1493,13 @@
1515 1493 'type' => 'int',
1516 1494 'min' => 0,
1517 1495 'max' => 1,
1518 1496 ),
1497 + 'credit_link' => array(
1498 + 'type' => 'int',
1499 + 'min' => 0,
1500 + 'max' => 1,
1501 + ),
1519 1502 'privacy_policy_page' => array(
1520 1503 'type' => 'int',
1521 1504 'min' => 0,
1522 1505 ),
@@ -1909,22 +1892,41 @@
1909 1892 );
1910 1893 }
1911 1894
1912 1895 // ═══════════════════════════════════════════════════════════════
1913 - // PRO-EXTENSIBLE STUBS
1914 - // These return minimal responses; Pro overrides via filters or
1915 - // registers its own REST routes that take precedence.
1896 + // ADD-ON ROUTES
1897 + // Core owns the route; the data comes from the add-on through a
1898 + // filter. Without a handler the answer is ADDON_INACTIVE. Core
1899 + // itself never checks a licence here (wordpress.org guideline 5):
1900 + // the functionality lives in the add-on, which only hooks in when
1901 + // it runs licensed.
1916 1902 // ═══════════════════════════════════════════════════════════════
1917 1903
1904 + /**
1905 + * Answer for a route whose add-on is not running.
1906 + *
1907 + * 404 with `code` so the SPA can tell it from an unknown route
1908 + * (`rest_no_route`); `ApiError` reads `body.code`.
1909 + */
1910 + private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response {
1911 + return new \WP_REST_Response(
1912 + array(
1913 + 'success' => false,
1914 + 'code' => 'ADDON_INACTIVE',
1915 + 'addon' => $addonId,
1916 + 'message' => sprintf(
1917 + /* translators: %s: add-on name */
1918 + __( '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' ),
1919 + $addonName
1920 + ),
1921 + ),
1922 + 404
1923 + );
1924 + }
1925 +
1918 1926 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1919 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1920 - return new \WP_REST_Response(
1921 - array(
1922 - 'success' => false,
1923 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1924 - ),
1925 - 403
1926 - );
1927 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1928 + return $this->addonInactive( 'analytics', 'Analytics' );
1927 1929 }
1928 1930
1929 1931 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1930 1932
@@ -1937,16 +1939,10 @@
1937 1939 );
1938 1940 }
1939 1941
1940 1942 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1941 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1942 - return new \WP_REST_Response(
1943 - array(
1944 - 'success' => false,
1945 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1946 - ),
1947 - 403
1948 - );
1943 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1944 + return $this->addonInactive( 'analytics', 'Analytics' );
1949 1945 }
1950 1946
1951 1947 $formId = (int) $request->get_param( 'form_id' );
1952 1948 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1960,16 +1956,10 @@
1960 1956 );
1961 1957 }
1962 1958
1963 1959 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1964 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1965 - return new \WP_REST_Response(
1966 - array(
1967 - 'success' => false,
1968 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1969 - ),
1970 - 403
1971 - );
1960 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1961 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1972 1962 }
1973 1963
1974 1964 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1975 1965
@@ -1982,16 +1972,10 @@
1982 1972 );
1983 1973 }
1984 1974
1985 1975 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1986 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1987 - return new \WP_REST_Response(
1988 - array(
1989 - 'success' => false,
1990 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1991 - ),
1992 - 403
1993 - );
1976 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1977 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1994 1978 }
1995 1979
1996 1980 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
1997 1981
@@ -2006,140 +1990,32 @@
2006 1990
2007 1991 /**
2008 1992 * POST /f12-doi/v1/optout/page/generate
2009 1993 *
2010 - * One-click generator for the opt-out landing page. Eliminates the
2011 - * onboarding-friction loop where the user has to manually create a
2012 - * page and paste the shortcodes before opt-out works at all.
1994 + * One-click generator for the opt-out landing page. The logic lives in
1995 + * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through
1996 + * the filter below; Core only owns the route.
2013 1997 *
2014 - * Algorithm:
2015 - * 1. Idempotent fast-path — scan `published` pages for the list
2016 - * shortcode. If one already exists, return its ID untouched
2017 - * (no duplicate creation, no content overwrite).
2018 - * 2. Title-collision safety — if a page named "Opt-Out" exists
2019 - * but WITHOUT the list shortcode, refuse to auto-modify. The
2020 - * user might have intentionally repurposed that title; we'd
2021 - * rather show a 409 with a clear message than clobber.
2022 - * 3. Insert a fresh page with both shortcodes (form + list) so
2023 - * the page is functional end-to-end out of the box.
2024 - *
2025 - * Response shape (always 200 unless error):
2026 - * { page_id, page_title, edit_url, view_url, created: bool }
2027 - *
2028 1998 * @return \WP_REST_Response
2029 1999 */
2030 2000 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2031 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2032 - return new \WP_REST_Response(
2033 - array(
2034 - 'success' => false,
2035 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2036 - ),
2037 - 403
2038 - );
2001 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
2002 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2039 2003 }
2040 2004
2041 - if ( ! current_user_can( 'publish_pages' ) ) {
2042 - return new \WP_REST_Response(
2043 - array(
2044 - 'success' => false,
2045 - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2046 - ),
2047 - 403
2048 - );
2049 - }
2005 + /**
2006 + * Filter: answer the opt-out page generator request.
2007 + *
2008 + * @param \WP_REST_Response|null $response Null until a handler answers.
2009 + * @param \WP_REST_Request $request The request.
2010 + *
2011 + * @since 5.8.0
2012 + */
2013 + $response = apply_filters( 'f12_doi_rest_optout_generate_page', null, $request );
2050 2014
2051 - $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2052 - $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2053 -
2054 - // 1. Idempotent fast-path — first page with the list shortcode wins.
2055 - $existing = get_posts(
2056 - array(
2057 - 'post_type' => 'page',
2058 - 'post_status' => 'publish',
2059 - 'posts_per_page' => 1,
2060 - 's' => $listShortcode,
2061 - 'fields' => 'ids',
2062 - 'no_found_rows' => true,
2063 - )
2064 - );
2065 - if ( ! empty( $existing ) ) {
2066 - $pageId = (int) $existing[0];
2067 - return new \WP_REST_Response(
2068 - array(
2069 - 'success' => true,
2070 - 'created' => false,
2071 - 'page_id' => $pageId,
2072 - 'page_title' => get_the_title( $pageId ),
2073 - 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2074 - 'view_url' => get_permalink( $pageId ),
2075 - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2076 - ),
2077 - 200
2078 - );
2079 - }
2080 -
2081 - // 2. Title collision — a page literally titled "Opt-Out" but
2082 - // without the shortcode is the user's own content. Refuse
2083 - // to silently modify it.
2084 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2085 - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2086 - // Plain null check, not instanceof: this replaces `?->ID`, which only
2087 - // short-circuits on null and does not care about the concrete class.
2088 - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2089 - if ( $collisionId > 0 ) {
2090 - return new \WP_REST_Response(
2091 - array(
2092 - 'success' => false,
2093 - 'code' => 'TITLE_COLLISION',
2094 - 'page_id' => $collisionId,
2095 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2096 - 'message' => sprintf(
2097 - /* translators: %s = page title */
2098 - __( 'A page titled "%s" already exists but doesn\'t contain the opt-out shortcode. Add the shortcode manually, or rename the page, then try again.', 'double-opt-in' ),
2099 - $desiredTitle
2100 - ),
2101 - ),
2102 - 409
2103 - );
2104 - }
2105 -
2106 - // 3. Insert.
2107 - $pageId = wp_insert_post(
2108 - array(
2109 - 'post_type' => 'page',
2110 - 'post_status' => 'publish',
2111 - 'post_title' => $desiredTitle,
2112 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2113 - 'post_author' => get_current_user_id(),
2114 - 'comment_status' => 'closed',
2115 - 'ping_status' => 'closed',
2116 - ),
2117 - true
2118 - );
2119 -
2120 - if ( is_wp_error( $pageId ) ) {
2121 - return new \WP_REST_Response(
2122 - array(
2123 - 'success' => false,
2124 - 'message' => $pageId->get_error_message(),
2125 - ),
2126 - 500
2127 - );
2128 - }
2129 -
2130 - return new \WP_REST_Response(
2131 - array(
2132 - 'success' => true,
2133 - 'created' => true,
2134 - 'page_id' => (int) $pageId,
2135 - 'page_title' => $desiredTitle,
2136 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2137 - 'view_url' => get_permalink( (int) $pageId ),
2138 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2139 - ),
2140 - 200
2141 - );
2015 + return $response instanceof \WP_REST_Response
2016 + ? $response
2017 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2142 2018 }
2143 2019
2144 2020 /**
2145 2021 * License gate for the User Creation endpoints.
@@ -2159,15 +2035,9 @@
2159 2035 }
2160 2036
2161 2037 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2162 2038 if ( ! $this->userCreationAuthorized() ) {
2163 - return new \WP_REST_Response(
2164 - array(
2165 - 'success' => false,
2166 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2167 - ),
2168 - 403
2169 - );
2039 + return $this->addonInactive( 'user-registration', 'User Registration' );
2170 2040 }
2171 2041
2172 2042 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2173 2043
@@ -2181,15 +2051,9 @@
2181 2051 }
2182 2052
2183 2053 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2184 2054 if ( ! $this->userCreationAuthorized() ) {
2185 - return new \WP_REST_Response(
2186 - array(
2187 - 'success' => false,
2188 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2189 - ),
2190 - 403
2191 - );
2055 + return $this->addonInactive( 'user-registration', 'User Registration' );
2192 2056 }
2193 2057
2194 2058 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2195 2059
@@ -2202,16 +2066,10 @@
2202 2066 );
2203 2067 }
2204 2068
2205 2069 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2206 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2207 - return new \WP_REST_Response(
2208 - array(
2209 - 'success' => false,
2210 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2211 - ),
2212 - 403
2213 - );
2070 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2071 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2214 2072 }
2215 2073
2216 2074 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2217 2075
@@ -2224,16 +2082,10 @@
2224 2082 );
2225 2083 }
2226 2084
2227 2085 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2228 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2229 - return new \WP_REST_Response(
2230 - array(
2231 - 'success' => false,
2232 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2233 - ),
2234 - 403
2235 - );
2086 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2087 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2236 2088 }
2237 2089
2238 2090 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2239 2091
@@ -2327,44 +2179,25 @@
2327 2179
2328 2180 return new \WP_REST_Response( $result, $status );
2329 2181 }
2330 2182
2331 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2332 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2333 - return new \WP_REST_Response(
2334 - array(
2335 - 'success' => false,
2336 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2337 - ),
2338 - 403
2339 - );
2340 - }
2183 + // ═══════════════════════════════════════════════════════════════
2184 + // HELPERS
2185 + // ═══════════════════════════════════════════════════════════════
2341 2186
2342 - $input = $request->get_json_params();
2187 + /**
2188 + * PHP form of REVOKED_SQL for a raw table row.
2189 + *
2190 + * @param array<string, mixed> $row The database row.
2191 + */
2192 + private static function isRevokedRow( array $row ): bool {
2193 + $optOutTime = (string) ( $row['optouttime'] ?? '' );
2343 2194
2344 - /**
2345 - * Filter to let Pro handle database export.
2346 - *
2347 - * @param array $result Result.
2348 - * @param array $input Export parameters.
2349 - * @since 4.2.0
2350 - */
2351 - $result = apply_filters(
2352 - 'f12_doi_rest_database_export',
2353 - array(
2354 - 'success' => false,
2355 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2356 - ),
2357 - $input
2358 - );
2359 -
2360 - return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 );
2195 + return (int) ( $row['doubleoptin'] ?? 0 ) !== 1
2196 + && (string) ( $row['ipaddr_optout'] ?? '' ) !== ''
2197 + && $optOutTime !== '' && $optOutTime !== '0';
2361 2198 }
2362 2199
2363 - // ═══════════════════════════════════════════════════════════════
2364 - // HELPERS
2365 - // ═══════════════════════════════════════════════════════════════
2366 -
2367 2200 /**
2368 2201 * Format an opt-in database row for the API response.
2369 2202 *
2370 2203 * @param array $row The database row.
@@ -2382,8 +2215,13 @@
2382 2215 'formId' => (int) $row['cf_form_id'],
2383 2216 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2384 2217 'category' => (int) $row['category'],
2385 2218 'confirmed' => (int) $row['doubleoptin'] === 1,
2219 + // Consent withdrawn via the opt-out (5.9.0). Not "pending".
2220 + 'revoked' => self::isRevokedRow( $row ),
2221 + // Confirmation mail: 'sent' (handed to the mail server), 'failed',
2222 + // or '' (recorded before 5.8.0). Since 5.8.0.
2223 + 'mailStatus' => (string) ( $row['mail_status'] ?? '' ),
2386 2224 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2387 2225 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2388 2226 );
2389 2227
@@ -2394,8 +2232,10 @@
2394 2232 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2395 2233 $data['consentText'] = $row['consent_text'];
2396 2234 $data['consentField'] = $row['consent_field'] ?? '';
2397 2235 $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] );
2236 + $data['mailError'] = (string) ( $row['mail_error'] ?? '' );
2237 + $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) );
2398 2238
2399 2239 // Category name
2400 2240 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2401 2241 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2408,28 +2248,31 @@
2408 2248 // configured, look up the value the user actually submitted.
2409 2249 // Truthy = explicit acknowledgment captured. Falsy = either
2410 2250 // gate wasn't enforced or this is a legacy record.
2411 2251 //
2412 - // Storage shape varies per integration:
2413 - // - CF7 / WPForms / GF (default path) store fields flat
2414 - // at the top level: $content[fieldName] = value.
2415 - // - Avada wraps fields under a `data` sub-key alongside
2416 - // metadata (field_labels, field_types, form_parameter)
2417 - // — its OnSubmit overrides the flat content set by
2418 - // createOptIn(). For Avada records, $content[fieldName]
2419 - // is undefined; the value lives at $content['data'][fieldName].
2252 + // Where that value sits differs per integration, and this
2253 + // reader got the list wrong twice:
2420 2254 //
2421 - // Pre-2026-05-01 we only checked the flat shape, so every
2422 - // Avada opt-in showed "User acknowledged: ✗ No" even when
2423 - // the user explicitly checked the GDPR box. The fallback
2424 - // below recognises the Avada shape too — adding a third
2425 - // shape would be the next addition.
2426 - $data['consentAcknowledged'] = ! empty( $data['consentField'] )
2427 - && is_array( $content )
2428 - && (
2429 - ! empty( $content[ $data['consentField'] ] )
2430 - || ! empty( $content['data'][ $data['consentField'] ] ?? null )
2431 - );
2255 + // 2026-05-01 Avada wraps its fields under `data`, so the
2256 + // flat lookup missed and every Avada opt-in
2257 + // showed "User acknowledged: ✗ No" even with
2258 + // the GDPR box explicitly checked.
2259 + // 2026-08-27 Elementor stores the whole $_POST parameter
2260 + // dict, so its fields sit under `form_fields`
2261 + // — the same symptom, one integration further
2262 + // on. The docblock added after the Avada fix
2263 + // had predicted exactly this ("adding a third
2264 + // shape would be the next addition").
2265 + //
2266 + // The shape list now lives in SubmittedContent, shared with
2267 + // OptInFrontend::addPlaceholders() — the other consumer that
2268 + // already knew all of them. A fourth integration with a
2269 + // fourth layout is taught to both at once.
2270 + //
2271 + // The lookup also tolerates a consent_field that the
2272 + // pre-5.3.2 sanitize_key() lowercased, so installations
2273 + // recover from the update without re-saving every form.
2274 + $data['consentAcknowledged'] = SubmittedContent::hasValue( $content, (string) $data['consentField'] );
2432 2275
2433 2276 // Parse mail_optin
2434 2277 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2435 2278 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
@@ -2612,10 +2455,10 @@
2612 2455 // First pass: every registered addon gets an entry, even if
2613 2456 // it contributes no UI. That lets the client show per-addon
2614 2457 // licensing/boot state without a second round-trip.
2615 2458 foreach ( $registered as $id => $addon ) {
2616 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2617 - $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2459 + $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2460 + $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2618 2461 unset( $fragments[ $id ] );
2619 2462 }
2620 2463
2621 2464 // Second pass: fragments for addons NOT in the registry
@@ -2853,11 +2696,18 @@
2853 2696 }
2854 2697
2855 2698 $activateUrl = null;
2856 2699 if ( $installed && ! $active ) {
2857 - $activateUrl = wp_nonce_url(
2858 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2859 - 'activate-plugin_' . $pluginFile
2700 + // Not wp_nonce_url(): it HTML-escapes & to &amp;, and this URL
2701 + // goes as JSON into an href — "plugin" and "_wpnonce" then
2702 + // arrived as "amp;plugin" and the activation failed.
2703 + $activateUrl = add_query_arg(
2704 + array(
2705 + 'action' => 'activate',
2706 + 'plugin' => rawurlencode( $pluginFile ),
2707 + '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ),
2708 + ),
2709 + self_admin_url( 'plugins.php' )
2860 2710 );
2861 2711 }
2862 2712
2863 2713 $registeredAddon = $registered[ $id ] ?? null;