formatter = $formatter ?? new ValueFormatter(); } /** * Builds every notification a submission triggers. * * @param Form $form Form that was submitted. * @param array $fields Fields of the form. * @param array $values Submitted values keyed by field key. * @param int $submission_id Stored submission ID. * * @return array The notifications to deliver. */ public function build( Form $form, array $fields, array $values, int $submission_id ): array { $settings = $this->settings( $form ); $tags = $this->merge_tags( $form, $fields, $values, $submission_id ); $notifications = array(); $admin = $this->build_admin( $form, $fields, $values, $settings['admin'], $tags, $submission_id ); if ( $admin instanceof Notification ) { $notifications[] = $admin; } $reply = $this->build_auto_reply( $form, $fields, $values, $settings['auto_reply'], $tags, $submission_id ); if ( $reply instanceof Notification ) { $notifications[] = $reply; } /** * Filters the notifications a submission triggers. * * @since 1.0.0 * * @param array $notifications Notifications to deliver. * @param Form $form Form that was submitted. * @param array $values Submitted values keyed by field key. * @param int $submission_id Stored submission ID. */ return (array) apply_filters( 'formpilot_notifications', $notifications, $form, $values, $submission_id ); } /** * Builds the notification sent to the site. * * @param Form $form Form that was submitted. * @param array $fields Fields of the form. * @param array $values Submitted values keyed by field key. * @param array $settings Admin notification settings. * @param MergeTags $tags Merge tag resolver. * @param int $submission_id Stored submission ID. * * @return Notification|null The notification, or null when it is switched off. */ private function build_admin( Form $form, array $fields, array $values, array $settings, MergeTags $tags, int $submission_id ): ?Notification { if ( empty( $settings['enabled'] ) ) { return null; } $recipients = $this->addresses( $tags->replace( (string) ( $settings['to'] ?? '' ) ) ); if ( array() === $recipients ) { return null; } $subject = $this->subject( $tags, (string) ( $settings['subject'] ?? '' ) ); if ( '' === $subject ) { $subject = sprintf( /* translators: %s: Form title. */ __( 'New submission: %s', 'devplugins-custom-form-builder' ), $form->title() ); } $message = (string) ( $settings['message'] ?? '' ); $body = $this->html_body( $message, $form, $fields, $values, $tags, $subject ); $text = $this->text_body( $message, $fields, $values, $tags ); $reply_to = $this->first_email( $fields, $values ); return new Notification( to: $recipients, subject: $subject, message: $body, text: $text, cc: $this->addresses( $tags->replace( (string) ( $settings['cc'] ?? '' ) ) ), bcc: $this->addresses( $tags->replace( (string) ( $settings['bcc'] ?? '' ) ) ), from_name: $this->from_name( $settings ), from_email: $this->from_email( $settings ), reply_to: $reply_to, attachments: empty( $settings['attach_files'] ) ? array() : $this->attachments( $values ), context: array( 'type' => 'admin', 'form_id' => $form->id(), 'submission_id' => $submission_id, ) ); } /** * Builds the confirmation sent to the visitor. * * @param Form $form Form that was submitted. * @param array $fields Fields of the form. * @param array $values Submitted values keyed by field key. * @param array $settings Auto reply settings. * @param MergeTags $tags Merge tag resolver. * @param int $submission_id Stored submission ID. * * @return Notification|null The notification, or null when it is switched off. */ private function build_auto_reply( Form $form, array $fields, array $values, array $settings, MergeTags $tags, int $submission_id ): ?Notification { if ( empty( $settings['enabled'] ) ) { return null; } $field_key = (string) ( $settings['to_field'] ?? '' ); $recipient = '' !== $field_key ? $this->value_as_text( $values[ $field_key ] ?? '' ) : $this->first_email( $fields, $values ); if ( '' === $recipient || ! is_email( $recipient ) ) { return null; } /* * `is_email()` says the string is shaped like an address, not that this site * should be sending to it. The auto-reply takes its recipient from whatever * the visitor typed, so without a bound anyone can make the site emit mail * to any address, `From:` its own verified domain, with an attacker-chosen * subject — a reflector for spam that inherits the site's reputation. This * caps how many of those one address can be made to send. */ if ( ! $this->may_auto_reply( $recipient ) ) { /** * Fires when an auto-reply is withheld because the recipient is over its limit. * * @since 1.0.0 * * @param string $recipient Address the reply would have gone to. * @param Form $form Form that was submitted. */ do_action( 'formpilot_auto_reply_throttled', $recipient, $form ); return null; } $subject = $this->subject( $tags, (string) ( $settings['subject'] ?? '' ) ); if ( '' === $subject ) { $subject = sprintf( /* translators: %s: Site title. */ __( 'Thanks for contacting %s', 'devplugins-custom-form-builder' ), $this->site_name() ); } $message = (string) ( $settings['message'] ?? '' ); $body = $this->html_body( $message, $form, $fields, $values, $tags, $subject, true ); $text = $this->text_body( $message, $fields, $values, $tags, true ); return new Notification( to: array( sanitize_email( $recipient ) ), subject: $subject, message: $body, text: $text, from_name: $this->from_name( $settings ), from_email: $this->from_email( $settings ), // Through the same validation as every other address on this object. // It used to skip the pipeline entirely, leaving `sanitize_header()` at // send time as the only thing between it and a malformed header. reply_to: $this->first_address( $tags->replace( (string) ( $settings['reply_to'] ?? '' ) ) ), context: array( 'type' => 'auto_reply', 'form_id' => $form->id(), 'submission_id' => $submission_id, ) ); } /** * Renders the HTML body of a notification. * * @param string $message Configured message, empty for the default layout. * @param Form $form Form that was submitted. * @param array $fields Fields of the form. * @param array $values Submitted values keyed by field key. * @param MergeTags $tags Merge tag resolver. * @param string $heading Heading shown above the content. * @param bool $is_reply Optional. Whether this is the visitor confirmation. Default false. */ private function html_body( string $message, Form $form, array $fields, array $values, MergeTags $tags, string $heading, bool $is_reply = false ): string { $table = Template::get( 'emails/fields', array( 'rows' => $this->rows( $fields, $values ), ) ); if ( '' === trim( $message ) ) { $content = $is_reply ? wpautop( esc_html__( 'Thank you for your message. We have received the following details:', 'devplugins-custom-form-builder' ) ) . $table : $table; } else { /* * Cleaned after the tags are filled in, not only when the template was * saved. A tag has no scheme at save time, so `` passed * the policy and a visitor then chose the link: `javascript:` or any site * at all, in an email sent from this domain. The fields table is the * plugin's own markup, with inline styles the policy would strip, so it * goes in afterwards through a token nobody can guess. * * A copy of the tags, so the table this registers is not still in place * when the plain-text body - or the next notification - resolves them. */ $token = 'formpilot-all-fields-' . wp_generate_password( 20, false ); $content = Escape::rich_text( $tags->with( 'all_fields', $token )->replace( $message ) ); $content = str_replace( $token, $table, wpautop( $content ) ); } return Template::get( 'emails/html', array( 'heading' => $heading, 'content' => $content, 'site_name' => $this->site_name(), 'site_url' => home_url( '/' ), 'form' => $form, ) ); } /** * Renders the plain text alternative of a notification. * * Sent alongside the HTML rather than instead of it. A message carrying only * an HTML part is unreadable in a text client and is treated as a spam * signal by several filters, so both halves travel together. * * @param string $message Configured message, empty for the default layout. * @param array $fields Fields of the form. * @param array $values Submitted values keyed by field key. * @param MergeTags $tags Merge tag resolver. * @param bool $is_reply Whether this is the visitor confirmation. */ private function text_body( string $message, array $fields, array $values, MergeTags $tags, bool $is_reply = false ): string { $lines = array(); foreach ( $this->rows( $fields, $values ) as $row ) { $lines[] = $row['label'] . ': ' . $row['value']; } $table = implode( "\n", $lines ); if ( '' === trim( $message ) ) { $intro = $is_reply ? __( 'Thank you for your message. We have received the following details:', 'devplugins-custom-form-builder' ) . "\n\n" : ''; return $intro . $table; } // See html_body(): a copy, not the shared instance. return $this->to_text( $tags->with( 'all_fields', $table )->replace( $message ) ); } /** * Turns a fragment of HTML into readable plain text. * * Merge tag values arrive escaped for HTML, so entities are decoded again * here; leaving them would show `&` to a text reader. * * @param string $html Markup to convert. */ private function to_text( string $html ): string { $text = (string) preg_replace( '##i', "\n", $html ); $text = (string) preg_replace( '#

#i', "\n\n", $text ); $text = wp_strip_all_tags( $text ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return trim( (string) preg_replace( "/\n{3,}/", "\n\n", $text ) ); } /** * Builds the label and value pairs shown in an email. * * @param array $fields Fields of the form. * @param array $values Submitted values keyed by field key. * * @return array The rows. */ private function rows( array $fields, array $values ): array { $rows = array(); foreach ( $fields as $field ) { if ( FieldTypes::HIDDEN === $field->type() ) { continue; } $key = $field->field_key(); if ( ! array_key_exists( $key, $values ) ) { continue; } $rows[] = array( 'label' => '' !== $field->label() ? $field->label() : $key, 'value' => $this->value_as_text( $values[ $key ] ), ); } return $rows; } /** * Builds the merge tags available to a notification. * * @param Form $form Form that was submitted. * @param array $fields Fields of the form. * @param array $values Submitted values keyed by field key. * @param int $submission_id Stored submission ID. */ private function merge_tags( Form $form, array $fields, array $values, int $submission_id ): MergeTags { $tags = new MergeTags( array( 'form_title' => esc_html( $form->title() ), 'form_id' => (string) $form->id(), 'entry_id' => (string) $submission_id, 'site_title' => esc_html( $this->site_name() ), 'site_url' => esc_url( home_url( '/' ) ), 'admin_email' => esc_html( (string) get_option( 'admin_email' ) ), 'date' => esc_html( wp_date( (string) get_option( 'date_format' ) ) ), 'time' => esc_html( wp_date( (string) get_option( 'time_format' ) ) ), ) ); foreach ( $fields as $field ) { $key = $field->field_key(); $tags->add( $key, esc_html( $this->value_as_text( $values[ $key ] ?? '' ) ) ); } return $tags; } /** * Returns the notification settings of a form. * * @param Form $form Form that was submitted. * * @return array{admin: array, auto_reply: array} The settings. */ private function settings( Form $form ): array { $settings = $form->settings(); $stored = isset( $settings['notifications'] ) && is_array( $settings['notifications'] ) ? $settings['notifications'] : array(); $default_recipients = Settings::text( 'admin_recipients' ); $admin = array_merge( array( 'enabled' => true, 'to' => '' !== $default_recipients ? $default_recipients : (string) get_option( 'admin_email' ), 'subject' => '', 'message' => '', 'cc' => '', 'bcc' => '', 'attach_files' => true, ), isset( $stored['admin'] ) && is_array( $stored['admin'] ) ? $stored['admin'] : array() ); $auto_reply = array_merge( array( 'enabled' => false, 'to_field' => '', 'subject' => '', 'message' => '', 'reply_to' => (string) get_option( 'admin_email' ), ), isset( $stored['auto_reply'] ) && is_array( $stored['auto_reply'] ) ? $stored['auto_reply'] : array() ); return array( 'admin' => $admin, 'auto_reply' => $auto_reply, ); } /** * Splits a recipient list into valid addresses. * * @param string $list Comma separated addresses. * * @return array The valid addresses. */ private function addresses( string $list ): array { $addresses = array(); foreach ( explode( ',', $list ) as $address ) { $address = sanitize_email( trim( $address ) ); if ( '' !== $address && is_email( $address ) ) { $addresses[] = $address; } } return array_values( array_unique( $addresses ) ); } /** * Returns the first valid address in a list, or an empty string. * * @param string $list Comma separated addresses. */ private function first_address( string $list ): string { return $this->addresses( $list )[0] ?? ''; } /** * Returns the first email address the visitor submitted. * * Used as the Reply-To of the admin notification, so answering an entry goes * back to the person who sent it. * * @param array $fields Fields of the form. * @param array $values Submitted values keyed by field key. * * @return string The address, or an empty string. */ private function first_email( array $fields, array $values ): string { foreach ( $fields as $field ) { if ( FieldTypes::EMAIL !== $field->type() ) { continue; } $value = $this->value_as_text( $values[ $field->field_key() ] ?? '' ); if ( '' !== $value && is_email( $value ) ) { return sanitize_email( $value ); } } return ''; } /** * Decides whether another auto-reply may go to an address. * * Keyed on the recipient rather than on the sender's IP, because the abuse * being bounded is "this address receives mail it did not ask for", and an * attacker with a proxy pool changes IP freely while the target stays the * same. * * @param string $recipient Address the reply would go to. * * @return bool True when the reply may be sent. */ private function may_auto_reply( string $recipient ): bool { /** * Filters how many auto-replies one address may receive per hour. * * Zero switches the limit off, which is only sensible where the form is * behind a login. * * @since 1.0.0 * * @param int $limit Replies per address per hour. Default 5. * @param string $recipient Address the reply would go to. */ $limit = (int) apply_filters( 'formpilot_auto_reply_limit', 5, $recipient ); if ( $limit <= 0 ) { return true; } $key = 'formpilot_reply_' . md5( self::mailbox( $recipient ) ); $sent = (int) get_transient( $key ); if ( $sent >= $limit ) { return false; } set_transient( $key, $sent + 1, HOUR_IN_SECONDS ); return true; } /** * Reduces an address to the mailbox it delivers to. * * The limit above was keyed on the address as typed, so `victim+1@`, * `victim+2@` - and at Gmail `v.ictim@` - were each a fresh allowance for the * same inbox, and the site could be made to mail one person without limit. Sub * addresses are dropped everywhere; dots only at Gmail, where they are ignored. * * @param string $address Email address. */ private static function mailbox( string $address ): string { $address = strtolower( trim( $address ) ); $at = strrpos( $address, '@' ); if ( false === $at ) { return $address; } $local = substr( $address, 0, $at ); $domain = substr( $address, $at + 1 ); $local = explode( '+', $local, 2 )[0]; if ( in_array( $domain, array( 'gmail.com', 'googlemail.com' ), true ) ) { $local = str_replace( '.', '', $local ); $domain = 'gmail.com'; } return $local . '@' . $domain; } /** * Collects the uploaded files of a submission. * * @param array $values Submitted values keyed by field key. * * @return array Absolute file paths. */ private function attachments( array $values ): array { $paths = array(); foreach ( $values as $value ) { if ( ! is_array( $value ) || empty( $value['file'] ) ) { continue; } /* * The path is asked for, not taken. `$value['file']` is a string out of a * database row, and it used to be pushed straight into the attachment * list — where the only remaining test was `is_readable()`, which is as * happy with `wp-config.php` as with an upload. `managed_path()` resolves * the path and answers only for files inside the plugin's own upload * directory. */ $path = UploadedFile::from_array( $value )->managed_path(); if ( '' !== $path ) { $paths[] = $path; } } return $paths; } /** * Renders a stored value as plain text. * * @param mixed $value Stored value. */ private function value_as_text( mixed $value ): string { if ( is_array( $value ) && isset( $value['url'], $value['name'] ) ) { return (string) $value['url']; } return $this->formatter->to_text( $value ); } /** * Returns the sender name of a notification. * * @param array $settings Notification settings. */ private function from_name( array $settings ): string { $name = trim( (string) ( $settings['from_name'] ?? '' ) ); if ( '' === $name ) { $name = Settings::text( 'from_name' ); } return '' !== $name ? $name : $this->site_name(); } /** * Returns the sender address of a notification. * * An empty value leaves the header off entirely, which lets an SMTP plugin * apply its own verified sender instead of fighting ours. * * @param array $settings Notification settings. */ private function from_email( array $settings ): string { $email = sanitize_email( (string) ( $settings['from_email'] ?? '' ) ); if ( ! is_email( $email ) ) { $email = sanitize_email( Settings::text( 'from_email' ) ); } return is_email( $email ) ? $email : ''; } /** * Returns the site name, with entities decoded. */ private function site_name(): string { return wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); } /** * Fills in a subject line. * * The tag values are escaped for the HTML body, and the subject reused them, * so a visitor called O'Brien arrived as `O'Brien` in every inbox. A * subject is plain text: decoded here, and line breaks are removed by the mailer. * * @param MergeTags $tags Tags for this submission. * @param string $subject Subject template. */ private function subject( MergeTags $tags, string $subject ): string { return trim( wp_specialchars_decode( $tags->replace( $subject ), ENT_QUOTES ) ); } }