diff --git a/README.md b/README.md index 49eb1708f..49014e6bd 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **Contributors:** [brainstormforce](https://profiles.wordpress.org/brainstormforce/) **Tags:** forms, ai forms, contact form, form builder, payment form **Requires at least:** 6.4 -**Tested up to:** 7.1.2 +**Tested up to:** 7.1.3 **Requires PHP:** 7.4 -**Stable tag:** 2.12.8 +**Stable tag:** 2.12.9 **License:** GPLv2 or later **License URI:** http://www.gnu.org/licenses/gpl-2.0.html @@ -426,6 +426,17 @@ Visit the [SureForms features page](https://sureforms.com/features/?utm_source=w You can report security issues through our [Bug Bounty Program](https://brainstormforce.com/bug-bounty-program/). We collaborate with Patchstack to validate, triage, and handle security reports. ## Changelog ## +### 2.12.9 - 13th October 2026 ### +* New: Added the `srfm_submission_accepted` developer action, fired as soon as a submission is accepted. +* New: Forms that do not store entries now warn you when a failed notification loses a submission. +* New: MCP Adapter can now be installed and activated in one click. +* Improvement: Failed notifications are no longer silently retried through PHP's mail(); the reason now shows in Entry Logs and Form Checks. Use the `srfm_allow_php_mail_fallback` filter to restore it. +* Improvement: Smart tags such as {get_input:name} now resolve only inside SureForms form content. A tag written in ordinary page or post content is shown as written. +* Fix: Deleting an option in a duplicated Multiple Choice block no longer removes it from the original. +* Fix: Form fields no longer fail accessibility scans for a missing error message reference. +* Fix: The submit button now stays disabled while a form redirects, preventing duplicate submissions. +* Fix: Unticked Checkbox fields now appear in entries and notification emails. +* Fix: This update addressed a security bug. Props to dzaku from Patchstack for reporting it responsibly to our team. ### 2.12.8 - 25th September 2026 ### * Improvement: The debug log no longer records routine validation stops, such as an empty required field, keeping it focused on real failures. * Fix: Entry Logs now record the email notification sent when a form is submitted. @@ -439,12 +450,6 @@ You can report security issues through our [Bug Bounty Program](https://brainsto * Fix: The conversion rate no longer counts submissions made by site editors while previewing a form. * Fix: The Edit Form icon is no longer oversized in Divi. * Fix: This update addressed a security bug. Props to nh4tvd from Patchstack for reporting it responsibly to our team. -### 2.12.6 - 2nd September 2026 ### -* New: Added an Enable Logs option that records form submission failures to a downloadable file, making it easier to diagnose issues on a live site. -* New: Added Views and Conversion Rate columns to the Forms list, so you can see how each form is performing at a glance. -* New: SureForms now detects a missing entries database table and offers a one-click repair, so submissions start saving again without manual database work. -* Fix: The Edit Form button no longer overlaps form fields on the front end. -* Fix: This update addressed a security bug. Props to Patchstack for reporting it responsibly to our team. The full changelog is available [here](https://sureforms.com/whats-new/?utm_source=wordpress.org&utm_medium=whats_new). ## Upgrade Notice ## \ No newline at end of file diff --git a/admin/admin.php b/admin/admin.php index a445eca49..58e66c85d 100644 --- a/admin/admin.php +++ b/admin/admin.php @@ -12,6 +12,10 @@ use SRFM\Inc\Client_Logger; use SRFM\Inc\Database\Register; use SRFM\Inc\Database\Tables\Entries; +use SRFM\Inc\Email_Delivery\Lost_Submissions; +use SRFM\Inc\Email_Delivery\Mailer; +use SRFM\Inc\Email_Delivery\Status as Email_Delivery_Status; +use SRFM\Inc\Email_Delivery\Suremail_Adapter; use SRFM\Inc\Generate_Form_Markup; use SRFM\Inc\Global_Settings\Global_Settings; use SRFM\Inc\Helper; @@ -617,10 +621,11 @@ public function dismiss_form_setup_card( $request ) { // date automatically (see BSF_Analytics_Events::track()) and dedupes per // event name, matching the sibling notice's telemetry. $events = [ - 'edit_form' => 'form_setup_widget_edit_form', - 'edit_thankyou' => 'form_setup_widget_edit_thankyou', - 'set_up_email' => 'form_setup_widget_set_up_email', - 'view_form' => 'form_setup_widget_view_form', + 'edit_form' => 'form_setup_widget_edit_form', + 'edit_thankyou' => 'form_setup_widget_edit_thankyou', + 'set_up_email' => 'form_setup_widget_set_up_email', + 'view_form' => 'form_setup_widget_view_form', + 'email_delivery' => 'form_setup_widget_email_delivery', ]; if ( isset( $events[ $action ] ) ) { @@ -702,6 +707,32 @@ public function render_form_setup_widget() { ], ]; + // Whether those notifications will actually be sent. A form imported by a + // site builder is often live before any mailer is set up, which is the + // owner this widget exists for. The status title is the label; a button + // only when there is something to do. + $email_delivery = Email_Delivery_Status::get_status(); + $delivery_row = [ + 'label' => Helper::get_string_value( $email_delivery['title'] ?? '' ), + 'cta' => '', + 'url' => '', + 'event' => 'email_delivery', + ]; + $delivery_state = Helper::get_string_value( $email_delivery['state'] ?? '' ); + + if ( 'not_configured' === $delivery_state ) { + // The Form Checks panel there installs SureMail in one click. + $delivery_row['cta'] = __( 'Set up email delivery', 'sureforms' ); + $delivery_row['url'] = admin_url( 'admin.php?page=sureforms_menu' ); + } elseif ( ! empty( $email_delivery['action']['url'] ) ) { + $delivery_row['cta'] = Helper::get_string_value( $email_delivery['action']['label'] ?? '' ); + $delivery_row['url'] = Helper::get_string_value( $email_delivery['action']['url'] ); + } + + if ( '' !== $delivery_row['label'] ) { + $rows[] = $delivery_row; + } + // Fall back to a generic label for an untitled form so the heading never // renders "Finish setting up " with a dangling space. $card_title = '' !== trim( (string) $card['title'] ) ? $card['title'] : __( 'your form', 'sureforms' ); @@ -726,7 +757,9 @@ public function render_form_setup_widget() {
  • - + + +
  • @@ -1410,8 +1443,9 @@ public function add_suremail_page() { // Check if SureMail is installed and active. if ( 'sureforms_smtp' === $submenu_page && file_exists( WP_PLUGIN_DIR . '/suremails/suremails.php' ) && is_plugin_active( 'suremails/suremails.php' ) ) { - // Plugin is installed and active - redirect to SureMail dashboard. - wp_safe_redirect( admin_url( 'options-general.php?page=suremail#/dashboard' ) ); + // Plugin is installed and active - redirect to SureMail dashboard. SureMail + // can live under Settings or as a top-level menu, so let it build the URL. + wp_safe_redirect( Suremail_Adapter::admin_url( '/dashboard' ) ); exit; } } @@ -1871,6 +1905,7 @@ public function enqueue_scripts() { 'currency_sign_position' => Payment_Helper::get_currency_sign_position(), ] ), + 'can_install_plugins' => current_user_can( 'install_plugins' ), 'mcp_adapter_status' => file_exists( WP_PLUGIN_DIR . '/mcp-adapter/mcp-adapter.php' ) ? ( is_plugin_active( 'mcp-adapter/mcp-adapter.php' ) ? 'active' : 'installed' ) : 'not_installed', @@ -1931,6 +1966,11 @@ public function enqueue_scripts() { $is_sureforms_screen = apply_filters( 'srfm_is_dashboard_screen', $is_sureforms_screen ); if ( $is_sureforms_screen ) { + // Built only where SureForms' own screens read it: on other admin pages the + // status (and, with SureMail inactive, a get_plugins() scan) is wasted work. + $localization_data['email_delivery'] = Email_Delivery_Status::get_status(); + $localization_data['email_delivery_offer'] = Email_Delivery_Status::get_offer(); + $asset_handle = '-dashboard'; wp_enqueue_style( SRFM_SLUG . $asset_handle . '-font', 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap', [], SRFM_VER ); @@ -2061,6 +2101,7 @@ public function enqueue_scripts() { 'suremail_status' => file_exists( WP_PLUGIN_DIR . '/suremails/suremails.php' ) ? ( is_plugin_active( 'suremails/suremails.php' ) ? 'active' : 'installed' ) : 'not_installed', + 'email_delivery_offer' => Email_Delivery_Status::get_offer(), ]; wp_localize_script( @@ -2187,6 +2228,7 @@ public function enqueue_scripts() { 'template_picker_nonce' => Helper::current_user_can() ? wp_create_nonce( 'wp_rest' ) : '', 'is_pro_active' => Helper::has_pro(), 'srfm_ai_usage_details' => AI_Helper::get_current_usage_details(), + 'has_ai_generated_forms' => AI_Helper::has_ai_generated_forms(), 'is_pro_license_active' => AI_Helper::is_pro_license_active(), 'srfm_ai_auth_user_email' => get_option( 'srfm_ai_auth_user_email' ), 'pricing_page_url' => Helper::get_sureforms_website_url( 'pricing' ), @@ -2884,14 +2926,32 @@ public function handle_notice_response() { 'dismissed' => 'submission_failure_notice_dismiss', ], 'notification_error' => [ - 'contact_support' => 'notification_failure_notice_cta', - 'help_me_fix' => 'notification_failure_notice_guide', - 'dismissed' => 'notification_failure_notice_dismiss', + 'connect_surecontact' => 'notification_failure_notice_connect', + 'contact_support' => 'notification_failure_notice_cta', + 'help_me_fix' => 'notification_failure_notice_guide', + 'view_logs' => 'notification_failure_notice_logs', + 'dismissed' => 'notification_failure_notice_dismiss', + ], + // Email delivery advice (#3150), dashboard panel only. Ignore goes through + // srfm_dismiss_action_item, which records its own dismiss event. + 'email_delivery_setup' => [ + 'setup_email_delivery' => 'email_setup_notice_install', + 'open_suremail' => 'email_setup_notice_connect', + 'connect_surecontact' => 'email_setup_notice_connect', + ], + 'email_delivery_limited' => [ + 'open_suremail' => 'email_limited_notice_cta', + ], + 'email_delivery_quota' => [ + 'open_suremail' => 'email_quota_notice_cta', ], 'integration_error' => [ 'contact_support' => 'integration_failure_notice_cta', 'dismissed' => 'integration_failure_notice_dismiss', ], + 'lost_submissions' => [ + 'help_me_fix' => 'lost_submissions_notice_guide', + ], 'caching_plugin' => [ 'help_me_fix' => 'caching_plugin_notice_cta', 'dismissed' => 'caching_plugin_notice_dismiss', @@ -3499,6 +3559,20 @@ public function render_action_item_notices() { $this->enqueue_notice_response_script(); + // Only when a notice carries Connect SureContact, so the other notices + // print nothing extra. + if ( ! empty( array_filter( array_column( $items, 'connect_label' ) ) ) ) { + ?> + +

    + + + + + get_first_party_action_items(); } + // Lost submissions, outside the logging gate and on every screen: data is + // gone, and Distraction Free (which turns logging off) must not hide it. + $warnings = array_merge( $warnings, $this->get_lost_submissions_action_items() ); + + // Email delivery advice, outside the logging gate: it is about the site's + // setup, not about recorded failures. On the SureForms dashboard only -- + // an owner who has not set up email yet needs telling where they manage + // forms, not on every admin screen -- which also keeps the form lookup + // behind it off every other page load. + $on_dashboard = Helper::validate_request_context( 'sureforms_menu', 'page' ); + if ( $on_dashboard ) { + $warnings = array_merge( $warnings, $this->get_email_delivery_action_items() ); + } + + // A quota running out also belongs on Entries, where an owner notices + // submissions arriving but not their emails. + if ( $on_dashboard || Helper::validate_request_context( SRFM_ENTRIES, 'page' ) ) { + $quota_items = $this->get_email_quota_action_items(); + $warnings = array_merge( $warnings, $quota_items ); + + // SureForms hides classic notices on its own screens and Entries has no + // Form Checks panel, so it gets the warning in its React notice bar. The + // bar reads the notices when the page data is filtered, after this. + if ( ! $on_dashboard ) { + foreach ( $quota_items as $quota_item ) { + Notice_Manager::register_notice( + [ + 'id' => 'srfm-' . $quota_item['id'], + 'variant' => $quota_item['status'], + 'title' => $quota_item['title'], + 'message' => $quota_item['message'], + 'actions' => [ + [ + 'label' => $quota_item['cta_label'], + 'url' => $quota_item['cta_url'], + 'target' => '_blank', + 'variant' => 'link', + ], + ], + 'dismissible' => false, + 'pages' => [ SRFM_ENTRIES ], + ] + ); + } + } + } + $this->track_action_item_impressions( $warnings ); /** @@ -3719,8 +3854,8 @@ public function get_action_items() { */ $items = Helper::apply_filters_as_array( 'srfm_action_items', $warnings ); - // Both URLs normalised once, here, rather than trusting each renderer to do - // it. Two things are being fixed at once. + // The CTA, guide and connect URLs normalised once, here, rather than + // trusting each renderer to do it. Two things are being fixed at once. // // The scheme: the classic notice runs esc_url() and drops anything outside // the allowlist, while React assigns href directly and react-dom 18 leaves @@ -3741,7 +3876,7 @@ public function get_action_items() { continue; } - foreach ( [ 'cta_url', 'guide_url' ] as $key ) { + foreach ( [ 'cta_url', 'guide_url', 'connect_url' ] as $key ) { if ( ! isset( $item[ $key ] ) ) { continue; } @@ -3929,6 +4064,24 @@ private function get_first_party_action_items() { ], ]; + // With SureMail sending, the reason for the failure is in its email log -- + // a closer next step than a general guide. + if ( 'suremail' === Mailer::get_active()['slug'] ) { + $categories['notification']['guide'] = Suremail_Adapter::logs_url(); + $categories['notification']['guide_label'] = __( 'View SureMail Logs', 'sureforms' ); + $categories['notification']['guide_action'] = 'view_logs'; + } + + // A form that does not store entries has no saved copy to fall back on: the + // email that failed was the only record of that submission. "The entry was + // saved" would be false, and the fix differs, so say both. + if ( isset( $open['notification'] ) && false === ( $open['notification']['entry_saved'] ?? true ) ) { + /* translators: %s: form title. */ + $categories['notification']['title'] = __( 'A notification failed on %s, and the submission was not stored.', 'sureforms' ); + $categories['notification']['generic'] = __( 'A notification failed, and the submission was not stored.', 'sureforms' ); + $categories['notification']['message'] = __( 'This form is set to never store entries, so the email was the only copy of that submission and it could not be sent. Set up email delivery, or turn off "Never store entry data after form submission" for this form, so submissions are not lost.', 'sureforms' ); + } + foreach ( $categories as $category => $copy ) { if ( ! isset( $open[ $category ] ) ) { continue; @@ -3970,9 +4123,15 @@ private function get_first_party_action_items() { // without a guide needs no branch in either renderer, and neither does // an item contributed through srfm_action_items. if ( ! empty( $copy['guide'] ) ) { - $warning['guide_label'] = __( 'Help Me Fix', 'sureforms' ); + $warning['guide_label'] = $copy['guide_label'] ?? __( 'Help Me Fix', 'sureforms' ); $warning['guide_url'] = $copy['guide']; - $warning['guide_action'] = 'help_me_fix'; + $warning['guide_action'] = $copy['guide_action'] ?? 'help_me_fix'; + } + + // A failed notification on a site with no sending service behind it: + // lead with the one-click fix. + if ( 'notification' === $category ) { + $warning = array_merge( $warning, $this->get_connect_surecontact_action() ); } $warnings[] = $warning; @@ -4008,6 +4167,308 @@ private function get_first_party_action_items() { return $warnings; } + /** + * "Connect SureContact" for a notification failure, when nothing is sending. + * + * Offered while SureMail is not active, or is active with no connection -- + * the cases where the failure is almost certainly the missing service. With + * a connection in place the failure is that connection's, and View SureMail + * Logs is the closer next step. Hidden with promotions. + * + * Leads to SureForms -> SMTP while SureMail is not active, which installs + * it; once active, straight to SureMail's SureContact screen. (The SMTP page + * hands over to that screen after install once #3205 is in.) The account + * itself is only ever created behind SureMail's consent. + * + * @since 2.12.9 + * @return array connect_* keys, or none. + */ + private function get_connect_surecontact_action() { + if ( Helper::hide_promotions() ) { + return []; + } + + $status = Email_Delivery_Status::get_status(); + $action = Helper::get_array_value( $status['action'] ?? [] ); + $type = Helper::get_string_value( $action['type'] ?? '' ); + + if ( 'limited' === Helper::get_string_value( $status['state'] ?? '' ) ) { + return []; + } + + if ( in_array( $type, [ 'install_activate', 'activate' ], true ) ) { + $url = admin_url( 'admin.php?page=sureforms_smtp' ); + } elseif ( 'open_suremail' === $type && 0 === (int) ( $status['suremail']['connections'] ?? -1 ) ) { + $url = Helper::get_string_value( $action['url'] ?? '' ); + } else { + return []; + } + + if ( '' === $url ) { + return []; + } + + return [ + 'connect_label' => __( 'Connect SureContact', 'sureforms' ), + 'connect_url' => $url, + 'connect_action' => 'connect_surecontact', + ]; + } + + /** + * Email delivery advice for the Form Checks panel. + * + * Shown only when a published form actually sends a notification: a site + * with no notifications has nothing for email delivery to break. Advisory and + * dismissible -- an actual failure is reported by notification_error, which + * is not. Hidden with promotions, since the fix is installing SureMail. + * + * @since 2.12.9 + * @return array> + */ + private function get_email_delivery_action_items() { + if ( Helper::hide_promotions() ) { + return []; + } + + $status = Email_Delivery_Status::get_status(); + $state = Helper::get_string_value( $status['state'] ?? '' ); + $action = Helper::get_array_value( $status['action'] ?? [] ); + $action_type = Helper::get_string_value( $action['type'] ?? '' ); + $installs = in_array( $action_type, [ 'install_activate', 'activate' ], true ); + // An unverified SureContact account that failed to send has hit its daily + // allowance: the advice is to verify, shown beside the failure itself. + $verify = 'verify_surecontact' === $action_type; + + // Keyed off the next step, not only the state: when a notification has + // already failed ("attention") and the fix is still to set up or connect + // SureMail, that is exactly when the owner needs this button -- found on a + // fresh ZipWP site, whose first notification failed with no mailer. + if ( 'limited' === $state || $verify ) { + $item_id = 'email_delivery_limited'; + } elseif ( $installs || 'open_suremail' === $action_type ) { + $item_id = 'email_delivery_setup'; + } else { + return []; + } + + $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) ); + + // A real failure brings the advice back even if it was ignored before, and + // keeps it until email works: it is no longer optional. + $failing = 'attention' === $state; + + if ( ( ! $failing && in_array( $item_id, $dismissed, true ) ) || ! $this->has_form_with_active_notification() ) { + return []; + } + + $title = Helper::get_string_value( $status['title'] ?? '' ); + $message = Helper::get_string_value( $status['message'] ?? '' ); + + // The failure itself is notification_error's to report; this item names + // the fix, so it does not repeat "a notification was not sent". + if ( 'attention' === $state && ! $verify ) { + if ( $installs ) { + $title = __( 'Email delivery is not set up', 'sureforms' ); + $message = __( 'Form notifications have no email service behind them, which is why they can fail. Set up email delivery so new entries reach your inbox.', 'sureforms' ); + } else { + $title = __( 'SureMail needs a sending connection', 'sureforms' ); + $message = __( 'SureMail has no working connection, so form notifications are not handed to an email service.', 'sureforms' ); + } + } + + $item = [ + 'id' => $item_id, + 'status' => 'warning', + 'title' => $title, + 'message' => $message, + 'cta_label' => Helper::get_string_value( $action['label'] ?? '' ), + 'cta_url' => Helper::get_string_value( $action['url'] ?? '' ), + 'cta_action' => 'open_suremail', + 'dismissible' => ! $failing, + ]; + + // SureMail is active with nothing to send through, and its SureContact + // screen is the fix: offer it as the same purple "Connect SureContact" the + // failure notice leads with, rather than a second wording of one action. + if ( 'open_suremail' === $action_type && ! $verify ) { + $connect = $this->get_connect_surecontact_action(); + + if ( ! empty( $connect ) && Suremail_Adapter::supports_surecontact() ) { + unset( $item['cta_label'], $item['cta_url'], $item['cta_action'] ); + $item = array_merge( $item, $connect ); + } + } + + // SureMail not active yet. The link is WordPress's own installer, which + // works in both renderers without JavaScript; the dashboard panel turns + // it into a one-click install when the user may install plugins. + if ( $installs ) { + $item['cta_url'] = 'Installed' === ( $status['suremail']['plugin_status'] ?? '' ) + ? admin_url( 'plugins.php?plugin_status=inactive' ) + : admin_url( 'plugin-install.php?s=SureMail&tab=search&type=term' ); + $item['cta_action'] = 'setup_email_delivery'; + + if ( ! empty( $status['can_install'] ) ) { + $item['cta_install'] = 'sure_mails'; + } + } + + $has_cta = '' !== ( $item['cta_label'] ?? '' ) && '' !== ( $item['cta_url'] ?? '' ); + $has_connect = ! empty( $item['connect_label'] ) && ! empty( $item['connect_url'] ); + + return $has_cta || $has_connect ? [ $item ] : []; + } + + /** + * Warning when the SureContact sending quota is nearly or fully used up. + * + * Shown on the SureForms dashboard and the Entries screen, where an owner + * waiting on submissions looks. The quota comes from a background refresh, + * which this queues when the cached copy has expired. + * + * Dismissing hides it until the quota recovers (a new period, or an upgrade); + * it comes back, and can no longer be dismissed, once nothing is left. + * + * @since 2.12.9 + * @return array> + */ + private function get_email_quota_action_items() { + if ( Helper::hide_promotions() ) { + return []; + } + + Suremail_Adapter::maybe_schedule_quota_refresh(); + + $status = Email_Delivery_Status::get_status(); + $surecontact = Helper::get_array_value( $status['surecontact'] ?? [] ); + $quota = isset( $surecontact['quota'] ) && is_array( $surecontact['quota'] ) ? $surecontact['quota'] : null; + $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) ); + + if ( null === $quota || empty( $surecontact['quota_low'] ) ) { + // Recovered: forget the dismissal so the next low point is shown again. + if ( null !== $quota && in_array( 'email_delivery_quota', $dismissed, true ) ) { + Helper::update_srfm_option( 'dismissed_action_items', array_values( array_diff( $dismissed, [ 'email_delivery_quota' ] ) ) ); + } + return []; + } + + $used = Helper::get_integer_value( $quota['used'] ?? 0 ); + $limit = Helper::get_integer_value( $quota['limit'] ?? 0 ); + $remaining = Helper::get_integer_value( $quota['remaining'] ?? 0 ); + $monthly = 'monthly' === ( $quota['period'] ?? '' ); + $exhausted = 0 === $remaining; + + if ( ! $exhausted && in_array( 'email_delivery_quota', $dismissed, true ) ) { + return []; + } + + if ( $exhausted ) { + $title = __( 'Your SureContact emails have run out', 'sureforms' ); + $message = $monthly + ? sprintf( + /* translators: %s: number of emails in the SureContact plan. */ + __( 'All %s emails for this month are used, so form notifications are not being sent until next month. Upgrade in SureMail to keep sending.', 'sureforms' ), + number_format_i18n( $limit ) + ) + : sprintf( + /* translators: %s: number of emails in the SureContact plan. */ + __( 'All %s emails are used, so form notifications are not being sent. Upgrade in SureMail to keep sending.', 'sureforms' ), + number_format_i18n( $limit ) + ); + } else { + $title = empty( $surecontact['is_paid'] ) + ? __( 'Your free SureContact emails are almost used up', 'sureforms' ) + : __( 'Your SureContact emails are almost used up', 'sureforms' ); + $message = $monthly + ? sprintf( + /* translators: 1: emails used, 2: emails in the plan. */ + __( '%1$s of %2$s emails used this month. When they run out, form notifications stop being sent until next month, unless you upgrade.', 'sureforms' ), + number_format_i18n( $used ), + number_format_i18n( $limit ) + ) + : sprintf( + /* translators: 1: emails used, 2: emails in the plan. */ + __( '%1$s of %2$s emails used. When they run out, form notifications stop being sent until you upgrade.', 'sureforms' ), + number_format_i18n( $used ), + number_format_i18n( $limit ) + ); + } + + return [ + [ + 'id' => 'email_delivery_quota', + 'status' => $exhausted ? 'error' : 'warning', + 'title' => $title, + 'message' => $message, + 'cta_label' => __( 'Upgrade in SureMail', 'sureforms' ), + 'cta_url' => Suremail_Adapter::upgrade_url(), + 'cta_action' => 'open_suremail', + 'dismissible' => ! $exhausted, + ], + ]; + } + + /** + * Whether any published form sends at least one email notification. + * + * @since 2.12.9 + * @return bool + */ + private function has_form_with_active_notification() { + // In pages, newest first, with each page's meta primed in one query: + // usually the first page answers, and sites with many forms whose newest + // fifty send nothing are still covered. + for ( $page = 1; $page <= 10; $page++ ) { + $form_ids = array_map( + [ Helper::class, 'get_integer_value' ], + get_posts( + [ + 'post_type' => SRFM_FORMS_POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => 50, + 'paged' => $page, + // A stable order across pages: forms published in the same + // second would otherwise be returned in any order. + 'orderby' => [ + 'date' => 'DESC', + 'ID' => 'DESC', + ], + 'fields' => 'ids', + 'no_found_rows' => true, + 'meta_key' => '_srfm_email_notification', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- SureForms dashboard only, paged by 50. + ] + ) + ); + + if ( empty( $form_ids ) ) { + return false; + } + + update_meta_cache( 'post', $form_ids ); + + foreach ( $form_ids as $form_id ) { + $notifications = get_post_meta( $form_id, '_srfm_email_notification', true ); + + if ( ! is_array( $notifications ) ) { + continue; + } + + foreach ( $notifications as $notification ) { + if ( is_array( $notification ) && ! empty( $notification['status'] ) ) { + return true; + } + } + } + + if ( count( $form_ids ) < 50 ) { + return false; + } + } + + return false; + } + /** * Nonce-protected URL that repairs the entries table. * @@ -5025,6 +5486,60 @@ private function get_support_message( $category = '', $form_title = '' ) { return implode( "\n", $lines ); } + /** + * Submissions lost on forms that never store entries. + * + * A never-store form keeps no copy of a submission, so when none of its + * notifications is accepted the submission is gone. Lost_Submissions keeps + * the count until the owner acknowledges it here. + * + * @since 2.12.9 + * @return array> + */ + private function get_lost_submissions_action_items() { + $lost = Lost_Submissions::get(); + if ( $lost['count'] < 1 ) { + return []; + } + + $titles = []; + foreach ( $lost['form_ids'] as $form_id ) { + $title = get_the_title( $form_id ); + if ( '' !== $title ) { + $titles[] = $title; + } + } + + $message = __( 'These forms are set to never store entries, and no email notification for them could be sent, so the submissions were not saved anywhere. Set up email delivery, or turn off "Never store entry data after form submission" on these forms.', 'sureforms' ); + if ( ! empty( $titles ) ) { + /* translators: %s: comma-separated form titles. */ + $message .= ' ' . sprintf( __( 'Forms: %s.', 'sureforms' ), implode( ', ', $titles ) ); + } + + return [ + [ + 'id' => 'lost_submissions', + 'status' => 'error', + 'title' => sprintf( + /* translators: %s: number of lost submissions. */ + _n( '%s form submission was lost', '%s form submissions were lost', $lost['count'], 'sureforms' ), + number_format_i18n( $lost['count'] ) + ), + 'message' => $message, + 'cta_label' => __( 'Help Me Fix', 'sureforms' ), + 'cta_url' => Helper::get_sureforms_website_url( + 'docs/troubleshooting-email-sending-in-sureforms/', + [ + 'utm_medium' => 'form_checks_notice', + 'utm_content' => 'lost_submissions', + ] + ), + 'cta_action' => 'help_me_fix', + 'dismissible' => true, + ], + ]; + } + /** * Record one dismissal, shared by the AJAX and no-JS entry points. * @@ -5037,7 +5552,15 @@ private function get_support_message( $category = '', $form_title = '' ) { * @return bool False when the id is not dismissible. */ private function dismiss_action_item( $item_id ) { - if ( ! in_array( $item_id, [ 'caching_plugin' ], true ) ) { + // "Ignore" on lost submissions means "I have seen them": the record is + // cleared rather than hidden, so the next loss shows again. + if ( 'lost_submissions' === $item_id ) { + Lost_Submissions::acknowledge(); + $this->track_notice_event( 'lost_submissions_notice_dismiss' ); + return true; + } + + if ( ! in_array( $item_id, [ 'caching_plugin', 'email_delivery_setup', 'email_delivery_limited', 'email_delivery_quota' ], true ) ) { return false; } diff --git a/admin/analytics.php b/admin/analytics.php index c652395f4..296c8f32f 100644 --- a/admin/analytics.php +++ b/admin/analytics.php @@ -7,8 +7,10 @@ namespace SRFM\Admin; +use SRFM\Inc\AI_Form_Builder\AI_Helper; use SRFM\Inc\Database\Register; use SRFM\Inc\Database\Tables\Entries; +use SRFM\Inc\Email_Delivery\Status as Email_Delivery_Status; use SRFM\Inc\Form_Views; use SRFM\Inc\Helper; use SRFM\Inc\Learn; @@ -92,6 +94,7 @@ static function () { add_action( 'current_screen', [ $this, 'track_first_editor_open' ] ); add_action( 'transition_post_status', [ $this, 'track_first_form_published' ], 10, 3 ); add_action( 'save_post', [ $this, 'track_embed_styling_configured' ], 10, 2 ); + add_action( 'admin_init', [ $this, 'track_email_delivery_status' ] ); // Detect state-based events on admin load (dedup prevents repeat tracking). $this->detect_state_events(); @@ -736,6 +739,58 @@ public function track_first_form_published( $new_status, $old_status, $post ) { ); } + /** + * Track AI form creation, broken down by the starter card used (if any). + * + * Keeps cumulative counters in srfm_options and queues the latest totals as + * the `ai_starter_usage` event each time an AI form is created, so the share + * of AI forms started from each card can be read from the warehouse. + * + * @param mixed $starter_id Starter card id (sanitized here), or empty string for a free-text prompt. + * @param bool $edited Whether the starter prompt was edited before generating. + * @since 2.12.9 + * @return void + */ + public static function track_ai_starter_usage( $starter_id, $edited ) { + // The id becomes an option key and an event property key, so clean it here + // rather than trusting every caller. + $starter_id = substr( sanitize_key( Helper::get_string_value( $starter_id ) ), 0, 40 ); + + $usage = Helper::get_srfm_option( 'ai_starter_usage', [] ); + $usage = is_array( $usage ) ? $usage : []; + + $counts = isset( $usage['starters'] ) && is_array( $usage['starters'] ) ? array_map( 'absint', $usage['starters'] ) : []; + + // Counts AI forms created since this tracking shipped, deleted ones included + // (unlike the `ai_generated_forms` stat, which counts forms that exist now). + $usage['ai_forms_created'] = absint( $usage['ai_forms_created'] ?? 0 ) + 1; + + // Cap distinct keys so a filtered starter list can't grow the option unbounded. + if ( '' !== $starter_id && ( isset( $counts[ $starter_id ] ) || count( $counts ) < 20 ) ) { + $counts[ $starter_id ] = absint( $counts[ $starter_id ] ?? 0 ) + 1; + + if ( $edited ) { + $usage['edited'] = absint( $usage['edited'] ?? 0 ) + 1; + } + } + + $usage['starters'] = $counts; + Helper::update_srfm_option( 'ai_starter_usage', $usage ); + + $properties = [ + 'ai_forms_created' => (string) $usage['ai_forms_created'], + 'edited' => (string) absint( $usage['edited'] ?? 0 ), + ]; + foreach ( $counts as $id => $count ) { + $properties[ 'starter_' . $id ] = (string) $count; + } + + // $force = true: skip the already-sent check and replace any queued copy, so + // the next flush sends these totals. Nothing is re-sent until another AI form + // is created. + self::events()->track( 'ai_starter_usage', (string) array_sum( $counts ), $properties, true ); + } + /** * Track embed styling configuration when a post/page is saved. * @@ -824,6 +879,47 @@ public function track_plugin_activated_event() { self::events()->track( 'plugin_activated', SRFM_VER, [ 'source' => $source ] ); } + /** + * Track the site's email delivery state. + * + * Whether form notifications have a real mailer behind them. A state, not a + * milestone, so it is re-sent (forced) whenever it changes -- and checked at + * most daily, because admin_init runs on every admin page load and the status + * can scan the plugins directory. Only fixed labels are sent: connection + * titles are owner-entered text. + * + * On admin_init rather than in detect_state_events(), which runs while + * plugins are still loading: pluggable functions do not exist yet, so a + * redefined wp_mail() would be missed and current_user_can() is undefined. + * + * @since 2.12.9 + * @return void + */ + public function track_email_delivery_status() { + if ( false !== get_transient( 'srfm_email_delivery_state_checked' ) ) { + return; + } + + set_transient( 'srfm_email_delivery_state_checked', 1, DAY_IN_SECONDS ); + + $email_delivery = Email_Delivery_Status::get_status(); + $mailer_slug = Helper::get_string_value( $email_delivery['mailer']['slug'] ?? '' ); + $verified = $email_delivery['surecontact']['email_verified'] ?? null; + $delivery_props = [ + 'mailer' => $mailer_slug, + 'mailer_plugin' => 'other' === $mailer_slug ? Helper::get_string_value( $email_delivery['mailer']['label'] ?? '' ) : '', + 'surecontact' => ! empty( $email_delivery['surecontact']['connected'] ) ? 'yes' : 'no', + 'surecontact_verified' => null === $verified ? 'unknown' : ( $verified ? 'yes' : 'no' ), + ]; + $delivery_state = Helper::get_string_value( $email_delivery['state'] ?? '' ); + $fingerprint = $delivery_state . '|' . implode( '|', $delivery_props ); + + if ( '' !== $delivery_state && Helper::get_srfm_option( 'email_delivery_state_tracked', '' ) !== $fingerprint ) { + self::events()->track( 'email_delivery_status', $delivery_state, $delivery_props, true ); + Helper::update_srfm_option( 'email_delivery_state_tracked', $fingerprint ); + } + } + /** * Extract non-inherit formTheme values from Bricks element data. * @@ -1007,6 +1103,49 @@ private function detect_state_events() { $onboarding_props['suremail_installed'] = (bool) $onboarding_analytics['suremailInstalled'] ? 'yes' : 'no'; } + // Email delivery step: the delivery state on arrival and on leaving, + // and what the owner did. Closed sets, intersected like the premium + // tabs below, because the blob is whatever the wizard POSTed. + if ( ! empty( $onboarding_analytics['emailDelivery'] ) && is_array( $onboarding_analytics['emailDelivery'] ) ) { + $email_delivery = $onboarding_analytics['emailDelivery']; + $states = [ 'attention', 'not_configured', 'suremail_no_connection', 'limited', 'other_mailer', 'ready' ]; + $step_actions = [ 'skip', 'cannot_install', 'install_other_smtp', 'continue_limited', 'continue_other_mailer', 'continue_ready', 'continue_attention', 'continue_not_set_up' ]; + + $state_props = [ + 'stateAtStep' => 'email_delivery_state_at_step', + 'finalState' => 'email_delivery_state_at_exit', + ]; + + foreach ( $state_props as $key => $prop ) { + if ( isset( $email_delivery[ $key ] ) && in_array( $email_delivery[ $key ], $states, true ) ) { + $onboarding_props[ $prop ] = $email_delivery[ $key ]; + } + } + + if ( isset( $email_delivery['action'] ) && in_array( $email_delivery['action'], $step_actions, true ) ) { + $onboarding_props['email_delivery_action'] = $email_delivery['action']; + } + + if ( isset( $email_delivery['suremailOpened'] ) ) { + $onboarding_props['email_delivery_suremail_opened'] = (bool) $email_delivery['suremailOpened'] ? 'yes' : 'no'; + } + + if ( isset( $email_delivery['consented'] ) ) { + $onboarding_props['email_delivery_consented'] = (bool) $email_delivery['consented'] ? 'yes' : 'no'; + } + + // The option card open when the step was left. + if ( isset( $email_delivery['option'] ) && in_array( $email_delivery['option'], [ 'surecontact', 'other_smtp' ], true ) ) { + $onboarding_props['email_delivery_option'] = $email_delivery['option']; + } + + // Outcome of the one-click SureContact sign-up, when it was tried. + $provision_outcomes = [ 'created', 'already_registered', 'unavailable', 'failed' ]; + if ( isset( $email_delivery['provision'] ) && in_array( $email_delivery['provision'], $provision_outcomes, true ) ) { + $onboarding_props['email_delivery_provision'] = $email_delivery['provision']; + } + } + if ( isset( $onboarding_analytics['accountConnected'] ) ) { $onboarding_props['account_connected'] = (bool) $onboarding_analytics['accountConnected'] ? 'yes' : 'no'; } @@ -1066,25 +1205,8 @@ private function detect_state_events() { // first_ai_form_generated: detect if any AI-generated form exists. // Guard with is_tracked() to skip the meta_query after the event is already tracked. - if ( ! self::events()->is_tracked( 'first_ai_form_generated' ) ) { - $ai_forms = get_posts( - [ - 'post_type' => SRFM_FORMS_POST_TYPE, - 'post_status' => 'any', - 'posts_per_page' => 1, - 'fields' => 'ids', - 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Runs once per lifecycle via is_tracked guard. - [ - 'key' => '_srfm_is_ai_generated', - 'value' => '', - 'compare' => '!=', - ], - ], - ] - ); - if ( ! empty( $ai_forms ) ) { - self::events()->track( 'first_ai_form_generated' ); - } + if ( ! self::events()->is_tracked( 'first_ai_form_generated' ) && AI_Helper::has_ai_generated_forms() ) { + self::events()->track( 'first_ai_form_generated' ); } // MCP / Abilities API first-enable events. diff --git a/assets/js/unminified/blocks/dropdown.js b/assets/js/unminified/blocks/dropdown.js index 3a9714a80..3f8b6eec9 100644 --- a/assets/js/unminified/blocks/dropdown.js +++ b/assets/js/unminified/blocks/dropdown.js @@ -32,11 +32,6 @@ function initializeDropdown() { let additionalConfig = {}; const inputName = element.getAttribute( 'name' ); - const errorContainerID = element - ?.closest( '.srfm-dropdown-block' ) - ?.querySelector( '.srfm-error-message' ) - ?.getAttribute( 'data-srfm-id' ); - if ( element.getAttribute( 'data-multiple' ) === 'true' ) { additionalConfig = { maxItems: null, // Enabling multi selection for dropdown. @@ -55,17 +50,22 @@ function initializeDropdown() { plugins: [ 'remove_button', 'clear_button' ], // Adding remove button to the selected options and clear button for the dropdown. openOnFocus: false, // Stop the dropdown from opening on focus. /** - * If the error container is present, assign it to the aria-describedby attribute. - * This will ensure that when there is a error, it will be announced by the screen reader - * when the field is in focus state. + * Mirror the select's aria-describedby onto the search input, so the error + * (linked only while shown) and the help text are announced on focus. * * If there are selected options, announce them on field focus. */ onFocus() { - if ( errorContainerID ) { + const describedBy = + element.getAttribute( 'aria-describedby' ); + if ( describedBy ) { tomInputInstance.control_input.setAttribute( 'aria-describedby', - errorContainerID + describedBy + ); + } else { + tomInputInstance.control_input.removeAttribute( + 'aria-describedby' ); } if ( diff --git a/assets/js/unminified/form-submit.js b/assets/js/unminified/form-submit.js index 10c514aa4..bf0c6e8f4 100644 --- a/assets/js/unminified/form-submit.js +++ b/assets/js/unminified/form-submit.js @@ -760,14 +760,29 @@ async function submitFormData( form ) { // "TypeError: Failed to fetch" with a short duration is itself the // diagnosis: the request never reached PHP -- blocked, offline or CORS. + // + // While the page is leaving, the browser cancels the request itself + // (Safari: "TypeError: Load failed"). That is logged as 'network' with no + // status, which Client_Logger::is_fault() does not count, so it cannot + // raise the "form submissions are failing" alert. + const message = `${ e?.name ?? 'Error' }: ${ + typeof e?.message === 'string' ? e.message : '' + }${ e?.code ? ` (${ e.code })` : '' }`; srfmLog.add( { - type: 'error', + type: navigatingAway ? 'network' : 'error', duration_ms: Math.round( performance.now() - startedAt ), - message: `${ e?.name ?? 'Error' }: ${ - typeof e?.message === 'string' ? e.message : '' - }${ e?.code ? ` (${ e.code })` : '' }`, + message: navigatingAway + ? `Submission cancelled as the page was unloading: ${ message }` + : message, } ); + // Only server-authored messages are shown to the visitor. A native fetch or + // JSON error is raw browser text ("Load failed", or a SyntaxError about an + // HTML challenge page), so an empty message shows the translated generic + // error instead, as the Pro login handler does. + const isRawBrowserError = + e instanceof TypeError || e instanceof SyntaxError; + /** * Surface the server's own message (e.g. an expired submission token, a * failed CAPTCHA, a REST route blocked by a firewall) instead of returning @@ -778,7 +793,10 @@ async function submitFormData( form ) { return { success: false, data: { - message: typeof e?.message === 'string' ? e.message : '', + message: + ! isRawBrowserError && typeof e?.message === 'string' + ? e.message + : '', log_message: e?.code ? `SureForms submission failed (${ e.code }).` : null, @@ -914,8 +932,52 @@ function showSuccessMessage( } } +/** + * True once this page has started to leave: a redirect confirmation or any + * other navigation. The browser cancels in-flight requests as the page unloads, + * so a request that fails in this window is not a sign the site is broken. + * Reset when the page comes back from the back/forward cache. + */ +let navigatingAway = false; + +window.addEventListener( 'pagehide', () => { + navigatingAway = true; +} ); + +window.addEventListener( 'pageshow', ( event ) => { + if ( event.persisted ) { + navigatingAway = false; + } +} ); + +/** + * Navigate to the confirmation URL. + * + * @param {string} url Redirect URL. + * @return {boolean} True when the page will unload. False when it stays on + * screen: a URL that only changes the fragment of the current + * page (including an empty `#`), or a non-http(s) URL such as + * mailto: or tel:, which hands off to another app. + */ function redirectToUrl( url ) { + let leavesPage = true; + try { + const target = new URL( url, window.location.href ); + const here = new URL( window.location.href ); + const isHttp = [ 'http:', 'https:' ].includes( target.protocol ); + const isFragmentOnly = + String( url ).includes( '#' ) && + target.origin === here.origin && + target.pathname === here.pathname && + target.search === here.search; + leavesPage = isHttp && ! isFragmentOnly; + } catch ( e ) { + // Not a parseable URL. The browser decides; assume it navigates. + } + + navigatingAway = leavesPage; window.location.assign( url ); + return leavesPage; } /** @@ -1216,13 +1278,41 @@ async function handleFormSubmission( submitType, loader ); + } else if ( + formStatus?.redirect_url && + redirectToUrl( formStatus.redirect_url ) + ) { + // Keep the button disabled and the loader on while the browser + // navigates. The old page can stay on screen for seconds, and a + // second tap would resubmit into a request the unload then cancels. + let fallbackTimer = 0; + // Back/forward cache restore: the page is back, so is the form. + const onPageShow = ( pageEvent ) => { + if ( pageEvent.persisted ) { + release(); + } + }; + const release = () => { + window.clearTimeout( fallbackTimer ); + window.removeEventListener( 'pageshow', onPageShow ); + loader.classList.remove( 'srfm-active' ); + enableSubmitButton( form ); + }; + window.addEventListener( 'pageshow', onPageShow ); + // ponytail: fixed 15s fallback for a redirect that never unloads the + // page (e.g. a URL served as a file download, or a beforeunload + // prompt the visitor cancelled). Make it configurable if a real slow + // redirect ever takes longer. The page is still here, so failures + // count again; if the navigation does commit later, pagehide sets + // navigatingAway back before the unload. + fallbackTimer = window.setTimeout( () => { + navigatingAway = false; + release(); + }, 15000 ); } else { - if ( formStatus?.redirect_url ) { - redirectToUrl( formStatus?.redirect_url ); - } loader.classList.remove( 'srfm-active' ); - // Re-enable submit button after redirect. + // Re-enable submit button: no redirect left the page. enableSubmitButton( form ); } } else { diff --git a/assets/js/unminified/frontend.js b/assets/js/unminified/frontend.js index b7a6faba7..2db985679 100644 --- a/assets/js/unminified/frontend.js +++ b/assets/js/unminified/frontend.js @@ -35,10 +35,10 @@ window.addGlobalSrfmObject = addGlobalSrfmObject; * * Description: * - When `hasError` is true, the function adds the "srfm-error" class to the container, enabling styling for invalid fields. - * - If an error message element with the `data-srfm-id` attribute exists as a child of the container, - * this function assigns its value as the `id` attribute to allow screen readers to announce the error when visible. - * - Conversely, if `hasError` is false, the function removes both the "srfm-error" class and `id` attribute from the - * error message element, making it hidden from screen readers when the field is valid. + * - The error message element gets its `id` (from `data-srfm-id`) and is added to the + * `aria-describedby` of the field controls marked with `data-srfm-error-target`, so screen + * readers announce it. When `hasError` is false, both are removed so that every + * `aria-describedby` reference always points to an element that exists. */ function toggleErrorState( container, hasError ) { // Check if the current class state already matches the intended state to avoid redundant operations @@ -49,17 +49,52 @@ function toggleErrorState( container, hasError ) { // Toggle the "srfm-error" class based on `hasError` state container.classList.toggle( 'srfm-error', hasError ); - // Manage the error message's `id` attribute for screen reader accessibility const errorMessage = container.querySelector( '.srfm-error-message' ); if ( errorMessage ) { - if ( hasError && errorMessage.hasAttribute( 'data-srfm-id' ) ) { - // Set the `id` to `data-srfm-id` value to allow screen readers to announce the error message - errorMessage.id = errorMessage.getAttribute( 'data-srfm-id' ); - } else { - // Remove the `id` when the field is valid, hiding the message from screen readers - errorMessage.removeAttribute( 'id' ); - } + linkErrorMessage( errorMessage, container, hasError ); + } +} + +/** + * Links or unlinks an error message to the field controls inside `scope`. + * + * The message only gets its `id` while it is shown, and the controls marked with + * `data-srfm-error-target` only reference that `id` while an element has it. The + * email confirm box shares the main box's `data-srfm-id`, so clearing one keeps + * the reference while the other is still shown. + * + * @param {HTMLElement} errorMessage - The `.srfm-error-message` element. + * @param {HTMLElement} scope - The element containing the field controls. + * @param {boolean} hasError - Whether the error is shown. + */ +function linkErrorMessage( errorMessage, scope, hasError ) { + const errorId = errorMessage.getAttribute( 'data-srfm-id' ); + if ( hasError && errorId ) { + errorMessage.id = errorId; + } else { + errorMessage.removeAttribute( 'id' ); + } + if ( ! errorId ) { + return; } + const linked = + hasError || !! errorMessage.ownerDocument.getElementById( errorId ); + + scope + .querySelectorAll( '[data-srfm-error-target]' ) + .forEach( ( control ) => { + const ids = ( control.getAttribute( 'aria-describedby' ) || '' ) + .split( /\s+/ ) + .filter( ( id ) => id && id !== errorId ); + if ( linked ) { + ids.unshift( errorId ); + } + if ( ids.length ) { + control.setAttribute( 'aria-describedby', ids.join( ' ' ) ); + } else { + control.removeAttribute( 'aria-describedby' ); + } + } ); } /** * Replaces "%s" or "%1$s", "%2$s", etc., placeholders in a string with provided arguments, in sequence. @@ -108,6 +143,7 @@ function srfmSprintfString( str, ...args ) { // Add the functions in the srfm global object so they can be accessed from anywhere within the project. addGlobalSrfmObject( 'toggleErrorState', toggleErrorState ); +addGlobalSrfmObject( 'linkErrorMessage', linkErrorMessage ); addGlobalSrfmObject( 'srfmSprintfString', srfmSprintfString ); // Character counter for textarea/input fields with min or max length configured. diff --git a/assets/js/unminified/test/toggle-error-state.test.js b/assets/js/unminified/test/toggle-error-state.test.js new file mode 100644 index 000000000..727bdceb0 --- /dev/null +++ b/assets/js/unminified/test/toggle-error-state.test.js @@ -0,0 +1,77 @@ +/* eslint-env jest */ +require( '../frontend.js' ); + +const renderField = ( describedBy = '' ) => { + document.body.innerHTML = ` +

    + +
    Help
    +
    Required
    +
    `; + return { + container: document.querySelector( '.srfm-block' ), + input: document.getElementById( 'field' ), + error: document.querySelector( '.srfm-error-message' ), + }; +}; + +describe( 'toggleErrorState', () => { + it( 'links the error message only while the error is shown', () => { + const { container, input, error } = renderField(); + + window.srfm.toggleErrorState( container, true ); + expect( error.id ).toBe( 'srfm-error-b1' ); + expect( input.getAttribute( 'aria-describedby' ) ).toBe( + 'srfm-error-b1' + ); + + window.srfm.toggleErrorState( container, false ); + expect( error.hasAttribute( 'id' ) ).toBe( false ); + expect( input.hasAttribute( 'aria-describedby' ) ).toBe( false ); + } ); + + it( 'keeps the help text reference', () => { + const { container, input } = renderField( 'srfm-description-b1' ); + + window.srfm.toggleErrorState( container, true ); + expect( input.getAttribute( 'aria-describedby' ) ).toBe( + 'srfm-error-b1 srfm-description-b1' + ); + + window.srfm.toggleErrorState( container, false ); + expect( input.getAttribute( 'aria-describedby' ) ).toBe( + 'srfm-description-b1' + ); + } ); + it( 'keeps a shared error id linked while another message still shows it', () => { + document.body.innerHTML = ` +
    + + +
    `; + const wrap = document.querySelector( '.wrap' ); + const confirmBlock = document.querySelector( + '.srfm-email-confirm-block' + ); + const confirmInput = document.getElementById( 'confirm' ); + + window.srfm.toggleErrorState( wrap, true ); + window.srfm.toggleErrorState( confirmBlock, true ); + window.srfm.toggleErrorState( wrap, false ); + + expect( confirmInput.getAttribute( 'aria-describedby' ) ).toBe( + 'srfm-error-e1' + ); + expect( document.getElementById( 'srfm-error-e1' ).textContent ).toBe( + 'Confirm' + ); + } ); +} ); diff --git a/assets/js/unminified/validation.js b/assets/js/unminified/validation.js index 5d33ce519..4a164e553 100644 --- a/assets/js/unminified/validation.js +++ b/assets/js/unminified/validation.js @@ -1211,14 +1211,21 @@ function addEmailBlurListener( areaInput, blockClass ) { errorContainer.innerHTML = lengthErrorMessage ? lengthErrorMessage : window?.srfm_submit?.messages?.srfm_valid_email; - errorContainer.id = - errorContainer.getAttribute( 'data-srfm-id' ); + window?.srfm?.linkErrorMessage( + errorContainer, + inputBlock, + true + ); } else { errorContainer.style.display = 'none'; inputBlock.parentElement.classList.remove( 'srfm-valid-email-error' ); - errorContainer.removeAttribute( 'id' ); + window?.srfm?.linkErrorMessage( + errorContainer, + inputBlock, + false + ); } } ); } ); diff --git a/images/onboarding/email-delivery/amazon-ses.svg b/images/onboarding/email-delivery/amazon-ses.svg new file mode 100644 index 000000000..0b2310e71 --- /dev/null +++ b/images/onboarding/email-delivery/amazon-ses.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/brevo.svg b/images/onboarding/email-delivery/brevo.svg new file mode 100644 index 000000000..cbec690d0 --- /dev/null +++ b/images/onboarding/email-delivery/brevo.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/gmail.svg b/images/onboarding/email-delivery/gmail.svg new file mode 100644 index 000000000..5747b4cf5 --- /dev/null +++ b/images/onboarding/email-delivery/gmail.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/mailgun.svg b/images/onboarding/email-delivery/mailgun.svg new file mode 100644 index 000000000..ecd55c3e6 --- /dev/null +++ b/images/onboarding/email-delivery/mailgun.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/mailjet.svg b/images/onboarding/email-delivery/mailjet.svg new file mode 100644 index 000000000..2743d1652 --- /dev/null +++ b/images/onboarding/email-delivery/mailjet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/onboarding/email-delivery/netcore.svg b/images/onboarding/email-delivery/netcore.svg new file mode 100644 index 000000000..6c306ef86 --- /dev/null +++ b/images/onboarding/email-delivery/netcore.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/outlook.svg b/images/onboarding/email-delivery/outlook.svg new file mode 100644 index 000000000..092a216f4 --- /dev/null +++ b/images/onboarding/email-delivery/outlook.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/postmark.svg b/images/onboarding/email-delivery/postmark.svg new file mode 100644 index 000000000..55cb1b273 --- /dev/null +++ b/images/onboarding/email-delivery/postmark.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/sendgrid.svg b/images/onboarding/email-delivery/sendgrid.svg new file mode 100644 index 000000000..83c7b7342 --- /dev/null +++ b/images/onboarding/email-delivery/sendgrid.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/smtp.svg b/images/onboarding/email-delivery/smtp.svg new file mode 100644 index 000000000..111999d79 --- /dev/null +++ b/images/onboarding/email-delivery/smtp.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/surecontact-free-emails.svg b/images/onboarding/email-delivery/surecontact-free-emails.svg new file mode 100644 index 000000000..48c87f654 --- /dev/null +++ b/images/onboarding/email-delivery/surecontact-free-emails.svg @@ -0,0 +1,5803 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/onboarding/email-delivery/surecontact-mails-free.svg b/images/onboarding/email-delivery/surecontact-mails-free.svg new file mode 100644 index 000000000..1f7911ebb --- /dev/null +++ b/images/onboarding/email-delivery/surecontact-mails-free.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/surecontact.svg b/images/onboarding/email-delivery/surecontact.svg new file mode 100644 index 000000000..88aca2ff3 --- /dev/null +++ b/images/onboarding/email-delivery/surecontact.svg @@ -0,0 +1 @@ + diff --git a/images/onboarding/email-delivery/suremail.svg b/images/onboarding/email-delivery/suremail.svg new file mode 100644 index 000000000..db068e51c --- /dev/null +++ b/images/onboarding/email-delivery/suremail.svg @@ -0,0 +1 @@ + diff --git a/inc/admin-ajax.php b/inc/admin-ajax.php index 23f729621..a9353f1a1 100644 --- a/inc/admin-ajax.php +++ b/inc/admin-ajax.php @@ -82,11 +82,33 @@ public function required_plugin_activate() { $plugin_init = isset( $_POST['init'] ) ? sanitize_text_field( wp_unslash( $_POST['init'] ) ) : ''; + // Activation runs the plugin's own activation code, so this needs the + // real capability (a multisite sub-site admin has manage_options but not + // activate_plugins) and only plugins SureForms itself offers. + if ( ! current_user_can( 'activate_plugins' ) ) { + wp_send_json_error( $response_data ); + } + + if ( ! in_array( $plugin_init, self::get_activatable_plugins(), true ) ) { + wp_send_json_error( + [ + 'success' => false, + 'message' => __( 'This plugin cannot be activated from SureForms.', 'sureforms' ), + ] + ); + } + $plugin_slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : ''; - $activate = activate_plugin( $plugin_init, '', false, true ); + // Not silent: a silent activation skips the plugin's activation hook, so + // whatever it sets up there never happens. SureMail, for one, creates its + // email log table on activation; installed from SureForms, its Email Logs + // screen failed with "Failed to retrieve email logs." Output from the + // hook is caught by activate_plugin() and returned as an error -- but the + // plugin is active by then, so that one error is not a failure. + $activate = activate_plugin( $plugin_init, '', false, false ); - if ( is_wp_error( $activate ) ) { + if ( is_wp_error( $activate ) && ! ( 'unexpected_output' === $activate->get_error_code() && is_plugin_active( $plugin_init ) ) ) { wp_send_json_error( [ 'success' => false, @@ -95,6 +117,8 @@ public function required_plugin_activate() { ); } + self::clear_activation_redirect( $plugin_init ); + if ( class_exists( 'BSF_UTM_Analytics' ) && is_callable( 'BSF_UTM_Analytics::update_referer' ) ) { $plugin_slug = pathinfo( $plugin_slug, PATHINFO_FILENAME ); BSF_UTM_Analytics::update_referer( 'sureforms', $plugin_slug ); @@ -108,6 +132,74 @@ public function required_plugin_activate() { ); } + /** + * Plugins the recommended-plugin installer may activate: the ones SureForms + * offers (Helper::sureforms_get_integration(), both Starter Templates + * editions) and the MCP adapter from Settings. + * + * @since 2.12.9 + * @return array Plugin files. + */ + public static function get_activatable_plugins() { + $plugins = [ 'mcp-adapter/mcp-adapter.php', 'astra-sites/astra-sites.php', 'astra-pro-sites/astra-pro-sites.php' ]; + + foreach ( Helper::sureforms_get_integration() as $integration ) { + if ( is_array( $integration ) && ! empty( $integration['path'] ) && is_string( $integration['path'] ) ) { + $plugins[] = $integration['path']; + } + } + + /** + * Filters the plugins SureForms' installer may activate. + * + * @since 2.12.9 + * @param array $plugins Plugin files. + */ + $plugins = apply_filters( 'srfm_activatable_plugins', $plugins ); + + return array_values( array_unique( array_filter( is_array( $plugins ) ? $plugins : [], 'is_string' ) ) ); + } + + /** + * Clear the "show my onboarding" flag a plugin sets on activation. + * + * Activation now runs the plugin's activation hook, and most of these plugins + * set a flag there that sends the owner to their onboarding on the next admin + * page load -- pulling them out of SureForms' own onboarding mid-step. + * + * @since 2.12.9 + * @param string $plugin_init Plugin file that was activated. + * @return void + */ + public static function clear_activation_redirect( $plugin_init ) { + /** + * Filters the redirect flags cleared after activating a plugin. + * + * @since 2.12.9 + * @param array $flags Plugin file => flag. + */ + $flags = apply_filters( + 'srfm_plugin_activation_redirect_flags', + [ + 'suremails/suremails.php' => [ 'option' => 'suremails_do_redirect' ], + 'surerank/surerank.php' => [ 'option' => 'surerank_redirect_on_activation' ], + 'suretriggers/suretriggers.php' => [ 'transient' => 'st-redirect-after-activation' ], + 'suredonation/suredonation.php' => [ 'option' => '__suredonation_do_redirect' ], + 'header-footer-elementor/header-footer-elementor.php' => [ 'option' => 'hfe_start_onboarding' ], + 'astra-sites/astra-sites.php' => [ 'option' => 'st_start_onboarding' ], + 'astra-pro-sites/astra-pro-sites.php' => [ 'option' => 'st_start_onboarding' ], + ] + ); + + $flag = is_array( $flags ) && isset( $flags[ $plugin_init ] ) && is_array( $flags[ $plugin_init ] ) ? $flags[ $plugin_init ] : []; + + if ( ! empty( $flag['option'] ) && is_string( $flag['option'] ) ) { + delete_option( $flag['option'] ); + } + if ( ! empty( $flag['transient'] ) && is_string( $flag['transient'] ) ) { + delete_transient( $flag['transient'] ); + } + } /** * Get ajax error message. * diff --git a/inc/ai-form-builder/ai-helper.php b/inc/ai-form-builder/ai-helper.php index 28c0fe265..27539f1bb 100644 --- a/inc/ai-form-builder/ai-helper.php +++ b/inc/ai-form-builder/ai-helper.php @@ -222,6 +222,37 @@ public static function is_pro_license_active() { return $licensing->is_license_active(); } + /** + * Check whether the site has any AI-generated form (any status except trash and auto-draft). + * + * Used by the Generate with AI screen to collapse the starter examples once + * the site has made its first AI form. + * + * @since 2.12.9 + * @return bool + */ + public static function has_ai_generated_forms() { + $ai_forms = get_posts( + [ + 'post_type' => SRFM_FORMS_POST_TYPE, + 'post_status' => 'any', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'orderby' => 'none', + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One-row lookup: on the Generate with AI screen, and once from Analytics behind is_tracked(). + [ + 'key' => '_srfm_is_ai_generated', + 'value' => '', + 'compare' => '!=', + ], + ], + ] + ); + + return ! empty( $ai_forms ); + } + /** * Sanitize an upstream error message before returning it to the client. * diff --git a/inc/client-logger.php b/inc/client-logger.php index 017087f34..e2188a526 100644 --- a/inc/client-logger.php +++ b/inc/client-logger.php @@ -83,7 +83,7 @@ class Client_Logger { * Option holding per-category failure state, keyed by category. * * Shape: [ category => [ 'count' => int, 'form_id' => int, 'form_title' => string, - * 'at' => int, 'acked' => int, 'acked_at' => int ] ]. + * 'at' => int, 'acked' => int, 'acked_at' => int, 'entry_saved' => bool ] ]. * * Kept per category because the three read completely differently to a site * owner: submissions failing means visitors cannot reach you, a notification @@ -199,13 +199,17 @@ public static function is_fault( array $entry ) { * The form is carried because "a form is failing" is not actionable on a site * with twenty of them -- the first thing anyone asks is which one. * - * @param string $category One of self::CATEGORIES. - * @param int $form_id Form the failure happened on. - * @param string $form_title Form title, resolved by the caller. + * @param string $category One of self::CATEGORIES. + * @param int $form_id Form the failure happened on. + * @param string $form_title Form title, resolved by the caller. + * @param array $context Optional. 'entry_saved' => false when the failure + * cost the submission itself (a form that does + * not store entries). Describes the latest failure. * @since 2.12.6 + * @since 2.12.9 Added $context. * @return void */ - public static function record_failure( $category, $form_id = 0, $form_title = '' ) { + public static function record_failure( $category, $form_id = 0, $form_title = '', $context = [] ) { // Logging off means nothing is recorded, not merely nothing displayed. // // The display side was already gated -- get_action_items() skips the @@ -233,18 +237,21 @@ public static function record_failure( $category, $form_id = 0, $form_title = '' $existing = $failures[ $category ] ?? []; $failures[ $category ] = [ - 'count' => Helper::get_integer_value( $existing['count'] ?? 0 ) + 1, - 'form_id' => absint( $form_id ), - 'form_title' => mb_substr( sanitize_text_field( $form_title ), 0, 100 ), - 'at' => time(), + 'count' => Helper::get_integer_value( $existing['count'] ?? 0 ) + 1, + 'form_id' => absint( $form_id ), + 'form_title' => mb_substr( sanitize_text_field( $form_title ), 0, 100 ), + 'at' => time(), // Preserved: a report already made still stands until this new count // overtakes it, which is what get_open_failures() compares. - 'acked' => Helper::get_integer_value( $existing['acked'] ?? 0 ), + 'acked' => Helper::get_integer_value( $existing['acked'] ?? 0 ), // Carried forward too. This array is rebuilt from a fixed set of keys, so // anything not named here is dropped -- and "when did I last report // this" quietly disappearing on the next failure is exactly the kind of // loss nobody notices until support asks. - 'acked_at' => Helper::get_integer_value( $existing['acked_at'] ?? 0 ), + 'acked_at' => Helper::get_integer_value( $existing['acked_at'] ?? 0 ), + // Of the latest failure, like form_id: the notice describes what just + // happened, and "the entry was saved" is false on a form that stores none. + 'entry_saved' => ! is_array( $context ) || ! isset( $context['entry_saved'] ) || (bool) $context['entry_saved'], ]; update_option( self::FAILURES_OPTION, $failures, false ); diff --git a/inc/create-new-form.php b/inc/create-new-form.php index b662aa5cd..8bf0255c2 100644 --- a/inc/create-new-form.php +++ b/inc/create-new-form.php @@ -8,6 +8,7 @@ namespace SRFM\Inc; +use SRFM\Admin\Analytics; use SRFM\Inc\AI_Form_Builder\AI_Helper; use SRFM\Inc\Traits\Get_Instance; use WP_Error; @@ -182,6 +183,12 @@ public static function create_form( $data ) { */ update_post_meta( $post_id, '_srfm_is_ai_generated', true ); + // Count the AI form against the starter card (if any) its prompt came from. + $starter_id = isset( $form_info_obj->ai_starter ) && is_string( $form_info_obj->ai_starter ) ? $form_info_obj->ai_starter : ''; + $edited = ! empty( $form_info_obj->ai_starter_edited ); + + Analytics::track_ai_starter_usage( $starter_id, $edited ); + return new WP_REST_Response( [ 'message' => __( 'SureForms Form created successfully.', 'sureforms' ), diff --git a/inc/email-delivery/lost-submissions.php b/inc/email-delivery/lost-submissions.php new file mode 100644 index 000000000..ab8db11f3 --- /dev/null +++ b/inc/email-delivery/lost-submissions.php @@ -0,0 +1,105 @@ + 0 ) { + $forms[] = $form_id; + } + + update_option( + self::OPTION, + [ + 'count' => $record['count'] + 1, + 'form_ids' => array_slice( $forms, -self::MAX_FORMS ), + 'last_at' => time(), + ], + false + ); + } + + /** + * The record, normalised. A count of 0 means nothing was lost. + * + * @since 2.12.9 + * @return array{count:int,form_ids:array,last_at:int} + */ + public static function get() { + $stored = get_option( self::OPTION, [] ); + $stored = is_array( $stored ) ? $stored : []; + + $forms = []; + if ( isset( $stored['form_ids'] ) && is_array( $stored['form_ids'] ) ) { + foreach ( $stored['form_ids'] as $form_id ) { + if ( is_numeric( $form_id ) && (int) $form_id > 0 ) { + $forms[] = absint( $form_id ); + } + } + } + + return [ + 'count' => Helper::get_integer_value( $stored['count'] ?? 0 ), + 'form_ids' => $forms, + 'last_at' => Helper::get_integer_value( $stored['last_at'] ?? 0 ), + ]; + } + + /** + * The owner has seen it: start counting again from zero. + * + * @since 2.12.9 + * @return void + */ + public static function acknowledge() { + delete_option( self::OPTION ); + } +} diff --git a/inc/email-delivery/mailer.php b/inc/email-delivery/mailer.php new file mode 100644 index 000000000..299b16f9f --- /dev/null +++ b/inc/email-delivery/mailer.php @@ -0,0 +1,499 @@ + Plugin file => display name. + */ + public static function get_known_plugins() { + $plugins = [ + self::SUREMAIL_FILE => 'SureMail', + 'wp-mail-smtp/wp_mail_smtp.php' => 'WP Mail SMTP', + 'wp-mail-smtp-pro/wp_mail_smtp.php' => 'WP Mail SMTP Pro', + 'post-smtp/postman-smtp.php' => 'Post SMTP', + 'easy-wp-smtp/easy-wp-smtp.php' => 'Easy WP SMTP', + 'wp-smtp/wp-smtp.php' => 'WP SMTP', + 'fluent-smtp/fluent-smtp.php' => 'FluentSMTP', + 'pepipost-smtp/pepipost-smtp.php' => 'Pepipost', + 'mail-bank/wp-mail-bank.php' => 'Mail Bank', + 'smtp-mailer/smtp-mailer.php' => 'SMTP Mailer', + 'site-mailer/site-mailer.php' => 'Site Mailer', + 'gosmtp/gosmtp.php' => 'GoSMTP', + 'wp-ses/wp-ses.php' => 'WP Offload SES Lite', + 'wp-offload-ses/wp-offload-ses.php' => 'WP Offload SES', + 'smtp2go/smtp2go-wordpress-plugin.php' => 'SMTP2GO', + 'postmark-approved-wordpress-plugin/postmark.php' => 'Postmark', + 'mailgun/mailgun.php' => 'Mailgun', + ]; + + /** + * Filters the mail plugins SureForms recognises. + * + * @since 2.12.9 + * @param array $plugins Plugin file => display name. + */ + $filtered = apply_filters( 'srfm_smtp_plugins', $plugins ); + + return is_array( $filtered ) ? $filtered : $plugins; + } + + /** + * Identify the mailer that will carry wp_mail() on this site. + * + * SureMail is checked first: it is the one mailer SureForms can read further + * state from, and a site running it next to another mail plugin is still + * sending through SureMail's connections. + * + * @since 2.12.9 + * @return array{slug:string,label:string,file:string} slug is one of + * suremail | other | wp_mail_override | none. + */ + public static function get_active() { + $mailer = self::detect(); + + /** + * Filters the detected mailer. + * + * For hosts that deliver mail through something SureForms cannot see — a + * platform relay, an mu-plugin — so the owner is not told email is unset. + * + * @since 2.12.9 + * @param array{slug:string,label:string,file:string} $mailer Detected mailer. + */ + $filtered = apply_filters( 'srfm_active_mailer', $mailer ); + + if ( ! is_array( $filtered ) || empty( $filtered['slug'] ) || ! is_string( $filtered['slug'] ) ) { + return $mailer; + } + + return [ + 'slug' => $filtered['slug'], + 'label' => isset( $filtered['label'] ) && is_string( $filtered['label'] ) ? $filtered['label'] : '', + 'file' => isset( $filtered['file'] ) && is_string( $filtered['file'] ) ? $filtered['file'] : '', + ]; + } + + /** + * Whether anything beyond PHP's own mail() will carry wp_mail(). + * + * @since 2.12.9 + * @return bool + */ + public static function has_mailer() { + return 'none' !== self::get_active()['slug']; + } + + /** + * Whether a failed wp_mail() may be retried through PHP's own mail(). + * + * Off by default. Core's wp_mail() already sends through mail() when no mail + * plugin is active, so a raw mail() that succeeds after it failed has usually + * skipped the validation that caught a broken From address — the email + * "works" and the misconfiguration stays hidden. With a mail plugin active it + * is worse: the email bypasses that plugin's routing and logs, and SureMail, + * which retries a rejected email itself, would send it twice. + * + * So the filter is only consulted when no mail plugin is active, for hosts + * where raw mail() genuinely is the only thing that works. + * + * @since 2.12.9 + * @param int $form_id Form the email belongs to. + * @param string $context What is being sent, e.g. 'notification'. + * @return bool + */ + public static function allow_php_mail_fallback( $form_id = 0, $context = 'notification' ) { + if ( self::has_mailer() ) { + return false; + } + + /** + * Filters whether a failed wp_mail() is retried through PHP's mail(). + * + * Only applied when no mail plugin is active. + * + * @since 2.12.9 + * @param bool $allow Whether to retry. Default false. + * @param int $form_id Form the email belongs to. + * @param string $context What is being sent, e.g. 'notification'. + */ + return (bool) apply_filters( 'srfm_allow_php_mail_fallback', false, (int) $form_id, (string) $context ); + } + + /** + * Whether a mailer error is about a recipient address rather than the + * site's mail setup. + * + * Recipients often come from what a visitor typed ({form:email}), so an + * address PHPMailer rejects says nothing about whether the site can send: + * it is logged on the entry, but must not put the whole site into + * "attention". An invalid From address is a site problem and stays one. + * + * @since 2.12.9 + * @param string $message Error message from wp_mail_failed. + * @return bool + */ + public static function is_recipient_error( $message ) { + $message = (string) $message; + if ( '' === $message ) { + return false; + } + + // PHPMailer names the header in parentheses, untranslated: "(to)", "(cc)". + if ( preg_match( '/\\((to|cc|bcc|reply-to)\\)/i', $message ) ) { + return true; + } + + // No recipient at all, or the mail server refusing the recipient (RCPT) + // over SMTP. API-based mailers word recipient rejections their own way and + // are not recognised here; those still count, which errs towards telling + // the owner. + $recipient_texts = [ + 'You must provide at least one recipient email address.', + __( 'You must provide at least one recipient email address.' ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Matching WordPress core's own PHPMailer string. + 'SMTP Error: The following recipients failed:', + trim( __( 'SMTP Error: The following recipients failed: ' ) ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Matching WordPress core's own PHPMailer string. + ]; + foreach ( $recipient_texts as $recipient_text ) { + if ( '' !== $recipient_text && false !== strpos( $message, $recipient_text ) ) { + return true; + } + } + + return false; + } + + /** + * The addresses a recipient error names, lowercased. + * + * PHPMailer lists refused recipients ("SMTP Error: The following recipients + * failed: a@b.c: 554 ...") and names invalid ones by header ("(to): x@"). + * + * @since 2.12.9 + * @param string $message Error message from wp_mail_failed. + * @return array + */ + public static function get_refused_addresses( $message ) { + $message = (string) $message; + $addresses = []; + + if ( preg_match_all( '/\\((?:to|cc|bcc)\\):\\s*([^\\s,]+)/i', $message, $named ) ) { + $addresses = array_merge( $addresses, $named[1] ); + } + if ( preg_match_all( '/[a-z0-9._%+\\-\']+@[a-z0-9.\\-]+\\.[a-z]{2,}/i', $message, $emails ) ) { + $addresses = array_merge( $addresses, $emails[0] ); + } + + return array_values( array_unique( array_map( 'strtolower', array_map( 'trim', $addresses ) ) ) ); + } + + /** + * Whether a failed send is down only to addresses the visitor typed. + * + * Decided by where each refused address came from, not by the error text: + * PHPMailer reports every refusal at the recipient step the same way, + * including a host's "554 Relay access denied" or "530 Authentication + * required", which are the site's setup failing. So a failure is the + * visitor's only when every address it names came from a field they filled + * in ({form:*}). An address the owner typed into the notification still + * counts, typos included. + * + * @since 2.12.9 + * @param string $message Error message from wp_mail_failed. + * @param array $visitor_addresses Recipients that came from the visitor's fields. + * @param bool $only_visitor Whether every recipient template is a visitor field. + * @return bool + */ + public static function is_visitor_recipient_failure( $message, $visitor_addresses, $only_visitor ) { + if ( ! self::is_recipient_error( $message ) ) { + return false; + } + + $refused = self::get_refused_addresses( $message ); + if ( empty( $refused ) ) { + // No address named ("You must provide at least one recipient"): the + // visitor's only if every recipient was theirs to fill in. + return (bool) $only_visitor; + } + + $visitor = array_map( 'strtolower', array_map( 'trim', (array) $visitor_addresses ) ); + foreach ( $refused as $address ) { + if ( ! in_array( $address, $visitor, true ) ) { + return false; + } + // Excused only when the address itself is wrong: PHPMailer rejected + // its syntax, or the server says there is no such mailbox. Anything + // else, including a refusal with no reason we can read, counts: a + // "Relay access denied" or "Authentication required" is the site. + if ( ! self::is_invalid_address_error( $message, $address ) && ! self::is_mailbox_refusal( self::get_refusal_reason( $message, $address ) ) ) { + return false; + } + } + + return true; + } + + /** + * Whether PHPMailer itself rejected an address as malformed, before any + * server saw it ("Invalid address: (to): x@"). + * + * @since 2.12.9 + * @param string $message Error message from wp_mail_failed. + * @param string $address A refused address, lowercased. + * @return bool + */ + public static function is_invalid_address_error( $message, $address ) { + $pattern = '/\\((?:to|cc|bcc)\\):\\s*' . preg_quote( (string) $address, '/' ) . '(?:[\\s,]|$)/i'; + return 1 === preg_match( $pattern, (string) $message ); + } + + /** + * What the mail server said about one refused address. + * + * PHPMailer lists refusals as "address: reason", and strips the SMTP reply + * code and enhanced status code from the reason before reporting it + * (PHPMailer 7.1.1, SMTP::sendCommand()): "v@x.org: : Relay + * access denied". So the reason is all there is to go on. + * + * @since 2.12.9 + * @param string $message Error message from wp_mail_failed. + * @param string $address A refused address, lowercased. + * @return string The reason, '' when none is given. + */ + public static function get_refusal_reason( $message, $address ) { + $message = (string) $message; + $marker = 'recipients failed:'; + $start = stripos( $message, $marker ); + $list = false === $start ? $message : substr( $message, $start + strlen( $marker ) ); + + $pattern = '/(?:^|[\\s,])' . preg_quote( (string) $address, '/' ) . '\\s*:\\s*(.*?)(?=(?:[\\r\\n]+|\\s)[^\\s:<>]+@[^\\s:<>]+\\s*:\\s|$)/is'; + if ( ! preg_match( $pattern, $list, $match ) ) { + return ''; + } + return trim( $match[1] ); + } + + /** + * Whether a server's reason says the mailbox itself does not exist, rather + * than the site being refused (relay, authentication, rate limits, policy). + * Kept to clear wording, so an unknown reason counts as the site's. + * + * @since 2.12.9 + * @param string $reason Reason from get_refusal_reason(). + * @return bool + */ + public static function is_mailbox_refusal( $reason ) { + $reason = strtolower( (string) $reason ); + if ( '' === $reason ) { + return false; + } + + // Some servers repeat the enhanced status in the text: 5.1.x is about + // the address (5.1.1 bad mailbox, 5.1.2 bad domain, 5.1.3 bad syntax). + if ( preg_match( '/\\b5\\.1\\.\\d{1,3}\\b/', $reason ) ) { + return true; + } + + $mailbox_wording = [ + 'user unknown', + 'unknown user', + 'no such user', + 'no such mailbox', + 'no such recipient', + 'unknown recipient', + 'recipient unknown', + 'user not found', + 'recipient not found', + 'mailbox not found', + 'mailbox does not exist', + 'does not exist', + 'invalid mailbox', + 'address rejected: user unknown', + ]; + foreach ( $mailbox_wording as $wording ) { + if ( false !== strpos( $reason, $wording ) ) { + return true; + } + } + + return false; + } + + /** + * Why a mailer did not accept an email, for when it gave no reason itself. + * + * @since 2.12.9 + * @return array{code:string,message:string} + */ + public static function get_rejection_reason() { + $mailer = self::get_active(); + + if ( 'suremail' === $mailer['slug'] ) { + return [ + 'code' => 'suremail_rejected', + 'message' => __( 'SureMail did not accept this email. It may retry it automatically; SureMail\'s email logs show the reason.', 'sureforms' ), + ]; + } + + if ( 'none' !== $mailer['slug'] ) { + return [ + 'code' => 'mailer_rejected', + 'message' => sprintf( + /* translators: %s: name of the site's mail plugin. */ + __( '%s did not accept this email. Its logs show the reason.', 'sureforms' ), + '' !== $mailer['label'] ? $mailer['label'] : __( 'Your mail plugin', 'sureforms' ) + ), + ]; + } + + return [ + 'code' => 'php_mail_failed', + 'message' => __( 'No SMTP plugin detected. Please configure an SMTP plugin to enable email sending.', 'sureforms' ), + ]; + } + + /** + * Detection without the filter. + * + * @since 2.12.9 + * @return array{slug:string,label:string,file:string} + */ + private static function detect() { + $active = self::get_active_plugin_files(); + $known = self::get_known_plugins(); + + $other = null; + foreach ( $known as $file => $label ) { + if ( self::SUREMAIL_FILE !== $file && in_array( $file, $active, true ) ) { + $other = [ + 'slug' => 'other', + 'label' => (string) $label, + 'file' => (string) $file, + ]; + break; + } + } + + if ( in_array( self::SUREMAIL_FILE, $active, true ) ) { + // SureMail with no connection hands mail to WordPress's default + // handler, which fires phpmailer_init, so another SMTP plugin that is + // also active is what actually sends. Report that one. + if ( null !== $other && 0 === Suremail_Adapter::get_readiness()['connections'] ) { + return $other; + } + + return [ + 'slug' => 'suremail', + 'label' => 'SureMail', + 'file' => self::SUREMAIL_FILE, + ]; + } + + if ( null !== $other ) { + return $other; + } + + $override = self::get_wp_mail_override_file(); + if ( '' !== $override ) { + return [ + 'slug' => 'wp_mail_override', + 'label' => basename( dirname( $override ) ), + 'file' => $override, + ]; + } + + return [ + 'slug' => 'none', + 'label' => '', + 'file' => '', + ]; + } + + /** + * Active plugin files, including network-activated ones. + * + * @since 2.12.9 + * @return array + */ + private static function get_active_plugin_files() { + $active = (array) get_option( 'active_plugins', [] ); + + if ( is_multisite() ) { + $network = (array) get_site_option( 'active_sitewide_plugins', [] ); + $active = array_merge( $active, array_keys( $network ) ); + } + + return array_values( array_filter( $active, 'is_string' ) ); + } + + /** + * The file that defines wp_mail(), when it is not core's. + * + * Core's wp_mail() is pluggable, so a host or plugin not on the list can own + * sending by redefining it. Empty when core's own definition is in use, or when + * pluggable functions have not been loaded yet. + * + * @since 2.12.9 + * @return string + */ + private static function get_wp_mail_override_file() { + if ( ! function_exists( 'wp_mail' ) ) { + return ''; + } + + try { + $file = ( new \ReflectionFunction( 'wp_mail' ) )->getFileName(); + } catch ( \ReflectionException $e ) { + return ''; + } + + if ( ! is_string( $file ) || '' === $file ) { + return ''; + } + + $inc = defined( 'WPINC' ) ? (string) constant( 'WPINC' ) : 'wp-includes'; + $core = realpath( ABSPATH . $inc . '/pluggable.php' ); + $real = realpath( $file ); + + if ( false === $core || false === $real || $core === $real ) { + return ''; + } + + return wp_normalize_path( $real ); + } +} diff --git a/inc/email-delivery/status.php b/inc/email-delivery/status.php new file mode 100644 index 000000000..b5e140b73 --- /dev/null +++ b/inc/email-delivery/status.php @@ -0,0 +1,710 @@ +|null + * @since 2.12.9 + */ + private static $memo = null; + + /** + * Current email delivery status. + * + * @since 2.12.9 + * @return array + */ + public static function get_status() { + if ( null !== self::$memo ) { + return self::$memo; + } + + $status = self::compute(); + + /** + * Filters the email delivery status. + * + * @since 2.12.9 + * @param array $status Status array. + */ + $filtered = apply_filters( 'srfm_email_delivery_status', $status ); + + self::$memo = is_array( $filtered ) ? $filtered : $status; + + return self::$memo; + } + + /** + * The free SureContact offer shown beside the one-click sign-up. + * + * One number, read by every screen that mentions it (onboarding, the SMTP + * page, the final onboarding step), so a change to the offer is one filter + * and never a copy edit. 0 hides the offer. + * + * @since 2.12.9 + * @return array + */ + public static function get_offer() { + /** + * Filters how many free emails a new SureContact account comes with. + * + * @since 2.12.9 + * @param int $free_emails Free emails; 0 hides the offer. + */ + $free_emails = apply_filters( 'srfm_surecontact_free_emails', 100 ); + + return [ + 'free_emails' => max( 0, is_numeric( $free_emails ) ? (int) $free_emails : 0 ), + ]; + } + + /** + * Record whether a notification was accepted by the mail system. + * + * Recorded whether or not debug logging is on: this is the site's current + * state, not a log, and the attention window bounds how stale it can get. + * + * SureMail retries a rejected email itself, on its own cron. A retry that + * succeeds is not reported back, so the failure stays open until the next + * notification from that form is accepted (or the window passes); the + * message points at SureMail's logs, which show the retry. + * + * @since 2.12.9 + * @param bool $accepted Whether wp_mail() accepted the email. + * @param int $form_id Form the notification belongs to. + * @param string $reason Short reason code for a failure. + * @param bool $entry_saved Whether the submission was stored as an entry. + * @return void + */ + public static function record_outcome( $accepted, $form_id = 0, $reason = '', $entry_saved = true ) { + $outcomes = self::get_outcomes(); + $now = time(); + + $form_id = absint( $form_id ); + + if ( $accepted ) { + // A success clears only its own form's failure: a working form B does + // not prove form A's notifications (often other recipients) go out. + // Without a form id it is not tied to one form, so it clears them all. + $recovering = 0 === $form_id ? ! empty( $outcomes['open_forms'] ) : isset( $outcomes['open_forms'][ $form_id ] ); + if ( ! $recovering && $now - $outcomes['last_success_at'] < self::SUCCESS_WRITE_INTERVAL ) { + return; + } + + if ( 0 === $form_id ) { + $outcomes['open_forms'] = []; + $outcomes['open_details'] = []; + } else { + unset( $outcomes['open_forms'][ $form_id ], $outcomes['open_details'][ $form_id ] ); + } + + $outcomes['last_success_at'] = $now; + $outcomes['consecutive_failures'] = 0; + + if ( empty( $outcomes['open_forms'] ) ) { + $outcomes['last_result'] = 'success'; + } else { + // Another form is still failing: the status describes that one, the + // newest still open, not the failure this success just cleared. + $outcomes = self::describe_newest_open_failure( $outcomes ); + } + } else { + $details = [ + 'reason' => substr( sanitize_key( (string) $reason ), 0, 64 ), + 'mailer' => Mailer::get_active()['slug'], + 'entry_saved' => (bool) $entry_saved, + ]; + + // A connection in place now was made before this failure, even if no + // SureForms screen has noticed it yet. Recorded before the throttle + // below, which must know whether the open failure is this setup's. + if ( 'suremail' === $details['mailer'] ) { + self::track_suremail_connection( $outcomes, Suremail_Adapter::get_readiness()['connections'], $now - 1 ); + } + + // Throttle repeats of a failure from the same setup only. One that + // predates the current SureMail connection belongs to an older setup and + // is ignored by the status, so this failure must be written with its own + // time -- or a new connection that also fails reads as working. + $last_for_form = $outcomes['open_forms'][ $form_id ] ?? 0; + $same_setup = $last_for_form > (int) ( $outcomes['suremail_connected_at'] ?? 0 ); + if ( $last_for_form > 0 && $same_setup && $now - $last_for_form < self::FAILURE_WRITE_INTERVAL ) { + // Throttled, but a repeat that says something new (another reason, + // mailer, or a submission not stored) still updates what is shown. + if ( ( $outcomes['open_details'][ $form_id ] ?? null ) === $details ) { + return; + } + $outcomes['open_details'][ $form_id ] = $details; + $outcomes = self::describe_newest_open_failure( $outcomes ); + update_option( self::OUTCOMES_OPTION, $outcomes, false ); + self::$memo = null; + return; + } + + $outcomes['open_forms'][ $form_id ] = $now; + $outcomes['open_details'][ $form_id ] = $details; + if ( count( $outcomes['open_forms'] ) > self::MAX_OPEN_FORMS ) { + asort( $outcomes['open_forms'] ); + $outcomes['open_forms'] = array_slice( $outcomes['open_forms'], -self::MAX_OPEN_FORMS, null, true ); + $outcomes['open_details'] = array_intersect_key( $outcomes['open_details'], $outcomes['open_forms'] ); + } + + $outcomes['last_result'] = 'failure'; + $outcomes['last_failure_at'] = $now; + $outcomes['last_failure_form_id'] = $form_id; + $outcomes['last_failure_reason'] = $details['reason']; + $outcomes['last_failure_mailer'] = $details['mailer']; + $outcomes['last_failure_entry_saved'] = $details['entry_saved']; + + // SureContact may have refused it for its daily allowance: ask for a + // fresh quota now instead of trusting a copy up to hours old. + if ( 'suremail' === $details['mailer'] ) { + Suremail_Adapter::request_quota_refresh(); + } + ++$outcomes['consecutive_failures']; + } + + update_option( self::OUTCOMES_OPTION, $outcomes, false ); + self::$memo = null; + } + + /** + * Stored send outcomes with defaults filled in. + * + * @since 2.12.9 + * @return array{last_result:string,last_success_at:int,last_failure_at:int,last_failure_form_id:int,last_failure_reason:string,last_failure_mailer:string,last_failure_entry_saved:bool,consecutive_failures:int,suremail_connected_at:int,suremail_lost_at:int,open_forms:array,open_details:array} + */ + public static function get_outcomes() { + $stored = get_option( self::OUTCOMES_OPTION, [] ); + $stored = is_array( $stored ) ? $stored : []; + + return [ + 'last_result' => isset( $stored['last_result'] ) && in_array( $stored['last_result'], [ 'success', 'failure' ], true ) ? $stored['last_result'] : '', + 'last_success_at' => isset( $stored['last_success_at'] ) ? absint( $stored['last_success_at'] ) : 0, + 'last_failure_at' => isset( $stored['last_failure_at'] ) ? absint( $stored['last_failure_at'] ) : 0, + 'last_failure_form_id' => isset( $stored['last_failure_form_id'] ) ? absint( $stored['last_failure_form_id'] ) : 0, + 'last_failure_reason' => isset( $stored['last_failure_reason'] ) && is_string( $stored['last_failure_reason'] ) ? $stored['last_failure_reason'] : '', + 'last_failure_mailer' => isset( $stored['last_failure_mailer'] ) && is_string( $stored['last_failure_mailer'] ) ? $stored['last_failure_mailer'] : '', + 'last_failure_entry_saved' => ! isset( $stored['last_failure_entry_saved'] ) || (bool) $stored['last_failure_entry_saved'], + 'consecutive_failures' => isset( $stored['consecutive_failures'] ) ? absint( $stored['consecutive_failures'] ) : 0, + 'suremail_connected_at' => isset( $stored['suremail_connected_at'] ) ? absint( $stored['suremail_connected_at'] ) : 0, + 'suremail_lost_at' => isset( $stored['suremail_lost_at'] ) ? absint( $stored['suremail_lost_at'] ) : 0, + 'open_forms' => self::read_open_forms( $stored ), + 'open_details' => self::read_open_details( $stored ), + ]; + } + + /** + * Drop the memoised status. + * + * @since 2.12.9 + * @return void + */ + public static function reset() { + self::$memo = null; + } + + /** + * Point the last_failure_* fields at the newest form still failing. + * + * @since 2.12.9 + * @param array $outcomes Outcomes with at least one open form. + * @return array + */ + private static function describe_newest_open_failure( array $outcomes ) { + $open = array_map( [ Helper::class, 'get_integer_value' ], Helper::get_array_value( $outcomes['open_forms'] ?? [] ) ); + arsort( $open ); + $form_id = Helper::get_integer_value( array_key_first( $open ) ); + $all_details = Helper::get_array_value( $outcomes['open_details'] ?? [] ); + $details = Helper::get_array_value( $all_details[ $form_id ] ?? [] ); + + $outcomes['last_result'] = 'failure'; + $outcomes['last_failure_at'] = $open[ $form_id ] ?? 0; + $outcomes['last_failure_form_id'] = $form_id; + $outcomes['last_failure_reason'] = Helper::get_string_value( $details['reason'] ?? $outcomes['last_failure_reason'] ?? '' ); + $outcomes['last_failure_mailer'] = Helper::get_string_value( $details['mailer'] ?? $outcomes['last_failure_mailer'] ?? '' ); + $outcomes['last_failure_entry_saved'] = (bool) ( $details['entry_saved'] ?? $outcomes['last_failure_entry_saved'] ?? true ); + return $outcomes; + } + + /** + * Per-form failure details, as form id => reason, mailer, entry_saved. + * + * @since 2.12.9 + * @param array $stored Raw stored outcomes. + * @return array + */ + private static function read_open_details( array $stored ) { + $details = []; + if ( isset( $stored['open_details'] ) && is_array( $stored['open_details'] ) ) { + foreach ( $stored['open_details'] as $form_id => $row ) { + if ( is_numeric( $form_id ) && is_array( $row ) ) { + $details[ absint( $form_id ) ] = [ + 'reason' => isset( $row['reason'] ) && is_string( $row['reason'] ) ? $row['reason'] : '', + 'mailer' => isset( $row['mailer'] ) && is_string( $row['mailer'] ) ? $row['mailer'] : '', + 'entry_saved' => ! isset( $row['entry_saved'] ) || (bool) $row['entry_saved'], + ]; + } + } + } + return $details; + } + + /** + * The open failures from stored outcomes, as form id => failed-at. + * + * Outcomes stored before failures were kept per form have only the last + * failure: it becomes that form's entry, so an open failure survives the + * update. + * + * @since 2.12.9 + * @param array $stored Raw stored outcomes. + * @return array + */ + private static function read_open_forms( array $stored ) { + if ( ! isset( $stored['open_forms'] ) ) { + $legacy = isset( $stored['last_result'] ) && 'failure' === $stored['last_result'] && ! empty( $stored['last_failure_at'] ); + return $legacy ? [ Helper::get_integer_value( $stored['last_failure_form_id'] ?? 0 ) => Helper::get_integer_value( $stored['last_failure_at'] ) ] : []; + } + + $open = []; + if ( is_array( $stored['open_forms'] ) ) { + foreach ( $stored['open_forms'] as $form_id => $failed_at ) { + if ( is_numeric( $form_id ) && is_numeric( $failed_at ) && (int) $failed_at > 0 ) { + $open[ absint( $form_id ) ] = absint( $failed_at ); + } + } + } + return $open; + } + + /** + * Build the status. + * + * @since 2.12.9 + * @return array + */ + private static function compute() { + $mailer = Mailer::get_active(); + $outcomes = self::get_outcomes(); + $now = time(); + + // From the mailer, not SUREMAILS_VERSION, so every part of the status agrees + // on one answer — including when a host declares SureMail via the filter. + $suremail_active = 'suremail' === $mailer['slug']; + $readiness = $suremail_active ? Suremail_Adapter::get_readiness() : null; + $connections = null !== $readiness ? $readiness['connections'] : 0; + + $connection_lost = false; + if ( null !== $readiness ) { + $connection_lost = self::track_suremail_connection( $outcomes, $connections ) || $readiness['default_missing']; + } + + // A failure from before the current SureMail connection was made belongs to + // the old setup (often PHP mail() on a fresh ZipWP site), not to this one: + // connecting is the fix, so it must not keep the site in "attention". + $connected_at = $connections > 0 ? $outcomes['suremail_connected_at'] : 0; + + // Any form with a failure inside the window, after the connection, that + // no later success from the same form has cleared. + $failure_open = false; + foreach ( $outcomes['open_forms'] as $failed_at ) { + if ( $failed_at >= $now - self::ATTENTION_WINDOW && $failed_at > $connected_at ) { + $failure_open = true; + break; + } + } + + $surecontact = null !== $readiness ? $readiness['surecontact'] : [ + 'connected' => false, + 'email_verified' => null, + 'is_paid' => null, + ]; + + // Refreshed in the background by Suremail_Adapter, never fetched here, so + // building the status stays free of remote calls. + $surecontact['quota'] = $suremail_active && $surecontact['connected'] ? Suremail_Adapter::get_surecontact_quota() : null; + $surecontact['quota_low'] = Suremail_Adapter::is_quota_low( $surecontact['quota'] ); + + if ( $failure_open || $connection_lost ) { + $state = 'attention'; + } elseif ( 'none' === $mailer['slug'] ) { + $state = 'not_configured'; + } elseif ( $suremail_active && 0 === $connections ) { + $state = 'suremail_no_connection'; + } elseif ( $suremail_active && $surecontact['connected'] && false === $surecontact['email_verified'] ) { + $state = 'limited'; + } elseif ( $suremail_active ) { + $state = 'ready'; + } else { + $state = 'other_mailer'; + } + + // SureMail with nothing to send through: the fix is a connection, whether + // one was lost or never made -- not SureMail's (empty) email logs. + $needs_connection = $connection_lost || ( $suremail_active && 0 === $connections ); + + // Why mail is failing, only when SureContact itself says so: an + // unverified account whose daily allowance is used up. A failure asks for + // a fresh quota (Suremail_Adapter::request_quota_refresh()), which also + // refreshes email_verified, and a used-up quota is cached for at most + // half an hour, so neither a verified account nor a reset allowance gets + // this advice. Any other failure keeps pointing at SureMail's logs. + $cause = ''; + $quota = $surecontact['quota']; + if ( 'attention' === $state && $suremail_active && ! $needs_connection && $surecontact['connected'] && false === $surecontact['email_verified'] + && is_array( $quota ) && 'daily' === $quota['period'] && 0 === $quota['remaining'] ) { + $cause = 'surecontact_cap'; + } + + $plugin_status = $suremail_active ? 'Activated' : self::get_suremail_plugin_status(); + $provider = null !== $readiness ? $readiness['provider_label'] : ''; + $copy = self::get_copy( $state, $mailer, $provider, $needs_connection, $failure_open, $outcomes, $cause ); + + return [ + 'state' => $state, + 'cause' => $cause, + 'severity' => $copy['severity'], + 'title' => $copy['title'], + 'message' => $copy['message'], + 'mailer' => [ + 'slug' => $mailer['slug'], + 'label' => $mailer['label'], + ], + 'provider' => $provider, + 'suremail' => [ + 'plugin_status' => $plugin_status, + 'version' => Suremail_Adapter::get_version(), + 'connections' => $connections, + 'onboarding_completed' => $suremail_active && Suremail_Adapter::is_onboarding_completed(), + 'supports_surecontact' => Suremail_Adapter::supports_surecontact(), + ], + 'surecontact' => $surecontact, + 'last_outcome' => [ + 'accepted_at' => $outcomes['last_success_at'], + 'failed_at' => $outcomes['last_failure_at'], + 'failed_form_id' => $outcomes['last_failure_form_id'], + 'reason' => $outcomes['last_failure_reason'], + 'entry_saved' => $outcomes['last_failure_entry_saved'], + ], + 'action' => self::get_action( $state, $mailer, $plugin_status, $needs_connection, $cause ), + 'links' => [ + 'setup' => $suremail_active ? Suremail_Adapter::setup_url() : '', + 'logs' => $suremail_active ? Suremail_Adapter::logs_url() : '', + 'guide' => Helper::get_sureforms_website_url( + 'docs/troubleshooting-email-sending-in-sureforms/', + [ 'utm_medium' => 'email_delivery_status' ] + ), + ], + 'can_install' => function_exists( 'wp_get_current_user' ) && current_user_can( 'install_plugins' ), + 'source' => null !== $readiness ? $readiness['source'] : 'heuristic', + ]; + } + + /** + * Remember when SureMail got its connection, and report losing it. + * + * SureMail rebalances its storage when a connection is deleted — including + * when SureContact revokes it remotely — so zero connections looks the same + * as never having set one up. The only way to tell is to have seen one. + * + * `suremail_connected_at` moves forward on every (re)connection, so failures + * from before it can be told apart from failures through it. Writes only on + * the transitions, not on every read; updates `$outcomes` in place. + * + * @since 2.12.9 + * @param array $outcomes Stored outcomes, updated in place. + * @param int $connections Current connection count. + * @param int|null $at When the connection was made, if known; now otherwise. + * @return bool Whether a previously seen connection is gone. + */ + private static function track_suremail_connection( array &$outcomes, $connections, $at = null ) { + $seen = ! empty( $outcomes['suremail_connected_at'] ); + + if ( $connections > 0 ) { + if ( ! $seen || ! empty( $outcomes['suremail_lost_at'] ) ) { + $outcomes['suremail_connected_at'] = null === $at ? time() : (int) $at; + $outcomes['suremail_lost_at'] = 0; + update_option( self::OUTCOMES_OPTION, $outcomes, false ); + } + return false; + } + + if ( ! $seen ) { + return false; + } + + if ( empty( $outcomes['suremail_lost_at'] ) ) { + $outcomes['suremail_lost_at'] = time(); + update_option( self::OUTCOMES_OPTION, $outcomes, false ); + } + return true; + } + + /** + * SureMail's install state when it is not loaded. + * + * @since 2.12.9 + * @return string Install | Installed | Activated. + */ + private static function get_suremail_plugin_status() { + if ( ! function_exists( 'get_plugins' ) || ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + return Helper::get_plugin_status( Mailer::SUREMAIL_FILE ); + } + + /** + * The next step to offer the owner. + * + * The install_activate and activate steps are carried out by the SureForms UI + * through the existing plugin installer, which is why they have no URL. + * + * @since 2.12.9 + * @param string $state Status state. + * @param array{slug:string,label:string,file:string} $mailer Active mailer. + * @param string $plugin_status SureMail install state. + * @param bool $needs_connection Whether SureMail has no connection to send through. + * @param string $cause Why mail is failing, from compute(); '' when unknown. + * @return array{type:string,label:string,url:string} + */ + private static function get_action( $state, array $mailer, $plugin_status, $needs_connection, $cause = '' ) { + $none = [ + 'type' => 'none', + 'label' => '', + 'url' => '', + ]; + + // SureMail's dashboard carries the verify banner with "Resend verification". + if ( '' !== $cause ) { + return [ + 'type' => 'verify_surecontact', + 'label' => __( 'Verify in SureMail', 'sureforms' ), + 'url' => Suremail_Adapter::admin_url( '/dashboard' ), + ]; + } + + if ( 'not_configured' === $state || ( 'attention' === $state && 'none' === $mailer['slug'] ) ) { + // Installing SureMail is a promotion, so it follows the same switch as + // the SureMail menu. The status text stays honest either way. + if ( Helper::hide_promotions() ) { + return $none; + } + + return [ + 'type' => 'Installed' === $plugin_status ? 'activate' : 'install_activate', + 'label' => 'Installed' === $plugin_status ? __( 'Activate SureMail', 'sureforms' ) : __( 'Set up email delivery', 'sureforms' ), + 'url' => '', + ]; + } + + if ( 'suremail_no_connection' === $state || ( 'attention' === $state && $needs_connection ) ) { + return [ + 'type' => 'open_suremail', + 'label' => Suremail_Adapter::supports_surecontact() ? __( 'Connect SureContact in SureMail', 'sureforms' ) : __( 'Add a connection in SureMail', 'sureforms' ), + 'url' => Suremail_Adapter::setup_url(), + ]; + } + + if ( 'limited' === $state ) { + return [ + 'type' => 'open_suremail', + 'label' => __( 'Finish in SureMail', 'sureforms' ), + 'url' => Suremail_Adapter::admin_url( '/dashboard' ), + ]; + } + + if ( 'attention' === $state && 'suremail' === $mailer['slug'] ) { + return [ + 'type' => 'view_logs', + 'label' => __( 'View SureMail logs', 'sureforms' ), + 'url' => Suremail_Adapter::logs_url(), + ]; + } + + return $none; + } + + /** + * Owner-facing title, message and severity. + * + * @since 2.12.9 + * @param string $state Status state. + * @param array{slug:string,label:string,file:string} $mailer Active mailer. + * @param string $provider SureMail provider label. + * @param bool $needs_connection Whether SureMail has no connection to send through. + * @param bool $failure_open Whether a notification failure is unresolved. + * @param array $outcomes Stored outcomes. + * @param string $cause Why mail is failing, from compute(); '' when unknown. + * @return array{severity:string,title:string,message:string} + */ + private static function get_copy( $state, array $mailer, $provider, $needs_connection, $failure_open, array $outcomes, $cause = '' ) { + switch ( $state ) { + case 'attention': + if ( '' !== $cause ) { + return [ + 'severity' => 'error', + 'title' => __( 'Verify your SureContact email to keep sending', 'sureforms' ), + 'message' => __( 'Your SureContact account is not verified, so it accepts only a few emails a day, and today\'s are used up. New form notifications are not sent until you verify the account email or the allowance resets.', 'sureforms' ), + ]; + } + + if ( $needs_connection && ! $failure_open ) { + return [ + 'severity' => 'error', + 'title' => __( 'SureMail lost its sending connection', 'sureforms' ), + 'message' => __( 'SureMail no longer has a connection, so form notifications are not handed to an email service. Reconnect in SureMail.', 'sureforms' ), + ]; + } + + if ( 'suremail' === $mailer['slug'] && $needs_connection ) { + $message = __( 'A recent form notification was not sent, and SureMail has no sending connection yet. Connect one so notifications are handed to an email service.', 'sureforms' ); + } elseif ( 'suremail' === $mailer['slug'] ) { + $message = __( 'SureMail did not accept a recent form notification. It may retry automatically; SureMail\'s email logs show the reason.', 'sureforms' ); + } elseif ( 'none' === $mailer['slug'] ) { + $message = __( 'A recent form notification could not be handed to your server\'s mail function. Set up email delivery so notifications are sent through a proper email service.', 'sureforms' ); + } else { + /* translators: %s: name of the site's mail plugin. */ + $message = sprintf( __( '%s did not accept a recent form notification. Its logs show the reason.', 'sureforms' ), $mailer['label'] ); + } + + if ( empty( $outcomes['last_failure_entry_saved'] ) && 'failure' === $outcomes['last_result'] ) { + $message .= ' ' . __( 'That form does not store entries, so the submission was not saved anywhere.', 'sureforms' ); + } + + return [ + 'severity' => 'error', + 'title' => __( 'A form notification was not sent', 'sureforms' ), + 'message' => $message, + ]; + + case 'not_configured': + return [ + 'severity' => 'warning', + 'title' => __( 'Email delivery is not set up', 'sureforms' ), + 'message' => __( 'Form notifications are handed to your server\'s built-in mail function, which many hosts block or send to spam. Set up email delivery so new entries reach your inbox.', 'sureforms' ), + ]; + + case 'suremail_no_connection': + return [ + 'severity' => 'warning', + 'title' => __( 'SureMail needs a sending connection', 'sureforms' ), + 'message' => __( 'SureMail is active but has no connection yet, so form notifications are not handed to an email service.', 'sureforms' ), + ]; + + case 'limited': + return [ + 'severity' => 'warning', + 'title' => __( 'SureContact sending is limited', 'sureforms' ), + 'message' => __( 'Your SureContact account email is not verified yet, so only a few emails a day are accepted. Verify it from the email SureContact sent you.', 'sureforms' ), + ]; + + case 'other_mailer': + return [ + 'severity' => 'info', + /* translators: %s: name of the site's mail plugin. */ + 'title' => sprintf( __( 'Emails are sent through %s', 'sureforms' ), '' !== $mailer['label'] ? $mailer['label'] : __( 'your mail plugin', 'sureforms' ) ), + 'message' => __( 'Form notifications are handed off to your mail plugin. Manage sending and logs there.', 'sureforms' ), + ]; + + default: + return [ + 'severity' => 'success', + 'title' => __( 'Email delivery is set up', 'sureforms' ), + 'message' => '' !== $provider + /* translators: %s: SureMail connection name, e.g. SureContact. */ + ? sprintf( __( 'Form notifications are handed off to SureMail (%s).', 'sureforms' ), $provider ) + : __( 'Form notifications are handed off to SureMail.', 'sureforms' ), + ]; + } + } +} diff --git a/inc/email-delivery/suremail-adapter.php b/inc/email-delivery/suremail-adapter.php new file mode 100644 index 000000000..91b70d1ef --- /dev/null +++ b/inc/email-delivery/suremail-adapter.php @@ -0,0 +1,543 @@ += 1`), it is used instead and the option read + * becomes dead code to remove. + * + * @package SureForms + * @since 2.12.9 + */ + +namespace SRFM\Inc\Email_Delivery; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Suremail_Adapter + * + * @since 2.12.9 + */ +class Suremail_Adapter { + /** + * SureMail's connections option. + * + * @since 2.12.9 + */ + public const CONNECTIONS_OPTION = 'suremails_connections'; + + /** + * SureMail's onboarding-completed option ('yes' once finished). + * + * @since 2.12.9 + */ + public const ONBOARDING_OPTION = 'suremails_onboarding_completed'; + + /** + * First SureMail release with the SureContact provider. + * + * @since 2.12.9 + */ + public const SURECONTACT_MIN_VERSION = '2.0.0'; + + /** + * SureMail's connection type for SureContact. + * + * @since 2.12.9 + */ + public const SURECONTACT_TYPE = 'SURECONTACT'; + + /** + * Transient holding the last known SureContact quota. + * + * @since 2.12.9 + */ + public const QUOTA_TRANSIENT = 'srfm_surecontact_quota'; + + /** + * Cron hook that refreshes the quota in the background. + * + * @since 2.12.9 + */ + public const QUOTA_REFRESH_HOOK = 'srfm_refresh_surecontact_quota'; + + /** + * How long a known quota is trusted, in seconds. + * + * @since 2.12.9 + */ + public const QUOTA_TTL = 6 * HOUR_IN_SECONDS; + + /** + * How long a nearly used-up quota is trusted, in seconds. Shorter, so an + * upgrade or a period reset clears the warning soon. + * + * @since 2.12.9 + */ + public const QUOTA_LOW_TTL = HOUR_IN_SECONDS; + + /** + * How long a used-up quota is trusted, in seconds. Its warning cannot be + * dismissed, so it must not outlive an upgrade by long. + * + * @since 2.12.9 + */ + public const QUOTA_EXHAUSTED_TTL = 30 * MINUTE_IN_SECONDS; + + /** + * Share of the cap left at which the quota counts as nearly used up. + * + * @since 2.12.9 + */ + public const QUOTA_LOW_SHARE = 0.2; + + /** + * SureMail's admin page slug. + * + * @since 2.12.9 + */ + private const PAGE_SLUG = 'suremail'; + + /** + * Whether SureMail is loaded. + * + * @since 2.12.9 + * @return bool + */ + public static function is_active() { + return defined( 'SUREMAILS_VERSION' ); + } + + /** + * Loaded SureMail version, or '' when it is not active. + * + * @since 2.12.9 + * @return string + */ + public static function get_version() { + return self::is_active() && is_string( constant( 'SUREMAILS_VERSION' ) ) ? (string) constant( 'SUREMAILS_VERSION' ) : ''; + } + + /** + * Whether the loaded SureMail can connect SureContact. + * + * @since 2.12.9 + * @return bool + */ + public static function supports_surecontact() { + $version = self::get_version(); + return '' !== $version && version_compare( $version, self::SURECONTACT_MIN_VERSION, '>=' ); + } + + /** + * Whether the owner finished SureMail's own onboarding. + * + * @since 2.12.9 + * @return bool + */ + public static function is_onboarding_completed() { + return 'yes' === get_option( self::ONBOARDING_OPTION, 'no' ); + } + + /** + * SureMail's sending readiness, normalised. + * + * `default_missing` flags a default that points at a row which no longer + * exists. SureMail's own delete paths (local and the SureContact-side + * disconnect webhook) rebalance the default, so this only catches storage + * left inconsistent some other way. A disconnect therefore reads the same as + * "never set up" here; Status remembers having seen a connection to tell the + * two apart. + * + * `email_verified` and `is_paid` are whatever SureMail last cached on the row, + * refreshed only when its own screens call SureContact. Null means unknown. + * + * @since 2.12.9 + * @return array{source:string,connections:int,default_type:string,default_missing:bool,provider_label:string,surecontact:array{connected:bool,email_verified:bool|null,is_paid:bool|null}} + */ + public static function get_readiness() { + $contract = self::get_contract_readiness(); + if ( null !== $contract ) { + return $contract; + } + + $result = self::empty_readiness( 'option' ); + + $settings = get_option( self::CONNECTIONS_OPTION, [] ); + if ( ! is_array( $settings ) ) { + return $result; + } + + $connections = isset( $settings['connections'] ) && is_array( $settings['connections'] ) ? $settings['connections'] : []; + $connections = array_filter( $connections, 'is_array' ); + + $result['connections'] = count( $connections ); + + $default = isset( $settings['default_connection'] ) && is_array( $settings['default_connection'] ) ? $settings['default_connection'] : []; + $default_id = isset( $default['id'] ) && is_scalar( $default['id'] ) ? (string) $default['id'] : ''; + + $row = '' !== $default_id && isset( $connections[ $default_id ] ) ? $connections[ $default_id ] : null; + + if ( null === $row ) { + $result['default_missing'] = '' !== $default_id; + // No usable default: SureMail sends through the highest-priority row. + $row = self::first_by_priority( $connections ); + } + + if ( null === $row ) { + return $result; + } + + $type = isset( $row['type'] ) && is_string( $row['type'] ) ? strtoupper( $row['type'] ) : ''; + $result['default_type'] = $type; + + if ( self::SURECONTACT_TYPE === $type ) { + $result['provider_label'] = 'SureContact'; + $result['surecontact'] = [ + 'connected' => true, + 'email_verified' => array_key_exists( 'email_verified', $row ) ? (bool) $row['email_verified'] : null, + 'is_paid' => array_key_exists( 'is_paid', $row ) ? (bool) $row['is_paid'] : null, + ]; + } else { + $title = isset( $row['connection_title'] ) && is_string( $row['connection_title'] ) ? $row['connection_title'] : ''; + $result['provider_label'] = '' !== $title ? $title : ucwords( strtolower( str_replace( '_', ' ', $type ) ) ); + } + + return $result; + } + + /** + * Link into SureMail's admin app. + * + * SureMail lives under Settings or as a top-level menu depending on its own + * setting, so its helper builds the base URL when loaded. + * + * @since 2.12.9 + * @param string $fragment Hash route, e.g. '/logs'. + * @return string + */ + public static function admin_url( $fragment = '' ) { + $fragment = ltrim( (string) $fragment, '#' ); + + $builder = [ '\SureMails\Inc\Utils\Utils', 'get_admin_url' ]; + if ( is_callable( $builder ) ) { + $url = call_user_func( $builder, $fragment ); + if ( is_string( $url ) && '' !== $url ) { + return $url; + } + } + + $url = admin_url( 'options-general.php?page=' . self::PAGE_SLUG ); + return '' === $fragment ? $url : $url . '#' . $fragment; + } + + /** + * Where the owner goes to set up sending. + * + * The provider picker, which leads with SureContact, until SureMail's own + * onboarding is done; the connections screen after. `#/onboarding/connect` + * is avoided: it relies on state SureMail's wizard sets on the way in. + * + * @since 2.12.9 + * @return string + */ + public static function setup_url() { + return self::admin_url( self::is_onboarding_completed() ? '/connections' : '/onboarding/connection' ); + } + + /** + * SureMail's email log screen. + * + * @since 2.12.9 + * @return string + */ + public static function logs_url() { + return self::admin_url( '/logs' ); + } + + /** + * Cached SureContact sending quota, or null when unknown. + * + * SureMail does not store the quota: its own meter asks SureContact live. + * SureForms keeps a copy refreshed in the background by + * refresh_surecontact_quota(), so no admin page waits on SureContact. + * + * @since 2.12.9 + * @return array{used:int,limit:int,remaining:int,period:string,checked_at:int}|null + */ + public static function get_surecontact_quota() { + $cached = get_transient( self::QUOTA_TRANSIENT ); + if ( ! is_array( $cached ) || empty( $cached['known'] ) ) { + return null; + } + + return [ + 'used' => isset( $cached['used'] ) ? (int) $cached['used'] : 0, + 'limit' => isset( $cached['limit'] ) ? (int) $cached['limit'] : 0, + 'remaining' => isset( $cached['remaining'] ) ? (int) $cached['remaining'] : 0, + 'period' => isset( $cached['period'] ) && is_string( $cached['period'] ) ? $cached['period'] : '', + 'checked_at' => isset( $cached['checked_at'] ) ? (int) $cached['checked_at'] : 0, + ]; + } + + /** + * Whether a quota is nearly used up: a real, finite cap with a fifth or + * less left. The daily allowance of an unverified account is left to the + * 'limited' state, which already says to verify. + * + * @since 2.12.9 + * @param array{used:int,limit:int,remaining:int,period:string,checked_at:int}|null $quota Quota from get_surecontact_quota(). + * @return bool + */ + public static function is_quota_low( $quota ) { + if ( ! is_array( $quota ) || $quota['limit'] <= 0 || 'daily' === $quota['period'] ) { + return false; + } + + return $quota['remaining'] <= (int) ceil( $quota['limit'] * self::QUOTA_LOW_SHARE ); + } + + /** + * Queue a background quota refresh when the cached copy has expired. + * + * Called from SureForms admin screens. The refresh itself runs on cron. + * + * @since 2.12.9 + * @return void + */ + public static function maybe_schedule_quota_refresh() { + if ( '' === self::get_surecontact_connection_id() || false !== get_transient( self::QUOTA_TRANSIENT ) ) { + return; + } + + if ( ! wp_next_scheduled( self::QUOTA_REFRESH_HOOK ) ) { + wp_schedule_single_event( time(), self::QUOTA_REFRESH_HOOK ); + } + } + + /** + * Fetch the quota again in the background, now. + * + * Called when a notification through SureMail fails, so the status can + * tell whether SureContact's allowance is why. The cached copy is kept + * until the fetch replaces it: deleting it would leave nothing at all on + * sites where cron never runs (DISABLE_WP_CRON without a real cron). + * + * @since 2.12.9 + * @return void + */ + public static function request_quota_refresh() { + if ( '' === self::get_surecontact_connection_id() ) { + return; + } + + if ( ! wp_next_scheduled( self::QUOTA_REFRESH_HOOK ) ) { + wp_schedule_single_event( time(), self::QUOTA_REFRESH_HOOK ); + } + } + + /** + * Ask SureContact for the quota through SureMail's status handler. + * + * Calls the handler behind SureMail's `surecontact/status` route directly, + * not over REST, because cron has no logged-in user to pass the route's + * permission check. SureMail reads its own encrypted key, so SureForms never + * touches it. A missing handler or a failed call caches "unknown" for an + * hour so a broken setup is not retried on every screen. + * + * @since 2.12.9 + * @return void + */ + public static function refresh_surecontact_quota() { + $connection_id = self::get_surecontact_connection_id(); + $instance = [ '\SureMails\Inc\API\SureContactStatus', 'instance' ]; + + if ( '' === $connection_id || ! is_callable( $instance ) ) { + set_transient( self::QUOTA_TRANSIENT, [ 'known' => false ], HOUR_IN_SECONDS ); + return; + } + + $request = new \WP_REST_Request( 'GET', '/suremails/v1/surecontact/status' ); + $request->set_param( 'connection_id', $connection_id ); + + $handler = call_user_func( $instance ); + $response = is_object( $handler ) && is_callable( [ $handler, 'status' ] ) ? call_user_func( [ $handler, 'status' ], $request ) : null; + $data = $response instanceof \WP_REST_Response && 200 === $response->get_status() ? $response->get_data() : null; + $cap = is_array( $data ) && isset( $data['cap'] ) && is_array( $data['cap'] ) ? $data['cap'] : null; + + if ( null === $cap || ! isset( $cap['limit'] ) || ! is_numeric( $cap['limit'] ) ) { + set_transient( self::QUOTA_TRANSIENT, [ 'known' => false ], HOUR_IN_SECONDS ); + return; + } + + $limit = (int) $cap['limit']; + $used = isset( $cap['used'] ) && is_numeric( $cap['used'] ) ? max( 0, (int) $cap['used'] ) : 0; + $quota = [ + 'used' => $used, + 'limit' => $limit, + 'remaining' => isset( $cap['remaining'] ) && is_numeric( $cap['remaining'] ) ? max( 0, (int) $cap['remaining'] ) : max( 0, $limit - $used ), + 'period' => isset( $cap['period'] ) && is_string( $cap['period'] ) ? sanitize_key( $cap['period'] ) : '', + 'checked_at' => time(), + ]; + + // Used up first, whatever the period: a daily allowance is never "low" + // (that is the limited state's job), but at zero it must be rechecked + // as soon as a monthly one. + if ( $limit > 0 && 0 === $quota['remaining'] ) { + $ttl = self::QUOTA_EXHAUSTED_TTL; + } elseif ( self::is_quota_low( $quota ) ) { + $ttl = self::QUOTA_LOW_TTL; + } else { + $ttl = self::QUOTA_TTL; + } + + set_transient( self::QUOTA_TRANSIENT, array_merge( [ 'known' => true ], $quota ), $ttl ); + } + + /** + * Where the owner upgrades: SureMail's dashboard, whose SureContact meter + * carries the upgrade button. `source` tells SureMail the click came from + * SureForms, ready for when it forwards attribution to SureContact. + * + * @since 2.12.9 + * @return string + */ + public static function upgrade_url() { + return add_query_arg( 'source', 'sureforms', self::admin_url( '/dashboard' ) ); + } + + /** + * The SureContact connection to read the quota for: the default one when it + * is SureContact, else the first SureContact row. '' when there is none or + * SureMail is not active. + * + * @since 2.12.9 + * @return string + */ + private static function get_surecontact_connection_id() { + // From the active plugins, like the rest of the status; whether SureMail's + // handler can be called is checked where it is called. + if ( 'suremail' !== Mailer::get_active()['slug'] ) { + return ''; + } + + $settings = get_option( self::CONNECTIONS_OPTION, [] ); + if ( ! is_array( $settings ) || ! isset( $settings['connections'] ) || ! is_array( $settings['connections'] ) ) { + return ''; + } + + $default_id = isset( $settings['default_connection']['id'] ) && is_scalar( $settings['default_connection']['id'] ) ? (string) $settings['default_connection']['id'] : ''; + $rows = array_filter( + $settings['connections'], + static function ( $row ) { + return is_array( $row ) && isset( $row['type'] ) && is_string( $row['type'] ) && self::SURECONTACT_TYPE === strtoupper( $row['type'] ); + } + ); + + if ( '' !== $default_id && isset( $rows[ $default_id ] ) ) { + return $default_id; + } + + $first = array_key_first( $rows ); + return null === $first ? '' : (string) $first; + } + + /** + * Readiness from SureMail's own contract, when it provides one. + * + * SureMail 2.0.2 does not ship this filter yet. It is the contract requested + * in brainstormforce/suremails#809; until it lands, has_filter() keeps this + * branch inert and the option read above is what runs. + * + * @since 2.12.9 + * @return array{source:string,connections:int,default_type:string,default_missing:bool,provider_label:string,surecontact:array{connected:bool,email_verified:bool|null,is_paid:bool|null}}|null + */ + private static function get_contract_readiness() { + if ( ! has_filter( 'suremails_delivery_readiness' ) ) { + return null; + } + + $data = apply_filters( 'suremails_delivery_readiness', null ); + + if ( ! is_array( $data ) || ! isset( $data['contract'] ) || (int) $data['contract'] < 1 ) { + return null; + } + + $result = self::empty_readiness( 'contract' ); + $result['connections'] = isset( $data['connections_count'] ) ? max( 0, (int) $data['connections_count'] ) : 0; + + $default = isset( $data['default_connection'] ) && is_array( $data['default_connection'] ) ? $data['default_connection'] : []; + $type = isset( $default['type'] ) && is_string( $default['type'] ) ? strtoupper( $default['type'] ) : ''; + + $result['default_type'] = $type; + $result['default_missing'] = isset( $data['status'] ) && 'disconnected' === $data['status']; + $result['provider_label'] = isset( $default['label'] ) && is_string( $default['label'] ) ? $default['label'] : ''; + + $provider = isset( $data['provider'] ) && is_array( $data['provider'] ) ? $data['provider'] : []; + if ( self::SURECONTACT_TYPE === $type ) { + $result['provider_label'] = 'SureContact'; + $result['surecontact'] = [ + 'connected' => true, + 'email_verified' => array_key_exists( 'email_verified', $provider ) ? (bool) $provider['email_verified'] : null, + 'is_paid' => array_key_exists( 'is_paid', $provider ) ? (bool) $provider['is_paid'] : null, + ]; + } + + return $result; + } + + /** + * Readiness with nothing connected. + * + * @since 2.12.9 + * @param string $source Where the data came from. + * @return array{source:string,connections:int,default_type:string,default_missing:bool,provider_label:string,surecontact:array{connected:bool,email_verified:bool|null,is_paid:bool|null}} + */ + private static function empty_readiness( $source ) { + return [ + 'source' => $source, + 'connections' => 0, + 'default_type' => '', + 'default_missing' => false, + 'provider_label' => '', + 'surecontact' => [ + 'connected' => false, + 'email_verified' => null, + 'is_paid' => null, + ], + ]; + } + + /** + * Connection row with the lowest priority number. + * + * @since 2.12.9 + * @param array $connections Connection rows. + * @return array|null + */ + private static function first_by_priority( array $connections ) { + $best = null; + $best_rank = PHP_INT_MAX; + + foreach ( $connections as $row ) { + if ( ! is_array( $row ) ) { + continue; + } + $rank = isset( $row['priority'] ) && is_numeric( $row['priority'] ) ? (int) $row['priority'] : PHP_INT_MAX - 1; + if ( $rank < $best_rank ) { + $best = $row; + $best_rank = $rank; + } + } + + return $best; + } +} diff --git a/inc/field-validation.php b/inc/field-validation.php index 1de07896d..c7c8fadbc 100644 --- a/inc/field-validation.php +++ b/inc/field-validation.php @@ -18,6 +18,29 @@ * Field Validation Class */ class Field_Validation { + /** + * Field blocks whose markup is a single checkbox input, mapped to the field type + * slug and the label their markup class falls back to. + * + * A browser submits nothing for an unticked checkbox, so these are the only fields + * that can be absent from a submission while every other field posts an empty value. + * The label fallbacks mirror the set_input_label() calls in Checkbox_Markup and + * GDPR_Markup; they are what the rendered input name encodes when a form leaves the + * label empty, so the two have to agree. + * + * @since 2.12.9 + */ + public const UNSUBMITTABLE_BLOCKS = [ + 'srfm/checkbox' => [ + 'type' => 'checkbox', + 'label' => 'Checkbox', + ], + 'srfm/gdpr' => [ + 'type' => 'gdpr', + 'label' => 'I consent to have this website store my information so they can respond to my inquiry.', + ], + ]; + /** * Add block configuration for form fields. * @@ -196,6 +219,56 @@ public static function prepared_validation_data( $current_form_id ) { return is_array( $get_form_config ) ? $get_form_config : []; } + /** + * Restore the fields a browser never submits when the visitor leaves them unset. + * + * An unticked checkbox is not posted at all, so its key never reaches the server: + * the field is missing from the entry and from notification emails, while every + * other field left empty arrives as '' and is shown as '-'. This rebuilds those + * keys from the form's own blocks so an untouched checkbox reads like every other + * untouched field. + * + * Submitted values are never replaced -- a key already present is left exactly as + * it arrived, so a ticked box keeps the value the browser sent. + * + * The key is assembled the same way the markup does it + * ({@see \SRFM\Inc\Fields\Base::set_input_label()} and the `name` attribute in + * checkbox-markup.php), so the two have to stay in step; test-field-validation.php + * asserts they do by rendering the field and comparing. + * + * @param array $form_data Submitted form data. + * @param int|mixed $form_id The form post id. + * @since 2.12.9 + * @return array Form data with the missing keys added. + */ + public static function backfill_unsubmitted_fields( $form_data, $form_id ) { + if ( ! is_array( $form_data ) ) { + return $form_data; + } + + $form_id = Helper::get_integer_value( $form_id ); + if ( $form_id <= 0 ) { + return $form_data; + } + + $walked = self::get_field_identifiers( $form_id ); + if ( empty( $walked['unsubmittable'] ) || ! is_array( $walked['unsubmittable'] ) ) { + return $form_data; + } + + foreach ( $walked['unsubmittable'] as $field ) { + $key = 'srfm-' . $field['type'] . '-' . $field['block_id'] . '-lbl-' . Helper::encode( $field['label'] ) . '-' . $field['slug']; + + // array_key_exists(), not isset(): a submitted null must still count as + // submitted, so this never overwrites what the visitor sent. + if ( ! array_key_exists( $key, $form_data ) ) { + $form_data[ $key ] = ''; + } + } + + return $form_data; + } + /** * Build the set of block ids that legitimately belong to a form. * @@ -539,7 +612,7 @@ public static function get_email_char_limits() { * * @param int $form_id The form post id, already normalised by the callers. * @since 2.12.8 - * @return array{ids:array,slugs:array} + * @return array{ids:array,slugs:array,unsubmittable:array>} */ private static function get_field_identifiers( $form_id ) { static $cache = []; @@ -556,21 +629,23 @@ private static function get_field_identifiers( $form_id ) { * * @param int $form_id The form post id. * @since 2.12.7 - * @return array{ids:array,slugs:array} + * @return array{ids:array,slugs:array,unsubmittable:array>} */ private static function walk_field_identifiers( $form_id ) { - $ids = []; - $slugs = []; - $post = get_post( $form_id ); + $ids = []; + $slugs = []; + $unsubmittable = []; + $post = get_post( $form_id ); if ( $post instanceof \WP_Post && ! empty( $post->post_content ) && function_exists( 'parse_blocks' ) ) { $visited = []; - self::collect_field_block_ids( parse_blocks( $post->post_content ), $ids, $slugs, $visited ); + self::collect_field_block_ids( parse_blocks( $post->post_content ), $ids, $slugs, $visited, 0, $unsubmittable ); } return [ - 'ids' => $ids, - 'slugs' => $slugs, + 'ids' => $ids, + 'slugs' => $slugs, + 'unsubmittable' => $unsubmittable, ]; } @@ -651,15 +726,18 @@ private static function strip_unknown_repeater_keys( $rows, $known_block_ids, $k /** * Recursively collect SureForms block ids from a parsed block tree. * - * @param array $blocks Parsed blocks from parse_blocks(). - * @param array $ids Accumulator of block id => true (by reference). - * @param array $slugs Accumulator of field slug => true (by reference). - * @param array $visited Expanded reusable-block post ids, guards cycles. - * @param int $depth Current recursion depth, guards pathological trees. + * @param array $blocks Parsed blocks from parse_blocks(). + * @param array $ids Accumulator of block id => true (by reference). + * @param array $slugs Accumulator of field slug => true (by reference). + * @param array $visited Expanded reusable-block post ids, guards cycles. + * @param int $depth Current recursion depth, guards pathological trees. + * @param array> $unsubmittable Accumulator of the checkbox/GDPR + * fields a browser cannot submit + * when unticked (by reference). * @since 2.12.3 * @return void */ - private static function collect_field_block_ids( $blocks, &$ids, &$slugs, &$visited, $depth = 0 ) { + private static function collect_field_block_ids( $blocks, &$ids, &$slugs, &$visited, $depth = 0, &$unsubmittable = [] ) { if ( ! is_array( $blocks ) || $depth > 50 ) { return; } @@ -689,6 +767,19 @@ private static function collect_field_block_ids( $blocks, &$ids, &$slugs, &$visi if ( ! empty( $attrs['slug'] ) && is_string( $attrs['slug'] ) ) { $slugs[ $attrs['slug'] ] = true; } + + // A browser posts nothing for an unticked checkbox, so these fields need + // rebuilding from the form itself -- see backfill_unsubmitted_fields(). + if ( isset( self::UNSUBMITTABLE_BLOCKS[ $block_name ] ) && ! empty( $attrs['block_id'] ) && is_string( $attrs['block_id'] ) ) { + $unsubmittable[] = [ + 'type' => self::UNSUBMITTABLE_BLOCKS[ $block_name ]['type'], + 'block_id' => $attrs['block_id'], + 'slug' => isset( $attrs['slug'] ) && is_string( $attrs['slug'] ) ? $attrs['slug'] : '', + 'label' => ! empty( $attrs['label'] ) && is_string( $attrs['label'] ) + ? $attrs['label'] + : self::UNSUBMITTABLE_BLOCKS[ $block_name ]['label'], + ]; + } } // Expand reusable/synced patterns so fields living inside a pattern count as @@ -699,13 +790,13 @@ private static function collect_field_block_ids( $blocks, &$ids, &$slugs, &$visi $visited[ $ref ] = true; $ref_post = get_post( $ref ); if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && '' !== $ref_post->post_content ) { - self::collect_field_block_ids( parse_blocks( $ref_post->post_content ), $ids, $slugs, $visited, $depth + 1 ); + self::collect_field_block_ids( parse_blocks( $ref_post->post_content ), $ids, $slugs, $visited, $depth + 1, $unsubmittable ); } } } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { - self::collect_field_block_ids( $block['innerBlocks'], $ids, $slugs, $visited, $depth + 1 ); + self::collect_field_block_ids( $block['innerBlocks'], $ids, $slugs, $visited, $depth + 1, $unsubmittable ); } } } diff --git a/inc/fields/base.php b/inc/fields/base.php index 5bd8d839b..e7739ba4f 100644 --- a/inc/fields/base.php +++ b/inc/fields/base.php @@ -538,7 +538,8 @@ protected function set_label_as_placeholder( $input_label = '' ) { * @return void */ protected function set_aria_described_by() { - $this->aria_described_by .= ' srfm-error-' . esc_attr( $this->block_id ); + // Only reference elements that exist in the rendered markup. The error message id + // is linked by toggleErrorState() in frontend.js on controls marked data-srfm-error-target. $this->aria_described_by .= ! empty( $this->help ) ? ' srfm-description-' . esc_attr( $this->block_id ) : ''; } } diff --git a/inc/fields/checkbox-markup.php b/inc/fields/checkbox-markup.php index f7277483b..7d4cf6c7b 100644 --- a/inc/fields/checkbox-markup.php +++ b/inc/fields/checkbox-markup.php @@ -47,7 +47,7 @@ public function markup() {
    aria_described_by ) ? "aria-describedby='" . esc_attr( trim( $this->aria_described_by ) ) . "'" : ''; ?> + aria_described_by ) ? "aria-describedby='" . esc_attr( trim( $this->aria_described_by ) ) . "'" : ''; ?> data-srfm-error-target name="srfm-slug ); ?>-block_id ); ?>field_name ); ?>" data-required="data_require_attr ); ?>" aria-required="data_require_attr ); ?>" type="checkbox" checked_attr ); ?>/>
    @@ -140,7 +140,7 @@ public function markup() {
    aria_described_by ) ? "aria-describedby='" . esc_attr( trim( $this->aria_described_by ) ) . "'" : ''; ?> + aria_described_by ) ? "aria-describedby='" . esc_attr( trim( $this->aria_described_by ) ) . "'" : ''; ?> data-srfm-error-target data-required="data_require_attr ); ?>" aria-required="data_require_attr ); ?>" value="default ); ?>" placeholder_attr ); ?> read_only ? 'readonly' : ''; ?> />
    diff --git a/inc/fields/gdpr-markup.php b/inc/fields/gdpr-markup.php index 9f9df19c6..eefeebf6b 100644 --- a/inc/fields/gdpr-markup.php +++ b/inc/fields/gdpr-markup.php @@ -55,7 +55,7 @@ public function markup() {
    aria_described_by ) ? "aria-describedby='" . esc_attr( trim( $this->aria_described_by ) ) . "'" : ''; ?> + aria_described_by ) ? "aria-describedby='" . esc_attr( trim( $this->aria_described_by ) ) . "'" : ''; ?> data-srfm-error-target data-required="data_require_attr ); ?>" aria-required="data_require_attr ); ?>" type="checkbox" checked_attr ); ?>/>