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 +324 -160 5.11.0 → 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;
@@ -32,14 +30,8 @@
32 30 class AdminRestController {
33 31
34 32 const API_NAMESPACE = 'f12-doi/v1';
35 33
36 - /**
37 - * SQL form of OptIn::isOptedOut(): not confirmed, withdrawal IP and time
38 - * recorded. A re-opt-in clears the time, so the row counts as confirmed again.
39 - */
40 - private const REVOKED_SQL = "(doubleoptin = 0 AND ipaddr_optout IS NOT NULL AND ipaddr_optout <> '' AND optouttime IS NOT NULL AND optouttime NOT IN ('', '0'))";
41 -
42 34 private LoggerInterface $logger;
43 35 private FormSettingsService $formService;
44 36 private FormSettingsValidator $formValidator;
45 37
@@ -517,8 +509,19 @@
517 509 'permission_callback' => array( $this, 'checkPermission' ),
518 510 )
519 511 );
520 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 +
521 524 // ── Addons manifest (UI mount-point system, plan §9) ────────
522 525 register_rest_route(
523 526 self::API_NAMESPACE,
524 527 '/addons',
@@ -623,10 +626,9 @@
623 626 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
624 627
625 628 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
626 629 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
627 - $revoked = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE " . self::REVOKED_SQL ); // phpcs:ignore WordPress.DB.PreparedSQL -- fixed SQL, no input.
628 - $pending = max( 0, $total - $confirmed - $revoked );
630 + $pending = $total - $confirmed;
629 631 $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0;
630 632
631 633 // Recent opt-ins (raw activity feed — not analytics).
632 634 // Time-bucketed activity, top-forms breakdown and the big
@@ -632,9 +634,9 @@
632 634 // Time-bucketed activity, top-forms breakdown and the big
633 635 // conversion-rate card moved into addon-analytics, which
634 636 // renders them at the `dashboard.widget` mount point.
635 637 $recent = $wpdb->get_results(
636 - "SELECT id, email, cf_form_id, doubleoptin, createtime, ipaddr_optout, optouttime FROM {$table} ORDER BY id DESC LIMIT 5",
638 + "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5",
637 639 ARRAY_A
638 640 );
639 641
640 642 foreach ( $recent as &$row ) {
@@ -640,18 +642,14 @@
640 642 foreach ( $recent as &$row ) {
641 643 $post = get_post( (int) $row['cf_form_id'] );
642 644 $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] );
643 645 $row['confirmed'] = (int) $row['doubleoptin'] === 1;
644 - $row['revoked'] = self::isRevokedRow( $row );
645 - unset( $row['ipaddr_optout'], $row['optouttime'] );
646 646 }
647 - unset( $row );
648 647
649 648 $data = array(
650 649 'totalOptins' => $total,
651 650 'confirmed' => $confirmed,
652 651 'pending' => $pending,
653 - 'revoked' => $revoked,
654 652 'conversionRate' => $rate,
655 653 'recentOptins' => $recent ?: array(),
656 654 );
657 655
@@ -732,11 +730,8 @@
732 730 if ( $status === 'confirmed' ) {
733 731 $where[] = 'doubleoptin = 1';
734 732 } elseif ( $status === 'pending' ) {
735 733 $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)';
736 - $where[] = 'NOT ' . self::REVOKED_SQL;
737 - } elseif ( $status === 'revoked' ) {
738 - $where[] = self::REVOKED_SQL;
739 734 }
740 735
741 736 if ( $formId !== null && $formId !== '' ) {
742 737 $where[] = 'cf_form_id = %d';
@@ -742,24 +737,8 @@
742 737 $where[] = 'cf_form_id = %d';
743 738 $params[] = (int) $formId;
744 739 }
745 740
746 - // Opt-ins whose confirmation mail could not be sent (5.8.0).
747 - if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) {
748 - $where[] = 'mail_status = %s';
749 - $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED;
750 - }
751 -
752 - // Confirmed opt-ins whose follow-up actions failed or have an
753 - // unknown outcome — the admin's "needs attention" list.
754 - if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
755 - $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
756 - $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic();
757 - $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN ("
758 - . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))';
759 - $params = array_merge( $params, $problems );
760 - }
761 -
762 741 $whereClause = implode( ' AND ', $where );
763 742
764 743 // Count
765 744 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -854,9 +833,9 @@
854 833 // Full row (id, hash, content, files, cf_form_id) so the
855 834 // pre-delete cascade hook from pre-doi-data-retention Step 1
856 835 // can fire with a payload that lets listeners reach into
857 836 // integration storage. ARRAY_A — listener-friendly.
858 - $row = $wpdb->get_row(
837 + $row = $wpdb->get_row(
859 838 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
860 839 ARRAY_A
861 840 );
862 841 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -914,32 +893,8 @@
914 893 200
915 894 );
916 895 }
917 896
918 - /**
919 - * The admin's answer for a resend that did not go out.
920 - */
921 - private static function resendRefusal( string $reason ): \WP_REST_Response {
922 - $map = array(
923 - ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ),
924 - ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ),
925 - ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ),
926 - ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ),
927 - ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ),
928 - );
929 - $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 );
930 - $message = $entry[0];
931 - $status = $entry[1];
932 -
933 - return new \WP_REST_Response(
934 - array(
935 - 'success' => false,
936 - 'message' => $message,
937 - ),
938 - $status
939 - );
940 - }
941 -
942 897 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
943 898 global $wpdb;
944 899 $id = (int) $request->get_param( 'id' );
945 900 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -979,23 +934,96 @@
979 934 */
980 935 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
981 936
982 937 if ( $result === null ) {
983 - $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
984 - ->get( ConfirmationMailResender::class )
985 - ->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 + }
986 968
987 - if ( ! $outcome->isSent() ) {
988 - 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 + }
989 998 }
990 - $result = true;
991 - } else {
992 - // An extension sent it; record the outcome all the same.
993 - 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 );
994 1016 }
995 1017
996 1018 if ( ! $result ) {
997 - 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 + );
998 1026 }
999 1027
1000 1028 AuditLogger::log(
1001 1029 AuditLogger::TYPE_EMAIL,
@@ -1892,41 +1920,22 @@
1892 1920 );
1893 1921 }
1894 1922
1895 1923 // ═══════════════════════════════════════════════════════════════
1896 - // ADD-ON ROUTES
1897 - // Core owns the route; the data comes from the add-on through a
1898 - // filter. Without a handler the answer is ADDON_INACTIVE. Core
1899 - // itself never checks a licence here (wordpress.org guideline 5):
1900 - // the functionality lives in the add-on, which only hooks in when
1901 - // 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.
1902 1927 // ═══════════════════════════════════════════════════════════════
1903 1928
1904 - /**
1905 - * Answer for a route whose add-on is not running.
1906 - *
1907 - * 404 with `code` so the SPA can tell it from an unknown route
1908 - * (`rest_no_route`); `ApiError` reads `body.code`.
1909 - */
1910 - private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response {
1911 - return new \WP_REST_Response(
1912 - array(
1913 - 'success' => false,
1914 - 'code' => 'ADDON_INACTIVE',
1915 - 'addon' => $addonId,
1916 - 'message' => sprintf(
1917 - /* translators: %s: add-on name */
1918 - __( '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' ),
1919 - $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' ),
1920 1935 ),
1921 - ),
1922 - 404
1923 - );
1924 - }
1925 -
1926 - public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1927 - if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1928 - return $this->addonInactive( 'analytics', 'Analytics' );
1936 + 403
1937 + );
1929 1938 }
1930 1939
1931 1940 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1932 1941
@@ -1939,10 +1948,16 @@
1939 1948 );
1940 1949 }
1941 1950
1942 1951 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1943 - if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1944 - 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 + );
1945 1960 }
1946 1961
1947 1962 $formId = (int) $request->get_param( 'form_id' );
1948 1963 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1956,10 +1971,16 @@
1956 1971 );
1957 1972 }
1958 1973
1959 1974 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1960 - if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1961 - 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 + );
1962 1983 }
1963 1984
1964 1985 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1965 1986
@@ -1972,10 +1993,16 @@
1972 1993 );
1973 1994 }
1974 1995
1975 1996 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1976 - if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1977 - 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 + );
1978 2005 }
1979 2006
1980 2007 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
1981 2008
@@ -1990,32 +2017,140 @@
1990 2017
1991 2018 /**
1992 2019 * POST /f12-doi/v1/optout/page/generate
1993 2020 *
1994 - * One-click generator for the opt-out landing page. The logic lives in
1995 - * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through
1996 - * 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.
1997 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 + *
1998 2039 * @return \WP_REST_Response
1999 2040 */
2000 2041 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2001 - if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
2002 - 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 + );
2003 2050 }
2004 2051
2005 - /**
2006 - * Filter: answer the opt-out page generator request.
2007 - *
2008 - * @param \WP_REST_Response|null $response Null until a handler answers.
2009 - * @param \WP_REST_Request $request The request.
2010 - *
2011 - * @since 5.8.0
2012 - */
2013 - $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 + }
2014 2061
2015 - return $response instanceof \WP_REST_Response
2016 - ? $response
2017 - : $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 + );
2018 2153 }
2019 2154
2020 2155 /**
2021 2156 * License gate for the User Creation endpoints.
@@ -2035,9 +2170,15 @@
2035 2170 }
2036 2171
2037 2172 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2038 2173 if ( ! $this->userCreationAuthorized() ) {
2039 - 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 + );
2040 2181 }
2041 2182
2042 2183 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2043 2184
@@ -2051,9 +2192,15 @@
2051 2192 }
2052 2193
2053 2194 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2054 2195 if ( ! $this->userCreationAuthorized() ) {
2055 - 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 + );
2056 2203 }
2057 2204
2058 2205 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2059 2206
@@ -2066,10 +2213,16 @@
2066 2213 );
2067 2214 }
2068 2215
2069 2216 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2070 - if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2071 - 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 + );
2072 2225 }
2073 2226
2074 2227 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2075 2228
@@ -2082,10 +2235,16 @@
2082 2235 );
2083 2236 }
2084 2237
2085 2238 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2086 - if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2087 - 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 + );
2088 2247 }
2089 2248
2090 2249 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2091 2250
@@ -2179,26 +2338,45 @@
2179 2338
2180 2339 return new \WP_REST_Response( $result, $status );
2181 2340 }
2182 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 +
2183 2374 // ═══════════════════════════════════════════════════════════════
2184 2375 // HELPERS
2185 2376 // ═══════════════════════════════════════════════════════════════
2186 2377
2187 2378 /**
2188 - * PHP form of REVOKED_SQL for a raw table row.
2189 - *
2190 - * @param array<string, mixed> $row The database row.
2191 - */
2192 - private static function isRevokedRow( array $row ): bool {
2193 - $optOutTime = (string) ( $row['optouttime'] ?? '' );
2194 -
2195 - return (int) ( $row['doubleoptin'] ?? 0 ) !== 1
2196 - && (string) ( $row['ipaddr_optout'] ?? '' ) !== ''
2197 - && $optOutTime !== '' && $optOutTime !== '0';
2198 - }
2199 -
2200 - /**
2201 2379 * Format an opt-in database row for the API response.
2202 2380 *
2203 2381 * @param array $row The database row.
2204 2382 * @param bool $detailed Whether to include full detail (content, mail data).
@@ -2215,13 +2393,8 @@
2215 2393 'formId' => (int) $row['cf_form_id'],
2216 2394 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2217 2395 'category' => (int) $row['category'],
2218 2396 'confirmed' => (int) $row['doubleoptin'] === 1,
2219 - // Consent withdrawn via the opt-out (5.9.0). Not "pending".
2220 - 'revoked' => self::isRevokedRow( $row ),
2221 - // Confirmation mail: 'sent' (handed to the mail server), 'failed',
2222 - // or '' (recorded before 5.8.0). Since 5.8.0.
2223 - 'mailStatus' => (string) ( $row['mail_status'] ?? '' ),
2224 2397 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2225 2398 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2226 2399 );
2227 2400
@@ -2232,10 +2405,8 @@
2232 2405 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2233 2406 $data['consentText'] = $row['consent_text'];
2234 2407 $data['consentField'] = $row['consent_field'] ?? '';
2235 2408 $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] );
2236 - $data['mailError'] = (string) ( $row['mail_error'] ?? '' );
2237 - $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) );
2238 2409
2239 2410 // Category name
2240 2411 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2241 2412 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2455,10 +2626,10 @@
2455 2626 // First pass: every registered addon gets an entry, even if
2456 2627 // it contributes no UI. That lets the client show per-addon
2457 2628 // licensing/boot state without a second round-trip.
2458 2629 foreach ( $registered as $id => $addon ) {
2459 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2460 - $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 );
2461 2632 unset( $fragments[ $id ] );
2462 2633 }
2463 2634
2464 2635 // Second pass: fragments for addons NOT in the registry
@@ -2696,18 +2867,11 @@
2696 2867 }
2697 2868
2698 2869 $activateUrl = null;
2699 2870 if ( $installed && ! $active ) {
2700 - // Not wp_nonce_url(): it HTML-escapes & to &amp;, and this URL
2701 - // goes as JSON into an href — "plugin" and "_wpnonce" then
2702 - // arrived as "amp;plugin" and the activation failed.
2703 - $activateUrl = add_query_arg(
2704 - array(
2705 - 'action' => 'activate',
2706 - 'plugin' => rawurlencode( $pluginFile ),
2707 - '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ),
2708 - ),
2709 - 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
2710 2874 );
2711 2875 }
2712 2876
2713 2877 $registeredAddon = $registered[ $id ] ?? null;