', '"', ',', ';' ); /** * 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 " (<address>)", * 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; } }