| @@ -15,8 +15,11 @@ | ||
| 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 | +use Forge12\DoubleOptIn\Subscription\SubscriptionGroups; | |
| 19 | 22 | use Forge12\Shared\LoggerInterface; |
| 20 | 23 | |
| 21 | 24 | if ( ! defined( 'ABSPATH' ) ) { |
| 22 | 25 | exit; |
| @@ -30,8 +33,14 @@ | ||
| 30 | 33 | class AdminRestController { |
| 31 | 34 | |
| 32 | 35 | const API_NAMESPACE = 'f12-doi/v1'; |
| 33 | 36 | |
| 37 | + /** | |
| 38 | + * SQL form of OptIn::isOptedOut(): not confirmed, withdrawal IP and time | |
| 39 | + * recorded. A re-opt-in clears the time, so the row counts as confirmed again. | |
| 40 | + */ | |
| 41 | + 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'))"; | |
| 42 | + | |
| 34 | 43 | private LoggerInterface $logger; |
| 35 | 44 | private FormSettingsService $formService; |
| 36 | 45 | private FormSettingsValidator $formValidator; |
| 37 | 46 | |
| @@ -105,8 +114,19 @@ | ||
| 105 | 114 | 'permission_callback' => array( $this, 'checkPermission' ), |
| 106 | 115 | ) |
| 107 | 116 | ); |
| 108 | 117 | |
| 118 | + // ── Subscription groups (read-only; managed by an add-on) ── | |
| 119 | + register_rest_route( | |
| 120 | + self::API_NAMESPACE, | |
| 121 | + '/subscription-groups', | |
| 122 | + array( | |
| 123 | + 'methods' => \WP_REST_Server::READABLE, | |
| 124 | + 'callback' => array( $this, 'getSubscriptionGroups' ), | |
| 125 | + 'permission_callback' => array( $this, 'checkPermission' ), | |
| 126 | + ) | |
| 127 | + ); | |
| 128 | + | |
| 109 | 129 | // ── Opt-Ins ──────────────────────────────────────────────── |
| 110 | 130 | register_rest_route( |
| 111 | 131 | self::API_NAMESPACE, |
| 112 | 132 | '/optins', |
| @@ -509,19 +529,8 @@ | ||
| 509 | 529 | 'permission_callback' => array( $this, 'checkPermission' ), |
| 510 | 530 | ) |
| 511 | 531 | ); |
| 512 | 532 | |
| 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 | 533 | // ── Addons manifest (UI mount-point system, plan §9) ──────── |
| 525 | 534 | register_rest_route( |
| 526 | 535 | self::API_NAMESPACE, |
| 527 | 536 | '/addons', |
| @@ -626,9 +635,10 @@ | ||
| 626 | 635 | $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; |
| 627 | 636 | |
| 628 | 637 | $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); |
| 629 | 638 | $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" ); |
| 630 | - $pending = $total - $confirmed; | |
| 639 | + $revoked = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE " . self::REVOKED_SQL ); // phpcs:ignore WordPress.DB.PreparedSQL -- fixed SQL, no input. | |
| 640 | + $pending = max( 0, $total - $confirmed - $revoked ); | |
| 631 | 641 | $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0; |
| 632 | 642 | |
| 633 | 643 | // Recent opt-ins (raw activity feed — not analytics). |
| 634 | 644 | // Time-bucketed activity, top-forms breakdown and the big |
| @@ -634,9 +644,9 @@ | ||
| 634 | 644 | // Time-bucketed activity, top-forms breakdown and the big |
| 635 | 645 | // conversion-rate card moved into addon-analytics, which |
| 636 | 646 | // renders them at the `dashboard.widget` mount point. |
| 637 | 647 | $recent = $wpdb->get_results( |
| 638 | - "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5", | |
| 648 | + "SELECT id, email, cf_form_id, doubleoptin, createtime, ipaddr_optout, optouttime FROM {$table} ORDER BY id DESC LIMIT 5", | |
| 639 | 649 | ARRAY_A |
| 640 | 650 | ); |
| 641 | 651 | |
| 642 | 652 | foreach ( $recent as &$row ) { |
| @@ -642,14 +652,18 @@ | ||
| 642 | 652 | foreach ( $recent as &$row ) { |
| 643 | 653 | $post = get_post( (int) $row['cf_form_id'] ); |
| 644 | 654 | $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ); |
| 645 | 655 | $row['confirmed'] = (int) $row['doubleoptin'] === 1; |
| 656 | + $row['revoked'] = self::isRevokedRow( $row ); | |
| 657 | + unset( $row['ipaddr_optout'], $row['optouttime'] ); | |
| 646 | 658 | } |
| 659 | + unset( $row ); | |
| 647 | 660 | |
| 648 | 661 | $data = array( |
| 649 | 662 | 'totalOptins' => $total, |
| 650 | 663 | 'confirmed' => $confirmed, |
| 651 | 664 | 'pending' => $pending, |
| 665 | + 'revoked' => $revoked, | |
| 652 | 666 | 'conversionRate' => $rate, |
| 653 | 667 | 'recentOptins' => $recent ?: array(), |
| 654 | 668 | ); |
| 655 | 669 | |
| @@ -701,8 +715,35 @@ | ||
| 701 | 715 | // ═══════════════════════════════════════════════════════════════ |
| 702 | 716 | // OPT-INS |
| 703 | 717 | // ═══════════════════════════════════════════════════════════════ |
| 704 | 718 | |
| 719 | + /** | |
| 720 | + * Subscription groups for the filter in the opt-in list. `available` | |
| 721 | + * is false when no add-on provides groups, so the SPA hides the | |
| 722 | + * controls instead of showing an empty filter. | |
| 723 | + */ | |
| 724 | + public function getSubscriptionGroups( \WP_REST_Request $request ): \WP_REST_Response { | |
| 725 | + $groups = array(); | |
| 726 | + foreach ( SubscriptionGroups::resolver()->groups() as $group ) { | |
| 727 | + $groups[] = array( | |
| 728 | + 'key' => $group->getKey(), | |
| 729 | + 'label' => $group->getLabel(), | |
| 730 | + 'memberCount' => count( $group->getMembers() ), | |
| 731 | + ); | |
| 732 | + } | |
| 733 | + | |
| 734 | + return new \WP_REST_Response( | |
| 735 | + array( | |
| 736 | + 'success' => true, | |
| 737 | + 'data' => array( | |
| 738 | + 'available' => SubscriptionGroups::isAvailable(), | |
| 739 | + 'groups' => $groups, | |
| 740 | + ), | |
| 741 | + ), | |
| 742 | + 200 | |
| 743 | + ); | |
| 744 | + } | |
| 745 | + | |
| 705 | 746 | public function getOptins( \WP_REST_Request $request ): \WP_REST_Response { |
| 706 | 747 | $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 ); |
| 707 | 748 | $perPage = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ?: 20 ) ); |
| 708 | 749 | $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' ); |
| @@ -730,8 +771,11 @@ | ||
| 730 | 771 | if ( $status === 'confirmed' ) { |
| 731 | 772 | $where[] = 'doubleoptin = 1'; |
| 732 | 773 | } elseif ( $status === 'pending' ) { |
| 733 | 774 | $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)'; |
| 775 | + $where[] = 'NOT ' . self::REVOKED_SQL; | |
| 776 | + } elseif ( $status === 'revoked' ) { | |
| 777 | + $where[] = self::REVOKED_SQL; | |
| 734 | 778 | } |
| 735 | 779 | |
| 736 | 780 | if ( $formId !== null && $formId !== '' ) { |
| 737 | 781 | $where[] = 'cf_form_id = %d'; |
| @@ -737,8 +781,28 @@ | ||
| 737 | 781 | $where[] = 'cf_form_id = %d'; |
| 738 | 782 | $params[] = (int) $formId; |
| 739 | 783 | } |
| 740 | 784 | |
| 785 | + // Subscription group (5.12.0). An unknown key selects nothing, so a | |
| 786 | + // stale link can never widen into the unfiltered list. | |
| 787 | + $groupKey = sanitize_text_field( (string) ( $request->get_param( 'group' ) ?? '' ) ); | |
| 788 | + if ( $groupKey !== '' ) { | |
| 789 | + $group = SubscriptionGroups::resolver()->findGroup( $groupKey ); | |
| 790 | + if ( $group === null ) { | |
| 791 | + $where[] = '1 = 0'; | |
| 792 | + } else { | |
| 793 | + list( $groupSql, $groupParams ) = $group->toSqlCondition(); | |
| 794 | + $where[] = $groupSql; | |
| 795 | + $params = array_merge( $params, $groupParams ); | |
| 796 | + } | |
| 797 | + } | |
| 798 | + | |
| 799 | + // Opt-ins whose confirmation mail could not be sent (5.8.0). | |
| 800 | + if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) { | |
| 801 | + $where[] = 'mail_status = %s'; | |
| 802 | + $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED; | |
| 803 | + } | |
| 804 | + | |
| 741 | 805 | // Confirmed opt-ins whose follow-up actions failed or have an |
| 742 | 806 | // unknown outcome — the admin's "needs attention" list. |
| 743 | 807 | if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) { |
| 744 | 808 | $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME; |
| @@ -801,8 +865,12 @@ | ||
| 801 | 865 | } |
| 802 | 866 | |
| 803 | 867 | $data = $this->formatOptinRow( $row, true ); |
| 804 | 868 | |
| 869 | + // Other sign-ups of the same address in the same subscription | |
| 870 | + // group (5.12.0). The records stay separate; this only links them. | |
| 871 | + $data['linkedOptIns'] = $this->linkedOptIns( $row ); | |
| 872 | + | |
| 805 | 873 | // Dev-mode UI hint: surface whether the reset-confirmation |
| 806 | 874 | // endpoint is reachable for this request, so the React detail |
| 807 | 875 | // page can show/hide the "Reset to pending" button without |
| 808 | 876 | // having to probe the endpoint and handle a 403. Mirrors |
| @@ -843,9 +911,9 @@ | ||
| 843 | 911 | // Full row (id, hash, content, files, cf_form_id) so the |
| 844 | 912 | // pre-delete cascade hook from pre-doi-data-retention Step 1 |
| 845 | 913 | // can fire with a payload that lets listeners reach into |
| 846 | 914 | // integration storage. ARRAY_A — listener-friendly. |
| 847 | - $row = $wpdb->get_row( | |
| 915 | + $row = $wpdb->get_row( | |
| 848 | 916 | $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ), |
| 849 | 917 | ARRAY_A |
| 850 | 918 | ); |
| 851 | 919 | $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null; |
| @@ -903,8 +971,32 @@ | ||
| 903 | 971 | 200 |
| 904 | 972 | ); |
| 905 | 973 | } |
| 906 | 974 | |
| 975 | + /** | |
| 976 | + * The admin's answer for a resend that did not go out. | |
| 977 | + */ | |
| 978 | + private static function resendRefusal( string $reason ): \WP_REST_Response { | |
| 979 | + $map = array( | |
| 980 | + ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ), | |
| 981 | + ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ), | |
| 982 | + ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ), | |
| 983 | + ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ), | |
| 984 | + ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ), | |
| 985 | + ); | |
| 986 | + $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 ); | |
| 987 | + $message = $entry[0]; | |
| 988 | + $status = $entry[1]; | |
| 989 | + | |
| 990 | + return new \WP_REST_Response( | |
| 991 | + array( | |
| 992 | + 'success' => false, | |
| 993 | + 'message' => $message, | |
| 994 | + ), | |
| 995 | + $status | |
| 996 | + ); | |
| 997 | + } | |
| 998 | + | |
| 907 | 999 | public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response { |
| 908 | 1000 | global $wpdb; |
| 909 | 1001 | $id = (int) $request->get_param( 'id' ); |
| 910 | 1002 | $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; |
| @@ -944,96 +1036,23 @@ | ||
| 944 | 1036 | */ |
| 945 | 1037 | $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row ); |
| 946 | 1038 | |
| 947 | 1039 | if ( $result === null ) { |
| 948 | - // Default resend logic: use stored mail data. | |
| 949 | - // | |
| 950 | - // `mail_optin` is shipped by every integration via | |
| 951 | - // {@see \forge12\contactform7\CF7DoubleOptIn\OptIn::set_mail_optin()}. | |
| 952 | - // That method takes a STRING (the rendered HTML body) — the | |
| 953 | - // admin opt-in-detail UI reads it as-is for the body | |
| 954 | - // preview. Earlier versions of this handler expected a | |
| 955 | - // serialized `['to' => ..., 'subject' => ..., 'body' => ...]` | |
| 956 | - // array and bailed with "Email data is incomplete" whenever | |
| 957 | - // the stored value was the (correct) plain body string — | |
| 958 | - // which is the production case for every free-version | |
| 959 | - // integration (CF7 / Avada / WPForms / Gravity / Elementor). | |
| 960 | - // User-reported 2026-05-13: clicking Resend yielded that | |
| 961 | - // error 100 % of the time. | |
| 962 | - // | |
| 963 | - // Both shapes are accepted now: the array form for Pro and | |
| 964 | - // any future caller that stores structured payloads, the | |
| 965 | - // plain string for the free-version integrations whose | |
| 966 | - // contract is documented in | |
| 967 | - // {@see \Forge12\DoubleOptIn\Wpforms\Tests\Unit\Integration\WPFormsSettingsApplyTest}. | |
| 968 | - $mailOptin = $row['mail_optin'] ?? ''; | |
| 969 | - if ( empty( $mailOptin ) ) { | |
| 970 | - return new \WP_REST_Response( | |
| 971 | - array( | |
| 972 | - 'success' => false, | |
| 973 | - 'message' => __( 'No email data available for resend.', 'double-opt-in' ), | |
| 974 | - ), | |
| 975 | - 400 | |
| 976 | - ); | |
| 977 | - } | |
| 1040 | + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance() | |
| 1041 | + ->get( ConfirmationMailResender::class ) | |
| 1042 | + ->resend( $id ); | |
| 978 | 1043 | |
| 979 | - $unserialized = maybe_unserialize( $mailOptin ); | |
| 980 | - | |
| 981 | - if ( is_array( $unserialized ) ) { | |
| 982 | - // Structured payload (Pro / future writers). | |
| 983 | - $to = $unserialized['to'] ?? ''; | |
| 984 | - $subject = $unserialized['subject'] ?? ''; | |
| 985 | - $body = $unserialized['body'] ?? ''; | |
| 986 | - $from = $unserialized['from'] ?? ''; | |
| 987 | - } else { | |
| 988 | - // Plain body string — the production case. Reconstruct | |
| 989 | - // `to` from the OptIn record's own `email` column and | |
| 990 | - // `subject` from the form's central settings. | |
| 991 | - $to = $row['email'] ?? ''; | |
| 992 | - $body = is_string( $unserialized ) ? $unserialized : (string) $mailOptin; | |
| 993 | - $subject = ''; | |
| 994 | - $from = ''; | |
| 995 | - | |
| 996 | - $formId = isset( $row['cf_form_id'] ) ? (int) $row['cf_form_id'] : 0; | |
| 997 | - if ( $formId > 0 && class_exists( '\\forge12\\contactform7\\CF7DoubleOptIn\\CF7DoubleOptIn' ) ) { | |
| 998 | - $formParam = \forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn::getInstance()->getParameter( $formId ); | |
| 999 | - $subject = (string) ( $formParam['subject'] ?? '' ); | |
| 1000 | - $senderEmail = (string) ( $formParam['sender'] ?? '' ); | |
| 1001 | - $senderName = (string) ( $formParam['sender_name'] ?? '' ); | |
| 1002 | - if ( $senderEmail !== '' ) { | |
| 1003 | - $from = $senderName !== '' | |
| 1004 | - ? $senderName . ' <' . $senderEmail . '>' | |
| 1005 | - : $senderEmail; | |
| 1006 | - } | |
| 1007 | - } | |
| 1044 | + if ( ! $outcome->isSent() ) { | |
| 1045 | + return self::resendRefusal( $outcome->getReason() ); | |
| 1008 | 1046 | } |
| 1009 | - | |
| 1010 | - if ( empty( $to ) || empty( $body ) ) { | |
| 1011 | - return new \WP_REST_Response( | |
| 1012 | - array( | |
| 1013 | - 'success' => false, | |
| 1014 | - 'message' => __( 'Email data is incomplete.', 'double-opt-in' ), | |
| 1015 | - ), | |
| 1016 | - 400 | |
| 1017 | - ); | |
| 1018 | - } | |
| 1019 | - | |
| 1020 | - $headers = array( 'Content-Type: text/html; charset=UTF-8' ); | |
| 1021 | - if ( ! empty( $from ) ) { | |
| 1022 | - $headers[] = 'From: ' . $from; | |
| 1023 | - } | |
| 1024 | - | |
| 1025 | - $result = wp_mail( $to, $subject !== '' ? $subject : __( 'Confirmation Email (resent)', 'double-opt-in' ), $body, $headers ); | |
| 1047 | + $result = true; | |
| 1048 | + } else { | |
| 1049 | + // An extension sent it; record the outcome all the same. | |
| 1050 | + do_action( 'f12_doi_optin_mail_result', $id, (bool) $result, '' ); | |
| 1026 | 1051 | } |
| 1027 | 1052 | |
| 1028 | 1053 | if ( ! $result ) { |
| 1029 | - return new \WP_REST_Response( | |
| 1030 | - array( | |
| 1031 | - 'success' => false, | |
| 1032 | - 'message' => __( 'Failed to send email.', 'double-opt-in' ), | |
| 1033 | - ), | |
| 1034 | - 500 | |
| 1035 | - ); | |
| 1054 | + return self::resendRefusal( ResendResult::SEND_FAILED ); | |
| 1036 | 1055 | } |
| 1037 | 1056 | |
| 1038 | 1057 | AuditLogger::log( |
| 1039 | 1058 | AuditLogger::TYPE_EMAIL, |
| @@ -1930,22 +1949,41 @@ | ||
| 1930 | 1949 | ); |
| 1931 | 1950 | } |
| 1932 | 1951 | |
| 1933 | 1952 | // ═══════════════════════════════════════════════════════════════ |
| 1934 | - // PRO-EXTENSIBLE STUBS | |
| 1935 | - // These return minimal responses; Pro overrides via filters or | |
| 1936 | - // registers its own REST routes that take precedence. | |
| 1953 | + // ADD-ON ROUTES | |
| 1954 | + // Core owns the route; the data comes from the add-on through a | |
| 1955 | + // filter. Without a handler the answer is ADDON_INACTIVE. Core | |
| 1956 | + // itself never checks a licence here (wordpress.org guideline 5): | |
| 1957 | + // the functionality lives in the add-on, which only hooks in when | |
| 1958 | + // it runs licensed. | |
| 1937 | 1959 | // ═══════════════════════════════════════════════════════════════ |
| 1938 | 1960 | |
| 1961 | + /** | |
| 1962 | + * Answer for a route whose add-on is not running. | |
| 1963 | + * | |
| 1964 | + * 404 with `code` so the SPA can tell it from an unknown route | |
| 1965 | + * (`rest_no_route`); `ApiError` reads `body.code`. | |
| 1966 | + */ | |
| 1967 | + private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response { | |
| 1968 | + return new \WP_REST_Response( | |
| 1969 | + array( | |
| 1970 | + 'success' => false, | |
| 1971 | + 'code' => 'ADDON_INACTIVE', | |
| 1972 | + 'addon' => $addonId, | |
| 1973 | + 'message' => sprintf( | |
| 1974 | + /* translators: %s: add-on name */ | |
| 1975 | + __( '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' ), | |
| 1976 | + $addonName | |
| 1977 | + ), | |
| 1978 | + ), | |
| 1979 | + 404 | |
| 1980 | + ); | |
| 1981 | + } | |
| 1982 | + | |
| 1939 | 1983 | public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response { |
| 1940 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1941 | - return new \WP_REST_Response( | |
| 1942 | - array( | |
| 1943 | - 'success' => false, | |
| 1944 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 1945 | - ), | |
| 1946 | - 403 | |
| 1947 | - ); | |
| 1984 | + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) { | |
| 1985 | + return $this->addonInactive( 'analytics', 'Analytics' ); | |
| 1948 | 1986 | } |
| 1949 | 1987 | |
| 1950 | 1988 | $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request ); |
| 1951 | 1989 | |
| @@ -1958,16 +1996,10 @@ | ||
| 1958 | 1996 | ); |
| 1959 | 1997 | } |
| 1960 | 1998 | |
| 1961 | 1999 | public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response { |
| 1962 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1963 | - return new \WP_REST_Response( | |
| 1964 | - array( | |
| 1965 | - 'success' => false, | |
| 1966 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 1967 | - ), | |
| 1968 | - 403 | |
| 1969 | - ); | |
| 2000 | + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) { | |
| 2001 | + return $this->addonInactive( 'analytics', 'Analytics' ); | |
| 1970 | 2002 | } |
| 1971 | 2003 | |
| 1972 | 2004 | $formId = (int) $request->get_param( 'form_id' ); |
| 1973 | 2005 | $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request ); |
| @@ -1981,16 +2013,10 @@ | ||
| 1981 | 2013 | ); |
| 1982 | 2014 | } |
| 1983 | 2015 | |
| 1984 | 2016 | public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 1985 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1986 | - return new \WP_REST_Response( | |
| 1987 | - array( | |
| 1988 | - 'success' => false, | |
| 1989 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 1990 | - ), | |
| 1991 | - 403 | |
| 1992 | - ); | |
| 2017 | + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) { | |
| 2018 | + return $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 1993 | 2019 | } |
| 1994 | 2020 | |
| 1995 | 2021 | $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request ); |
| 1996 | 2022 | |
| @@ -2003,16 +2029,10 @@ | ||
| 2003 | 2029 | ); |
| 2004 | 2030 | } |
| 2005 | 2031 | |
| 2006 | 2032 | public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2007 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2008 | - return new \WP_REST_Response( | |
| 2009 | - array( | |
| 2010 | - 'success' => false, | |
| 2011 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2012 | - ), | |
| 2013 | - 403 | |
| 2014 | - ); | |
| 2033 | + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) { | |
| 2034 | + return $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 2015 | 2035 | } |
| 2016 | 2036 | |
| 2017 | 2037 | $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request ); |
| 2018 | 2038 | |
| @@ -2027,140 +2047,32 @@ | ||
| 2027 | 2047 | |
| 2028 | 2048 | /** |
| 2029 | 2049 | * POST /f12-doi/v1/optout/page/generate |
| 2030 | 2050 | * |
| 2031 | - * One-click generator for the opt-out landing page. Eliminates the | |
| 2032 | - * onboarding-friction loop where the user has to manually create a | |
| 2033 | - * page and paste the shortcodes before opt-out works at all. | |
| 2051 | + * One-click generator for the opt-out landing page. The logic lives in | |
| 2052 | + * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through | |
| 2053 | + * the filter below; Core only owns the route. | |
| 2034 | 2054 | * |
| 2035 | - * Algorithm: | |
| 2036 | - * 1. Idempotent fast-path — scan `published` pages for the list | |
| 2037 | - * shortcode. If one already exists, return its ID untouched | |
| 2038 | - * (no duplicate creation, no content overwrite). | |
| 2039 | - * 2. Title-collision safety — if a page named "Opt-Out" exists | |
| 2040 | - * but WITHOUT the list shortcode, refuse to auto-modify. The | |
| 2041 | - * user might have intentionally repurposed that title; we'd | |
| 2042 | - * rather show a 409 with a clear message than clobber. | |
| 2043 | - * 3. Insert a fresh page with both shortcodes (form + list) so | |
| 2044 | - * the page is functional end-to-end out of the box. | |
| 2045 | - * | |
| 2046 | - * Response shape (always 200 unless error): | |
| 2047 | - * { page_id, page_title, edit_url, view_url, created: bool } | |
| 2048 | - * | |
| 2049 | 2055 | * @return \WP_REST_Response |
| 2050 | 2056 | */ |
| 2051 | 2057 | public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response { |
| 2052 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2053 | - return new \WP_REST_Response( | |
| 2054 | - array( | |
| 2055 | - 'success' => false, | |
| 2056 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2057 | - ), | |
| 2058 | - 403 | |
| 2059 | - ); | |
| 2058 | + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) { | |
| 2059 | + return $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 2060 | 2060 | } |
| 2061 | 2061 | |
| 2062 | - if ( ! current_user_can( 'publish_pages' ) ) { | |
| 2063 | - return new \WP_REST_Response( | |
| 2064 | - array( | |
| 2065 | - 'success' => false, | |
| 2066 | - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ), | |
| 2067 | - ), | |
| 2068 | - 403 | |
| 2069 | - ); | |
| 2070 | - } | |
| 2062 | + /** | |
| 2063 | + * Filter: answer the opt-out page generator request. | |
| 2064 | + * | |
| 2065 | + * @param \WP_REST_Response|null $response Null until a handler answers. | |
| 2066 | + * @param \WP_REST_Request $request The request. | |
| 2067 | + * | |
| 2068 | + * @since 5.8.0 | |
| 2069 | + */ | |
| 2070 | + $response = apply_filters( 'f12_doi_rest_optout_generate_page', null, $request ); | |
| 2071 | 2071 | |
| 2072 | - $listShortcode = '[f12-cf7-doubleoptin-optout-list]'; | |
| 2073 | - $formShortcode = '[f12-cf7-doubleoptin-optout-form]'; | |
| 2074 | - | |
| 2075 | - // 1. Idempotent fast-path — first page with the list shortcode wins. | |
| 2076 | - $existing = get_posts( | |
| 2077 | - array( | |
| 2078 | - 'post_type' => 'page', | |
| 2079 | - 'post_status' => 'publish', | |
| 2080 | - 'posts_per_page' => 1, | |
| 2081 | - 's' => $listShortcode, | |
| 2082 | - 'fields' => 'ids', | |
| 2083 | - 'no_found_rows' => true, | |
| 2084 | - ) | |
| 2085 | - ); | |
| 2086 | - if ( ! empty( $existing ) ) { | |
| 2087 | - $pageId = (int) $existing[0]; | |
| 2088 | - return new \WP_REST_Response( | |
| 2089 | - array( | |
| 2090 | - 'success' => true, | |
| 2091 | - 'created' => false, | |
| 2092 | - 'page_id' => $pageId, | |
| 2093 | - 'page_title' => get_the_title( $pageId ), | |
| 2094 | - 'edit_url' => get_edit_post_link( $pageId, 'raw' ), | |
| 2095 | - 'view_url' => get_permalink( $pageId ), | |
| 2096 | - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ), | |
| 2097 | - ), | |
| 2098 | - 200 | |
| 2099 | - ); | |
| 2100 | - } | |
| 2101 | - | |
| 2102 | - // 2. Title collision — a page literally titled "Opt-Out" but | |
| 2103 | - // without the shortcode is the user's own content. Refuse | |
| 2104 | - // to silently modify it. | |
| 2105 | - $desiredTitle = __( 'Opt-Out', 'double-opt-in' ); | |
| 2106 | - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' ); | |
| 2107 | - // Plain null check, not instanceof: this replaces `?->ID`, which only | |
| 2108 | - // short-circuits on null and does not care about the concrete class. | |
| 2109 | - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0; | |
| 2110 | - if ( $collisionId > 0 ) { | |
| 2111 | - return new \WP_REST_Response( | |
| 2112 | - array( | |
| 2113 | - 'success' => false, | |
| 2114 | - 'code' => 'TITLE_COLLISION', | |
| 2115 | - 'page_id' => $collisionId, | |
| 2116 | - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ), | |
| 2117 | - 'message' => sprintf( | |
| 2118 | - /* translators: %s = page title */ | |
| 2119 | - __( 'A page titled "%s" already exists but doesn\'t contain the opt-out shortcode. Add the shortcode manually, or rename the page, then try again.', 'double-opt-in' ), | |
| 2120 | - $desiredTitle | |
| 2121 | - ), | |
| 2122 | - ), | |
| 2123 | - 409 | |
| 2124 | - ); | |
| 2125 | - } | |
| 2126 | - | |
| 2127 | - // 3. Insert. | |
| 2128 | - $pageId = wp_insert_post( | |
| 2129 | - array( | |
| 2130 | - 'post_type' => 'page', | |
| 2131 | - 'post_status' => 'publish', | |
| 2132 | - 'post_title' => $desiredTitle, | |
| 2133 | - 'post_content' => $formShortcode . "\n\n" . $listShortcode, | |
| 2134 | - 'post_author' => get_current_user_id(), | |
| 2135 | - 'comment_status' => 'closed', | |
| 2136 | - 'ping_status' => 'closed', | |
| 2137 | - ), | |
| 2138 | - true | |
| 2139 | - ); | |
| 2140 | - | |
| 2141 | - if ( is_wp_error( $pageId ) ) { | |
| 2142 | - return new \WP_REST_Response( | |
| 2143 | - array( | |
| 2144 | - 'success' => false, | |
| 2145 | - 'message' => $pageId->get_error_message(), | |
| 2146 | - ), | |
| 2147 | - 500 | |
| 2148 | - ); | |
| 2149 | - } | |
| 2150 | - | |
| 2151 | - return new \WP_REST_Response( | |
| 2152 | - array( | |
| 2153 | - 'success' => true, | |
| 2154 | - 'created' => true, | |
| 2155 | - 'page_id' => (int) $pageId, | |
| 2156 | - 'page_title' => $desiredTitle, | |
| 2157 | - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ), | |
| 2158 | - 'view_url' => get_permalink( (int) $pageId ), | |
| 2159 | - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ), | |
| 2160 | - ), | |
| 2161 | - 200 | |
| 2162 | - ); | |
| 2072 | + return $response instanceof \WP_REST_Response | |
| 2073 | + ? $response | |
| 2074 | + : $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 2163 | 2075 | } |
| 2164 | 2076 | |
| 2165 | 2077 | /** |
| 2166 | 2078 | * License gate for the User Creation endpoints. |
| @@ -2180,15 +2092,9 @@ | ||
| 2180 | 2092 | } |
| 2181 | 2093 | |
| 2182 | 2094 | public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2183 | 2095 | if ( ! $this->userCreationAuthorized() ) { |
| 2184 | - return new \WP_REST_Response( | |
| 2185 | - array( | |
| 2186 | - 'success' => false, | |
| 2187 | - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ), | |
| 2188 | - ), | |
| 2189 | - 403 | |
| 2190 | - ); | |
| 2096 | + return $this->addonInactive( 'user-registration', 'User Registration' ); | |
| 2191 | 2097 | } |
| 2192 | 2098 | |
| 2193 | 2099 | $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request ); |
| 2194 | 2100 | |
| @@ -2202,15 +2108,9 @@ | ||
| 2202 | 2108 | } |
| 2203 | 2109 | |
| 2204 | 2110 | public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2205 | 2111 | if ( ! $this->userCreationAuthorized() ) { |
| 2206 | - return new \WP_REST_Response( | |
| 2207 | - array( | |
| 2208 | - 'success' => false, | |
| 2209 | - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ), | |
| 2210 | - ), | |
| 2211 | - 403 | |
| 2212 | - ); | |
| 2112 | + return $this->addonInactive( 'user-registration', 'User Registration' ); | |
| 2213 | 2113 | } |
| 2214 | 2114 | |
| 2215 | 2115 | $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request ); |
| 2216 | 2116 | |
| @@ -2223,16 +2123,10 @@ | ||
| 2223 | 2123 | ); |
| 2224 | 2124 | } |
| 2225 | 2125 | |
| 2226 | 2126 | public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2227 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2228 | - return new \WP_REST_Response( | |
| 2229 | - array( | |
| 2230 | - 'success' => false, | |
| 2231 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2232 | - ), | |
| 2233 | - 403 | |
| 2234 | - ); | |
| 2127 | + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) { | |
| 2128 | + return $this->addonInactive( 'cleverreach', 'CleverReach' ); | |
| 2235 | 2129 | } |
| 2236 | 2130 | |
| 2237 | 2131 | $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request ); |
| 2238 | 2132 | |
| @@ -2245,16 +2139,10 @@ | ||
| 2245 | 2139 | ); |
| 2246 | 2140 | } |
| 2247 | 2141 | |
| 2248 | 2142 | public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2249 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2250 | - return new \WP_REST_Response( | |
| 2251 | - array( | |
| 2252 | - 'success' => false, | |
| 2253 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2254 | - ), | |
| 2255 | - 403 | |
| 2256 | - ); | |
| 2143 | + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) { | |
| 2144 | + return $this->addonInactive( 'cleverreach', 'CleverReach' ); | |
| 2257 | 2145 | } |
| 2258 | 2146 | |
| 2259 | 2147 | $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request ); |
| 2260 | 2148 | |
| @@ -2348,44 +2236,25 @@ | ||
| 2348 | 2236 | |
| 2349 | 2237 | return new \WP_REST_Response( $result, $status ); |
| 2350 | 2238 | } |
| 2351 | 2239 | |
| 2352 | - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response { | |
| 2353 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2354 | - return new \WP_REST_Response( | |
| 2355 | - array( | |
| 2356 | - 'success' => false, | |
| 2357 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2358 | - ), | |
| 2359 | - 403 | |
| 2360 | - ); | |
| 2361 | - } | |
| 2240 | + // ═══════════════════════════════════════════════════════════════ | |
| 2241 | + // HELPERS | |
| 2242 | + // ═══════════════════════════════════════════════════════════════ | |
| 2362 | 2243 | |
| 2363 | - $input = $request->get_json_params(); | |
| 2244 | + /** | |
| 2245 | + * PHP form of REVOKED_SQL for a raw table row. | |
| 2246 | + * | |
| 2247 | + * @param array<string, mixed> $row The database row. | |
| 2248 | + */ | |
| 2249 | + private static function isRevokedRow( array $row ): bool { | |
| 2250 | + $optOutTime = (string) ( $row['optouttime'] ?? '' ); | |
| 2364 | 2251 | |
| 2365 | - /** | |
| 2366 | - * Filter to let Pro handle database export. | |
| 2367 | - * | |
| 2368 | - * @param array $result Result. | |
| 2369 | - * @param array $input Export parameters. | |
| 2370 | - * @since 4.2.0 | |
| 2371 | - */ | |
| 2372 | - $result = apply_filters( | |
| 2373 | - 'f12_doi_rest_database_export', | |
| 2374 | - array( | |
| 2375 | - 'success' => false, | |
| 2376 | - 'message' => __( 'Export not available.', 'double-opt-in' ), | |
| 2377 | - ), | |
| 2378 | - $input | |
| 2379 | - ); | |
| 2380 | - | |
| 2381 | - return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 ); | |
| 2252 | + return (int) ( $row['doubleoptin'] ?? 0 ) !== 1 | |
| 2253 | + && (string) ( $row['ipaddr_optout'] ?? '' ) !== '' | |
| 2254 | + && $optOutTime !== '' && $optOutTime !== '0'; | |
| 2382 | 2255 | } |
| 2383 | 2256 | |
| 2384 | - // ═══════════════════════════════════════════════════════════════ | |
| 2385 | - // HELPERS | |
| 2386 | - // ═══════════════════════════════════════════════════════════════ | |
| 2387 | - | |
| 2388 | 2257 | /** |
| 2389 | 2258 | * Format an opt-in database row for the API response. |
| 2390 | 2259 | * |
| 2391 | 2260 | * @param array $row The database row. |
| @@ -2392,8 +2261,74 @@ | ||
| 2392 | 2261 | * @param bool $detailed Whether to include full detail (content, mail data). |
| 2393 | 2262 | * |
| 2394 | 2263 | * @return array Formatted data. |
| 2395 | 2264 | */ |
| 2265 | + /** | |
| 2266 | + * @param array<string, mixed> $row A database row. | |
| 2267 | + * | |
| 2268 | + * @return array{key:string, label:string}|null | |
| 2269 | + */ | |
| 2270 | + private function subscriptionOfRow( array $row ): ?array { | |
| 2271 | + $group = SubscriptionGroups::resolver()->groupForForm( | |
| 2272 | + (int) ( $row['cf_form_id'] ?? 0 ), | |
| 2273 | + (string) ( $row['form_ref'] ?? '' ) | |
| 2274 | + ); | |
| 2275 | + | |
| 2276 | + return $group === null ? null : array( | |
| 2277 | + 'key' => $group->getKey(), | |
| 2278 | + 'label' => $group->getLabel(), | |
| 2279 | + ); | |
| 2280 | + } | |
| 2281 | + | |
| 2282 | + /** | |
| 2283 | + * The other records of the same address that belong to the same | |
| 2284 | + * subscription group as the given record. | |
| 2285 | + * | |
| 2286 | + * @param array<string, mixed> $row A database row. | |
| 2287 | + * | |
| 2288 | + * @return array<int, array<string, mixed>> | |
| 2289 | + */ | |
| 2290 | + private function linkedOptIns( array $row ): array { | |
| 2291 | + $group = SubscriptionGroups::resolver()->groupForForm( | |
| 2292 | + (int) ( $row['cf_form_id'] ?? 0 ), | |
| 2293 | + (string) ( $row['form_ref'] ?? '' ) | |
| 2294 | + ); | |
| 2295 | + $email = (string) ( $row['email'] ?? '' ); | |
| 2296 | + if ( $group === null || $email === '' ) { | |
| 2297 | + return array(); | |
| 2298 | + } | |
| 2299 | + | |
| 2300 | + global $wpdb; | |
| 2301 | + $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; | |
| 2302 | + list( $groupSql, $groupParams ) = $group->toSqlCondition(); | |
| 2303 | + | |
| 2304 | + // The group condition is built from fixed column names and `%d`/`%s` | |
| 2305 | + // placeholders only; every value travels in the parameter list. | |
| 2306 | + $found = $wpdb->get_results( | |
| 2307 | + $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders come from SubscriptionGroup::toSqlCondition(). | |
| 2308 | + "SELECT * FROM {$table} WHERE email = %s AND id <> %d AND {$groupSql} ORDER BY id DESC LIMIT 50", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| 2309 | + array_merge( array( $email, (int) $row['id'] ), $groupParams ) | |
| 2310 | + ), | |
| 2311 | + ARRAY_A | |
| 2312 | + ); | |
| 2313 | + | |
| 2314 | + $linked = array(); | |
| 2315 | + foreach ( (array) $found as $other ) { | |
| 2316 | + $data = $this->formatOptinRow( $other ); | |
| 2317 | + $linked[] = array( | |
| 2318 | + 'id' => $data['id'], | |
| 2319 | + 'formId' => $data['formId'], | |
| 2320 | + 'formName' => $data['formName'], | |
| 2321 | + 'formRef' => $data['formRef'], | |
| 2322 | + 'confirmed' => $data['confirmed'], | |
| 2323 | + 'revoked' => $data['revoked'], | |
| 2324 | + 'createtime' => $data['createtime'], | |
| 2325 | + ); | |
| 2326 | + } | |
| 2327 | + | |
| 2328 | + return $linked; | |
| 2329 | + } | |
| 2330 | + | |
| 2396 | 2331 | private function formatOptinRow( array $row, bool $detailed = false ): array { |
| 2397 | 2332 | $post = get_post( (int) $row['cf_form_id'] ); |
| 2398 | 2333 | |
| 2399 | 2334 | $data = array( |
| @@ -2403,12 +2338,22 @@ | ||
| 2403 | 2338 | 'formId' => (int) $row['cf_form_id'], |
| 2404 | 2339 | 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ), |
| 2405 | 2340 | 'category' => (int) $row['category'], |
| 2406 | 2341 | 'confirmed' => (int) $row['doubleoptin'] === 1, |
| 2342 | + // Consent withdrawn via the opt-out (5.9.0). Not "pending". | |
| 2343 | + 'revoked' => self::isRevokedRow( $row ), | |
| 2344 | + // Confirmation mail: 'sent' (handed to the mail server), 'failed', | |
| 2345 | + // or '' (recorded before 5.8.0). Since 5.8.0. | |
| 2346 | + 'mailStatus' => (string) ( $row['mail_status'] ?? '' ), | |
| 2407 | 2347 | 'createtime' => $this->toSiteLocalTime( $row['createtime'] ), |
| 2408 | 2348 | 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ), |
| 2409 | 2349 | ); |
| 2410 | 2350 | |
| 2351 | + // Instance inside the form id, e.g. the Elementor widget (5.12.0). | |
| 2352 | + $data['formRef'] = (string) ( $row['form_ref'] ?? '' ); | |
| 2353 | + // Subscription group of the record, or null (5.12.0). | |
| 2354 | + $data['subscription'] = $this->subscriptionOfRow( $row ); | |
| 2355 | + | |
| 2411 | 2356 | if ( $detailed ) { |
| 2412 | 2357 | $data['ipRegister'] = $row['ipaddr_register']; |
| 2413 | 2358 | $data['ipConfirmation'] = $row['ipaddr_confirmation']; |
| 2414 | 2359 | $data['ipOptout'] = $row['ipaddr_optout']; |
| @@ -2415,8 +2360,10 @@ | ||
| 2415 | 2360 | $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] ); |
| 2416 | 2361 | $data['consentText'] = $row['consent_text']; |
| 2417 | 2362 | $data['consentField'] = $row['consent_field'] ?? ''; |
| 2418 | 2363 | $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] ); |
| 2364 | + $data['mailError'] = (string) ( $row['mail_error'] ?? '' ); | |
| 2365 | + $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) ); | |
| 2419 | 2366 | |
| 2420 | 2367 | // Category name |
| 2421 | 2368 | $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] ); |
| 2422 | 2369 | $data['categoryName'] = $cat ? $cat->get_name() : null; |
| @@ -2636,10 +2583,10 @@ | ||
| 2636 | 2583 | // First pass: every registered addon gets an entry, even if |
| 2637 | 2584 | // it contributes no UI. That lets the client show per-addon |
| 2638 | 2585 | // licensing/boot state without a second round-trip. |
| 2639 | 2586 | foreach ( $registered as $id => $addon ) { |
| 2640 | - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array(); | |
| 2641 | - $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment ); | |
| 2587 | + $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array(); | |
| 2588 | + $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment ); | |
| 2642 | 2589 | unset( $fragments[ $id ] ); |
| 2643 | 2590 | } |
| 2644 | 2591 | |
| 2645 | 2592 | // Second pass: fragments for addons NOT in the registry |
| @@ -2877,11 +2824,18 @@ | ||
| 2877 | 2824 | } |
| 2878 | 2825 | |
| 2879 | 2826 | $activateUrl = null; |
| 2880 | 2827 | if ( $installed && ! $active ) { |
| 2881 | - $activateUrl = wp_nonce_url( | |
| 2882 | - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ), | |
| 2883 | - 'activate-plugin_' . $pluginFile | |
| 2828 | + // Not wp_nonce_url(): it HTML-escapes & to &, and this URL | |
| 2829 | + // goes as JSON into an href — "plugin" and "_wpnonce" then | |
| 2830 | + // arrived as "amp;plugin" and the activation failed. | |
| 2831 | + $activateUrl = add_query_arg( | |
| 2832 | + array( | |
| 2833 | + 'action' => 'activate', | |
| 2834 | + 'plugin' => rawurlencode( $pluginFile ), | |
| 2835 | + '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ), | |
| 2836 | + ), | |
| 2837 | + self_admin_url( 'plugins.php' ) | |
| 2884 | 2838 | ); |
| 2885 | 2839 | } |
| 2886 | 2840 | |
| 2887 | 2841 | $registeredAddon = $registered[ $id ] ?? null; |