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 ) . '',
'',
'',
'',
'
',
'',
'',
'',
'',
'| ',
'' . esc_html( $site ) . '',
' | ',
' ',
'',
'| ',
// Kept on lines of its own: the body is as long as the email, and PHPMailer re-encodes
// the whole message if any single line passes 998 characters.
$body_html,
' | ',
' ',
'',
'',
' ',
' ',
' | ',
'
',
'
',
'',
'',
);
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;
}
}