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 +119 -322 5.6.2 → 5.8.0 View file →
@@ -15,8 +15,10 @@
15 15 use Forge12\DoubleOptIn\FormSettings\FormSettingsDTO;
16 16 use Forge12\DoubleOptIn\FormSettings\FormSettingsService;
17 17 use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator;
18 18 use Forge12\DoubleOptIn\Integration\SubmittedContent;
19 +use Forge12\DoubleOptIn\Service\ConfirmationMailResender;
20 +use Forge12\DoubleOptIn\Service\ResendResult;
19 21 use Forge12\Shared\LoggerInterface;
20 22
21 23 if ( ! defined( 'ABSPATH' ) ) {
22 24 exit;
@@ -509,19 +511,8 @@
509 511 'permission_callback' => array( $this, 'checkPermission' ),
510 512 )
511 513 );
512 514
513 - // ── Database Export (Pro-extensible) ────────────────────────
514 - register_rest_route(
515 - self::API_NAMESPACE,
516 - '/database/export',
517 - array(
518 - 'methods' => \WP_REST_Server::CREATABLE,
519 - 'callback' => array( $this, 'exportDatabase' ),
520 - 'permission_callback' => array( $this, 'checkPermission' ),
521 - )
522 - );
523 -
524 515 // ── Addons manifest (UI mount-point system, plan §9) ────────
525 516 register_rest_route(
526 517 self::API_NAMESPACE,
527 518 '/addons',
@@ -737,8 +728,14 @@
737 728 $where[] = 'cf_form_id = %d';
738 729 $params[] = (int) $formId;
739 730 }
740 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 +
741 738 // Confirmed opt-ins whose follow-up actions failed or have an
742 739 // unknown outcome — the admin's "needs attention" list.
743 740 if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
744 741 $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
@@ -843,9 +840,9 @@
843 840 // Full row (id, hash, content, files, cf_form_id) so the
844 841 // pre-delete cascade hook from pre-doi-data-retention Step 1
845 842 // can fire with a payload that lets listeners reach into
846 843 // integration storage. ARRAY_A — listener-friendly.
847 - $row = $wpdb->get_row(
844 + $row = $wpdb->get_row(
848 845 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
849 846 ARRAY_A
850 847 );
851 848 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -903,8 +900,32 @@
903 900 200
904 901 );
905 902 }
906 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 +
907 928 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
908 929 global $wpdb;
909 930 $id = (int) $request->get_param( 'id' );
910 931 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -944,96 +965,23 @@
944 965 */
945 966 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
946 967
947 968 if ( $result === null ) {
948 - // Default resend logic: use stored mail data.
949 - //
950 - // `mail_optin` is shipped by every integration via
951 - // {@see \forge12\contactform7\CF7DoubleOptIn\OptIn::set_mail_optin()}.
952 - // That method takes a STRING (the rendered HTML body) — the
953 - // admin opt-in-detail UI reads it as-is for the body
954 - // preview. Earlier versions of this handler expected a
955 - // serialized `['to' => ..., 'subject' => ..., 'body' => ...]`
956 - // array and bailed with "Email data is incomplete" whenever
957 - // the stored value was the (correct) plain body string —
958 - // which is the production case for every free-version
959 - // integration (CF7 / Avada / WPForms / Gravity / Elementor).
960 - // User-reported 2026-05-13: clicking Resend yielded that
961 - // error 100 % of the time.
962 - //
963 - // Both shapes are accepted now: the array form for Pro and
964 - // any future caller that stores structured payloads, the
965 - // plain string for the free-version integrations whose
966 - // contract is documented in
967 - // {@see \Forge12\DoubleOptIn\Wpforms\Tests\Unit\Integration\WPFormsSettingsApplyTest}.
968 - $mailOptin = $row['mail_optin'] ?? '';
969 - if ( empty( $mailOptin ) ) {
970 - return new \WP_REST_Response(
971 - array(
972 - 'success' => false,
973 - 'message' => __( 'No email data available for resend.', 'double-opt-in' ),
974 - ),
975 - 400
976 - );
977 - }
969 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
970 + ->get( ConfirmationMailResender::class )
971 + ->resend( $id );
978 972
979 - $unserialized = maybe_unserialize( $mailOptin );
980 -
981 - if ( is_array( $unserialized ) ) {
982 - // Structured payload (Pro / future writers).
983 - $to = $unserialized['to'] ?? '';
984 - $subject = $unserialized['subject'] ?? '';
985 - $body = $unserialized['body'] ?? '';
986 - $from = $unserialized['from'] ?? '';
987 - } else {
988 - // Plain body string — the production case. Reconstruct
989 - // `to` from the OptIn record's own `email` column and
990 - // `subject` from the form's central settings.
991 - $to = $row['email'] ?? '';
992 - $body = is_string( $unserialized ) ? $unserialized : (string) $mailOptin;
993 - $subject = '';
994 - $from = '';
995 -
996 - $formId = isset( $row['cf_form_id'] ) ? (int) $row['cf_form_id'] : 0;
997 - if ( $formId > 0 && class_exists( '\\forge12\\contactform7\\CF7DoubleOptIn\\CF7DoubleOptIn' ) ) {
998 - $formParam = \forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn::getInstance()->getParameter( $formId );
999 - $subject = (string) ( $formParam['subject'] ?? '' );
1000 - $senderEmail = (string) ( $formParam['sender'] ?? '' );
1001 - $senderName = (string) ( $formParam['sender_name'] ?? '' );
1002 - if ( $senderEmail !== '' ) {
1003 - $from = $senderName !== ''
1004 - ? $senderName . ' <' . $senderEmail . '>'
1005 - : $senderEmail;
1006 - }
1007 - }
973 + if ( ! $outcome->isSent() ) {
974 + return self::resendRefusal( $outcome->getReason() );
1008 975 }
1009 -
1010 - if ( empty( $to ) || empty( $body ) ) {
1011 - return new \WP_REST_Response(
1012 - array(
1013 - 'success' => false,
1014 - 'message' => __( 'Email data is incomplete.', 'double-opt-in' ),
1015 - ),
1016 - 400
1017 - );
1018 - }
1019 -
1020 - $headers = array( 'Content-Type: text/html; charset=UTF-8' );
1021 - if ( ! empty( $from ) ) {
1022 - $headers[] = 'From: ' . $from;
1023 - }
1024 -
1025 - $result = wp_mail( $to, $subject !== '' ? $subject : __( 'Confirmation Email (resent)', 'double-opt-in' ), $body, $headers );
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, '' );
1026 980 }
1027 981
1028 982 if ( ! $result ) {
1029 - return new \WP_REST_Response(
1030 - array(
1031 - 'success' => false,
1032 - 'message' => __( 'Failed to send email.', 'double-opt-in' ),
1033 - ),
1034 - 500
1035 - );
983 + return self::resendRefusal( ResendResult::SEND_FAILED );
1036 984 }
1037 985
1038 986 AuditLogger::log(
1039 987 AuditLogger::TYPE_EMAIL,
@@ -1930,22 +1878,41 @@
1930 1878 );
1931 1879 }
1932 1880
1933 1881 // ═══════════════════════════════════════════════════════════════
1934 - // PRO-EXTENSIBLE STUBS
1935 - // These return minimal responses; Pro overrides via filters or
1936 - // 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.
1937 1888 // ═══════════════════════════════════════════════════════════════
1938 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 +
1939 1912 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1940 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1941 - return new \WP_REST_Response(
1942 - array(
1943 - 'success' => false,
1944 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1945 - ),
1946 - 403
1947 - );
1913 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1914 + return $this->addonInactive( 'analytics', 'Analytics' );
1948 1915 }
1949 1916
1950 1917 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1951 1918
@@ -1958,16 +1925,10 @@
1958 1925 );
1959 1926 }
1960 1927
1961 1928 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1962 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1963 - return new \WP_REST_Response(
1964 - array(
1965 - 'success' => false,
1966 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1967 - ),
1968 - 403
1969 - );
1929 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1930 + return $this->addonInactive( 'analytics', 'Analytics' );
1970 1931 }
1971 1932
1972 1933 $formId = (int) $request->get_param( 'form_id' );
1973 1934 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1981,16 +1942,10 @@
1981 1942 );
1982 1943 }
1983 1944
1984 1945 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1985 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1986 - return new \WP_REST_Response(
1987 - array(
1988 - 'success' => false,
1989 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1990 - ),
1991 - 403
1992 - );
1946 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1947 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1993 1948 }
1994 1949
1995 1950 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1996 1951
@@ -2003,16 +1958,10 @@
2003 1958 );
2004 1959 }
2005 1960
2006 1961 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
2007 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2008 - return new \WP_REST_Response(
2009 - array(
2010 - 'success' => false,
2011 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2012 - ),
2013 - 403
2014 - );
1962 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1963 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2015 1964 }
2016 1965
2017 1966 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2018 1967
@@ -2027,140 +1976,32 @@
2027 1976
2028 1977 /**
2029 1978 * POST /f12-doi/v1/optout/page/generate
2030 1979 *
2031 - * One-click generator for the opt-out landing page. Eliminates the
2032 - * onboarding-friction loop where the user has to manually create a
2033 - * page and paste the shortcodes before opt-out works at all.
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.
2034 1983 *
2035 - * Algorithm:
2036 - * 1. Idempotent fast-path — scan `published` pages for the list
2037 - * shortcode. If one already exists, return its ID untouched
2038 - * (no duplicate creation, no content overwrite).
2039 - * 2. Title-collision safety — if a page named "Opt-Out" exists
2040 - * but WITHOUT the list shortcode, refuse to auto-modify. The
2041 - * user might have intentionally repurposed that title; we'd
2042 - * rather show a 409 with a clear message than clobber.
2043 - * 3. Insert a fresh page with both shortcodes (form + list) so
2044 - * the page is functional end-to-end out of the box.
2045 - *
2046 - * Response shape (always 200 unless error):
2047 - * { page_id, page_title, edit_url, view_url, created: bool }
2048 - *
2049 1984 * @return \WP_REST_Response
2050 1985 */
2051 1986 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2052 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2053 - return new \WP_REST_Response(
2054 - array(
2055 - 'success' => false,
2056 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2057 - ),
2058 - 403
2059 - );
1987 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
1988 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2060 1989 }
2061 1990
2062 - if ( ! current_user_can( 'publish_pages' ) ) {
2063 - return new \WP_REST_Response(
2064 - array(
2065 - 'success' => false,
2066 - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2067 - ),
2068 - 403
2069 - );
2070 - }
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 );
2071 2000
2072 - $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2073 - $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2074 -
2075 - // 1. Idempotent fast-path — first page with the list shortcode wins.
2076 - $existing = get_posts(
2077 - array(
2078 - 'post_type' => 'page',
2079 - 'post_status' => 'publish',
2080 - 'posts_per_page' => 1,
2081 - 's' => $listShortcode,
2082 - 'fields' => 'ids',
2083 - 'no_found_rows' => true,
2084 - )
2085 - );
2086 - if ( ! empty( $existing ) ) {
2087 - $pageId = (int) $existing[0];
2088 - return new \WP_REST_Response(
2089 - array(
2090 - 'success' => true,
2091 - 'created' => false,
2092 - 'page_id' => $pageId,
2093 - 'page_title' => get_the_title( $pageId ),
2094 - 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2095 - 'view_url' => get_permalink( $pageId ),
2096 - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2097 - ),
2098 - 200
2099 - );
2100 - }
2101 -
2102 - // 2. Title collision — a page literally titled "Opt-Out" but
2103 - // without the shortcode is the user's own content. Refuse
2104 - // to silently modify it.
2105 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2106 - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2107 - // Plain null check, not instanceof: this replaces `?->ID`, which only
2108 - // short-circuits on null and does not care about the concrete class.
2109 - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2110 - if ( $collisionId > 0 ) {
2111 - return new \WP_REST_Response(
2112 - array(
2113 - 'success' => false,
2114 - 'code' => 'TITLE_COLLISION',
2115 - 'page_id' => $collisionId,
2116 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2117 - 'message' => sprintf(
2118 - /* translators: %s = page title */
2119 - __( 'A page titled "%s" already exists but doesn\'t contain the opt-out shortcode. Add the shortcode manually, or rename the page, then try again.', 'double-opt-in' ),
2120 - $desiredTitle
2121 - ),
2122 - ),
2123 - 409
2124 - );
2125 - }
2126 -
2127 - // 3. Insert.
2128 - $pageId = wp_insert_post(
2129 - array(
2130 - 'post_type' => 'page',
2131 - 'post_status' => 'publish',
2132 - 'post_title' => $desiredTitle,
2133 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2134 - 'post_author' => get_current_user_id(),
2135 - 'comment_status' => 'closed',
2136 - 'ping_status' => 'closed',
2137 - ),
2138 - true
2139 - );
2140 -
2141 - if ( is_wp_error( $pageId ) ) {
2142 - return new \WP_REST_Response(
2143 - array(
2144 - 'success' => false,
2145 - 'message' => $pageId->get_error_message(),
2146 - ),
2147 - 500
2148 - );
2149 - }
2150 -
2151 - return new \WP_REST_Response(
2152 - array(
2153 - 'success' => true,
2154 - 'created' => true,
2155 - 'page_id' => (int) $pageId,
2156 - 'page_title' => $desiredTitle,
2157 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2158 - 'view_url' => get_permalink( (int) $pageId ),
2159 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2160 - ),
2161 - 200
2162 - );
2001 + return $response instanceof \WP_REST_Response
2002 + ? $response
2003 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2163 2004 }
2164 2005
2165 2006 /**
2166 2007 * License gate for the User Creation endpoints.
@@ -2180,15 +2021,9 @@
2180 2021 }
2181 2022
2182 2023 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2183 2024 if ( ! $this->userCreationAuthorized() ) {
2184 - return new \WP_REST_Response(
2185 - array(
2186 - 'success' => false,
2187 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2188 - ),
2189 - 403
2190 - );
2025 + return $this->addonInactive( 'user-registration', 'User Registration' );
2191 2026 }
2192 2027
2193 2028 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2194 2029
@@ -2202,15 +2037,9 @@
2202 2037 }
2203 2038
2204 2039 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2205 2040 if ( ! $this->userCreationAuthorized() ) {
2206 - return new \WP_REST_Response(
2207 - array(
2208 - 'success' => false,
2209 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2210 - ),
2211 - 403
2212 - );
2041 + return $this->addonInactive( 'user-registration', 'User Registration' );
2213 2042 }
2214 2043
2215 2044 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2216 2045
@@ -2223,16 +2052,10 @@
2223 2052 );
2224 2053 }
2225 2054
2226 2055 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2227 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2228 - return new \WP_REST_Response(
2229 - array(
2230 - 'success' => false,
2231 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2232 - ),
2233 - 403
2234 - );
2056 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2057 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2235 2058 }
2236 2059
2237 2060 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2238 2061
@@ -2245,16 +2068,10 @@
2245 2068 );
2246 2069 }
2247 2070
2248 2071 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2249 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2250 - return new \WP_REST_Response(
2251 - array(
2252 - 'success' => false,
2253 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2254 - ),
2255 - 403
2256 - );
2072 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2073 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2257 2074 }
2258 2075
2259 2076 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2260 2077
@@ -2348,40 +2165,8 @@
2348 2165
2349 2166 return new \WP_REST_Response( $result, $status );
2350 2167 }
2351 2168
2352 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2353 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2354 - return new \WP_REST_Response(
2355 - array(
2356 - 'success' => false,
2357 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2358 - ),
2359 - 403
2360 - );
2361 - }
2362 -
2363 - $input = $request->get_json_params();
2364 -
2365 - /**
2366 - * Filter to let Pro handle database export.
2367 - *
2368 - * @param array $result Result.
2369 - * @param array $input Export parameters.
2370 - * @since 4.2.0
2371 - */
2372 - $result = apply_filters(
2373 - 'f12_doi_rest_database_export',
2374 - array(
2375 - 'success' => false,
2376 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2377 - ),
2378 - $input
2379 - );
2380 -
2381 - return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 );
2382 - }
2383 -
2384 2169 // ═══════════════════════════════════════════════════════════════
2385 2170 // HELPERS
2386 2171 // ═══════════════════════════════════════════════════════════════
2387 2172
@@ -2403,8 +2188,11 @@
2403 2188 'formId' => (int) $row['cf_form_id'],
2404 2189 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2405 2190 'category' => (int) $row['category'],
2406 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'] ?? '' ),
2407 2195 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2408 2196 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2409 2197 );
2410 2198
@@ -2415,8 +2203,10 @@
2415 2203 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2416 2204 $data['consentText'] = $row['consent_text'];
2417 2205 $data['consentField'] = $row['consent_field'] ?? '';
2418 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'] ?? '' ) );
2419 2209
2420 2210 // Category name
2421 2211 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2422 2212 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2636,10 +2426,10 @@
2636 2426 // First pass: every registered addon gets an entry, even if
2637 2427 // it contributes no UI. That lets the client show per-addon
2638 2428 // licensing/boot state without a second round-trip.
2639 2429 foreach ( $registered as $id => $addon ) {
2640 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2641 - $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 );
2642 2432 unset( $fragments[ $id ] );
2643 2433 }
2644 2434
2645 2435 // Second pass: fragments for addons NOT in the registry
@@ -2877,11 +2667,18 @@
2877 2667 }
2878 2668
2879 2669 $activateUrl = null;
2880 2670 if ( $installed && ! $active ) {
2881 - $activateUrl = wp_nonce_url(
2882 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2883 - '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' )
2884 2681 );
2885 2682 }
2886 2683
2887 2684 $registeredAddon = $registered[ $id ] ?? null;