PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.0
5.12.0 5.13.0 5.13.1 5.11.0 5.10.0 5.9.0 5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 All 47 releases
← All changes | src/Admin/AdminRestController.php +322 -129 5.8.1 → 5.5.0 View file →
@@ -15,10 +15,8 @@
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;
21 19 use Forge12\Shared\LoggerInterface;
22 20
23 21 if ( ! defined( 'ABSPATH' ) ) {
24 22 exit;
@@ -511,8 +509,19 @@
511 509 'permission_callback' => array( $this, 'checkPermission' ),
512 510 )
513 511 );
514 512
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 +
515 524 // ── Addons manifest (UI mount-point system, plan §9) ────────
516 525 register_rest_route(
517 526 self::API_NAMESPACE,
518 527 '/addons',
@@ -728,24 +737,8 @@
728 737 $where[] = 'cf_form_id = %d';
729 738 $params[] = (int) $formId;
730 739 }
731 740
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 -
748 741 $whereClause = implode( ' AND ', $where );
749 742
750 743 // Count
751 744 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -840,9 +833,9 @@
840 833 // Full row (id, hash, content, files, cf_form_id) so the
841 834 // pre-delete cascade hook from pre-doi-data-retention Step 1
842 835 // can fire with a payload that lets listeners reach into
843 836 // integration storage. ARRAY_A — listener-friendly.
844 - $row = $wpdb->get_row(
837 + $row = $wpdb->get_row(
845 838 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
846 839 ARRAY_A
847 840 );
848 841 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -900,32 +893,8 @@
900 893 200
901 894 );
902 895 }
903 896
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 -
928 897 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
929 898 global $wpdb;
930 899 $id = (int) $request->get_param( 'id' );
931 900 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -965,23 +934,96 @@
965 934 */
966 935 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
967 936
968 937 if ( $result === null ) {
969 - $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
970 - ->get( ConfirmationMailResender::class )
971 - ->resend( $id );
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 + }
972 968
973 - if ( ! $outcome->isSent() ) {
974 - return self::resendRefusal( $outcome->getReason() );
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 + }
975 998 }
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, '' );
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 );
980 1016 }
981 1017
982 1018 if ( ! $result ) {
983 - return self::resendRefusal( ResendResult::SEND_FAILED );
1019 + return new \WP_REST_Response(
1020 + array(
1021 + 'success' => false,
1022 + 'message' => __( 'Failed to send email.', 'double-opt-in' ),
1023 + ),
1024 + 500
1025 + );
984 1026 }
985 1027
986 1028 AuditLogger::log(
987 1029 AuditLogger::TYPE_EMAIL,
@@ -1878,41 +1920,22 @@
1878 1920 );
1879 1921 }
1880 1922
1881 1923 // ═══════════════════════════════════════════════════════════════
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.
1924 + // PRO-EXTENSIBLE STUBS
1925 + // These return minimal responses; Pro overrides via filters or
1926 + // registers its own REST routes that take precedence.
1888 1927 // ═══════════════════════════════════════════════════════════════
1889 1928
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
1929 + 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' ),
1906 1935 ),
1907 - ),
1908 - 404
1909 - );
1910 - }
1911 -
1912 - public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1913 - if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1914 - return $this->addonInactive( 'analytics', 'Analytics' );
1936 + 403
1937 + );
1915 1938 }
1916 1939
1917 1940 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1918 1941
@@ -1925,10 +1948,16 @@
1925 1948 );
1926 1949 }
1927 1950
1928 1951 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1929 - if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1930 - return $this->addonInactive( 'analytics', 'Analytics' );
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 + );
1931 1960 }
1932 1961
1933 1962 $formId = (int) $request->get_param( 'form_id' );
1934 1963 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1942,10 +1971,16 @@
1942 1971 );
1943 1972 }
1944 1973
1945 1974 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1946 - if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1947 - return $this->addonInactive( 'opt-out', 'Opt-Out' );
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 + );
1948 1983 }
1949 1984
1950 1985 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1951 1986
@@ -1958,10 +1993,16 @@
1958 1993 );
1959 1994 }
1960 1995
1961 1996 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1962 - if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1963 - return $this->addonInactive( 'opt-out', 'Opt-Out' );
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 + );
1964 2005 }
1965 2006
1966 2007 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
1967 2008
@@ -1976,32 +2017,140 @@
1976 2017
1977 2018 /**
1978 2019 * POST /f12-doi/v1/optout/page/generate
1979 2020 *
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.
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.
1983 2024 *
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 + *
1984 2039 * @return \WP_REST_Response
1985 2040 */
1986 2041 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
1987 - if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
1988 - return $this->addonInactive( 'opt-out', 'Opt-Out' );
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 + );
1989 2050 }
1990 2051
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 );
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 + }
2000 2061
2001 - return $response instanceof \WP_REST_Response
2002 - ? $response
2003 - : $this->addonInactive( 'opt-out', 'Opt-Out' );
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 + );
2004 2153 }
2005 2154
2006 2155 /**
2007 2156 * License gate for the User Creation endpoints.
@@ -2021,9 +2170,15 @@
2021 2170 }
2022 2171
2023 2172 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2024 2173 if ( ! $this->userCreationAuthorized() ) {
2025 - return $this->addonInactive( 'user-registration', 'User Registration' );
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 + );
2026 2181 }
2027 2182
2028 2183 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2029 2184
@@ -2037,9 +2192,15 @@
2037 2192 }
2038 2193
2039 2194 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2040 2195 if ( ! $this->userCreationAuthorized() ) {
2041 - return $this->addonInactive( 'user-registration', 'User Registration' );
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 + );
2042 2203 }
2043 2204
2044 2205 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2045 2206
@@ -2052,10 +2213,16 @@
2052 2213 );
2053 2214 }
2054 2215
2055 2216 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2056 - if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2057 - return $this->addonInactive( 'cleverreach', 'CleverReach' );
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 + );
2058 2225 }
2059 2226
2060 2227 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2061 2228
@@ -2068,10 +2235,16 @@
2068 2235 );
2069 2236 }
2070 2237
2071 2238 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2072 - if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2073 - return $this->addonInactive( 'cleverreach', 'CleverReach' );
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 + );
2074 2247 }
2075 2248
2076 2249 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2077 2250
@@ -2165,8 +2338,40 @@
2165 2338
2166 2339 return new \WP_REST_Response( $result, $status );
2167 2340 }
2168 2341
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 +
2169 2374 // ═══════════════════════════════════════════════════════════════
2170 2375 // HELPERS
2171 2376 // ═══════════════════════════════════════════════════════════════
2172 2377
@@ -2188,11 +2393,8 @@
2188 2393 'formId' => (int) $row['cf_form_id'],
2189 2394 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2190 2395 'category' => (int) $row['category'],
2191 2396 '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'] ?? '' ),
2195 2397 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2196 2398 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2197 2399 );
2198 2400
@@ -2203,10 +2405,8 @@
2203 2405 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2204 2406 $data['consentText'] = $row['consent_text'];
2205 2407 $data['consentField'] = $row['consent_field'] ?? '';
2206 2408 $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'] ?? '' ) );
2209 2409
2210 2410 // Category name
2211 2411 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2212 2412 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2426,10 +2626,10 @@
2426 2626 // First pass: every registered addon gets an entry, even if
2427 2627 // it contributes no UI. That lets the client show per-addon
2428 2628 // licensing/boot state without a second round-trip.
2429 2629 foreach ( $registered as $id => $addon ) {
2430 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2431 - $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2630 + $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2631 + $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2432 2632 unset( $fragments[ $id ] );
2433 2633 }
2434 2634
2435 2635 // Second pass: fragments for addons NOT in the registry
@@ -2667,18 +2867,11 @@
2667 2867 }
2668 2868
2669 2869 $activateUrl = null;
2670 2870 if ( $installed && ! $active ) {
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' )
2871 + $activateUrl = wp_nonce_url(
2872 + self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2873 + 'activate-plugin_' . $pluginFile
2681 2874 );
2682 2875 }
2683 2876
2684 2877 $registeredAddon = $registered[ $id ] ?? null;