Files
2026-07-02 15:54:39 -06:00

392 lines
12 KiB
PHP

<?php
/**
* Confirmation email form action.
*
* Sends an auto-reply to the person who submitted the form.
* Plain text only. Never uses user data in the From header.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Confirmation email form action class.
*/
class GenerateBlocks_Pro_Form_Action_Confirmation_Email {
/**
* Register the action with the registry.
*/
public static function init() {
GenerateBlocks_Pro_Form_Action_Registry::register( 'confirmation-email', [ __CLASS__, 'send' ] );
}
/**
* Validate confirmation email settings before publishing or delivery.
*
* @param array $form_settings The form block settings.
* @param array|null $schema Optional derived field schema list.
* @return true|WP_Error
*/
public static function validate_settings( $form_settings, $schema = null ) {
if ( is_array( $schema ) ) {
$email_field = self::get_email_field( $form_settings );
if ( '' === $email_field ) {
return new WP_Error(
'gb_form_confirmation_email_field_missing',
__( 'Confirmation email field is required.', 'generateblocks-pro' ),
[ 'status' => 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 <a@b.com>" (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;
}
}