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 -340 5.1.5 → 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,137 +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 - $collisionId = (int) get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' )?->ID;
2086 - if ( $collisionId > 0 ) {
2087 - return new \WP_REST_Response(
2088 - array(
2089 - 'success' => false,
2090 - 'code' => 'TITLE_COLLISION',
2091 - 'page_id' => $collisionId,
2092 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2093 - 'message' => sprintf(
2094 - /* translators: %s = page title */
2095 - __( 'A page titled "%s" already exists but doesn\'t contain the opt-out shortcode. Add the shortcode manually, or rename the page, then try again.', 'double-opt-in' ),
2096 - $desiredTitle
2097 - ),
2098 - ),
2099 - 409
2100 - );
2101 - }
2102 -
2103 - // 3. Insert.
2104 - $pageId = wp_insert_post(
2105 - array(
2106 - 'post_type' => 'page',
2107 - 'post_status' => 'publish',
2108 - 'post_title' => $desiredTitle,
2109 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2110 - 'post_author' => get_current_user_id(),
2111 - 'comment_status' => 'closed',
2112 - 'ping_status' => 'closed',
2113 - ),
2114 - true
2115 - );
2116 -
2117 - if ( is_wp_error( $pageId ) ) {
2118 - return new \WP_REST_Response(
2119 - array(
2120 - 'success' => false,
2121 - 'message' => $pageId->get_error_message(),
2122 - ),
2123 - 500
2124 - );
2125 - }
2126 -
2127 - return new \WP_REST_Response(
2128 - array(
2129 - 'success' => true,
2130 - 'created' => true,
2131 - 'page_id' => (int) $pageId,
2132 - 'page_title' => $desiredTitle,
2133 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2134 - 'view_url' => get_permalink( (int) $pageId ),
2135 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2136 - ),
2137 - 200
2138 - );
2015 + return $response instanceof \WP_REST_Response
2016 + ? $response
2017 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2139 2018 }
2140 2019
2141 2020 /**
2142 2021 * License gate for the User Creation endpoints.
@@ -2156,15 +2035,9 @@
2156 2035 }
2157 2036
2158 2037 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2159 2038 if ( ! $this->userCreationAuthorized() ) {
2160 - return new \WP_REST_Response(
2161 - array(
2162 - 'success' => false,
2163 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2164 - ),
2165 - 403
2166 - );
2039 + return $this->addonInactive( 'user-registration', 'User Registration' );
2167 2040 }
2168 2041
2169 2042 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2170 2043
@@ -2178,15 +2051,9 @@
2178 2051 }
2179 2052
2180 2053 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2181 2054 if ( ! $this->userCreationAuthorized() ) {
2182 - return new \WP_REST_Response(
2183 - array(
2184 - 'success' => false,
2185 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2186 - ),
2187 - 403
2188 - );
2055 + return $this->addonInactive( 'user-registration', 'User Registration' );
2189 2056 }
2190 2057
2191 2058 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2192 2059
@@ -2199,16 +2066,10 @@
2199 2066 );
2200 2067 }
2201 2068
2202 2069 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2203 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2204 - return new \WP_REST_Response(
2205 - array(
2206 - 'success' => false,
2207 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2208 - ),
2209 - 403
2210 - );
2070 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2071 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2211 2072 }
2212 2073
2213 2074 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2214 2075
@@ -2221,16 +2082,10 @@
2221 2082 );
2222 2083 }
2223 2084
2224 2085 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2225 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2226 - return new \WP_REST_Response(
2227 - array(
2228 - 'success' => false,
2229 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2230 - ),
2231 - 403
2232 - );
2086 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2087 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2233 2088 }
2234 2089
2235 2090 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2236 2091
@@ -2324,44 +2179,25 @@
2324 2179
2325 2180 return new \WP_REST_Response( $result, $status );
2326 2181 }
2327 2182
2328 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2329 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2330 - return new \WP_REST_Response(
2331 - array(
2332 - 'success' => false,
2333 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2334 - ),
2335 - 403
2336 - );
2337 - }
2183 + // ═══════════════════════════════════════════════════════════════
2184 + // HELPERS
2185 + // ═══════════════════════════════════════════════════════════════
2338 2186
2339 - $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'] ?? '' );
2340 2194
2341 - /**
2342 - * Filter to let Pro handle database export.
2343 - *
2344 - * @param array $result Result.
2345 - * @param array $input Export parameters.
2346 - * @since 4.2.0
2347 - */
2348 - $result = apply_filters(
2349 - 'f12_doi_rest_database_export',
2350 - array(
2351 - 'success' => false,
2352 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2353 - ),
2354 - $input
2355 - );
2356 -
2357 - return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 );
2195 + return (int) ( $row['doubleoptin'] ?? 0 ) !== 1
2196 + && (string) ( $row['ipaddr_optout'] ?? '' ) !== ''
2197 + && $optOutTime !== '' && $optOutTime !== '0';
2358 2198 }
2359 2199
2360 - // ═══════════════════════════════════════════════════════════════
2361 - // HELPERS
2362 - // ═══════════════════════════════════════════════════════════════
2363 -
2364 2200 /**
2365 2201 * Format an opt-in database row for the API response.
2366 2202 *
2367 2203 * @param array $row The database row.
@@ -2379,8 +2215,13 @@
2379 2215 'formId' => (int) $row['cf_form_id'],
2380 2216 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2381 2217 'category' => (int) $row['category'],
2382 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'] ?? '' ),
2383 2224 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2384 2225 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2385 2226 );
2386 2227
@@ -2391,8 +2232,10 @@
2391 2232 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2392 2233 $data['consentText'] = $row['consent_text'];
2393 2234 $data['consentField'] = $row['consent_field'] ?? '';
2394 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'] ?? '' ) );
2395 2238
2396 2239 // Category name
2397 2240 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2398 2241 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2405,28 +2248,31 @@
2405 2248 // configured, look up the value the user actually submitted.
2406 2249 // Truthy = explicit acknowledgment captured. Falsy = either
2407 2250 // gate wasn't enforced or this is a legacy record.
2408 2251 //
2409 - // Storage shape varies per integration:
2410 - // - CF7 / WPForms / GF (default path) store fields flat
2411 - // at the top level: $content[fieldName] = value.
2412 - // - Avada wraps fields under a `data` sub-key alongside
2413 - // metadata (field_labels, field_types, form_parameter)
2414 - // — its OnSubmit overrides the flat content set by
2415 - // createOptIn(). For Avada records, $content[fieldName]
2416 - // is undefined; the value lives at $content['data'][fieldName].
2252 + // Where that value sits differs per integration, and this
2253 + // reader got the list wrong twice:
2417 2254 //
2418 - // Pre-2026-05-01 we only checked the flat shape, so every
2419 - // Avada opt-in showed "User acknowledged: ✗ No" even when
2420 - // the user explicitly checked the GDPR box. The fallback
2421 - // below recognises the Avada shape too — adding a third
2422 - // shape would be the next addition.
2423 - $data['consentAcknowledged'] = ! empty( $data['consentField'] )
2424 - && is_array( $content )
2425 - && (
2426 - ! empty( $content[ $data['consentField'] ] )
2427 - || ! empty( $content['data'][ $data['consentField'] ] ?? null )
2428 - );
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'] );
2429 2275
2430 2276 // Parse mail_optin
2431 2277 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2432 2278 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
@@ -2609,10 +2455,10 @@
2609 2455 // First pass: every registered addon gets an entry, even if
2610 2456 // it contributes no UI. That lets the client show per-addon
2611 2457 // licensing/boot state without a second round-trip.
2612 2458 foreach ( $registered as $id => $addon ) {
2613 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2614 - $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 );
2615 2461 unset( $fragments[ $id ] );
2616 2462 }
2617 2463
2618 2464 // Second pass: fragments for addons NOT in the registry
@@ -2850,11 +2696,18 @@
2850 2696 }
2851 2697
2852 2698 $activateUrl = null;
2853 2699 if ( $installed && ! $active ) {
2854 - $activateUrl = wp_nonce_url(
2855 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2856 - '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' )
2857 2710 );
2858 2711 }
2859 2712
2860 2713 $registeredAddon = $registered[ $id ] ?? null;