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 ) );
}
}