diff --git a/README.md b/README.md index 374c99865..1b92b8049 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Requires at least:** 6.4 **Tested up to:** 7.0 **Requires PHP:** 7.4 -**Stable tag:** 2.12.0 +**Stable tag:** 2.12.1 **License:** GPLv2 or later **License URI:** http://www.gnu.org/licenses/gpl-2.0.html @@ -458,6 +458,12 @@ Yes. SureForms Business includes fully functional user registration forms and lo You can report security issues through our [Bug Bounty Program](https://brainstormforce.com/bug-bounty-program/). We collaborate with Patchstack to provide opportunities for researchers to report vulnerabilities. The Patchstack team will help validate, triage, and handle any reported security issues. ## Changelog ## +### 2.12.1 - 8th July 2026 ### +* New: Added an AI-powered quick draft flow on the dashboard to generate forms faster, with built-in usage tracking. +* Improvement: Email fields now enforce standard RFC 5321 length limits (a 64-character local part and 255-character domain) to prevent oversized submissions, with a filter to customize the limits. +* Fix: Reset form option not working for some fields. +* Fix: Resolved a Stripe one-time payment error that could cause card payments to fail with an HTTP 400 response. +* Fix: Restored bullet point visibility in the rich text admin editor so formatted lists display correctly. ### 2.12.0 - 24th June 2026 ### * New: Added action hooks around payment success, cancellation, and refund events so plugins such as SureMembers, LMS, and CRMs can grant or revoke access for both Stripe and PayPal. * Fix: Cancel Subscription now routes through the correct payment gateway so PayPal subscriptions cancel properly instead of always calling Stripe. @@ -468,9 +474,6 @@ You can report security issues through our [Bug Bounty Program](https://brainsto * Fix: Phone field auto country detection always resolved to the United States. * Fix: Corrected the Cloudflare Turnstile "Get Keys" link. * Fix: This update addressed a security bug. Props to Yaswanth Reddy Sunkara for reporting it responsibly to our team. -### 2.11.0 - 10th June 2026 ### -* New: Added a Form Migrator to import forms from Contact Form 7, WPForms, Gravity Forms, and Ninja Forms in a single click. -* New: Added native WPML support to translate each form individually using String Packages. The full changelog is available [here](https://sureforms.com/whats-new/?utm_source=wordpress.org&utm_medium=whats_new). ## Upgrade Notice ## diff --git a/admin/admin.php b/admin/admin.php index 5f9dd8de2..09c7a1a7e 100644 --- a/admin/admin.php +++ b/admin/admin.php @@ -40,6 +40,17 @@ class Admin { */ public const RATING_NOTICE_THRESHOLD = 3; + /** + * Inline CSS for Quill 1.x (react-quill) list markers. + * + * Quill 1.x renders bullet/numbered list markers via CSS ::before pseudo-elements, + * whereas the vendor quill.snow.css targets .ql-ui child elements (Quill 2.x approach). + * This constant is shared by enqueue_styles() and enqueue_scripts() to prevent drift. + * + * @since 2.5.2 + */ + public const QUILL_1X_INLINE_CSS = '.ql-editor ul,.ql-editor ol{padding-left:1.5em}.ql-editor ul>li,.ql-editor ol>li{list-style-type:none}.ql-editor ol li:not(.ql-direction-rtl),.ql-editor ul li:not(.ql-direction-rtl){padding-left:1.5em}.ql-editor ol li.ql-direction-rtl,.ql-editor ul li.ql-direction-rtl{padding-right:1.5em}.ql-editor ul>li::before{content:"\2022"}.ql-editor li::before{display:inline-block;white-space:nowrap;width:1.2em}.ql-editor li:not(.ql-direction-rtl)::before{margin-left:-1.5em;margin-right:.3em;text-align:right}.ql-editor li.ql-direction-rtl::before{margin-left:.3em;margin-right:-1.5em}.ql-editor ol li{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;counter-increment:list-0}.ql-editor ol li::before{content:counter(list-0,decimal) ". "}.ql-editor ol li.ql-indent-1{counter-increment:list-1;counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-1::before{content:counter(list-1,lower-alpha) ". "}.ql-editor ol li.ql-indent-2{counter-increment:list-2;counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-2::before{content:counter(list-2,lower-roman) ". "}.ql-editor ol li.ql-indent-3{counter-increment:list-3;counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-3::before{content:counter(list-3,decimal) ". "}.ql-editor ol li.ql-indent-4{counter-increment:list-4;counter-reset:list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-4::before{content:counter(list-4,lower-alpha) ". "}.ql-editor ol li.ql-indent-5{counter-increment:list-5;counter-reset:list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-5::before{content:counter(list-5,lower-roman) ". "}.ql-editor ol li.ql-indent-6{counter-increment:list-6;counter-reset:list-7 list-8 list-9}.ql-editor ol li.ql-indent-6::before{content:counter(list-6,decimal) ". "}.ql-editor ol li.ql-indent-7{counter-increment:list-7;counter-reset:list-8 list-9}.ql-editor ol li.ql-indent-7::before{content:counter(list-7,lower-alpha) ". "}.ql-editor ol li.ql-indent-8{counter-increment:list-8;counter-reset:list-9}.ql-editor ol li.ql-indent-8::before{content:counter(list-8,lower-roman) ". "}.ql-editor ol li.ql-indent-9{counter-increment:list-9}.ql-editor ol li.ql-indent-9::before{content:counter(list-9,decimal) ". "}'; + /** * Dashboard widget entries data. * @@ -120,10 +131,14 @@ public function __construct() { add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] ); add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] ); add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] ); + add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] ); // Register dashboard widget only if there are recent entries. add_action( 'admin_init', [ $this, 'maybe_register_dashboard_widget' ] ); + // Enqueue the AI quick draft widget script on the dashboard screen. + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_ai_dashboard_widget_assets' ] ); + // Save first form creation time stamp. add_action( 'admin_init', [ $this, 'save_first_form_creation_time_stamp' ] ); add_action( 'admin_notices', [ $this, 'display_srfm_rating_notice' ] ); @@ -894,6 +909,7 @@ public function enqueue_styles() { wp_enqueue_style( SRFM_SLUG . '-intl', $vendor_css_uri . 'intl/intlTelInput-backend.min.css', [], SRFM_VER ); wp_enqueue_style( SRFM_SLUG . '-common', $css_uri . 'common' . $file_prefix . '.css', [], SRFM_VER ); wp_enqueue_style( SRFM_SLUG . '-reactQuill', $vendor_css_uri . 'quill/quill.snow.css', [], SRFM_VER ); + wp_add_inline_style( SRFM_SLUG . '-reactQuill', self::QUILL_1X_INLINE_CSS ); wp_enqueue_style( SRFM_SLUG . '-single-form-modal', $css_uri . 'single-form-setting' . $file_prefix . '.css', [], SRFM_VER ); // if version is equal to or lower than 6.6.2 then add compatibility css. @@ -1334,6 +1350,7 @@ public function enqueue_scripts() { // Enqueue Tailwind and Quill editor styles for the settings page. wp_enqueue_style( SRFM_SLUG . '-settings-build', SRFM_URL . 'assets/build/settings.css', [], SRFM_VER, 'all' ); wp_enqueue_style( SRFM_SLUG . '-reactQuill', SRFM_URL . 'assets/css/minified/deps/quill/quill.snow.css', [], SRFM_VER ); + wp_add_inline_style( SRFM_SLUG . '-reactQuill', self::QUILL_1X_INLINE_CSS ); $script_translations_handlers[] = SRFM_SLUG . '-settings'; } @@ -1921,6 +1938,9 @@ public function maybe_register_dashboard_widget() { return; } + // Register the AI quick draft widget for capable users (the capability gate above applies); unlike the recent-entries widget below, it is not conditional on having entries. + add_action( 'wp_dashboard_setup', [ $this, 'register_ai_dashboard_widget' ] ); + // Quick check if there are any entries in the last 7 days. $seven_days_ago = strtotime( '-7 days' ); $total_entries = Entries::get_entries_count_after( $seven_days_ago ); @@ -1956,6 +1976,178 @@ public function register_dashboard_widget() { ); } + /** + * Register the AI quick draft dashboard widget. + * + * @return void + * @since 2.12.1 + */ + public function register_ai_dashboard_widget() { + wp_add_dashboard_widget( + 'sureforms_ai_quick_draft', + __( 'SureForms AI Quick Draft', 'sureforms' ), + [ $this, 'render_ai_dashboard_widget' ], + null, + null, + 'normal', + 'high' + ); + } + + /** + * Render AI quick draft dashboard widget content. + * + * @return void + * @since 2.12.1 + */ + public function render_ai_dashboard_widget() { + ?> +
+ in the render callback, so it passes Plugin Check and keeps server values + * out of the markup. Server values are passed through wp_localize_script. + * + * @param string $hook_suffix The current admin page hook suffix. + * @return void + * @since 2.12.1 + */ + public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) { + // Only on the main dashboard, and only for capable users (matches the widget gate). + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) { + return; + } + + // Register an inline-only handle (empty src) — the WordPress-core pattern for attaching + // localized data plus an inline script without shipping a separate asset file. + wp_register_script( 'srfm-ai-dashboard-widget', '', [], SRFM_VER, true ); + wp_enqueue_script( 'srfm-ai-dashboard-widget' ); + + wp_localize_script( + 'srfm-ai-dashboard-widget', + 'srfmAiDashboardWidget', + [ + 'redirectUrl' => admin_url( 'admin.php?page=add-new-form' ), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'srfm_ai_widget_usage' ), + 'redirectingTxt' => __( 'Redirecting...', 'sureforms' ), + ] + ); + + $inline_script = <<<'JS' +( function () { + const config = window.srfmAiDashboardWidget || {}; + const generateButton = document.getElementById( 'srfm-ai-dashboard-generate' ); + const promptField = document.getElementById( 'srfm-ai-dashboard-prompt' ); + const charCount = document.getElementById( 'srfm-ai-dashboard-char-count' ); + if ( ! generateButton || ! promptField ) { + return; + } + + const updateWidgetState = function () { + const promptValue = promptField.value.trim(); + generateButton.disabled = ! promptValue; + if ( charCount ) { + charCount.textContent = `${ promptField.value.length }/2000`; + } + }; + + const triggerGeneration = function () { + const prompt = promptField.value.trim(); + if ( ! prompt ) { + promptField.focus(); + return; + } + + generateButton.disabled = true; + generateButton.textContent = config.redirectingTxt; + + const redirectUrl = new URL( config.redirectUrl, window.location.origin ); + redirectUrl.searchParams.set( 'srfm_ai_dashboard_prompt', prompt ); + + const requestBody = new URLSearchParams(); + requestBody.append( 'action', 'srfm_ai_widget_usage' ); + requestBody.append( 'nonce', config.nonce ); + + fetch( config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: requestBody.toString(), + } ).finally( function () { + window.location.href = redirectUrl.toString(); + } ); + }; + + promptField.addEventListener( 'input', updateWidgetState ); + generateButton.addEventListener( 'click', triggerGeneration ); + promptField.addEventListener( 'keydown', function ( event ) { + if ( event.key === 'Enter' && ( event.metaKey || event.ctrlKey ) ) { + event.preventDefault(); + triggerGeneration(); + } + } ); + + updateWidgetState(); +}() ); +JS; + + wp_add_inline_script( 'srfm-ai-dashboard-widget', $inline_script ); + } + + /** + * Track AI dashboard widget usage. + * + * @return void + * @since 2.12.1 + */ + public function track_ai_widget_usage() { + if ( ! check_ajax_referer( 'srfm_ai_widget_usage', 'nonce', false ) ) { + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 ); + } + + if ( ! Helper::current_user_can() ) { + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 ); + } + + $current_count = (int) Helper::get_srfm_option( 'ai_dashboard_widget_uses', 0 ) + 1; + Helper::update_srfm_option( 'ai_dashboard_widget_uses', $current_count ); + + // Emit an analytics event so usage lands in the warehouse via events_record. + // $force = true because this is a cumulative counter, not a one-time event — + // it must re-send the latest count each cycle (bypasses one-time dedup). + Analytics::events()->track( 'ai_dashboard_widget_used', (string) $current_count, [], true ); + + wp_send_json_success(); + } + /** * Render the dashboard widget content. * diff --git a/assets/js/unminified/blocks/dropdown.js b/assets/js/unminified/blocks/dropdown.js index 0f7c00731..3a9714a80 100644 --- a/assets/js/unminified/blocks/dropdown.js +++ b/assets/js/unminified/blocks/dropdown.js @@ -437,3 +437,28 @@ document.addEventListener( 'srfm_form_before_submission', ( e ) => { // Make dropdown initialization function available globally for repeater fields window.srfmInitializeDropdownField = initializeDropdown; window.srfmDestroyDropdownField = destroyTomSelect; + +// Reset TomSelect dropdowns when the form is reset. +document.addEventListener( 'srfm_form_reset', ( e ) => { + const form = e.detail?.form; + if ( ! form ) { + return; + } + + form.querySelectorAll( '.srfm-dropdown-common' ).forEach( ( dropdown ) => { + const inputName = dropdown.getAttribute( 'name' ); + const instance = window?.srfm?.[ inputName ]; + if ( instance ) { + instance.clear(); + } + + // Also clear the hidden input that holds the submitted value. + const hiddenInput = dropdown + .closest( '.srfm-dropdown-block' ) + ?.querySelector( '.srfm-input-dropdown-hidden' ); + if ( hiddenInput ) { + hiddenInput.setAttribute( 'value', '' ); + hiddenInput.dispatchEvent( new Event( 'change', { bubbles: true } ) ); + } + } ); +} ); diff --git a/assets/js/unminified/form-submit.js b/assets/js/unminified/form-submit.js index 4b7fde776..cca718ce4 100644 --- a/assets/js/unminified/form-submit.js +++ b/assets/js/unminified/form-submit.js @@ -437,6 +437,14 @@ function showSuccessMessage( }, 500 ); } else if ( afterSubmission === 'reset form' ) { form.reset(); + // Dispatch event so custom field implementations (TomSelect dropdowns, + // date/time pickers, signature pads, etc.) can reset their own state, + // since the native form.reset() only resets standard HTML elements. + document.dispatchEvent( + new CustomEvent( 'srfm_form_reset', { + detail: { form }, + } ) + ); } element.innerHTML = message; container.classList.add( 'srfm-active' ); diff --git a/assets/js/unminified/validation.js b/assets/js/unminified/validation.js index 1bce70b9d..5d33ce519 100644 --- a/assets/js/unminified/validation.js +++ b/assets/js/unminified/validation.js @@ -1,5 +1,22 @@ import { applyFilters } from '@wordpress/hooks'; +/** + * Resolve the RFC 5321 email character limits. + * + * Reads the server-resolved limits localized into `srfm_submit` (which honor the + * `srfm_email_field_char_limits` filter), falling back to the RFC defaults so a site that + * raises a limit isn't false-blocked client-side with the wrong number. + * + * @return {{local: number, domain: number}} Resolved limits (0 disables a check). + */ +function getEmailCharLimits() { + const limits = window?.srfm_submit?.email_char_limits; + return { + local: limits?.local ?? 64, + domain: limits?.domain ?? 255, + }; +} + async function getUniqueValidationData( checkData, formId, ajaxUrl, token ) { let queryString = 'action=validation_ajax_action&token=' + @@ -321,6 +338,54 @@ export async function fieldValidation( validateResult = true; } + // RFC 5321 length limits — local part <= 64, domain <= 255 (split on the + // last @). Mirrors the server-side check (which is authoritative, so a + // `srfm_email_field_char_limits` filter override is still enforced there). + // Returns the part-specific, value-filled message, or '' when within limits. + const emailLengthError = ( val ) => { + if ( typeof val !== 'string' || ! val.includes( '@' ) ) { + return ''; + } + const { local: localMax, domain: domainMax } = + getEmailCharLimits(); + const at = val.lastIndexOf( '@' ); + if ( localMax > 0 && val.slice( 0, at ).length > localMax ) { + return window?.srfm?.srfmSprintfString( + window?.srfm_submit?.messages + ?.srfm_email_local_max_length, + localMax + ); + } + if ( + domainMax > 0 && + val.slice( at + 1 ).length > domainMax + ) { + return window?.srfm?.srfmSprintfString( + window?.srfm_submit?.messages + ?.srfm_email_domain_max_length, + domainMax + ); + } + return ''; + }; + + const mainLengthError = inputValue + ? emailLengthError( inputValue ) + : ''; + if ( mainLengthError ) { + if ( errorMessage ) { + errorMessage.textContent = mainLengthError; + // The email field's error message is hidden by default and only + // revealed via an explicit display:block (same as the format-error + // and confirm-mismatch handlers) — without this the red border + // shows but the message stays hidden. + errorMessage.style.display = 'block'; + } + window?.srfm?.toggleErrorState( parent, true ); + setFirstErrorInput( inputField, parent ); + validateResult = true; + } + if ( confirmParent ) { const confirmInput = confirmParent.querySelector( '.srfm-input-email-confirm' @@ -358,6 +423,21 @@ export async function fieldValidation( window?.srfm?.toggleErrorState( confirmParent, false ); } + // Length check on the confirm value too (it must match the main + // value, but flag it directly so the error surfaces on this input). + const confirmLengthError = confirmValue + ? emailLengthError( confirmValue ) + : ''; + if ( confirmLengthError ) { + if ( confirmError ) { + confirmError.textContent = confirmLengthError; + confirmError.style.display = 'block'; + } + window?.srfm?.toggleErrorState( confirmParent, true ); + setFirstErrorInput( confirmInput, confirmParent ); + validateResult = true; + } + // remove the error message on input of the email confirm field confirmInput.addEventListener( 'input', () => { window?.srfm?.toggleErrorState( confirmParent, false ); @@ -1089,14 +1169,48 @@ function addEmailBlurListener( areaInput, blockClass ) { confirmErrorContainer.style.display = 'none'; } + // RFC 5321 length limits (local <= 64, domain <= 255, split on the last @). + // A too-long address can still be a valid format, so flag it here too and + // show the length-specific message; otherwise fall back to the format error. + const { local: localMax, domain: domainMax } = getEmailCharLimits(); + const emailValueAt = emailField.value.lastIndexOf( '@' ); + let lengthErrorMessage = ''; + if ( emailValueAt !== -1 ) { + if ( + localMax > 0 && + emailField.value.slice( 0, emailValueAt ).length > localMax + ) { + lengthErrorMessage = window?.srfm?.srfmSprintfString( + window?.srfm_submit?.messages + ?.srfm_email_local_max_length, + localMax + ); + } else if ( + domainMax > 0 && + emailField.value.slice( emailValueAt + 1 ).length > domainMax + ) { + lengthErrorMessage = window?.srfm?.srfmSprintfString( + window?.srfm_submit?.messages + ?.srfm_email_domain_max_length, + domainMax + ); + } + } + // Handle general email validation - if ( '' !== emailField?.value && ! isValidEmail ) { + if ( + '' !== emailField?.value && + ( ! isValidEmail || lengthErrorMessage ) + ) { inputBlock.parentElement.classList.add( 'srfm-valid-email-error' ); errorContainer.style.display = 'block'; - errorContainer.innerHTML = - window?.srfm_submit?.messages?.srfm_valid_email; + // Length message wins whenever set (matches the submit path); fall back + // to the generic format error only when there's no length error. + errorContainer.innerHTML = lengthErrorMessage + ? lengthErrorMessage + : window?.srfm_submit?.messages?.srfm_valid_email; errorContainer.id = errorContainer.getAttribute( 'data-srfm-id' ); } else { diff --git a/inc/abilities/settings/update-global-settings.php b/inc/abilities/settings/update-global-settings.php index a473db6ba..3d88a2af3 100644 --- a/inc/abilities/settings/update-global-settings.php +++ b/inc/abilities/settings/update-global-settings.php @@ -68,6 +68,8 @@ class Update_Global_Settings extends Abstract_Ability { 'srfm_confirm_email_same', 'srfm_valid_email', 'srfm_textarea_min_chars', + 'srfm_email_local_max_length', + 'srfm_email_domain_max_length', 'srfm_input_min_value', 'srfm_input_max_value', 'srfm_dropdown_min_selections', diff --git a/inc/ai-form-builder/ai-form-builder.php b/inc/ai-form-builder/ai-form-builder.php index c4886f2af..39aca1b88 100644 --- a/inc/ai-form-builder/ai-form-builder.php +++ b/inc/ai-form-builder/ai-form-builder.php @@ -52,6 +52,18 @@ public function generate_ai_form( $request ) { array_unshift( $messages, $current_message ); } + // Bail if no usable prompt remained after filtering empty messages. + if ( empty( $messages ) || empty( $messages[0]['content'] ) ) { + wp_send_json_error( [ 'message' => __( 'No prompt was supplied.', 'sureforms' ) ] ); + } + + // Server-side prompt-length cap. The UI enforces a 2000-char limit via maxlength, but that + // is client-side only and can be bypassed by a crafted request, so mirror it here. This is + // cost/resource hardening — output is always escaped, so this is not an XSS concern. + if ( mb_strlen( (string) $messages[0]['content'] ) > 2000 ) { + wp_send_json_error( [ 'message' => __( 'The prompt is too long. Please shorten it and try again.', 'sureforms' ) ] ); + } + // Get the response from the endpoint. $response = AI_Helper::get_chat_completions_response( apply_filters( diff --git a/inc/field-validation.php b/inc/field-validation.php index e02c0e9d2..8e4c3e77d 100644 --- a/inc/field-validation.php +++ b/inc/field-validation.php @@ -295,6 +295,34 @@ public static function validate_form_data( $form_data, $current_form_id ) { $not_valid_fields[ $key ] = sprintf( $min_chars_message, $min_length ); } } + + // Email field RFC 5321 length limits (local part / domain), overridable via filter. + // Only the main email value is in form data (the confirm input has no `name`), + // so the server validates that value; the client mirrors this for both inputs. + // Split on the LAST @ per RFC 5321 so the local part may contain a quoted @. + $at_pos = is_string( $value ) && '' !== $value ? strrpos( $value, '@' ) : false; + if ( 'srfm-email' === $get_field_name && is_string( $value ) && false !== $at_pos ) { + $email_limits = self::get_email_char_limits(); + $local_max = $email_limits['local']; + $domain_max = $email_limits['domain']; + $local_len = mb_strlen( substr( $value, 0, $at_pos ) ); + $domain_len = mb_strlen( substr( $value, $at_pos + 1 ) ); + + $dynamic_messages = Translatable::dynamic_validation_messages(); + if ( $local_max > 0 && $local_len > $local_max ) { + $local_message = isset( $dynamic_messages['srfm_email_local_max_length'] ) && is_string( $dynamic_messages['srfm_email_local_max_length'] ) && '' !== $dynamic_messages['srfm_email_local_max_length'] + ? $dynamic_messages['srfm_email_local_max_length'] + /* translators: %s: maximum characters allowed before the @ symbol. */ + : __( 'The part before @ may not exceed %s characters.', 'sureforms' ); + $not_valid_fields[ $key ] = sprintf( $local_message, $local_max ); + } elseif ( $domain_max > 0 && $domain_len > $domain_max ) { + $domain_message = isset( $dynamic_messages['srfm_email_domain_max_length'] ) && is_string( $dynamic_messages['srfm_email_domain_max_length'] ) && '' !== $dynamic_messages['srfm_email_domain_max_length'] + ? $dynamic_messages['srfm_email_domain_max_length'] + /* translators: %s: maximum characters allowed after the @ symbol. */ + : __( 'The part after @ may not exceed %s characters.', 'sureforms' ); + $not_valid_fields[ $key ] = sprintf( $domain_message, $domain_max ); + } + } } // Return the array of invalid fields and their error messages. @@ -302,6 +330,41 @@ public static function validate_form_data( $form_data, $current_form_id ) { return $not_valid_fields; } + /** + * Resolve the Email field character limits (RFC 5321), split on the last @. + * + * Single source of truth shared by the server validation and the limits localized to the + * frontend script, so a filter override applies consistently to both. + * + * @return array{local:int,domain:int} Resolved limits. A value of 0 disables that check. + * @since 2.12.1 + */ + public static function get_email_char_limits() { + /** + * Filters the Email field character limits (RFC 5321). + * + * @param array $limits { + * Character limits for the email value, split on the last @. + * + * @type int $local Max characters before the @. 0 disables the check. Default 64. + * @type int $domain Max characters after the @. 0 disables the check. Default 255. + * } + * @since 2.12.1 + */ + $email_limits = apply_filters( + 'srfm_email_field_char_limits', + [ + 'local' => 64, + 'domain' => 255, + ] + ); + + return [ + 'local' => isset( $email_limits['local'] ) ? absint( $email_limits['local'] ) : 64, + 'domain' => isset( $email_limits['domain'] ) ? absint( $email_limits['domain'] ) : 255, + ]; + } + /** * Process payment block configuration. * diff --git a/inc/frontend-assets.php b/inc/frontend-assets.php index a5c05bb39..f9cbd003b 100644 --- a/inc/frontend-assets.php +++ b/inc/frontend-assets.php @@ -142,10 +142,13 @@ public function register_scripts() { SRFM_SLUG . '-form-submit', SRFM_SLUG . '_submit', [ - 'site_url' => site_url(), - 'nonce' => wp_create_nonce( 'wp_rest' ), - 'messages' => $validation_messages, - 'is_rtl' => $is_rtl, + 'site_url' => site_url(), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'messages' => $validation_messages, + 'is_rtl' => $is_rtl, + // Resolved RFC 5321 email limits so the client honors the + // srfm_email_field_char_limits filter instead of hardcoding 64/255. + 'email_char_limits' => Field_Validation::get_email_char_limits(), ] ); diff --git a/inc/global-settings/global-settings.php b/inc/global-settings/global-settings.php index 6a916ee71..14856005a 100644 --- a/inc/global-settings/global-settings.php +++ b/inc/global-settings/global-settings.php @@ -225,6 +225,8 @@ public static function srfm_save_general_settings_dynamic_opt( $setting_options 'srfm_confirm_email_same', 'srfm_valid_email', 'srfm_textarea_min_chars', + 'srfm_email_local_max_length', + 'srfm_email_domain_max_length', 'srfm_input_min_value', 'srfm_input_max_value', 'srfm_dropdown_min_selections', diff --git a/inc/lib/bsf-analytics/changelog.txt b/inc/lib/bsf-analytics/changelog.txt index e6b2d6cf6..3f050dd9a 100644 --- a/inc/lib/bsf-analytics/changelog.txt +++ b/inc/lib/bsf-analytics/changelog.txt @@ -1,3 +1,6 @@ +v1.1.29 - 30-June-2026 +- Improvement: Added `spectra-blocks` slug to UTM analytics. + v1.1.28 - 22-June-2026 - Fix: Deactivation survey loaded a non-existent RTL stylesheet (`feedback.min-rtl.css`) producing a 404 in RTL locales. Registered the file suffix so the correct `feedback-rtl.min.css` is requested. diff --git a/inc/lib/bsf-analytics/modules/utm-analytics.php b/inc/lib/bsf-analytics/modules/utm-analytics.php index 86479f048..8f8bd2a66 100644 --- a/inc/lib/bsf-analytics/modules/utm-analytics.php +++ b/inc/lib/bsf-analytics/modules/utm-analytics.php @@ -45,6 +45,7 @@ class BSF_UTM_Analytics { 'power-coupons', 'presto-player', 'sigmize', + 'spectra-blocks', 'surecart', 'surecontact', 'surecookie', diff --git a/inc/lib/bsf-analytics/version.json b/inc/lib/bsf-analytics/version.json index c25d50351..06d738c31 100644 --- a/inc/lib/bsf-analytics/version.json +++ b/inc/lib/bsf-analytics/version.json @@ -1,3 +1,3 @@ { - "bsf-analytics-ver": "1.1.28" + "bsf-analytics-ver": "1.1.29" } diff --git a/inc/payments/front-end.php b/inc/payments/front-end.php index 23945979b..4e7ea2f3e 100644 --- a/inc/payments/front-end.php +++ b/inc/payments/front-end.php @@ -136,18 +136,19 @@ public function create_payment_intent() { // Create payment intent with confirm: true for immediate processing. $payment_intent_data = [ - 'secret_key' => $secret_key, - 'amount' => $amount, - 'currency' => strtolower( $currency ), - 'description' => $description, - 'confirm' => false, // Will be confirmed by frontend. - 'receipt_email' => $customer_email, - 'license_key' => $license_key, - 'automatic_payment_methods' => [ - 'enabled' => true, - 'allow_redirects' => 'never', - ], - 'metadata' => [ + 'secret_key' => $secret_key, + 'amount' => $amount, + 'currency' => strtolower( $currency ), + 'description' => $description, + 'confirm' => false, // Will be confirmed by frontend. + 'receipt_email' => $customer_email, + 'license_key' => $license_key, + // One-time payments use manual capture; methods that don't support it (Bacs, Link, Cash App, BNPL) make + // Stripe reject the deferred Elements session in live mode, and an automatic-payment-methods intent can't + // be confirmed by the card-scoped client Element. Pin to card so the client Element, this payload, and the + // middleware intent all agree (Apple/Google Pay are still surfaced through 'card'). + 'payment_method_types' => [ 'card' ], + 'metadata' => [ 'source' => 'SureForms', 'block_id' => $block_id, 'original_amount' => $amount, diff --git a/inc/rest-api.php b/inc/rest-api.php index 6d12f4a37..d9c000277 100644 --- a/inc/rest-api.php +++ b/inc/rest-api.php @@ -953,12 +953,24 @@ public function get_entry_logs( $request ) { if ( ! is_array( $log ) ) { continue; } - $formatted_logs[] = [ + $formatted_log = [ 'id' => $offset + $index, // Use offset-based ID for consistent deletion. 'title' => $log['title'] ?? '', 'timestamp' => $log['timestamp'] ?? time(), 'messages' => $log['messages'] ?? [], ]; + + // Pass through (sanitized) retry metadata so an integration/webhook log row can + // offer a "Retry" action for that specific failed trigger. Set by the Pro + // webhook / native-integration dispatchers as [ 'type' => webhook|native, 'id' =>