array( email id, where the text is, placeholders ). * * Where the text is: '' when the filter passes the text itself, otherwise the key of the array * it passes. Placeholders: '' when core sends the text exactly as filtered, otherwise the kind * of token core fills in afterwards (see PLACEHOLDERS). * * The email id is what the adminkeep_nice_emails_wrap filter is told. */ const EMAILS = array( // Accounts. 'retrieve_password_notification_email' => array( 'password_reset', 'message', '' ), 'wp_new_user_notification_email' => array( 'new_user', 'message', '' ), 'wp_new_user_notification_email_admin' => array( 'new_user_admin', 'message', '' ), 'password_change_email' => array( 'password_changed', 'message', 'hash' ), 'wp_password_change_notification_email' => array( 'password_changed_admin', 'message', '' ), 'email_change_email' => array( 'email_changed', 'message', 'hash' ), 'new_user_email_content' => array( 'email_change_request', '', 'hash' ), 'new_admin_email_content' => array( 'admin_email_change_request', '', 'hash' ), 'site_admin_email_change_email' => array( 'admin_email_changed', 'message', 'hash' ), // Comments. 'comment_notification_text' => array( 'comment_notification', '', '' ), 'comment_moderation_text' => array( 'comment_moderation', '', '' ), // Updates and site health. 'auto_core_update_email' => array( 'core_update', 'body', '' ), 'auto_plugin_theme_update_email' => array( 'plugin_theme_update', 'body', '' ), 'automatic_updates_debug_email' => array( 'update_debug', 'body', '' ), 'recovery_mode_email' => array( 'recovery_mode', 'message', '' ), // Personal data requests. 'user_request_action_email_content' => array( 'privacy_request', '', 'hash' ), 'user_request_confirmed_email_content' => array( 'privacy_request_confirmed', '', 'hash' ), 'wp_privacy_personal_data_email_content' => array( 'privacy_export_ready', '', 'hash' ), 'user_erasure_fulfillment_email_content' => array( 'privacy_erasure_done', '', 'hash' ), // Multisite sign-up, activation and network administration. 'wpmu_signup_blog_notification_email' => array( 'signup_site', '', 'sprintf' ), 'wpmu_signup_user_notification_email' => array( 'signup_user', '', 'sprintf' ), 'update_welcome_email' => array( 'welcome_site', '', '' ), 'update_welcome_user_email' => array( 'welcome_user', '', 'welcome' ), 'new_network_admin_email_content' => array( 'network_admin_email_change_request', '', 'hash' ), 'network_admin_email_change_email' => array( 'network_admin_email_changed', 'message', 'hash' ), ); /** * The tokens core fills in after a filter, by kind, as regex alternations. `%%` is sprintf's * literal percent sign and is matched as one; every other token matches any text. */ const PLACEHOLDERS = array( 'hash' => '###[A-Z_]+###', 'sprintf' => '%%|%(?:\d+\$)?s', 'welcome' => 'SITE_NAME|USERNAME|PASSWORD|LOGINLINK', ); /** * Core emails noted since the last wp_mail() call, oldest first. * * @var array */ private $noted = array(); /** * Feature id. * * @return string */ public function id() { return 'nice_emails'; } /** * Label. * * @return string */ public function label() { return __( 'Nice Default Emails', 'adminkeep' ); } /** * Description. * * @return string */ public function description() { return __( 'Sends the plain-text emails WordPress itself writes, such as password resets, new-user notices and comment notifications, in a clean HTML layout with your site\'s name on it.', 'adminkeep' ); } /** * Settings schema. Nothing to decide: one layout, coloured and named from the site. * * @return array */ public function settings_schema() { return array(); } /** * No fields, so no labels. * * @param string $key Field key. * @return string */ public function field_label( $key ) { return ''; } /** * What switching this on actually does. * * @return string[] */ public function how_it_works() { return array( __( 'Covers the emails WordPress core writes: password resets, new-user notices, password and email change notices, comment notifications, automatic update reports, the technical-issue (recovery mode) email, personal data requests, and multisite sign-up and activation.', 'adminkeep' ), __( 'Your site\'s name heads each email and signs it off with a link home. The text itself is unchanged: line breaks are kept and web addresses become links.', 'adminkeep' ), __( 'Emails from WooCommerce, form plugins and every other plugin are left exactly as they are, and so is any email that is already HTML.', 'adminkeep' ), __( 'Works with or without the SMTP feature. With Email Log on, the log shows the HTML version, which is what was sent.', 'adminkeep' ), __( 'Nothing is stored. Switch it off and the next email goes out as plain text again.', 'adminkeep' ), __( 'For developers: the adminkeep_nice_emails_wrap filter receives true or false, the email\'s id (such as password_reset or comment_notification, or an empty string for an email that is not one of core\'s) and the wp_mail() arguments. Return false to keep an email plain, or true to give a plain-text email of your own the same layout.', 'adminkeep' ), ); } /** * Register hooks. * * @param array $settings Resolved settings. Unused — this feature has none. * @return void */ public function boot( array $settings ) { // Last on each core filter, so the text noted is the text core goes on to send. foreach ( array_keys( self::EMAILS ) as $hook ) { add_filter( $hook, array( $this, 'note' ), PHP_INT_MAX ); } add_filter( 'wp_mail', array( $this, 'wrap' ), self::WRAP_PRIORITY ); } /** * Note the text of a core email about to be sent. A filter used as a listener: the value goes * back exactly as it came. * * @param mixed $value The text, or the email array, the core filter passes. * @return mixed The same value. */ public function note( $value ) { $hook = current_filter(); if ( ! isset( self::EMAILS[ $hook ] ) ) { return $value; } list( $email_id, $key, $placeholders ) = self::EMAILS[ $hook ]; if ( '' === $key ) { $text = $value; } else { $text = is_array( $value ) && isset( $value[ $key ] ) ? $value[ $key ] : null; } if ( is_string( $text ) && '' !== trim( $text ) ) { $this->noted[] = array( 'id' => $email_id, 'text' => $text, 'placeholders' => $placeholders, ); if ( count( $this->noted ) > self::MAX_NOTED ) { array_shift( $this->noted ); } } return $value; } /** * Wrap the email wp_mail() is about to send, if it is a core email just noted. * * @param array $atts wp_mail() arguments. * @return array */ public function wrap( $atts ) { // Every note is spent on this call, whether or not it matches: see the class comment. $noted = $this->noted; $this->noted = array(); try { return $this->wrapped( $atts, $noted ); } catch ( \Throwable $e ) { // A template that cannot be built must never cost the site an email. return $atts; } } /** * The arguments with the email wrapped, or unchanged. * * @param mixed $atts wp_mail() arguments. * @param array $noted Notes taken since the last send. * @return mixed */ private function wrapped( $atts, array $noted ) { if ( ! is_array( $atts ) || ! isset( $atts['message'] ) || ! is_string( $atts['message'] ) ) { return $atts; } $headers = isset( $atts['headers'] ) ? $atts['headers'] : ''; foreach ( self::declared_types( $headers ) as $type ) { if ( 'text/plain' !== $type ) { return $atts; } } $email_id = self::identify( $atts['message'], $noted ); /** * Whether an email goes out in the Nice Default Emails layout. * * Not asked about an email that already declares HTML or multipart content: those are never * wrapped. * * @param bool $wrap True for one of WordPress core's own emails, false otherwise. * @param string $email_id Which core email this is (for example 'password_reset', * 'new_user', 'comment_notification'; see NiceEmails::EMAILS), * or '' when it is not one of core's. * @param array $atts wp_mail() arguments: to, subject, message, headers, attachments. */ $wrap = (bool) apply_filters( 'adminkeep_nice_emails_wrap', '' !== $email_id, $email_id, $atts ); if ( ! $wrap ) { return $atts; } $body = HtmlEmail::body( $atts['message'] ); if ( '' === $body ) { return $atts; } $atts['message'] = HtmlEmail::document( $body ); $atts['headers'] = self::html_headers( $headers ); return $atts; } /** * Which noted core email a body is, or ''. * * @param string $message Body wp_mail() was given. * @param array $noted Notes taken since the last send. * @return string Email id, or ''. */ private static function identify( $message, array $noted ) { foreach ( array_reverse( $noted ) as $note ) { if ( self::matches( $message, $note['text'], $note['placeholders'] ) ) { return $note['id']; } } return ''; } /** * Whether a body is the noted text, with core's placeholders filled in. * * @param string $message Body wp_mail() was given. * @param string $text Text noted at the core filter. * @param string $placeholders Placeholder kind, or '' for none. * @return bool */ private static function matches( $message, $text, $placeholders ) { if ( $message === $text ) { return true; } if ( ! isset( self::PLACEHOLDERS[ $placeholders ] ) ) { return false; } $parts = preg_split( '/(' . self::PLACEHOLDERS[ $placeholders ] . ')/', $text, -1, PREG_SPLIT_DELIM_CAPTURE ); if ( ! is_array( $parts ) || count( $parts ) < 2 ) { return false; } $pattern = ''; foreach ( $parts as $index => $part ) { if ( 0 === $index % 2 ) { $pattern .= preg_quote( $part, '~' ); } elseif ( '%%' === $part ) { $pattern .= '%'; } else { $pattern .= '.*?'; } } return 1 === preg_match( '~\A' . $pattern . '\z~s', $message ); } /** * Every content type the headers declare, lower-cased, without parameters. * * @param string|array $headers wp_mail() headers. * @return string[] */ private static function declared_types( $headers ) { $types = array(); foreach ( self::header_lines( $headers ) as $line ) { if ( 0 === stripos( ltrim( $line ), 'content-type:' ) ) { $value = substr( ltrim( $line ), strlen( 'content-type:' ) ); $types[] = strtolower( trim( explode( ';', $value )[0] ) ); } } return $types; } /** * The headers with any text/plain content type swapped for HTML, in the shape they came in. * * @param string|array $headers wp_mail() headers. * @return string|array */ private static function html_headers( $headers ) { $kept = array(); foreach ( self::header_lines( $headers ) as $line ) { if ( 0 !== stripos( ltrim( $line ), 'content-type:' ) ) { $kept[] = $line; } } $kept[] = 'Content-Type: text/html; charset=' . get_bloginfo( 'charset' ); return is_array( $headers ) ? $kept : implode( "\n", $kept ); } /** * Headers as wp_mail() reads them: one per line, blank lines dropped. * * @param string|array $headers wp_mail() headers. * @return string[] */ private static function header_lines( $headers ) { $lines = is_array( $headers ) ? $headers : explode( "\n", str_replace( "\r\n", "\n", (string) $headers ) ); return array_values( array_filter( array_map( 'strval', array_filter( $lines, 'is_scalar' ) ), static function ( $line ) { return '' !== trim( $line ); } ) ); } }