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 +129 -322 5.3.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,24 @@
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 +
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 +
741 748 $whereClause = implode( ' AND ', $where );
742 749
743 750 // Count
744 751 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -833,9 +840,9 @@
833 840 // Full row (id, hash, content, files, cf_form_id) so the
834 841 // pre-delete cascade hook from pre-doi-data-retention Step 1
835 842 // can fire with a payload that lets listeners reach into
836 843 // integration storage. ARRAY_A — listener-friendly.
837 - $row = $wpdb->get_row(
844 + $row = $wpdb->get_row(
838 845 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
839 846 ARRAY_A
840 847 );
841 848 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -893,8 +900,32 @@
893 900 200
894 901 );
895 902 }
896 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 +
897 928 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
898 929 global $wpdb;
899 930 $id = (int) $request->get_param( 'id' );
900 931 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -934,96 +965,23 @@
934 965 */
935 966 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
936 967
937 968 if ( $result === null ) {
938 - // Default resend logic: use stored mail data.
939 - //
940 - // `mail_optin` is shipped by every integration via
941 - // {@see \forge12\contactform7\CF7DoubleOptIn\OptIn::set_mail_optin()}.
942 - // That method takes a STRING (the rendered HTML body) — the
943 - // admin opt-in-detail UI reads it as-is for the body
944 - // preview. Earlier versions of this handler expected a
945 - // serialized `['to' => ..., 'subject' => ..., 'body' => ...]`
946 - // array and bailed with "Email data is incomplete" whenever
947 - // the stored value was the (correct) plain body string —
948 - // which is the production case for every free-version
949 - // integration (CF7 / Avada / WPForms / Gravity / Elementor).
950 - // User-reported 2026-05-13: clicking Resend yielded that
951 - // error 100 % of the time.
952 - //
953 - // Both shapes are accepted now: the array form for Pro and
954 - // any future caller that stores structured payloads, the
955 - // plain string for the free-version integrations whose
956 - // contract is documented in
957 - // {@see \Forge12\DoubleOptIn\Wpforms\Tests\Unit\Integration\WPFormsSettingsApplyTest}.
958 - $mailOptin = $row['mail_optin'] ?? '';
959 - if ( empty( $mailOptin ) ) {
960 - return new \WP_REST_Response(
961 - array(
962 - 'success' => false,
963 - 'message' => __( 'No email data available for resend.', 'double-opt-in' ),
964 - ),
965 - 400
966 - );
967 - }
969 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
970 + ->get( ConfirmationMailResender::class )
971 + ->resend( $id );
968 972
969 - $unserialized = maybe_unserialize( $mailOptin );
970 -
971 - if ( is_array( $unserialized ) ) {
972 - // Structured payload (Pro / future writers).
973 - $to = $unserialized['to'] ?? '';
974 - $subject = $unserialized['subject'] ?? '';
975 - $body = $unserialized['body'] ?? '';
976 - $from = $unserialized['from'] ?? '';
977 - } else {
978 - // Plain body string — the production case. Reconstruct
979 - // `to` from the OptIn record's own `email` column and
980 - // `subject` from the form's central settings.
981 - $to = $row['email'] ?? '';
982 - $body = is_string( $unserialized ) ? $unserialized : (string) $mailOptin;
983 - $subject = '';
984 - $from = '';
985 -
986 - $formId = isset( $row['cf_form_id'] ) ? (int) $row['cf_form_id'] : 0;
987 - if ( $formId > 0 && class_exists( '\\forge12\\contactform7\\CF7DoubleOptIn\\CF7DoubleOptIn' ) ) {
988 - $formParam = \forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn::getInstance()->getParameter( $formId );
989 - $subject = (string) ( $formParam['subject'] ?? '' );
990 - $senderEmail = (string) ( $formParam['sender'] ?? '' );
991 - $senderName = (string) ( $formParam['sender_name'] ?? '' );
992 - if ( $senderEmail !== '' ) {
993 - $from = $senderName !== ''
994 - ? $senderName . ' <' . $senderEmail . '>'
995 - : $senderEmail;
996 - }
997 - }
973 + if ( ! $outcome->isSent() ) {
974 + return self::resendRefusal( $outcome->getReason() );
998 975 }
999 -
1000 - if ( empty( $to ) || empty( $body ) ) {
1001 - return new \WP_REST_Response(
1002 - array(
1003 - 'success' => false,
1004 - 'message' => __( 'Email data is incomplete.', 'double-opt-in' ),
1005 - ),
1006 - 400
1007 - );
1008 - }
1009 -
1010 - $headers = array( 'Content-Type: text/html; charset=UTF-8' );
1011 - if ( ! empty( $from ) ) {
1012 - $headers[] = 'From: ' . $from;
1013 - }
1014 -
1015 - $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, '' );
1016 980 }
1017 981
1018 982 if ( ! $result ) {
1019 - return new \WP_REST_Response(
1020 - array(
1021 - 'success' => false,
1022 - 'message' => __( 'Failed to send email.', 'double-opt-in' ),
1023 - ),
1024 - 500
1025 - );
983 + return self::resendRefusal( ResendResult::SEND_FAILED );
1026 984 }
1027 985
1028 986 AuditLogger::log(
1029 987 AuditLogger::TYPE_EMAIL,
@@ -1920,22 +1878,41 @@
1920 1878 );
1921 1879 }
1922 1880
1923 1881 // ═══════════════════════════════════════════════════════════════
1924 - // PRO-EXTENSIBLE STUBS
1925 - // These return minimal responses; Pro overrides via filters or
1926 - // 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.
1927 1888 // ═══════════════════════════════════════════════════════════════
1928 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 +
1929 1912 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1930 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1931 - return new \WP_REST_Response(
1932 - array(
1933 - 'success' => false,
1934 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1935 - ),
1936 - 403
1937 - );
1913 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1914 + return $this->addonInactive( 'analytics', 'Analytics' );
1938 1915 }
1939 1916
1940 1917 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1941 1918
@@ -1948,16 +1925,10 @@
1948 1925 );
1949 1926 }
1950 1927
1951 1928 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1952 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1953 - return new \WP_REST_Response(
1954 - array(
1955 - 'success' => false,
1956 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1957 - ),
1958 - 403
1959 - );
1929 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1930 + return $this->addonInactive( 'analytics', 'Analytics' );
1960 1931 }
1961 1932
1962 1933 $formId = (int) $request->get_param( 'form_id' );
1963 1934 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1971,16 +1942,10 @@
1971 1942 );
1972 1943 }
1973 1944
1974 1945 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1975 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1976 - return new \WP_REST_Response(
1977 - array(
1978 - 'success' => false,
1979 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
1980 - ),
1981 - 403
1982 - );
1946 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1947 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1983 1948 }
1984 1949
1985 1950 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1986 1951
@@ -1993,16 +1958,10 @@
1993 1958 );
1994 1959 }
1995 1960
1996 1961 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1997 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1998 - return new \WP_REST_Response(
1999 - array(
2000 - 'success' => false,
2001 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2002 - ),
2003 - 403
2004 - );
1962 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1963 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2005 1964 }
2006 1965
2007 1966 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2008 1967
@@ -2017,140 +1976,32 @@
2017 1976
2018 1977 /**
2019 1978 * POST /f12-doi/v1/optout/page/generate
2020 1979 *
2021 - * One-click generator for the opt-out landing page. Eliminates the
2022 - * onboarding-friction loop where the user has to manually create a
2023 - * 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.
2024 1983 *
2025 - * Algorithm:
2026 - * 1. Idempotent fast-path — scan `published` pages for the list
2027 - * shortcode. If one already exists, return its ID untouched
2028 - * (no duplicate creation, no content overwrite).
2029 - * 2. Title-collision safety — if a page named "Opt-Out" exists
2030 - * but WITHOUT the list shortcode, refuse to auto-modify. The
2031 - * user might have intentionally repurposed that title; we'd
2032 - * rather show a 409 with a clear message than clobber.
2033 - * 3. Insert a fresh page with both shortcodes (form + list) so
2034 - * the page is functional end-to-end out of the box.
2035 - *
2036 - * Response shape (always 200 unless error):
2037 - * { page_id, page_title, edit_url, view_url, created: bool }
2038 - *
2039 1984 * @return \WP_REST_Response
2040 1985 */
2041 1986 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2042 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2043 - return new \WP_REST_Response(
2044 - array(
2045 - 'success' => false,
2046 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2047 - ),
2048 - 403
2049 - );
1987 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
1988 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2050 1989 }
2051 1990
2052 - if ( ! current_user_can( 'publish_pages' ) ) {
2053 - return new \WP_REST_Response(
2054 - array(
2055 - 'success' => false,
2056 - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2057 - ),
2058 - 403
2059 - );
2060 - }
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 );
2061 2000
2062 - $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2063 - $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2064 -
2065 - // 1. Idempotent fast-path — first page with the list shortcode wins.
2066 - $existing = get_posts(
2067 - array(
2068 - 'post_type' => 'page',
2069 - 'post_status' => 'publish',
2070 - 'posts_per_page' => 1,
2071 - 's' => $listShortcode,
2072 - 'fields' => 'ids',
2073 - 'no_found_rows' => true,
2074 - )
2075 - );
2076 - if ( ! empty( $existing ) ) {
2077 - $pageId = (int) $existing[0];
2078 - return new \WP_REST_Response(
2079 - array(
2080 - 'success' => true,
2081 - 'created' => false,
2082 - 'page_id' => $pageId,
2083 - 'page_title' => get_the_title( $pageId ),
2084 - 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2085 - 'view_url' => get_permalink( $pageId ),
2086 - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2087 - ),
2088 - 200
2089 - );
2090 - }
2091 -
2092 - // 2. Title collision — a page literally titled "Opt-Out" but
2093 - // without the shortcode is the user's own content. Refuse
2094 - // to silently modify it.
2095 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2096 - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2097 - // Plain null check, not instanceof: this replaces `?->ID`, which only
2098 - // short-circuits on null and does not care about the concrete class.
2099 - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2100 - if ( $collisionId > 0 ) {
2101 - return new \WP_REST_Response(
2102 - array(
2103 - 'success' => false,
2104 - 'code' => 'TITLE_COLLISION',
2105 - 'page_id' => $collisionId,
2106 - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2107 - 'message' => sprintf(
2108 - /* translators: %s = page title */
2109 - __( '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' ),
2110 - $desiredTitle
2111 - ),
2112 - ),
2113 - 409
2114 - );
2115 - }
2116 -
2117 - // 3. Insert.
2118 - $pageId = wp_insert_post(
2119 - array(
2120 - 'post_type' => 'page',
2121 - 'post_status' => 'publish',
2122 - 'post_title' => $desiredTitle,
2123 - 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2124 - 'post_author' => get_current_user_id(),
2125 - 'comment_status' => 'closed',
2126 - 'ping_status' => 'closed',
2127 - ),
2128 - true
2129 - );
2130 -
2131 - if ( is_wp_error( $pageId ) ) {
2132 - return new \WP_REST_Response(
2133 - array(
2134 - 'success' => false,
2135 - 'message' => $pageId->get_error_message(),
2136 - ),
2137 - 500
2138 - );
2139 - }
2140 -
2141 - return new \WP_REST_Response(
2142 - array(
2143 - 'success' => true,
2144 - 'created' => true,
2145 - 'page_id' => (int) $pageId,
2146 - 'page_title' => $desiredTitle,
2147 - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2148 - 'view_url' => get_permalink( (int) $pageId ),
2149 - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2150 - ),
2151 - 200
2152 - );
2001 + return $response instanceof \WP_REST_Response
2002 + ? $response
2003 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2153 2004 }
2154 2005
2155 2006 /**
2156 2007 * License gate for the User Creation endpoints.
@@ -2170,15 +2021,9 @@
2170 2021 }
2171 2022
2172 2023 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2173 2024 if ( ! $this->userCreationAuthorized() ) {
2174 - return new \WP_REST_Response(
2175 - array(
2176 - 'success' => false,
2177 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2178 - ),
2179 - 403
2180 - );
2025 + return $this->addonInactive( 'user-registration', 'User Registration' );
2181 2026 }
2182 2027
2183 2028 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2184 2029
@@ -2192,15 +2037,9 @@
2192 2037 }
2193 2038
2194 2039 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2195 2040 if ( ! $this->userCreationAuthorized() ) {
2196 - return new \WP_REST_Response(
2197 - array(
2198 - 'success' => false,
2199 - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2200 - ),
2201 - 403
2202 - );
2041 + return $this->addonInactive( 'user-registration', 'User Registration' );
2203 2042 }
2204 2043
2205 2044 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2206 2045
@@ -2213,16 +2052,10 @@
2213 2052 );
2214 2053 }
2215 2054
2216 2055 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2217 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2218 - return new \WP_REST_Response(
2219 - array(
2220 - 'success' => false,
2221 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2222 - ),
2223 - 403
2224 - );
2056 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2057 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2225 2058 }
2226 2059
2227 2060 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2228 2061
@@ -2235,16 +2068,10 @@
2235 2068 );
2236 2069 }
2237 2070
2238 2071 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2239 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2240 - return new \WP_REST_Response(
2241 - array(
2242 - 'success' => false,
2243 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2244 - ),
2245 - 403
2246 - );
2072 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2073 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2247 2074 }
2248 2075
2249 2076 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2250 2077
@@ -2338,40 +2165,8 @@
2338 2165
2339 2166 return new \WP_REST_Response( $result, $status );
2340 2167 }
2341 2168
2342 - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2343 - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2344 - return new \WP_REST_Response(
2345 - array(
2346 - 'success' => false,
2347 - 'message' => __( 'Pro version required.', 'double-opt-in' ),
2348 - ),
2349 - 403
2350 - );
2351 - }
2352 -
2353 - $input = $request->get_json_params();
2354 -
2355 - /**
2356 - * Filter to let Pro handle database export.
2357 - *
2358 - * @param array $result Result.
2359 - * @param array $input Export parameters.
2360 - * @since 4.2.0
2361 - */
2362 - $result = apply_filters(
2363 - 'f12_doi_rest_database_export',
2364 - array(
2365 - 'success' => false,
2366 - 'message' => __( 'Export not available.', 'double-opt-in' ),
2367 - ),
2368 - $input
2369 - );
2370 -
2371 - return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 );
2372 - }
2373 -
2374 2169 // ═══════════════════════════════════════════════════════════════
2375 2170 // HELPERS
2376 2171 // ═══════════════════════════════════════════════════════════════
2377 2172
@@ -2393,8 +2188,11 @@
2393 2188 'formId' => (int) $row['cf_form_id'],
2394 2189 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2395 2190 'category' => (int) $row['category'],
2396 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'] ?? '' ),
2397 2195 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2398 2196 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2399 2197 );
2400 2198
@@ -2405,8 +2203,10 @@
2405 2203 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2406 2204 $data['consentText'] = $row['consent_text'];
2407 2205 $data['consentField'] = $row['consent_field'] ?? '';
2408 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'] ?? '' ) );
2409 2209
2410 2210 // Category name
2411 2211 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2412 2212 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2626,10 +2426,10 @@
2626 2426 // First pass: every registered addon gets an entry, even if
2627 2427 // it contributes no UI. That lets the client show per-addon
2628 2428 // licensing/boot state without a second round-trip.
2629 2429 foreach ( $registered as $id => $addon ) {
2630 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2631 - $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 );
2632 2432 unset( $fragments[ $id ] );
2633 2433 }
2634 2434
2635 2435 // Second pass: fragments for addons NOT in the registry
@@ -2867,11 +2667,18 @@
2867 2667 }
2868 2668
2869 2669 $activateUrl = null;
2870 2670 if ( $installed && ! $active ) {
2871 - $activateUrl = wp_nonce_url(
2872 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2873 - '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' )
2874 2681 );
2875 2682 }
2876 2683
2877 2684 $registeredAddon = $registered[ $id ] ?? null;