', '"', ',', ';' );
/**
* Settings.
*
* @var ContactFormConfig
*/
private $config;
/**
* Constructor.
*
* @param ContactFormConfig $config Settings.
*/
public function __construct( ContactFormConfig $config ) {
$this->config = $config;
}
/**
* Where the message goes: the address in the settings, or the site's admin email.
*
* @return string
*/
public function recipient() {
$to = (string) $this->config->get( 'to' );
return '' !== $to ? $to : (string) get_option( 'admin_email' );
}
/**
* The prefix core puts on its own emails: the site title in brackets, entities decoded,
* because the title is stored HTML-escaped and a subject is plain text.
*
* @return string
*/
public static function default_prefix() {
return '[' . wp_specialchars_decode( (string) get_option( 'blogname' ), ENT_QUOTES ) . ']';
}
/**
* The subject's prefix: the setting, or the default.
*
* @return string
*/
public function prefix() {
$prefix = (string) $this->config->get( 'subject_prefix' );
return '' !== $prefix ? $prefix : self::default_prefix();
}
/**
* The subject. In the site's language: the request that sends it carries no cookies, so it
* runs in the site locale, and the email is for the owner.
*
* @param string $name The visitor's name.
* @return string
*/
public function subject( $name ) {
/* translators: %s: the name a visitor typed into the contact form. */
return $this->prefix() . ' ' . sprintf( __( 'New message from %s', 'adminkeep' ), self::header_name( $name ) );
}
/**
* A name made safe for a header: the unsafe characters become spaces, then runs of space
* collapse to one.
*
* @param string $name The visitor's name.
* @return string
*/
public static function header_name( $name ) {
return trim( (string) preg_replace( '/\s+/', ' ', str_replace( self::HEADER_UNSAFE, ' ', (string) $name ) ) );
}
/**
* The headers. From is left alone on purpose: sending "from" the visitor's address fails
* SPF and DMARC at the owner's own mail provider. Reply-To makes the owner's reply go to them.
*
* @param string $name The visitor's name.
* @param string $email The visitor's address, already accepted by is_email().
* @return string[]
*/
public function headers( $name, $email ) {
$email = sanitize_email( $email );
$display = self::header_name( $name );
return array(
'Reply-To: ' . ( '' === $display ? $email : $display . ' <' . $email . '>' ),
'Content-Type: text/plain; charset=UTF-8',
);
}
/**
* The body: who wrote, then what they wrote, then which page of the site it came from when
* that can be told safely. The name is kept as typed here: the body is plain text, not a header.
*
* @param string $name The visitor's name.
* @param string $email The visitor's address.
* @param string $message The message, already cleaned.
* @param string $source The address of the page the form was sent from, or ''.
* @return string
*/
public function body( $name, $email, $message, $source = '' ) {
$lines = array(
/* translators: %s: the visitor's name. */
sprintf( __( 'Name: %s', 'adminkeep' ), $name ),
/* translators: %s: the visitor's email address. */
sprintf( __( 'Email: %s', 'adminkeep' ), $email ),
'',
$message,
);
$source = $this->source( $source );
if ( '' !== $source ) {
$lines[] = '';
$lines[] = '--';
$lines[] = $source;
}
return implode( "\n", $lines );
}
/**
* The "Sent from" line for the page the form was sent from, or '' when it cannot be told safely.
*
* The address comes from the visitor's request, so it is used only when it is on this site: a
* link to anywhere else never reaches the inbox. A post is named, as "
()",
* only while a visitor could open it, so a draft's or a private page's title and address are
* never mailed out on a visitor's say-so. Any other page of the site (an archive, the blog home)
* is given as its address alone, without the query string or fragment: those would carry search
* words and tracking tags into the email.
*
* @param string $url The page's address.
* @return string
*/
private function source( $url ) {
$url = trim( (string) $url );
$from = '' === $url ? false : wp_parse_url( $url );
$home = wp_parse_url( home_url( '/' ) );
if ( ! is_array( $from ) || ! isset( $from['scheme'], $from['host'] ) || ! is_array( $home ) || ! isset( $home['host'] ) ) {
return '';
}
$port = isset( $from['port'] ) ? ':' . (int) $from['port'] : '';
$home_port = isset( $home['port'] ) ? ':' . (int) $home['port'] : '';
if ( ! in_array( strtolower( $from['scheme'] ), array( 'http', 'https' ), true )
|| strtolower( $from['host'] ) !== strtolower( $home['host'] )
|| $home_port !== $port ) {
return '';
}
$post_id = url_to_postid( $url );
if ( $post_id > 0 ) {
$post = get_post( $post_id );
if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status || ! is_post_publicly_viewable( $post ) ) {
return '';
}
$title = html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES, 'UTF-8' );
/* translators: 1: page title, 2: page address. */
return sprintf( __( 'Sent from %1$s (%2$s)', 'adminkeep' ), $title, get_permalink( $post ) );
}
// esc_url_raw() also drops what a plain-text line must not hold, such as a raw line break.
$address = esc_url_raw( strtolower( $from['scheme'] ) . '://' . strtolower( $from['host'] ) . $port . ( isset( $from['path'] ) ? $from['path'] : '/' ) );
/* translators: %s: page address. */
return '' === $address ? '' : sprintf( __( 'Sent from %s', 'adminkeep' ), $address );
}
/**
* Send the message.
*
* Two filters live only for this one call, and are removed in finally whatever happens:
*
* - The Nice Default Emails opt-in. That feature wraps only core's own emails unless told
* otherwise; this says yes for this email alone. Priority 9, so a site's own filter at the
* default priority still decides, and a closure, so removing it cannot remove a site's
* identical __return_true.
* - A content-type pin. Core runs wp_mail_content_type after reading our header, so a plugin
* that answers "text/html" for everything would otherwise send the visitor's text as HTML.
* The pin repeats what the final headers declare: text/html only when Nice Default Emails
* has wrapped, and escaped, the body.
*
* @param string $name The visitor's name.
* @param string $email The visitor's address, already accepted by is_email().
* @param string $message The message, already cleaned.
* @param string $source The address of the page the form was sent from, or ''.
* @return bool Whether wp_mail() reported success.
*/
public function send( $name, $email, $message, $source = '' ) {
$type = 'text/plain';
$opt_in = static function () {
return true;
};
$capture = static function ( $atts ) use ( &$type ) {
$type = is_array( $atts ) && isset( $atts['headers'] ) && self::declares_html( $atts['headers'] ) ? 'text/html' : 'text/plain';
return $atts;
};
$pin = static function () use ( &$type ) {
return $type;
};
add_filter( 'adminkeep_nice_emails_wrap', $opt_in, 9 );
add_filter( 'wp_mail', $capture, PHP_INT_MAX );
add_filter( 'wp_mail_content_type', $pin, PHP_INT_MAX );
try {
return (bool) wp_mail(
$this->recipient(),
$this->subject( $name ),
$this->body( $name, $email, $message, $source ),
$this->headers( $name, $email )
);
} finally {
remove_filter( 'adminkeep_nice_emails_wrap', $opt_in, 9 );
remove_filter( 'wp_mail', $capture, PHP_INT_MAX );
remove_filter( 'wp_mail_content_type', $pin, PHP_INT_MAX );
}
}
/**
* Whether wp_mail() headers declare an HTML body.
*
* @param string|array $headers Headers as wp_mail() takes them.
* @return bool
*/
private static function declares_html( $headers ) {
$lines = is_array( $headers ) ? $headers : explode( "\n", str_replace( "\r\n", "\n", (string) $headers ) );
foreach ( $lines as $line ) {
if ( is_scalar( $line ) && preg_match( '/^\s*content-type:\s*text\/html\b/i', (string) $line ) ) {
return true;
}
}
return false;
}
}