PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.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 3.0.51 All 41 releases
← All changes | src/Admin/AdminRestController.php +152 -341 5.3.1 → 5.8.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;
@@ -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,
@@ -1919,22 +1878,41 @@
1919 1878 );
1920 1879 }
1921 1880
1922 1881 // ═══════════════════════════════════════════════════════════════
1923 - // PRO-EXTENSIBLE STUBS
1924 - // These return minimal responses; Pro overrides via filters or
1925 - // 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.
1926 1888 // ═══════════════════════════════════════════════════════════════
1927 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 +
1928 1912 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1929 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1930 - return new \WP_REST_Response(
1931 - array(
1932 - 'success' => false,
1933 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1934 - ),
1935 - 403
1936 - );
1913 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1914 + return $this->addonInactive( 'analytics', 'Analytics' );
1937 1915 }
1938 1916
1939 1917 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1940 1918
@@ -1947,16 +1925,10 @@
1947 1925 );
1948 1926 }
1949 1927
1950 1928 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1951 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1952 - return new \WP_REST_Response(
1953 - array(
1954 - 'success' => false,
1955 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1956 - ),
1957 - 403
1958 - );
1929 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1930 + return $this->addonInactive( 'analytics', 'Analytics' );
1959 1931 }
1960 1932
1961 1933 $formId = (int) $request->get_param( 'form_id' );
1962 1934 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1970,16 +1942,10 @@
1970 1942 );
1971 1943 }
1972 1944
1973 1945 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1974 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1975 - return new \WP_REST_Response(
1976 - array(
1977 - 'success' => false,
1978 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1979 - ),
1980 - 403
1981 - );
1946 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1947 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1982 1948 }
1983 1949
1984 1950 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1985 1951
@@ -1992,16 +1958,10 @@
1992 1958 );
1993 1959 }
1994 1960
1995 1961 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1996 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1997 - return new \WP_REST_Response(
1998 - array(
1999 - 'success' => false,
2000 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2001 - ),
2002 - 403
2003 - );
1962 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1963 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2004 1964 }
2005 1965
2006 1966 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2007 1967
@@ -2016,140 +1976,32 @@
2016 1976
2017 1977 /**
2018 1978 * POST /f12-doi/v1/optout/page/generate
2019 1979 *
2020 - * One-click generator for the opt-out landing page. Eliminates the
2021 - * onboarding-friction loop where the user has to manually create a
2022 - * 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.
2023 1983 *
2024 - * Algorithm:
2025 - * 1. Idempotent fast-path — scan `published` pages for the list
2026 - * shortcode. If one already exists, return its ID untouched
2027 - * (no duplicate creation, no content overwrite).
2028 - * 2. Title-collision safety — if a page named "Opt-Out" exists
2029 - * but WITHOUT the list shortcode, refuse to auto-modify. The
2030 - * user might have intentionally repurposed that title; we'd
2031 - * rather show a 409 with a clear message than clobber.
2032 - * 3. Insert a fresh page with both shortcodes (form + list) so
2033 - * the page is functional end-to-end out of the box.
2034 - *
2035 - * Response shape (always 200 unless error):
2036 - * { page_id, page_title, edit_url, view_url, created: bool }
2037 - *
2038 1984 * @return \WP_REST_Response
2039 1985 */
2040 1986 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2041 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2042 - return new \WP_REST_Response(
2043 - array(
2044 - 'success' => false,
2045 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2046 - ),
2047 - 403
2048 - );
1987 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
1988 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2049 1989 }
2050 1990
2051 - if ( ! current_user_can( 'publish_pages' ) ) {
2052 - return new \WP_REST_Response(
2053 - array(
2054 - 'success' => false,
2055 - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2056 - ),
2057 - 403
2058 - );
2059 - }
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 );
2060 2000
2061 - $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2062 - $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2063 -
2064 - // 1. Idempotent fast-path — first page with the list shortcode wins.
2065 - $existing = get_posts(
2066 - array(
2067 - 'post_type' => 'page',
2068 - 'post_status' => 'publish',
2069 - 'posts_per_page' => 1,
2070 - 's' => $listShortcode,
2071 - 'fields' => 'ids',
2072 - 'no_found_rows' => true,
2073 - )
2074 - );
2075 - if ( ! empty( $existing ) ) {
2076 - $pageId = (int) $existing[0];
2077 - return new \WP_REST_Response(
2078 - array(
2079 - 'success' => true,
2080 - 'created' => false,
2081 - 'page_id' => $pageId,
2082 - 'page_title' => get_the_title( $pageId ),
2083 - 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2084 - 'view_url' => get_permalink( $pageId ),
2085 - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2086 - ),
2087 - 200
2088 - );
2089 - }
2090 -
2091 - // 2. Title collision — a page literally titled "Opt-Out" but
2092 - // without the shortcode is the user's own content. Refuse
2093 - // to silently modify it.
2094 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2095 - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2096 - // Plain null check, not instanceof: this replaces `?->ID`, which only
2097 - // short-circuits on null and does not care about the concrete class.
2098 - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2099 - if ( $collisionId > 0 ) {
2100 - return new \WP_REST_Response(
2101 - array(
2102 - 'success' => false,
2103 - 'code' => 'TITLE_COLLISION',
2104 - 'page_id' => $collisionId,
2105 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2106 - 'message' => sprintf(
2107 - /* translators: %s = page title */
2108 - __( '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' ),
2109 - $desiredTitle
2110 - ),
2111 - ),
2112 - 409
2113 - );
2114 - }
2115 -
2116 - // 3. Insert.
2117 - $pageId = wp_insert_post(
2118 - array(
2119 - 'post_type' => 'page',
2120 - 'post_status' => 'publish',
2121 - 'post_title' => $desiredTitle,
2122 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2123 - 'post_author' => get_current_user_id(),
2124 - 'comment_status' => 'closed',
2125 - 'ping_status' => 'closed',
2126 - ),
2127 - true
2128 - );
2129 -
2130 - if ( is_wp_error( $pageId ) ) {
2131 - return new \WP_REST_Response(
2132 - array(
2133 - 'success' => false,
2134 - 'message' => $pageId->get_error_message(),
2135 - ),
2136 - 500
2137 - );
2138 - }
2139 -
2140 - return new \WP_REST_Response(
2141 - array(
2142 - 'success' => true,
2143 - 'created' => true,
2144 - 'page_id' => (int) $pageId,
2145 - 'page_title' => $desiredTitle,
2146 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2147 - 'view_url' => get_permalink( (int) $pageId ),
2148 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2149 - ),
2150 - 200
2151 - );
2001 + return $response instanceof \WP_REST_Response
2002 + ? $response
2003 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2152 2004 }
2153 2005
2154 2006 /**
2155 2007 * License gate for the User Creation endpoints.
@@ -2169,15 +2021,9 @@
2169 2021 }
2170 2022
2171 2023 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2172 2024 if ( ! $this->userCreationAuthorized() ) {
2173 - return new \WP_REST_Response(
2174 - array(
2175 - 'success' => false,
2176 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2177 - ),
2178 - 403
2179 - );
2025 + return $this->addonInactive( 'user-registration', 'User Registration' );
2180 2026 }
2181 2027
2182 2028 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2183 2029
@@ -2191,15 +2037,9 @@
2191 2037 }
2192 2038
2193 2039 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2194 2040 if ( ! $this->userCreationAuthorized() ) {
2195 - return new \WP_REST_Response(
2196 - array(
2197 - 'success' => false,
2198 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2199 - ),
2200 - 403
2201 - );
2041 + return $this->addonInactive( 'user-registration', 'User Registration' );
2202 2042 }
2203 2043
2204 2044 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2205 2045
@@ -2212,16 +2052,10 @@
2212 2052 );
2213 2053 }
2214 2054
2215 2055 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2216 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2217 - return new \WP_REST_Response(
2218 - array(
2219 - 'success' => false,
2220 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2221 - ),
2222 - 403
2223 - );
2056 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2057 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2224 2058 }
2225 2059
2226 2060 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2227 2061
@@ -2234,16 +2068,10 @@
2234 2068 );
2235 2069 }
2236 2070
2237 2071 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2238 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2239 - return new \WP_REST_Response(
2240 - array(
2241 - 'success' => false,
2242 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2243 - ),
2244 - 403
2245 - );
2072 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2073 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2246 2074 }
2247 2075
2248 2076 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2249 2077
@@ -2337,40 +2165,8 @@
2337 2165
2338 2166 return new \WP_REST_Response( $result, $status );
2339 2167 }
2340 2168
2341 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2342 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2343 - return new \WP_REST_Response(
2344 - array(
2345 - 'success' => false,
2346 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2347 - ),
2348 - 403
2349 - );
2350 - }
2351 -
2352 - $input = $request->get_json_params();
2353 -
2354 - /**
2355 - * Filter to let Pro handle database export.
2356 - *
2357 - * @param array $result Result.
2358 - * @param array $input Export parameters.
2359 - * @since 4.2.0
2360 - */
2361 - $result = apply_filters(
2362 - 'f12_doi_rest_database_export',
2363 - array(
2364 - 'success' => false,
2365 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2366 - ),
2367 - $input
2368 - );
2369 -
2370 - return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 );
2371 - }
2372 -
2373 2169 // ═══════════════════════════════════════════════════════════════
2374 2170 // HELPERS
2375 2171 // ═══════════════════════════════════════════════════════════════
2376 2172
@@ -2392,8 +2188,11 @@
2392 2188 'formId' => (int) $row['cf_form_id'],
2393 2189 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2394 2190 'category' => (int) $row['category'],
2395 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'] ?? '' ),
2396 2195 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2397 2196 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2398 2197 );
2399 2198
@@ -2404,8 +2203,10 @@
2404 2203 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2405 2204 $data['consentText'] = $row['consent_text'];
2406 2205 $data['consentField'] = $row['consent_field'] ?? '';
2407 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'] ?? '' ) );
2408 2209
2409 2210 // Category name
2410 2211 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2411 2212 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2418,28 +2219,31 @@
2418 2219 // configured, look up the value the user actually submitted.
2419 2220 // Truthy = explicit acknowledgment captured. Falsy = either
2420 2221 // gate wasn't enforced or this is a legacy record.
2421 2222 //
2422 - // Storage shape varies per integration:
2423 - // - CF7 / WPForms / GF (default path) store fields flat
2424 - // at the top level: $content[fieldName] = value.
2425 - // - Avada wraps fields under a `data` sub-key alongside
2426 - // metadata (field_labels, field_types, form_parameter)
2427 - // — its OnSubmit overrides the flat content set by
2428 - // createOptIn(). For Avada records, $content[fieldName]
2429 - // 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:
2430 2225 //
2431 - // Pre-2026-05-01 we only checked the flat shape, so every
2432 - // Avada opt-in showed "User acknowledged: ✗ No" even when
2433 - // the user explicitly checked the GDPR box. The fallback
2434 - // below recognises the Avada shape too — adding a third
2435 - // shape would be the next addition.
2436 - $data['consentAcknowledged'] = ! empty( $data['consentField'] )
2437 - && is_array( $content )
2438 - && (
2439 - ! empty( $content[ $data['consentField'] ] )
2440 - || ! empty( $content['data'][ $data['consentField'] ] ?? null )
2441 - );
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'] );
2442 2246
2443 2247 // Parse mail_optin
2444 2248 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2445 2249 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
@@ -2622,10 +2426,10 @@
2622 2426 // First pass: every registered addon gets an entry, even if
2623 2427 // it contributes no UI. That lets the client show per-addon
2624 2428 // licensing/boot state without a second round-trip.
2625 2429 foreach ( $registered as $id => $addon ) {
2626 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2627 - $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 );
2628 2432 unset( $fragments[ $id ] );
2629 2433 }
2630 2434
2631 2435 // Second pass: fragments for addons NOT in the registry
@@ -2863,11 +2667,18 @@
2863 2667 }
2864 2668
2865 2669 $activateUrl = null;
2866 2670 if ( $installed && ! $active ) {
2867 - $activateUrl = wp_nonce_url(
2868 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2869 - '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' )
2870 2681 );
2871 2682 }
2872 2683
2873 2684 $registeredAddon = $registered[ $id ] ?? null;