PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.1
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.1
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 3.0.51 All 41 releases
← All changes | src/Admin/AdminRestController.php +162 -338 5.1.5 → 5.8.1 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;
@@ -508,19 +511,8 @@
508 511 'permission_callback' => array( $this, 'checkPermission' ),
509 512 )
510 513 );
511 514
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 515 // ── Addons manifest (UI mount-point system, plan §9) ────────
524 516 register_rest_route(
525 517 self::API_NAMESPACE,
526 518 '/addons',
@@ -736,8 +728,24 @@
736 728 $where[] = 'cf_form_id = %d';
737 729 $params[] = (int) $formId;
738 730 }
739 731
732 + // Opt-ins whose confirmation mail could not be sent (5.8.0).
733 + if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) {
734 + $where[] = 'mail_status = %s';
735 + $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED;
736 + }
737 +
738 + // Confirmed opt-ins whose follow-up actions failed or have an
739 + // unknown outcome — the admin's "needs attention" list.
740 + if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
741 + $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
742 + $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic();
743 + $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN ("
744 + . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))';
745 + $params = array_merge( $params, $problems );
746 + }
747 +
740 748 $whereClause = implode( ' AND ', $where );
741 749
742 750 // Count
743 751 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -832,9 +840,9 @@
832 840 // Full row (id, hash, content, files, cf_form_id) so the
833 841 // pre-delete cascade hook from pre-doi-data-retention Step 1
834 842 // can fire with a payload that lets listeners reach into
835 843 // integration storage. ARRAY_A — listener-friendly.
836 - $row = $wpdb->get_row(
844 + $row = $wpdb->get_row(
837 845 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
838 846 ARRAY_A
839 847 );
840 848 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -892,8 +900,32 @@
892 900 200
893 901 );
894 902 }
895 903
904 + /**
905 + * The admin's answer for a resend that did not go out.
906 + */
907 + private static function resendRefusal( string $reason ): \WP_REST_Response {
908 + $map = array(
909 + ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ),
910 + ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ),
911 + ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ),
912 + ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ),
913 + ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ),
914 + );
915 + $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 );
916 + $message = $entry[0];
917 + $status = $entry[1];
918 +
919 + return new \WP_REST_Response(
920 + array(
921 + 'success' => false,
922 + 'message' => $message,
923 + ),
924 + $status
925 + );
926 + }
927 +
896 928 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
897 929 global $wpdb;
898 930 $id = (int) $request->get_param( 'id' );
899 931 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -933,96 +965,23 @@
933 965 */
934 966 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
935 967
936 968 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 - }
969 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
970 + ->get( ConfirmationMailResender::class )
971 + ->resend( $id );
967 972
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 - }
973 + if ( ! $outcome->isSent() ) {
974 + return self::resendRefusal( $outcome->getReason() );
997 975 }
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 );
976 + $result = true;
977 + } else {
978 + // An extension sent it; record the outcome all the same.
979 + do_action( 'f12_doi_optin_mail_result', $id, (bool) $result, '' );
1015 980 }
1016 981
1017 982 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 - );
983 + return self::resendRefusal( ResendResult::SEND_FAILED );
1025 984 }
1026 985
1027 986 AuditLogger::log(
1028 987 AuditLogger::TYPE_EMAIL,
@@ -1439,8 +1398,13 @@
1439 1398
1440 1399 public function getSettings( \WP_REST_Request $request ): \WP_REST_Response {
1441 1400 $defaults = array(
1442 1401 'telemetry' => 1,
1402 + // Optional "Double Opt-In by Forge12" credit on the confirmation
1403 + // page. Defaults to 0 and must stay that way: wordpress.org
1404 + // guideline 10 requires credit links to be off unless the site
1405 + // owner explicitly turns them on.
1406 + 'credit_link' => 0,
1443 1407 'delete' => 12,
1444 1408 'delete_unconfirmed' => 7,
1445 1409 'delete_period' => 'months',
1446 1410 'delete_unconfirmed_period' => 'months',
@@ -1515,8 +1479,13 @@
1515 1479 'type' => 'int',
1516 1480 'min' => 0,
1517 1481 'max' => 1,
1518 1482 ),
1483 + 'credit_link' => array(
1484 + 'type' => 'int',
1485 + 'min' => 0,
1486 + 'max' => 1,
1487 + ),
1519 1488 'privacy_policy_page' => array(
1520 1489 'type' => 'int',
1521 1490 'min' => 0,
1522 1491 ),
@@ -1909,22 +1878,41 @@
1909 1878 );
1910 1879 }
1911 1880
1912 1881 // ═══════════════════════════════════════════════════════════════
1913 - // PRO-EXTENSIBLE STUBS
1914 - // These return minimal responses; Pro overrides via filters or
1915 - // registers its own REST routes that take precedence.
1882 + // ADD-ON ROUTES
1883 + // Core owns the route; the data comes from the add-on through a
1884 + // filter. Without a handler the answer is ADDON_INACTIVE. Core
1885 + // itself never checks a licence here (wordpress.org guideline 5):
1886 + // the functionality lives in the add-on, which only hooks in when
1887 + // it runs licensed.
1916 1888 // ═══════════════════════════════════════════════════════════════
1917 1889
1890 + /**
1891 + * Answer for a route whose add-on is not running.
1892 + *
1893 + * 404 with `code` so the SPA can tell it from an unknown route
1894 + * (`rest_no_route`); `ApiError` reads `body.code`.
1895 + */
1896 + private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response {
1897 + return new \WP_REST_Response(
1898 + array(
1899 + 'success' => false,
1900 + 'code' => 'ADDON_INACTIVE',
1901 + 'addon' => $addonId,
1902 + 'message' => sprintf(
1903 + /* translators: %s: add-on name */
1904 + __( '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' ),
1905 + $addonName
1906 + ),
1907 + ),
1908 + 404
1909 + );
1910 + }
1911 +
1918 1912 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 - );
1913 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1914 + return $this->addonInactive( 'analytics', 'Analytics' );
1927 1915 }
1928 1916
1929 1917 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1930 1918
@@ -1937,16 +1925,10 @@
1937 1925 );
1938 1926 }
1939 1927
1940 1928 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 - );
1929 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1930 + return $this->addonInactive( 'analytics', 'Analytics' );
1949 1931 }
1950 1932
1951 1933 $formId = (int) $request->get_param( 'form_id' );
1952 1934 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1960,16 +1942,10 @@
1960 1942 );
1961 1943 }
1962 1944
1963 1945 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 - );
1946 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1947 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1972 1948 }
1973 1949
1974 1950 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1975 1951
@@ -1982,16 +1958,10 @@
1982 1958 );
1983 1959 }
1984 1960
1985 1961 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 - );
1962 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1963 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1994 1964 }
1995 1965
1996 1966 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
1997 1967
@@ -2006,137 +1976,32 @@
2006 1976
2007 1977 /**
2008 1978 * POST /f12-doi/v1/optout/page/generate
2009 1979 *
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.
1980 + * One-click generator for the opt-out landing page. The logic lives in
1981 + * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through
1982 + * the filter below; Core only owns the route.
2013 1983 *
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 1984 * @return \WP_REST_Response
2029 1985 */
2030 1986 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 - );
1987 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
1988 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2039 1989 }
2040 1990
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 - }
1991 + /**
1992 + * Filter: answer the opt-out page generator request.
1993 + *
1994 + * @param \WP_REST_Response|null $response Null until a handler answers.
1995 + * @param \WP_REST_Request $request The request.
1996 + *
1997 + * @since 5.8.0
1998 + */
1999 + $response = apply_filters( 'f12_doi_rest_optout_generate_page', null, $request );
2050 2000
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 - );
2001 + return $response instanceof \WP_REST_Response
2002 + ? $response
2003 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2139 2004 }
2140 2005
2141 2006 /**
2142 2007 * License gate for the User Creation endpoints.
@@ -2156,15 +2021,9 @@
2156 2021 }
2157 2022
2158 2023 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2159 2024 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 - );
2025 + return $this->addonInactive( 'user-registration', 'User Registration' );
2167 2026 }
2168 2027
2169 2028 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2170 2029
@@ -2178,15 +2037,9 @@
2178 2037 }
2179 2038
2180 2039 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2181 2040 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 - );
2041 + return $this->addonInactive( 'user-registration', 'User Registration' );
2189 2042 }
2190 2043
2191 2044 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2192 2045
@@ -2199,16 +2052,10 @@
2199 2052 );
2200 2053 }
2201 2054
2202 2055 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 - );
2056 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2057 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2211 2058 }
2212 2059
2213 2060 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2214 2061
@@ -2221,16 +2068,10 @@
2221 2068 );
2222 2069 }
2223 2070
2224 2071 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 - );
2072 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2073 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2233 2074 }
2234 2075
2235 2076 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2236 2077
@@ -2324,40 +2165,8 @@
2324 2165
2325 2166 return new \WP_REST_Response( $result, $status );
2326 2167 }
2327 2168
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 - }
2338 -
2339 - $input = $request->get_json_params();
2340 -
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 );
2358 - }
2359 -
2360 2169 // ═══════════════════════════════════════════════════════════════
2361 2170 // HELPERS
2362 2171 // ═══════════════════════════════════════════════════════════════
2363 2172
@@ -2379,8 +2188,11 @@
2379 2188 'formId' => (int) $row['cf_form_id'],
2380 2189 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2381 2190 'category' => (int) $row['category'],
2382 2191 'confirmed' => (int) $row['doubleoptin'] === 1,
2192 + // Confirmation mail: 'sent' (handed to the mail server), 'failed',
2193 + // or '' (recorded before 5.8.0). Since 5.8.0.
2194 + 'mailStatus' => (string) ( $row['mail_status'] ?? '' ),
2383 2195 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2384 2196 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2385 2197 );
2386 2198
@@ -2391,8 +2203,10 @@
2391 2203 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2392 2204 $data['consentText'] = $row['consent_text'];
2393 2205 $data['consentField'] = $row['consent_field'] ?? '';
2394 2206 $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] );
2207 + $data['mailError'] = (string) ( $row['mail_error'] ?? '' );
2208 + $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) );
2395 2209
2396 2210 // Category name
2397 2211 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2398 2212 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2405,28 +2219,31 @@
2405 2219 // configured, look up the value the user actually submitted.
2406 2220 // Truthy = explicit acknowledgment captured. Falsy = either
2407 2221 // gate wasn't enforced or this is a legacy record.
2408 2222 //
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].
2223 + // Where that value sits differs per integration, and this
2224 + // reader got the list wrong twice:
2417 2225 //
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 - );
2226 + // 2026-05-01 Avada wraps its fields under `data`, so the
2227 + // flat lookup missed and every Avada opt-in
2228 + // showed "User acknowledged: ✗ No" even with
2229 + // the GDPR box explicitly checked.
2230 + // 2026-08-27 Elementor stores the whole $_POST parameter
2231 + // dict, so its fields sit under `form_fields`
2232 + // — the same symptom, one integration further
2233 + // on. The docblock added after the Avada fix
2234 + // had predicted exactly this ("adding a third
2235 + // shape would be the next addition").
2236 + //
2237 + // The shape list now lives in SubmittedContent, shared with
2238 + // OptInFrontend::addPlaceholders() — the other consumer that
2239 + // already knew all of them. A fourth integration with a
2240 + // fourth layout is taught to both at once.
2241 + //
2242 + // The lookup also tolerates a consent_field that the
2243 + // pre-5.3.2 sanitize_key() lowercased, so installations
2244 + // recover from the update without re-saving every form.
2245 + $data['consentAcknowledged'] = SubmittedContent::hasValue( $content, (string) $data['consentField'] );
2429 2246
2430 2247 // Parse mail_optin
2431 2248 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2432 2249 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
@@ -2609,10 +2426,10 @@
2609 2426 // First pass: every registered addon gets an entry, even if
2610 2427 // it contributes no UI. That lets the client show per-addon
2611 2428 // licensing/boot state without a second round-trip.
2612 2429 foreach ( $registered as $id => $addon ) {
2613 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2614 - $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2430 + $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2431 + $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2615 2432 unset( $fragments[ $id ] );
2616 2433 }
2617 2434
2618 2435 // Second pass: fragments for addons NOT in the registry
@@ -2850,11 +2667,18 @@
2850 2667 }
2851 2668
2852 2669 $activateUrl = null;
2853 2670 if ( $installed && ! $active ) {
2854 - $activateUrl = wp_nonce_url(
2855 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2856 - 'activate-plugin_' . $pluginFile
2671 + // Not wp_nonce_url(): it HTML-escapes & to &amp;, and this URL
2672 + // goes as JSON into an href — "plugin" and "_wpnonce" then
2673 + // arrived as "amp;plugin" and the activation failed.
2674 + $activateUrl = add_query_arg(
2675 + array(
2676 + 'action' => 'activate',
2677 + 'plugin' => rawurlencode( $pluginFile ),
2678 + '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ),
2679 + ),
2680 + self_admin_url( 'plugins.php' )
2857 2681 );
2858 2682 }
2859 2683
2860 2684 $registeredAddon = $registered[ $id ] ?? null;