$values Validated values. * @param array $context Request context: ip, user_agent, post (?\WP_Post). * @param string[] $attachments Absolute paths of uploaded files (notification only). */ public function __construct( private readonly Form $form, private readonly array $values, private readonly array $context = array(), private readonly array $attachments = array(), ) {} /** * The notification as last built by send(), so a failed one can be kept. * * @var array|null */ private ?array $message = null; /** * Why the last notification failed, as reported through wp_mail_failed. * * @var string */ private string $error = ''; /** * Sends the notification and, when enabled, the auto-reply. * * @return bool Whether the main notification was handed to the mail server. */ public function send(): bool { $this->message = $this->notification(); if ( null === $this->message ) { return false; } $sent = $this->deliver( $this->message ); if ( $sent && ! empty( $this->form->mail['autoreply_enabled'] ) ) { $this->send_autoreply(); } return $sent; } /** * The notification built by the last send(), or null. * * @return array|null */ public function last_message(): ?array { return $this->message; } /** * Builds the notification email, or null when it has no valid recipient. * * @return array{to:string[],subject:string,body:string,headers:string[],attachments:string[]}|null */ public function notification(): ?array { $mail = $this->form->mail; $recipients = $this->addresses( '' !== $mail['recipient'] ? (string) $mail['recipient'] : (string) get_option( 'admin_email' ) ); if ( array() === $recipients ) { return null; } $headers = array( $this->content_type() ); foreach ( array( 'cc' => 'Cc', 'bcc' => 'Bcc', ) as $key => $header ) { $list = $this->addresses( (string) ( $mail[ $key ] ?? '' ) ); if ( array() !== $list ) { $headers[] = $header . ': ' . implode( ', ', $list ); } } $reply_to = $this->header_value( (string) $mail['reply_to'] ); if ( '' !== $reply_to && is_email( $reply_to ) ) { $headers[] = 'Reply-To: ' . $reply_to; } $message = array( 'to' => $recipients, 'subject' => $this->header_value( (string) $mail['subject'] ), 'body' => $this->body_value( (string) $mail['body'] ), 'headers' => $headers, // Never attached to the auto-reply: that would let anyone send files through your site. 'attachments' => ! empty( $mail['attach_files'] ) ? $this->attachments : array(), ); /** * Filters the notification email before it is sent. * * @param array{to:string[],subject:string,body:string,headers:string[]} $message Email parts. * @param Form $form Form. * @param array $values Submitted values. */ return apply_filters( 'tflexforms_mail', $message, $this->form, $this->values ); } /** * Sends an already built email with the form's From settings, e.g. a * notification kept from a failed attempt. * * @param array $message Email parts: to, subject, body, headers, attachments. * @return bool Whether it was handed to the mail server. */ public function deliver( array $message ): bool { $this->error = ''; $capture = function ( $error ): void { if ( $error instanceof \WP_Error ) { $this->error = $error->get_error_message(); } }; add_action( 'wp_mail_failed', $capture ); try { return $this->dispatch( $message ); } finally { remove_action( 'wp_mail_failed', $capture ); } } /** * Why the last notification failed ('' when it was sent, or when the * mailer gave no reason). */ public function last_error(): string { return $this->error; } /** * Sends the optional auto-reply to the visitor. Failures are ignored so a * bad visitor address never turns a successful submission into an error. */ private function send_autoreply(): void { $mail = $this->form->mail; // The auto-reply address must come from an email-type field. if ( ! preg_match( '/^\[([A-Za-z][A-Za-z0-9_-]*)\]$/', (string) $mail['autoreply_to'], $m ) ) { return; } $fields = $this->form->input_fields(); if ( ! isset( $fields[ $m[1] ] ) || 'email' !== $fields[ $m[1] ]->type ) { return; } $to = $this->values[ $m[1] ] ?? ''; if ( ! is_string( $to ) || '' === $to || ! is_email( $to ) ) { return; } $this->dispatch( array( 'to' => array( $to ), 'subject' => $this->header_value( (string) $mail['autoreply_subject'] ), 'body' => $this->body_value( (string) $mail['autoreply_body'] ), 'headers' => array( $this->content_type() ), ) ); } /** * Content-Type header for this form's mail. */ private function content_type(): string { return ! empty( $this->form->mail['use_html'] ) ? 'Content-Type: text/html; charset=UTF-8' : 'Content-Type: text/plain; charset=UTF-8'; } /** * Resolves an admin-defined address list. Plain addresses are kept; * a [field] placeholder is replaced by the piped values of the choices the * visitor picked. Anything else (including non-piped fields) is dropped. * * @param string $addresses Comma-separated addresses and placeholders. * @return string[] */ public function addresses( string $addresses ): array { $fields = $this->form->input_fields(); $out = array(); foreach ( explode( ',', $addresses ) as $item ) { $item = trim( $item ); if ( Form::is_placeholder( $item ) ) { $name = trim( $item, '[]' ); $field = $fields[ $name ] ?? null; if ( null === $field || ! $field->has_pipes() ) { continue; } $picked = $this->values[ $name ] ?? array(); foreach ( (array) $picked as $label ) { foreach ( explode( ',', $field->piped_value( (string) $label ) ) as $address ) { $out[] = trim( $address ); } } continue; } $out[] = $item; } $valid = array_filter( $out, static fn( string $a ): bool => '' !== $a && ! preg_match( '/[\s\x00-\x1F\x7F]/', $a ) && false !== is_email( $a ) ); return array_values( array_unique( $valid ) ); } /** * Calls wp_mail() with the form's From name applied for this message only. * * @param array $message Email parts. */ private function dispatch( array $message ): bool { $from_name = $this->header_value( (string) $this->form->mail['from_name'] ); $from_email = (string) ( $this->form->mail['from_email'] ?? '' ); $name_cb = static fn( string $name ): string => '' !== $from_name ? $from_name : $name; $email_cb = static fn( string $email ): string => '' !== $from_email && is_email( $from_email ) ? $from_email : $email; add_filter( 'wp_mail_from_name', $name_cb ); add_filter( 'wp_mail_from', $email_cb ); try { return wp_mail( $message['to'], $message['subject'], $message['body'], $message['headers'], $message['attachments'] ?? array() ); } finally { remove_filter( 'wp_mail_from_name', $name_cb ); remove_filter( 'wp_mail_from', $email_cb ); } } /** * Replaces placeholders for use in a header (subject, Reply-To, From name). * * @param string $template Template text. */ public function header_value( string $template ): string { $value = $this->replace( $template, false ); // Strip every kind of line break and control character: the core of // header-injection protection. $value = preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', $value ) ?? ''; return trim( preg_replace( '/\s{2,}/u', ' ', $value ) ?? '' ); } /** * Replaces placeholders for use in a body (plain text or HTML). * * @param string $template Template text. */ public function body_value( string $template ): string { $html = ! empty( $this->form->mail['use_html'] ); $template = str_replace( array( "\r\n", "\r" ), "\n", $template ); if ( ! empty( $this->form->mail['exclude_blank'] ) ) { $template = $this->drop_blank_lines( $template ); } $value = $this->replace( $template, $html ); $value = preg_replace( '/[\x00-\x08\x0B-\x1F\x7F]/u', '', $value ) ?? ''; if ( $html ) { $value = '' . wpautop( $value ) . ''; } return $value; } /** * Removes lines whose field placeholders all resolved to empty values. * * @param string $template Template text. */ private function drop_blank_lines( string $template ): string { $fields = $this->form->input_fields(); $lines = array(); foreach ( explode( "\n", $template ) as $line ) { preg_match_all( '/\[(?:_raw_)?([A-Za-z][A-Za-z0-9_-]*)\]/', $line, $m ); $used = array_filter( $m[1], static fn( string $name ): bool => isset( $fields[ $name ] ) ); if ( array() !== $used ) { $filled = array_filter( $used, function ( string $name ): bool { $value = $this->values[ $name ] ?? ''; return is_array( $value ) ? array() !== $value : '' !== $value; } ); if ( array() === $filled ) { continue; } } $lines[] = $line; } return implode( "\n", $lines ); } /** * Single-pass placeholder replacement. Because it is one pass, a visitor * typing "[other-field]" into a field cannot pull in other values. * * @param string $template Template text. * @param bool $html Escape values for HTML. */ private function replace( string $template, bool $html ): string { $special = $this->special_values(); $fields = $this->form->input_fields(); $result = preg_replace_callback( '/\[(_?[A-Za-z][A-Za-z0-9_-]*)\]/', function ( array $m ) use ( $special, $fields, $html ): string { $key = $m[1]; $raw = false; if ( str_starts_with( $key, '_raw_' ) && isset( $fields[ substr( $key, 5 ) ] ) ) { $key = substr( $key, 5 ); $raw = true; } if ( isset( $special[ $key ] ) ) { $value = $special[ $key ]; } elseif ( isset( $fields[ $key ] ) ) { $value = $this->field_value( $fields[ $key ], $raw ); } else { return $m[0]; } return $html ? str_replace( "\n", '
', esc_html( $value ) ) : $value; }, $template ); return $result ?? $template; } /** * Text for one field: piped values by default, visible labels with [_raw_name]. * * @param Field $field Field. * @param bool $raw Use visible labels instead of piped values. */ private function field_value( Field $field, bool $raw ): string { $value = $this->values[ $field->name ] ?? ''; if ( 'acceptance' === $field->type ) { return '1' === $value ? __( 'Accepted', 'flexsolutions-form-builder' ) : __( 'Not accepted', 'flexsolutions-form-builder' ); } $free = (string) ( $this->values[ Validator::free_text_key( $field->name ) ] ?? '' ); $other = $field->free_text_choice(); $items = array(); foreach ( array_map( 'strval', (array) $value ) as $label ) { $text = ! $raw && $field->has_pipes() ? $field->piped_value( $label ) : $label; if ( '' !== $other && $label === $other && '' !== $free ) { $text .= ': ' . $free; // "Other: what the visitor typed". } $items[] = $text; } return implode( ', ', $items ); } /** * Values of the special placeholders. * * @return array */ private function special_values(): array { $post = $this->context['post'] ?? null; $post = $post instanceof \WP_Post ? $post : null; $user = wp_get_current_user(); return array( '_site_title' => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ), '_site_description' => wp_specialchars_decode( (string) get_bloginfo( 'description' ), ENT_QUOTES ), '_site_url' => home_url( '/' ), '_site_domain' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), '_site_admin_email' => (string) get_option( 'admin_email' ), '_form_title' => $this->form->title, '_date' => wp_date( (string) get_option( 'date_format' ) ), '_time' => wp_date( (string) get_option( 'time_format' ) ), '_remote_ip' => (string) ( $this->context['ip'] ?? '' ), '_user_agent' => (string) ( $this->context['user_agent'] ?? '' ), '_url' => null !== $post ? (string) get_permalink( $post ) : '', '_post_id' => null !== $post ? (string) $post->ID : '', '_post_title' => null !== $post ? html_entity_decode( get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) : '', '_post_url' => null !== $post ? (string) get_permalink( $post ) : '', '_user_login' => $user->exists() ? $user->user_login : '', '_user_email' => $user->exists() ? $user->user_email : '', '_user_display_name' => $user->exists() ? $user->display_name : '', ); } }