| @@ -14,8 +14,11 @@ | ||
| 14 | 14 | use Forge12\DoubleOptIn\Audit\AuditLogger; |
| 15 | 15 | use Forge12\DoubleOptIn\FormSettings\FormSettingsDTO; |
| 16 | 16 | use Forge12\DoubleOptIn\FormSettings\FormSettingsService; |
| 17 | 17 | use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator; |
| 18 | +use Forge12\DoubleOptIn\Integration\SubmittedContent; | |
| 19 | +use Forge12\DoubleOptIn\Service\ConfirmationMailResender; | |
| 20 | +use Forge12\DoubleOptIn\Service\ResendResult; | |
| 18 | 21 | use Forge12\Shared\LoggerInterface; |
| 19 | 22 | |
| 20 | 23 | if ( ! defined( 'ABSPATH' ) ) { |
| 21 | 24 | exit; |
| @@ -508,19 +511,8 @@ | ||
| 508 | 511 | 'permission_callback' => array( $this, 'checkPermission' ), |
| 509 | 512 | ) |
| 510 | 513 | ); |
| 511 | 514 | |
| 512 | - // ── Database Export (Pro-extensible) ──────────────────────── | |
| 513 | - register_rest_route( | |
| 514 | - self::API_NAMESPACE, | |
| 515 | - '/database/export', | |
| 516 | - array( | |
| 517 | - 'methods' => \WP_REST_Server::CREATABLE, | |
| 518 | - 'callback' => array( $this, 'exportDatabase' ), | |
| 519 | - 'permission_callback' => array( $this, 'checkPermission' ), | |
| 520 | - ) | |
| 521 | - ); | |
| 522 | - | |
| 523 | 515 | // ── Addons manifest (UI mount-point system, plan §9) ──────── |
| 524 | 516 | register_rest_route( |
| 525 | 517 | self::API_NAMESPACE, |
| 526 | 518 | '/addons', |
| @@ -736,8 +728,24 @@ | ||
| 736 | 728 | $where[] = 'cf_form_id = %d'; |
| 737 | 729 | $params[] = (int) $formId; |
| 738 | 730 | } |
| 739 | 731 | |
| 732 | + // Opt-ins whose confirmation mail could not be sent (5.8.0). | |
| 733 | + if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) { | |
| 734 | + $where[] = 'mail_status = %s'; | |
| 735 | + $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED; | |
| 736 | + } | |
| 737 | + | |
| 738 | + // Confirmed opt-ins whose follow-up actions failed or have an | |
| 739 | + // unknown outcome — the admin's "needs attention" list. | |
| 740 | + if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) { | |
| 741 | + $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME; | |
| 742 | + $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic(); | |
| 743 | + $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN (" | |
| 744 | + . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))'; | |
| 745 | + $params = array_merge( $params, $problems ); | |
| 746 | + } | |
| 747 | + | |
| 740 | 748 | $whereClause = implode( ' AND ', $where ); |
| 741 | 749 | |
| 742 | 750 | // Count |
| 743 | 751 | $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}"; |
| @@ -832,9 +840,9 @@ | ||
| 832 | 840 | // Full row (id, hash, content, files, cf_form_id) so the |
| 833 | 841 | // pre-delete cascade hook from pre-doi-data-retention Step 1 |
| 834 | 842 | // can fire with a payload that lets listeners reach into |
| 835 | 843 | // integration storage. ARRAY_A — listener-friendly. |
| 836 | - $row = $wpdb->get_row( | |
| 844 | + $row = $wpdb->get_row( | |
| 837 | 845 | $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ), |
| 838 | 846 | ARRAY_A |
| 839 | 847 | ); |
| 840 | 848 | $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null; |
| @@ -892,8 +900,32 @@ | ||
| 892 | 900 | 200 |
| 893 | 901 | ); |
| 894 | 902 | } |
| 895 | 903 | |
| 904 | + /** | |
| 905 | + * The admin's answer for a resend that did not go out. | |
| 906 | + */ | |
| 907 | + private static function resendRefusal( string $reason ): \WP_REST_Response { | |
| 908 | + $map = array( | |
| 909 | + ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ), | |
| 910 | + ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ), | |
| 911 | + ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ), | |
| 912 | + ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ), | |
| 913 | + ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ), | |
| 914 | + ); | |
| 915 | + $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 ); | |
| 916 | + $message = $entry[0]; | |
| 917 | + $status = $entry[1]; | |
| 918 | + | |
| 919 | + return new \WP_REST_Response( | |
| 920 | + array( | |
| 921 | + 'success' => false, | |
| 922 | + 'message' => $message, | |
| 923 | + ), | |
| 924 | + $status | |
| 925 | + ); | |
| 926 | + } | |
| 927 | + | |
| 896 | 928 | public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response { |
| 897 | 929 | global $wpdb; |
| 898 | 930 | $id = (int) $request->get_param( 'id' ); |
| 899 | 931 | $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; |
| @@ -933,96 +965,23 @@ | ||
| 933 | 965 | */ |
| 934 | 966 | $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row ); |
| 935 | 967 | |
| 936 | 968 | if ( $result === null ) { |
| 937 | - // Default resend logic: use stored mail data. | |
| 938 | - // | |
| 939 | - // `mail_optin` is shipped by every integration via | |
| 940 | - // {@see \forge12\contactform7\CF7DoubleOptIn\OptIn::set_mail_optin()}. | |
| 941 | - // That method takes a STRING (the rendered HTML body) — the | |
| 942 | - // admin opt-in-detail UI reads it as-is for the body | |
| 943 | - // preview. Earlier versions of this handler expected a | |
| 944 | - // serialized `['to' => ..., 'subject' => ..., 'body' => ...]` | |
| 945 | - // array and bailed with "Email data is incomplete" whenever | |
| 946 | - // the stored value was the (correct) plain body string — | |
| 947 | - // which is the production case for every free-version | |
| 948 | - // integration (CF7 / Avada / WPForms / Gravity / Elementor). | |
| 949 | - // User-reported 2026-05-13: clicking Resend yielded that | |
| 950 | - // error 100 % of the time. | |
| 951 | - // | |
| 952 | - // Both shapes are accepted now: the array form for Pro and | |
| 953 | - // any future caller that stores structured payloads, the | |
| 954 | - // plain string for the free-version integrations whose | |
| 955 | - // contract is documented in | |
| 956 | - // {@see \Forge12\DoubleOptIn\Wpforms\Tests\Unit\Integration\WPFormsSettingsApplyTest}. | |
| 957 | - $mailOptin = $row['mail_optin'] ?? ''; | |
| 958 | - if ( empty( $mailOptin ) ) { | |
| 959 | - return new \WP_REST_Response( | |
| 960 | - array( | |
| 961 | - 'success' => false, | |
| 962 | - 'message' => __( 'No email data available for resend.', 'double-opt-in' ), | |
| 963 | - ), | |
| 964 | - 400 | |
| 965 | - ); | |
| 966 | - } | |
| 969 | + $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance() | |
| 970 | + ->get( ConfirmationMailResender::class ) | |
| 971 | + ->resend( $id ); | |
| 967 | 972 | |
| 968 | - $unserialized = maybe_unserialize( $mailOptin ); | |
| 969 | - | |
| 970 | - if ( is_array( $unserialized ) ) { | |
| 971 | - // Structured payload (Pro / future writers). | |
| 972 | - $to = $unserialized['to'] ?? ''; | |
| 973 | - $subject = $unserialized['subject'] ?? ''; | |
| 974 | - $body = $unserialized['body'] ?? ''; | |
| 975 | - $from = $unserialized['from'] ?? ''; | |
| 976 | - } else { | |
| 977 | - // Plain body string — the production case. Reconstruct | |
| 978 | - // `to` from the OptIn record's own `email` column and | |
| 979 | - // `subject` from the form's central settings. | |
| 980 | - $to = $row['email'] ?? ''; | |
| 981 | - $body = is_string( $unserialized ) ? $unserialized : (string) $mailOptin; | |
| 982 | - $subject = ''; | |
| 983 | - $from = ''; | |
| 984 | - | |
| 985 | - $formId = isset( $row['cf_form_id'] ) ? (int) $row['cf_form_id'] : 0; | |
| 986 | - if ( $formId > 0 && class_exists( '\\forge12\\contactform7\\CF7DoubleOptIn\\CF7DoubleOptIn' ) ) { | |
| 987 | - $formParam = \forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn::getInstance()->getParameter( $formId ); | |
| 988 | - $subject = (string) ( $formParam['subject'] ?? '' ); | |
| 989 | - $senderEmail = (string) ( $formParam['sender'] ?? '' ); | |
| 990 | - $senderName = (string) ( $formParam['sender_name'] ?? '' ); | |
| 991 | - if ( $senderEmail !== '' ) { | |
| 992 | - $from = $senderName !== '' | |
| 993 | - ? $senderName . ' <' . $senderEmail . '>' | |
| 994 | - : $senderEmail; | |
| 995 | - } | |
| 996 | - } | |
| 973 | + if ( ! $outcome->isSent() ) { | |
| 974 | + return self::resendRefusal( $outcome->getReason() ); | |
| 997 | 975 | } |
| 998 | - | |
| 999 | - if ( empty( $to ) || empty( $body ) ) { | |
| 1000 | - return new \WP_REST_Response( | |
| 1001 | - array( | |
| 1002 | - 'success' => false, | |
| 1003 | - 'message' => __( 'Email data is incomplete.', 'double-opt-in' ), | |
| 1004 | - ), | |
| 1005 | - 400 | |
| 1006 | - ); | |
| 1007 | - } | |
| 1008 | - | |
| 1009 | - $headers = array( 'Content-Type: text/html; charset=UTF-8' ); | |
| 1010 | - if ( ! empty( $from ) ) { | |
| 1011 | - $headers[] = 'From: ' . $from; | |
| 1012 | - } | |
| 1013 | - | |
| 1014 | - $result = wp_mail( $to, $subject !== '' ? $subject : __( 'Confirmation Email (resent)', 'double-opt-in' ), $body, $headers ); | |
| 976 | + $result = true; | |
| 977 | + } else { | |
| 978 | + // An extension sent it; record the outcome all the same. | |
| 979 | + do_action( 'f12_doi_optin_mail_result', $id, (bool) $result, '' ); | |
| 1015 | 980 | } |
| 1016 | 981 | |
| 1017 | 982 | if ( ! $result ) { |
| 1018 | - return new \WP_REST_Response( | |
| 1019 | - array( | |
| 1020 | - 'success' => false, | |
| 1021 | - 'message' => __( 'Failed to send email.', 'double-opt-in' ), | |
| 1022 | - ), | |
| 1023 | - 500 | |
| 1024 | - ); | |
| 983 | + return self::resendRefusal( ResendResult::SEND_FAILED ); | |
| 1025 | 984 | } |
| 1026 | 985 | |
| 1027 | 986 | AuditLogger::log( |
| 1028 | 987 | AuditLogger::TYPE_EMAIL, |
| @@ -1919,22 +1878,41 @@ | ||
| 1919 | 1878 | ); |
| 1920 | 1879 | } |
| 1921 | 1880 | |
| 1922 | 1881 | // ═══════════════════════════════════════════════════════════════ |
| 1923 | - // PRO-EXTENSIBLE STUBS | |
| 1924 | - // These return minimal responses; Pro overrides via filters or | |
| 1925 | - // registers its own REST routes that take precedence. | |
| 1882 | + // ADD-ON ROUTES | |
| 1883 | + // Core owns the route; the data comes from the add-on through a | |
| 1884 | + // filter. Without a handler the answer is ADDON_INACTIVE. Core | |
| 1885 | + // itself never checks a licence here (wordpress.org guideline 5): | |
| 1886 | + // the functionality lives in the add-on, which only hooks in when | |
| 1887 | + // it runs licensed. | |
| 1926 | 1888 | // ═══════════════════════════════════════════════════════════════ |
| 1927 | 1889 | |
| 1890 | + /** | |
| 1891 | + * Answer for a route whose add-on is not running. | |
| 1892 | + * | |
| 1893 | + * 404 with `code` so the SPA can tell it from an unknown route | |
| 1894 | + * (`rest_no_route`); `ApiError` reads `body.code`. | |
| 1895 | + */ | |
| 1896 | + private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response { | |
| 1897 | + return new \WP_REST_Response( | |
| 1898 | + array( | |
| 1899 | + 'success' => false, | |
| 1900 | + 'code' => 'ADDON_INACTIVE', | |
| 1901 | + 'addon' => $addonId, | |
| 1902 | + 'message' => sprintf( | |
| 1903 | + /* translators: %s: add-on name */ | |
| 1904 | + __( 'This feature is provided by the %s add-on. Install and activate the add-on with a valid license to use it.', 'double-opt-in' ), | |
| 1905 | + $addonName | |
| 1906 | + ), | |
| 1907 | + ), | |
| 1908 | + 404 | |
| 1909 | + ); | |
| 1910 | + } | |
| 1911 | + | |
| 1928 | 1912 | public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response { |
| 1929 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1930 | - return new \WP_REST_Response( | |
| 1931 | - array( | |
| 1932 | - 'success' => false, | |
| 1933 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 1934 | - ), | |
| 1935 | - 403 | |
| 1936 | - ); | |
| 1913 | + if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) { | |
| 1914 | + return $this->addonInactive( 'analytics', 'Analytics' ); | |
| 1937 | 1915 | } |
| 1938 | 1916 | |
| 1939 | 1917 | $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request ); |
| 1940 | 1918 | |
| @@ -1947,16 +1925,10 @@ | ||
| 1947 | 1925 | ); |
| 1948 | 1926 | } |
| 1949 | 1927 | |
| 1950 | 1928 | public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response { |
| 1951 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1952 | - return new \WP_REST_Response( | |
| 1953 | - array( | |
| 1954 | - 'success' => false, | |
| 1955 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 1956 | - ), | |
| 1957 | - 403 | |
| 1958 | - ); | |
| 1929 | + if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) { | |
| 1930 | + return $this->addonInactive( 'analytics', 'Analytics' ); | |
| 1959 | 1931 | } |
| 1960 | 1932 | |
| 1961 | 1933 | $formId = (int) $request->get_param( 'form_id' ); |
| 1962 | 1934 | $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request ); |
| @@ -1970,16 +1942,10 @@ | ||
| 1970 | 1942 | ); |
| 1971 | 1943 | } |
| 1972 | 1944 | |
| 1973 | 1945 | public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 1974 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1975 | - return new \WP_REST_Response( | |
| 1976 | - array( | |
| 1977 | - 'success' => false, | |
| 1978 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 1979 | - ), | |
| 1980 | - 403 | |
| 1981 | - ); | |
| 1946 | + if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) { | |
| 1947 | + return $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 1982 | 1948 | } |
| 1983 | 1949 | |
| 1984 | 1950 | $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request ); |
| 1985 | 1951 | |
| @@ -1992,16 +1958,10 @@ | ||
| 1992 | 1958 | ); |
| 1993 | 1959 | } |
| 1994 | 1960 | |
| 1995 | 1961 | public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 1996 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1997 | - return new \WP_REST_Response( | |
| 1998 | - array( | |
| 1999 | - 'success' => false, | |
| 2000 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2001 | - ), | |
| 2002 | - 403 | |
| 2003 | - ); | |
| 1962 | + if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) { | |
| 1963 | + return $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 2004 | 1964 | } |
| 2005 | 1965 | |
| 2006 | 1966 | $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request ); |
| 2007 | 1967 | |
| @@ -2016,140 +1976,32 @@ | ||
| 2016 | 1976 | |
| 2017 | 1977 | /** |
| 2018 | 1978 | * POST /f12-doi/v1/optout/page/generate |
| 2019 | 1979 | * |
| 2020 | - * One-click generator for the opt-out landing page. Eliminates the | |
| 2021 | - * onboarding-friction loop where the user has to manually create a | |
| 2022 | - * page and paste the shortcodes before opt-out works at all. | |
| 1980 | + * One-click generator for the opt-out landing page. The logic lives in | |
| 1981 | + * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through | |
| 1982 | + * the filter below; Core only owns the route. | |
| 2023 | 1983 | * |
| 2024 | - * Algorithm: | |
| 2025 | - * 1. Idempotent fast-path — scan `published` pages for the list | |
| 2026 | - * shortcode. If one already exists, return its ID untouched | |
| 2027 | - * (no duplicate creation, no content overwrite). | |
| 2028 | - * 2. Title-collision safety — if a page named "Opt-Out" exists | |
| 2029 | - * but WITHOUT the list shortcode, refuse to auto-modify. The | |
| 2030 | - * user might have intentionally repurposed that title; we'd | |
| 2031 | - * rather show a 409 with a clear message than clobber. | |
| 2032 | - * 3. Insert a fresh page with both shortcodes (form + list) so | |
| 2033 | - * the page is functional end-to-end out of the box. | |
| 2034 | - * | |
| 2035 | - * Response shape (always 200 unless error): | |
| 2036 | - * { page_id, page_title, edit_url, view_url, created: bool } | |
| 2037 | - * | |
| 2038 | 1984 | * @return \WP_REST_Response |
| 2039 | 1985 | */ |
| 2040 | 1986 | public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response { |
| 2041 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2042 | - return new \WP_REST_Response( | |
| 2043 | - array( | |
| 2044 | - 'success' => false, | |
| 2045 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2046 | - ), | |
| 2047 | - 403 | |
| 2048 | - ); | |
| 1987 | + if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) { | |
| 1988 | + return $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 2049 | 1989 | } |
| 2050 | 1990 | |
| 2051 | - if ( ! current_user_can( 'publish_pages' ) ) { | |
| 2052 | - return new \WP_REST_Response( | |
| 2053 | - array( | |
| 2054 | - 'success' => false, | |
| 2055 | - 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ), | |
| 2056 | - ), | |
| 2057 | - 403 | |
| 2058 | - ); | |
| 2059 | - } | |
| 1991 | + /** | |
| 1992 | + * Filter: answer the opt-out page generator request. | |
| 1993 | + * | |
| 1994 | + * @param \WP_REST_Response|null $response Null until a handler answers. | |
| 1995 | + * @param \WP_REST_Request $request The request. | |
| 1996 | + * | |
| 1997 | + * @since 5.8.0 | |
| 1998 | + */ | |
| 1999 | + $response = apply_filters( 'f12_doi_rest_optout_generate_page', null, $request ); | |
| 2060 | 2000 | |
| 2061 | - $listShortcode = '[f12-cf7-doubleoptin-optout-list]'; | |
| 2062 | - $formShortcode = '[f12-cf7-doubleoptin-optout-form]'; | |
| 2063 | - | |
| 2064 | - // 1. Idempotent fast-path — first page with the list shortcode wins. | |
| 2065 | - $existing = get_posts( | |
| 2066 | - array( | |
| 2067 | - 'post_type' => 'page', | |
| 2068 | - 'post_status' => 'publish', | |
| 2069 | - 'posts_per_page' => 1, | |
| 2070 | - 's' => $listShortcode, | |
| 2071 | - 'fields' => 'ids', | |
| 2072 | - 'no_found_rows' => true, | |
| 2073 | - ) | |
| 2074 | - ); | |
| 2075 | - if ( ! empty( $existing ) ) { | |
| 2076 | - $pageId = (int) $existing[0]; | |
| 2077 | - return new \WP_REST_Response( | |
| 2078 | - array( | |
| 2079 | - 'success' => true, | |
| 2080 | - 'created' => false, | |
| 2081 | - 'page_id' => $pageId, | |
| 2082 | - 'page_title' => get_the_title( $pageId ), | |
| 2083 | - 'edit_url' => get_edit_post_link( $pageId, 'raw' ), | |
| 2084 | - 'view_url' => get_permalink( $pageId ), | |
| 2085 | - 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ), | |
| 2086 | - ), | |
| 2087 | - 200 | |
| 2088 | - ); | |
| 2089 | - } | |
| 2090 | - | |
| 2091 | - // 2. Title collision — a page literally titled "Opt-Out" but | |
| 2092 | - // without the shortcode is the user's own content. Refuse | |
| 2093 | - // to silently modify it. | |
| 2094 | - $desiredTitle = __( 'Opt-Out', 'double-opt-in' ); | |
| 2095 | - $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' ); | |
| 2096 | - // Plain null check, not instanceof: this replaces `?->ID`, which only | |
| 2097 | - // short-circuits on null and does not care about the concrete class. | |
| 2098 | - $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0; | |
| 2099 | - if ( $collisionId > 0 ) { | |
| 2100 | - return new \WP_REST_Response( | |
| 2101 | - array( | |
| 2102 | - 'success' => false, | |
| 2103 | - 'code' => 'TITLE_COLLISION', | |
| 2104 | - 'page_id' => $collisionId, | |
| 2105 | - 'edit_url' => get_edit_post_link( $collisionId, 'raw' ), | |
| 2106 | - 'message' => sprintf( | |
| 2107 | - /* translators: %s = page title */ | |
| 2108 | - __( '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' ), | |
| 2109 | - $desiredTitle | |
| 2110 | - ), | |
| 2111 | - ), | |
| 2112 | - 409 | |
| 2113 | - ); | |
| 2114 | - } | |
| 2115 | - | |
| 2116 | - // 3. Insert. | |
| 2117 | - $pageId = wp_insert_post( | |
| 2118 | - array( | |
| 2119 | - 'post_type' => 'page', | |
| 2120 | - 'post_status' => 'publish', | |
| 2121 | - 'post_title' => $desiredTitle, | |
| 2122 | - 'post_content' => $formShortcode . "\n\n" . $listShortcode, | |
| 2123 | - 'post_author' => get_current_user_id(), | |
| 2124 | - 'comment_status' => 'closed', | |
| 2125 | - 'ping_status' => 'closed', | |
| 2126 | - ), | |
| 2127 | - true | |
| 2128 | - ); | |
| 2129 | - | |
| 2130 | - if ( is_wp_error( $pageId ) ) { | |
| 2131 | - return new \WP_REST_Response( | |
| 2132 | - array( | |
| 2133 | - 'success' => false, | |
| 2134 | - 'message' => $pageId->get_error_message(), | |
| 2135 | - ), | |
| 2136 | - 500 | |
| 2137 | - ); | |
| 2138 | - } | |
| 2139 | - | |
| 2140 | - return new \WP_REST_Response( | |
| 2141 | - array( | |
| 2142 | - 'success' => true, | |
| 2143 | - 'created' => true, | |
| 2144 | - 'page_id' => (int) $pageId, | |
| 2145 | - 'page_title' => $desiredTitle, | |
| 2146 | - 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ), | |
| 2147 | - 'view_url' => get_permalink( (int) $pageId ), | |
| 2148 | - 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ), | |
| 2149 | - ), | |
| 2150 | - 200 | |
| 2151 | - ); | |
| 2001 | + return $response instanceof \WP_REST_Response | |
| 2002 | + ? $response | |
| 2003 | + : $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 2152 | 2004 | } |
| 2153 | 2005 | |
| 2154 | 2006 | /** |
| 2155 | 2007 | * License gate for the User Creation endpoints. |
| @@ -2169,15 +2021,9 @@ | ||
| 2169 | 2021 | } |
| 2170 | 2022 | |
| 2171 | 2023 | public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2172 | 2024 | if ( ! $this->userCreationAuthorized() ) { |
| 2173 | - return new \WP_REST_Response( | |
| 2174 | - array( | |
| 2175 | - 'success' => false, | |
| 2176 | - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ), | |
| 2177 | - ), | |
| 2178 | - 403 | |
| 2179 | - ); | |
| 2025 | + return $this->addonInactive( 'user-registration', 'User Registration' ); | |
| 2180 | 2026 | } |
| 2181 | 2027 | |
| 2182 | 2028 | $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request ); |
| 2183 | 2029 | |
| @@ -2191,15 +2037,9 @@ | ||
| 2191 | 2037 | } |
| 2192 | 2038 | |
| 2193 | 2039 | public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2194 | 2040 | if ( ! $this->userCreationAuthorized() ) { |
| 2195 | - return new \WP_REST_Response( | |
| 2196 | - array( | |
| 2197 | - 'success' => false, | |
| 2198 | - 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ), | |
| 2199 | - ), | |
| 2200 | - 403 | |
| 2201 | - ); | |
| 2041 | + return $this->addonInactive( 'user-registration', 'User Registration' ); | |
| 2202 | 2042 | } |
| 2203 | 2043 | |
| 2204 | 2044 | $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request ); |
| 2205 | 2045 | |
| @@ -2212,16 +2052,10 @@ | ||
| 2212 | 2052 | ); |
| 2213 | 2053 | } |
| 2214 | 2054 | |
| 2215 | 2055 | public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2216 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2217 | - return new \WP_REST_Response( | |
| 2218 | - array( | |
| 2219 | - 'success' => false, | |
| 2220 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2221 | - ), | |
| 2222 | - 403 | |
| 2223 | - ); | |
| 2056 | + if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) { | |
| 2057 | + return $this->addonInactive( 'cleverreach', 'CleverReach' ); | |
| 2224 | 2058 | } |
| 2225 | 2059 | |
| 2226 | 2060 | $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request ); |
| 2227 | 2061 | |
| @@ -2234,16 +2068,10 @@ | ||
| 2234 | 2068 | ); |
| 2235 | 2069 | } |
| 2236 | 2070 | |
| 2237 | 2071 | public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2238 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2239 | - return new \WP_REST_Response( | |
| 2240 | - array( | |
| 2241 | - 'success' => false, | |
| 2242 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2243 | - ), | |
| 2244 | - 403 | |
| 2245 | - ); | |
| 2072 | + if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) { | |
| 2073 | + return $this->addonInactive( 'cleverreach', 'CleverReach' ); | |
| 2246 | 2074 | } |
| 2247 | 2075 | |
| 2248 | 2076 | $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request ); |
| 2249 | 2077 | |
| @@ -2337,40 +2165,8 @@ | ||
| 2337 | 2165 | |
| 2338 | 2166 | return new \WP_REST_Response( $result, $status ); |
| 2339 | 2167 | } |
| 2340 | 2168 | |
| 2341 | - public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response { | |
| 2342 | - if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2343 | - return new \WP_REST_Response( | |
| 2344 | - array( | |
| 2345 | - 'success' => false, | |
| 2346 | - 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2347 | - ), | |
| 2348 | - 403 | |
| 2349 | - ); | |
| 2350 | - } | |
| 2351 | - | |
| 2352 | - $input = $request->get_json_params(); | |
| 2353 | - | |
| 2354 | - /** | |
| 2355 | - * Filter to let Pro handle database export. | |
| 2356 | - * | |
| 2357 | - * @param array $result Result. | |
| 2358 | - * @param array $input Export parameters. | |
| 2359 | - * @since 4.2.0 | |
| 2360 | - */ | |
| 2361 | - $result = apply_filters( | |
| 2362 | - 'f12_doi_rest_database_export', | |
| 2363 | - array( | |
| 2364 | - 'success' => false, | |
| 2365 | - 'message' => __( 'Export not available.', 'double-opt-in' ), | |
| 2366 | - ), | |
| 2367 | - $input | |
| 2368 | - ); | |
| 2369 | - | |
| 2370 | - return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 ); | |
| 2371 | - } | |
| 2372 | - | |
| 2373 | 2169 | // ═══════════════════════════════════════════════════════════════ |
| 2374 | 2170 | // HELPERS |
| 2375 | 2171 | // ═══════════════════════════════════════════════════════════════ |
| 2376 | 2172 | |
| @@ -2392,8 +2188,11 @@ | ||
| 2392 | 2188 | 'formId' => (int) $row['cf_form_id'], |
| 2393 | 2189 | 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ), |
| 2394 | 2190 | 'category' => (int) $row['category'], |
| 2395 | 2191 | 'confirmed' => (int) $row['doubleoptin'] === 1, |
| 2192 | + // Confirmation mail: 'sent' (handed to the mail server), 'failed', | |
| 2193 | + // or '' (recorded before 5.8.0). Since 5.8.0. | |
| 2194 | + 'mailStatus' => (string) ( $row['mail_status'] ?? '' ), | |
| 2396 | 2195 | 'createtime' => $this->toSiteLocalTime( $row['createtime'] ), |
| 2397 | 2196 | 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ), |
| 2398 | 2197 | ); |
| 2399 | 2198 | |
| @@ -2404,8 +2203,10 @@ | ||
| 2404 | 2203 | $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] ); |
| 2405 | 2204 | $data['consentText'] = $row['consent_text']; |
| 2406 | 2205 | $data['consentField'] = $row['consent_field'] ?? ''; |
| 2407 | 2206 | $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] ); |
| 2207 | + $data['mailError'] = (string) ( $row['mail_error'] ?? '' ); | |
| 2208 | + $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) ); | |
| 2408 | 2209 | |
| 2409 | 2210 | // Category name |
| 2410 | 2211 | $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] ); |
| 2411 | 2212 | $data['categoryName'] = $cat ? $cat->get_name() : null; |
| @@ -2418,28 +2219,31 @@ | ||
| 2418 | 2219 | // configured, look up the value the user actually submitted. |
| 2419 | 2220 | // Truthy = explicit acknowledgment captured. Falsy = either |
| 2420 | 2221 | // gate wasn't enforced or this is a legacy record. |
| 2421 | 2222 | // |
| 2422 | - // Storage shape varies per integration: | |
| 2423 | - // - CF7 / WPForms / GF (default path) store fields flat | |
| 2424 | - // at the top level: $content[fieldName] = value. | |
| 2425 | - // - Avada wraps fields under a `data` sub-key alongside | |
| 2426 | - // metadata (field_labels, field_types, form_parameter) | |
| 2427 | - // — its OnSubmit overrides the flat content set by | |
| 2428 | - // createOptIn(). For Avada records, $content[fieldName] | |
| 2429 | - // is undefined; the value lives at $content['data'][fieldName]. | |
| 2223 | + // Where that value sits differs per integration, and this | |
| 2224 | + // reader got the list wrong twice: | |
| 2430 | 2225 | // |
| 2431 | - // Pre-2026-05-01 we only checked the flat shape, so every | |
| 2432 | - // Avada opt-in showed "User acknowledged: ✗ No" even when | |
| 2433 | - // the user explicitly checked the GDPR box. The fallback | |
| 2434 | - // below recognises the Avada shape too — adding a third | |
| 2435 | - // shape would be the next addition. | |
| 2436 | - $data['consentAcknowledged'] = ! empty( $data['consentField'] ) | |
| 2437 | - && is_array( $content ) | |
| 2438 | - && ( | |
| 2439 | - ! empty( $content[ $data['consentField'] ] ) | |
| 2440 | - || ! empty( $content['data'][ $data['consentField'] ] ?? null ) | |
| 2441 | - ); | |
| 2226 | + // 2026-05-01 Avada wraps its fields under `data`, so the | |
| 2227 | + // flat lookup missed and every Avada opt-in | |
| 2228 | + // showed "User acknowledged: ✗ No" even with | |
| 2229 | + // the GDPR box explicitly checked. | |
| 2230 | + // 2026-08-27 Elementor stores the whole $_POST parameter | |
| 2231 | + // dict, so its fields sit under `form_fields` | |
| 2232 | + // — the same symptom, one integration further | |
| 2233 | + // on. The docblock added after the Avada fix | |
| 2234 | + // had predicted exactly this ("adding a third | |
| 2235 | + // shape would be the next addition"). | |
| 2236 | + // | |
| 2237 | + // The shape list now lives in SubmittedContent, shared with | |
| 2238 | + // OptInFrontend::addPlaceholders() — the other consumer that | |
| 2239 | + // already knew all of them. A fourth integration with a | |
| 2240 | + // fourth layout is taught to both at once. | |
| 2241 | + // | |
| 2242 | + // The lookup also tolerates a consent_field that the | |
| 2243 | + // pre-5.3.2 sanitize_key() lowercased, so installations | |
| 2244 | + // recover from the update without re-saving every form. | |
| 2245 | + $data['consentAcknowledged'] = SubmittedContent::hasValue( $content, (string) $data['consentField'] ); | |
| 2442 | 2246 | |
| 2443 | 2247 | // Parse mail_optin |
| 2444 | 2248 | $mailOptin = maybe_unserialize( $row['mail_optin'] ); |
| 2445 | 2249 | $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array(); |
| @@ -2622,10 +2426,10 @@ | ||
| 2622 | 2426 | // First pass: every registered addon gets an entry, even if |
| 2623 | 2427 | // it contributes no UI. That lets the client show per-addon |
| 2624 | 2428 | // licensing/boot state without a second round-trip. |
| 2625 | 2429 | foreach ( $registered as $id => $addon ) { |
| 2626 | - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array(); | |
| 2627 | - $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment ); | |
| 2430 | + $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array(); | |
| 2431 | + $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment ); | |
| 2628 | 2432 | unset( $fragments[ $id ] ); |
| 2629 | 2433 | } |
| 2630 | 2434 | |
| 2631 | 2435 | // Second pass: fragments for addons NOT in the registry |
| @@ -2863,11 +2667,18 @@ | ||
| 2863 | 2667 | } |
| 2864 | 2668 | |
| 2865 | 2669 | $activateUrl = null; |
| 2866 | 2670 | if ( $installed && ! $active ) { |
| 2867 | - $activateUrl = wp_nonce_url( | |
| 2868 | - self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ), | |
| 2869 | - 'activate-plugin_' . $pluginFile | |
| 2671 | + // Not wp_nonce_url(): it HTML-escapes & to &, and this URL | |
| 2672 | + // goes as JSON into an href — "plugin" and "_wpnonce" then | |
| 2673 | + // arrived as "amp;plugin" and the activation failed. | |
| 2674 | + $activateUrl = add_query_arg( | |
| 2675 | + array( | |
| 2676 | + 'action' => 'activate', | |
| 2677 | + 'plugin' => rawurlencode( $pluginFile ), | |
| 2678 | + '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ), | |
| 2679 | + ), | |
| 2680 | + self_admin_url( 'plugins.php' ) | |
| 2870 | 2681 | ); |
| 2871 | 2682 | } |
| 2872 | 2683 | |
| 2873 | 2684 | $registeredAddon = $registered[ $id ] ?? null; |