PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.9.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.9.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 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 All 42 releases
← All changes | src/Admin/AdminRestController.php +160 -324 5.4.0 → 5.9.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;
@@ -30,8 +32,14 @@
30 32 class AdminRestController {
31 33
32 34 const API_NAMESPACE = 'f12-doi/v1';
33 35
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 +
34 42 private LoggerInterface $logger;
35 43 private FormSettingsService $formService;
36 44 private FormSettingsValidator $formValidator;
37 45
@@ -509,19 +517,8 @@
509 517 'permission_callback' => array( $this, 'checkPermission' ),
510 518 )
511 519 );
512 520
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 521 // ── Addons manifest (UI mount-point system, plan §9) ────────
525 522 register_rest_route(
526 523 self::API_NAMESPACE,
527 524 '/addons',
@@ -626,9 +623,10 @@
626 623 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
627 624
628 625 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
629 626 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
630 - $pending = $total - $confirmed;
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 );
631 629 $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0;
632 630
633 631 // Recent opt-ins (raw activity feed — not analytics).
634 632 // Time-bucketed activity, top-forms breakdown and the big
@@ -634,9 +632,9 @@
634 632 // Time-bucketed activity, top-forms breakdown and the big
635 633 // conversion-rate card moved into addon-analytics, which
636 634 // renders them at the `dashboard.widget` mount point.
637 635 $recent = $wpdb->get_results(
638 - "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5",
636 + "SELECT id, email, cf_form_id, doubleoptin, createtime, ipaddr_optout, optouttime FROM {$table} ORDER BY id DESC LIMIT 5",
639 637 ARRAY_A
640 638 );
641 639
642 640 foreach ( $recent as &$row ) {
@@ -642,14 +640,18 @@
642 640 foreach ( $recent as &$row ) {
643 641 $post = get_post( (int) $row['cf_form_id'] );
644 642 $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] );
645 643 $row['confirmed'] = (int) $row['doubleoptin'] === 1;
644 + $row['revoked'] = self::isRevokedRow( $row );
645 + unset( $row['ipaddr_optout'], $row['optouttime'] );
646 646 }
647 + unset( $row );
647 648
648 649 $data = array(
649 650 'totalOptins' => $total,
650 651 'confirmed' => $confirmed,
651 652 'pending' => $pending,
653 + 'revoked' => $revoked,
652 654 'conversionRate' => $rate,
653 655 'recentOptins' => $recent ?: array(),
654 656 );
655 657
@@ -730,8 +732,11 @@
730 732 if ( $status === 'confirmed' ) {
731 733 $where[] = 'doubleoptin = 1';
732 734 } elseif ( $status === 'pending' ) {
733 735 $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)';
736 + $where[] = 'NOT ' . self::REVOKED_SQL;
737 + } elseif ( $status === 'revoked' ) {
738 + $where[] = self::REVOKED_SQL;
734 739 }
735 740
736 741 if ( $formId !== null && $formId !== '' ) {
737 742 $where[] = 'cf_form_id = %d';
@@ -737,8 +742,24 @@
737 742 $where[] = 'cf_form_id = %d';
738 743 $params[] = (int) $formId;
739 744 }
740 745
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 +
741 762 $whereClause = implode( ' AND ', $where );
742 763
743 764 // Count
744 765 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -833,9 +854,9 @@
833 854 // Full row (id, hash, content, files, cf_form_id) so the
834 855 // pre-delete cascade hook from pre-doi-data-retention Step 1
835 856 // can fire with a payload that lets listeners reach into
836 857 // integration storage. ARRAY_A — listener-friendly.
837 - $row = $wpdb->get_row(
858 + $row = $wpdb->get_row(
838 859 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
839 860 ARRAY_A
840 861 );
841 862 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
@@ -893,8 +914,32 @@
893 914 200
894 915 );
895 916 }
896 917
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 +
897 942 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
898 943 global $wpdb;
899 944 $id = (int) $request->get_param( 'id' );
900 945 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
@@ -934,96 +979,23 @@
934 979 */
935 980 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
936 981
937 982 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 - }
983 + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
984 + ->get( ConfirmationMailResender::class )
985 + ->resend( $id );
968 986
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 - }
987 + if ( ! $outcome->isSent() ) {
988 + return self::resendRefusal( $outcome->getReason() );
998 989 }
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 );
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, '' );
1016 994 }
1017 995
1018 996 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 - );
997 + return self::resendRefusal( ResendResult::SEND_FAILED );
1026 998 }
1027 999
1028 1000 AuditLogger::log(
1029 1001 AuditLogger::TYPE_EMAIL,
@@ -1920,22 +1892,41 @@
1920 1892 );
1921 1893 }
1922 1894
1923 1895 // ═══════════════════════════════════════════════════════════════
1924 - // PRO-EXTENSIBLE STUBS
1925 - // These return minimal responses; Pro overrides via filters or
1926 - // registers its own REST routes that take precedence.
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.
1927 1902 // ═══════════════════════════════════════════════════════════════
1928 1903
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
1920 + ),
1921 + ),
1922 + 404
1923 + );
1924 + }
1925 +
1929 1926 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 - );
1927 + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1928 + return $this->addonInactive( 'analytics', 'Analytics' );
1938 1929 }
1939 1930
1940 1931 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1941 1932
@@ -1948,16 +1939,10 @@
1948 1939 );
1949 1940 }
1950 1941
1951 1942 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 - );
1943 + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1944 + return $this->addonInactive( 'analytics', 'Analytics' );
1960 1945 }
1961 1946
1962 1947 $formId = (int) $request->get_param( 'form_id' );
1963 1948 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
@@ -1971,16 +1956,10 @@
1971 1956 );
1972 1957 }
1973 1958
1974 1959 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 - );
1960 + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1961 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
1983 1962 }
1984 1963
1985 1964 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1986 1965
@@ -1993,16 +1972,10 @@
1993 1972 );
1994 1973 }
1995 1974
1996 1975 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 - );
1976 + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1977 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2005 1978 }
2006 1979
2007 1980 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2008 1981
@@ -2017,140 +1990,32 @@
2017 1990
2018 1991 /**
2019 1992 * POST /f12-doi/v1/optout/page/generate
2020 1993 *
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.
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.
2024 1997 *
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 1998 * @return \WP_REST_Response
2040 1999 */
2041 2000 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 - );
2001 + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
2002 + return $this->addonInactive( 'opt-out', 'Opt-Out' );
2050 2003 }
2051 2004
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 - }
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 );
2061 2014
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 - );
2015 + return $response instanceof \WP_REST_Response
2016 + ? $response
2017 + : $this->addonInactive( 'opt-out', 'Opt-Out' );
2153 2018 }
2154 2019
2155 2020 /**
2156 2021 * License gate for the User Creation endpoints.
@@ -2170,15 +2035,9 @@
2170 2035 }
2171 2036
2172 2037 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2173 2038 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 - );
2039 + return $this->addonInactive( 'user-registration', 'User Registration' );
2181 2040 }
2182 2041
2183 2042 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2184 2043
@@ -2192,15 +2051,9 @@
2192 2051 }
2193 2052
2194 2053 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2195 2054 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 - );
2055 + return $this->addonInactive( 'user-registration', 'User Registration' );
2203 2056 }
2204 2057
2205 2058 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2206 2059
@@ -2213,16 +2066,10 @@
2213 2066 );
2214 2067 }
2215 2068
2216 2069 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 - );
2070 + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2071 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2225 2072 }
2226 2073
2227 2074 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2228 2075
@@ -2235,16 +2082,10 @@
2235 2082 );
2236 2083 }
2237 2084
2238 2085 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 - );
2086 + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2087 + return $this->addonInactive( 'cleverreach', 'CleverReach' );
2247 2088 }
2248 2089
2249 2090 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2250 2091
@@ -2338,44 +2179,25 @@
2338 2179
2339 2180 return new \WP_REST_Response( $result, $status );
2340 2181 }
2341 2182
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 - }
2183 + // ═══════════════════════════════════════════════════════════════
2184 + // HELPERS
2185 + // ═══════════════════════════════════════════════════════════════
2352 2186
2353 - $input = $request->get_json_params();
2187 + /**
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'] ?? '' );
2354 2194
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 );
2195 + return (int) ( $row['doubleoptin'] ?? 0 ) !== 1
2196 + && (string) ( $row['ipaddr_optout'] ?? '' ) !== ''
2197 + && $optOutTime !== '' && $optOutTime !== '0';
2372 2198 }
2373 2199
2374 - // ═══════════════════════════════════════════════════════════════
2375 - // HELPERS
2376 - // ═══════════════════════════════════════════════════════════════
2377 -
2378 2200 /**
2379 2201 * Format an opt-in database row for the API response.
2380 2202 *
2381 2203 * @param array $row The database row.
@@ -2393,8 +2215,13 @@
2393 2215 'formId' => (int) $row['cf_form_id'],
2394 2216 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2395 2217 'category' => (int) $row['category'],
2396 2218 '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'] ?? '' ),
2397 2224 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2398 2225 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2399 2226 );
2400 2227
@@ -2405,8 +2232,10 @@
2405 2232 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2406 2233 $data['consentText'] = $row['consent_text'];
2407 2234 $data['consentField'] = $row['consent_field'] ?? '';
2408 2235 $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'] ?? '' ) );
2409 2238
2410 2239 // Category name
2411 2240 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2412 2241 $data['categoryName'] = $cat ? $cat->get_name() : null;
@@ -2626,10 +2455,10 @@
2626 2455 // First pass: every registered addon gets an entry, even if
2627 2456 // it contributes no UI. That lets the client show per-addon
2628 2457 // licensing/boot state without a second round-trip.
2629 2458 foreach ( $registered as $id => $addon ) {
2630 - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2631 - $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2459 + $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2460 + $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2632 2461 unset( $fragments[ $id ] );
2633 2462 }
2634 2463
2635 2464 // Second pass: fragments for addons NOT in the registry
@@ -2867,11 +2696,18 @@
2867 2696 }
2868 2697
2869 2698 $activateUrl = null;
2870 2699 if ( $installed && ! $active ) {
2871 - $activateUrl = wp_nonce_url(
2872 - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2873 - 'activate-plugin_' . $pluginFile
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' )
2874 2710 );
2875 2711 }
2876 2712
2877 2713 $registeredAddon = $registered[ $id ] ?? null;