block only holds dark-mode overrides, which cannot be written inline; a * client that drops it (Gmail does) still shows the light version, and clients that darken email * themselves are told through the color-scheme meta that both schemes are fine. */ class HtmlEmail { /** Text colour on the card. WordPress admin's own near-black. */ const TEXT = '#1d2327'; /** Secondary text: the footer. */ const MUTED = '#646970'; /** Links in the body. WordPress admin blue. */ const LINK = '#2271b1'; /** The page behind the card. */ const PAGE = '#f0f0f1'; /** System fonts only: a web font would need a request, and most clients block those. */ const FONTS = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"; /** * The plain text as HTML: escaped first, so nothing in it is read as markup, then line breaks, * then links. Links last, because make_clickable() works on markup and must only ever see * markup this method made. * * @param string $text Plain-text body. * @return string HTML fragment, or '' when the text could not be escaped (it is not valid in * the site's charset, and esc_html() refuses it whole). */ public static function body( $text ) { // Core ends most emails on a blank line or two, which would otherwise close the card on // empty
tags. $text = rtrim( str_replace( array( "\r\n", "\r" ), "\n", (string) $text ) ); if ( '' === trim( $text ) ) { return ''; } $html = esc_html( $text ); if ( '' === $html ) { return ''; } $html = self::linked( nl2br( $html ) ); // make_clickable() writes bare anchors. Clients colour those their own way, often a purple // that is hard to read on the card, so give them the template's colour. Only anchors this // method made can be here: the text was escaped before make_clickable() ran. return str_replace( '", the form plain-text emails use * to fence a link, would link to "…>" and show it; a quoted address would end in """ or * "'", which breaks the link outright. So each stretch between those entities is linked on * its own, and an address stops where the bracket or quote around it starts. * * @param string $html Escaped text, already through nl2br(). * @return string */ private static function linked( $html ) { $parts = preg_split( '/(<|>|"|')/', $html, -1, PREG_SPLIT_DELIM_CAPTURE ); if ( ! is_array( $parts ) ) { return make_clickable( $html ); } $linked = ''; foreach ( $parts as $index => $part ) { $linked .= 0 === $index % 2 ? make_clickable( $part ) : $part; } return $linked; } /** * The full document around a body fragment. * * @param string $body_html Body made by body(). * @return string */ public static function document( $body_html ) { $site = self::site_name(); $home = home_url( '/' ); $host = untrailingslashit( (string) preg_replace( '~^https?://~i', '', $home ) ); $dir = is_rtl() ? 'rtl' : 'ltr'; $page = 'margin:0;padding:0;background-color:' . self::PAGE . ';'; $font = 'font-family:' . self::FONTS . ';'; $lines = array( '', '', '', '', '', '', '', '' . esc_html( $site ) . '', '', '', '', '', '', '', '', '', '', '', ); return implode( "\n", $lines ); } /** * The site's name as people read it: the option is stored HTML-encoded, so decode it once here * and escape it once on output. A site with no name is called by its host instead. * * @return string */ private static function site_name() { $name = trim( wp_specialchars_decode( (string) get_option( 'blogname' ), ENT_QUOTES ) ); if ( '' === $name ) { $name = (string) wp_parse_url( home_url(), PHP_URL_HOST ); } return $name; } }