400 ] ); } $field = self::find_schema_field( $schema, $email_field ); if ( ! $field ) { return new WP_Error( 'gb_form_confirmation_email_field_missing', sprintf( /* translators: %s: email field name. */ __( 'Confirmation email field "%s" does not exist.', 'generateblocks-pro' ), $email_field ), [ 'status' => 400 ] ); } $field_type = $field['type'] ?? ''; if ( ! in_array( $field_type, [ 'email', 'text' ], true ) ) { return new WP_Error( 'gb_form_confirmation_email_field_invalid', sprintf( /* translators: %s: email field name. */ __( 'Confirmation email field "%s" must be an email or text field.', 'generateblocks-pro' ), $email_field ), [ 'status' => 400 ] ); } } return true; } /** * Validate the submitted confirmation email recipient before delivery. * * @param array $sanitized_data The sanitized form data. * @param array $form_settings The form block settings. * @return true|WP_Error */ public static function validate_submission( $sanitized_data, $form_settings ) { $email_field = self::get_email_field( $form_settings ); if ( '' === $email_field ) { return new WP_Error( 'missing_email_field', 'Confirmation email field is not configured.', [ 'status' => 400 ] ); } // is_array catches both missing key and shape mutation by a // generateblocks_form_validate_submission filter that returned // a flat scalar instead of the [value, label, type] array. $field = $sanitized_data[ $email_field ] ?? null; if ( ! is_array( $field ) ) { return new WP_Error( 'missing_email_field', 'Confirmation email field not found in submission.', [ 'status' => 400 ] ); } $to = sanitize_email( (string) ( $field['value'] ?? '' ) ); if ( ! is_email( $to ) ) { return new WP_Error( 'invalid_recipient', 'Invalid recipient email for confirmation.', [ 'status' => 400 ] ); } return true; } /** * Send a confirmation email to the submitter. * * @param array $sanitized_data The sanitized form data (field_name => ['value' => ..., 'label' => ..., 'type' => ...]). * @param array $form_settings The form block settings. * @param array $context Request context (post_id, page_url). * @return true|WP_Error True on success, WP_Error on failure. */ public static function send( $sanitized_data, $form_settings, $context = [] ) { $validation = self::validate_submission( $sanitized_data, $form_settings ); if ( is_wp_error( $validation ) ) { return $validation; } $email_field = self::get_email_field( $form_settings ); $to_field = $sanitized_data[ $email_field ] ?? null; $to = sanitize_email( is_array( $to_field ) ? (string) ( $to_field['value'] ?? '' ) : '' ); // Build subject with merge tag support. $raw_subject = $form_settings['confirmationEmailSubject'] ?? ''; if ( empty( $raw_subject ) ) { $raw_subject = sprintf( /* translators: %s: site name */ __( 'Thank you for contacting %s', 'generateblocks-pro' ), get_bloginfo( 'name' ) ); } $subject = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( self::replace_merge_tags( $raw_subject, $sanitized_data ) ); // Build body with merge tag support. $raw_body = $form_settings['confirmationEmailBody'] ?? ''; if ( empty( $raw_body ) ) { $raw_body = __( 'Thank you for your submission. We will get back to you soon.', 'generateblocks-pro' ); } $body = self::replace_merge_tags( $raw_body, $sanitized_data ); // Plain text only. Never set From from user data. $headers = [ 'Content-Type: text/plain; charset=UTF-8' ]; $reply_to_header = self::build_reply_to_header( $form_settings['confirmationEmailReplyToEmail'] ?? '', $form_settings['confirmationEmailReplyToName'] ?? '' ); if ( '' !== $reply_to_header ) { $headers[] = $reply_to_header; } /** * Filter the confirmation email arguments before sending. * * @since 2.6.0 * @param array $email_args { * Email arguments. * * @type string $to Recipient email address. * @type string $subject Email subject line. * @type string $body Email body content. * @type array $headers Email headers. * } * @param array $sanitized_data The sanitized form data. * @param array $form_settings The form block settings. * @param array $context Request context. */ $email_args = apply_filters( 'generateblocks_form_confirmation_email_args', [ 'to' => $to, 'subject' => $subject, 'body' => $body, 'headers' => $headers, ], $sanitized_data, $form_settings, $context ); // Re-validate filter output: defensive against third-party callbacks // returning values with embedded newlines (header injection) or // reshaping the array entirely. if ( ! is_array( $email_args ) ) { $email_args = []; } $raw_to = $email_args['to'] ?? ''; // Two-pass sanitization: strip CR/LF/tab + percent-encoded variants // before sanitize_email() so a filter that returned an address with // embedded newlines can't leak into wp_mail's `To` header. Belt and // suspenders — sanitize_email() rejects most malformed addresses // already, but this matches how `subject` and `headers` are // hardened above so the contract is symmetric. $safe_to = is_string( $raw_to ) ? sanitize_email( GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( $raw_to ) ) : ''; $raw_subject = $email_args['subject'] ?? ''; $safe_subject = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( is_string( $raw_subject ) ? $raw_subject : '' ); $safe_headers = self::sanitize_filter_headers( $email_args['headers'] ?? [] ); $raw_body = $email_args['body'] ?? ''; $safe_body = is_string( $raw_body ) ? $raw_body : ''; if ( ! is_email( $safe_to ) ) { return new WP_Error( 'confirmation_email_invalid_to', 'No valid email recipient.' ); } // This email is submitter-facing: with no other sender customization, // wp_mail sends as core's literal "WordPress". Swap that default for // the site name, scoped to this send only. add_filter( 'wp_mail_from_name', [ __CLASS__, 'filter_default_from_name' ], 999 ); try { $sent = wp_mail( $safe_to, $safe_subject, $safe_body, $safe_headers ); } finally { remove_filter( 'wp_mail_from_name', [ __CLASS__, 'filter_default_from_name' ], 999 ); } if ( ! $sent ) { return new WP_Error( 'confirmation_email_failed', 'Failed to send confirmation email.' ); } return true; } /** * Swap core's literal "WordPress" default From name for the site name. * * Runs at priority 999 so any name customized earlier — an SMTP plugin * or a site-wide wp_mail_from_name filter — passes through untouched; * only the unmodified core default is replaced. * * @param mixed $name Current From display name. * @return mixed From display name. */ public static function filter_default_from_name( $name ) { if ( 'WordPress' !== $name ) { return $name; } $site_name = trim( GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ) ); return '' !== $site_name ? $site_name : $name; } /** * Re-validate the `headers` array returned by a filter callback. * * Note: we only strip CR/LF/tab and their percent-encoded variants here. * Running sanitize_text_field() would mangle valid display-name email * syntax like "Reply-To: Name " (strip_tags eats the <...>). * * @param mixed $headers The filtered headers value. * @return array Sanitized headers. */ private static function sanitize_filter_headers( $headers ) { if ( ! is_array( $headers ) ) { return []; } $safe = []; foreach ( $headers as $header ) { if ( ! is_string( $header ) ) { continue; } $header = preg_replace( '/[\r\n\t]/', '', $header ); $header = str_replace( [ '%0a', '%0d', '%0A', '%0D' ], '', $header ); $header = trim( $header ); if ( '' !== $header ) { $safe[] = $header; } } return $safe; } /** * Build a safe Reply-To header from static confirmation email settings. * * @param mixed $raw_email Configured reply-to email. * @param mixed $raw_name Configured reply-to display name. * @return string Header line, or empty string when no valid email exists. */ private static function build_reply_to_header( $raw_email, $raw_name ) { $email = is_scalar( $raw_email ) ? sanitize_email( (string) $raw_email ) : ''; if ( ! is_email( $email ) ) { return ''; } $email = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( $email ); $name = is_scalar( $raw_name ) ? sanitize_text_field( (string) $raw_name ) : ''; $name = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( $name ); $name = trim( str_replace( [ '<', '>', '"', ',', ':', '\\' ], '', $name ) ); if ( '' === $name ) { return 'Reply-To: ' . $email; } return 'Reply-To: ' . $name . ' <' . $email . '>'; } /** * Replace {field:name} merge tags with sanitized field values. * * Uses sanitize_textarea_field() for textarea fields to preserve newlines, * and sanitize_text_field() for everything else. * * @param string $text The text containing merge tags. * @param array $sanitized_data The sanitized form data. * @return string Text with merge tags replaced. */ public static function replace_merge_tags( $text, $sanitized_data ) { return GenerateBlocks_Pro_Form_Sanitizer::replace_merge_tags( $text, $sanitized_data ); } /** * Get the configured recipient field. * * @param array $form_settings The form block settings. * @return string */ private static function get_email_field( $form_settings ) { if ( isset( $form_settings['confirmationEmailField'] ) && ( is_array( $form_settings['confirmationEmailField'] ) || is_object( $form_settings['confirmationEmailField'] ) ) ) { return ''; } return isset( $form_settings['confirmationEmailField'] ) ? sanitize_key( $form_settings['confirmationEmailField'] ) : ''; } /** * Find a field in the derived schema by name. * * @param array $schema Field schema list. * @param string $name Field name. * @return array|null */ private static function find_schema_field( $schema, $name ) { foreach ( $schema as $field ) { if ( is_array( $field ) && ( $field['name'] ?? '' ) === $name ) { return $field; } } return null; } }