tags of its own: see head_tags(). * * A multipart body is stored exactly as it was handed to wp_mail(); it is split into its HTML and * plain-text parts here, at display, never at save. */ class EmailLogPreview { /** * Nonce action for preview links. */ const NONCE = 'adminkeep_email_log_preview'; /** * Inline styles and data: images only. Remote images are refused on purpose: a tracking * pixel would otherwise report that an administrator opened the email. */ const CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"; /** * The same policy for the document's own tag, which is all a srcdoc iframe gets. * Without `sandbox`: browsers ignore that directive in a , and the iframe's sandbox="" * attribute provides it there. */ const META_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:"; /** * Pattern for what older templates put ahead of their first tag and that never renders: a * byte-order mark, XML prologs and complete comments, with whitespace around them. Every * repeat is possessive and a comment cannot run past its own end, so the pattern cannot * stretch one comment over the markup between two of them, and a long run of comments costs * no backtracking. The empty `` / `` comments and the `--!>` close are left out: * they are not matched as inert, so a body using them is never treated as having a preamble. */ const PREAMBLE = '(?:\xEF\xBB\xBF)?\s*+(?:(?:<\?[^>]*+>|)\s*+)*+'; /** * Link to one row's preview. Raw, not escaped: callers escape for their context. * * @param int $id Row id. * @param string $view 'preview', 'text' or 'source'. * @return string */ public static function url( $id, $view = 'preview' ) { $args = array( 'preview' => (int) $id, '_wpnonce' => wp_create_nonce( self::NONCE ), ); if ( in_array( $view, array( 'text', 'source' ), true ) ) { $args['view'] = $view; } return add_query_arg( $args, EmailLogScreen::url() ); } /** * Response headers for the document. * * @return string[] */ public static function headers() { return array( 'Content-Type: text/html; charset=UTF-8', 'Content-Security-Policy: ' . self::CSP, 'X-Content-Type-Options: nosniff', 'Referrer-Policy: no-referrer', 'X-Frame-Options: SAMEORIGIN', // The CSP's default-src does not cover dns-prefetch, which browsers treat as its own // mechanism; without this a link's hostname in the stored HTML could still be resolved // on hover, and a tracker could read that resolution as an "opened" signal. 'X-DNS-Prefetch-Control: off', ); } /** * The row's own Content-Type header value, or '' when it has none. Header names are stored * lower case, but this reads case-insensitively so a caller does not have to know that. * * @param array $row Stored row. * @return string */ public static function content_type( array $row ) { $headers = isset( $row['headers'] ) && is_array( $row['headers'] ) ? $row['headers'] : array(); foreach ( $headers as $name => $value ) { if ( 'content-type' === strtolower( (string) $name ) && is_scalar( $value ) ) { return (string) $value; } } return ''; } /** * Whether the email was HTML: it said so in its Content-Type, or it reads as a document. * * @param array $row Stored row. * @return bool */ public static function is_html( array $row ) { if ( false !== stripos( self::content_type( $row ), 'text/html' ) ) { return true; } // A list row carries only the head of its body (EmailLogStore::LIST_COLUMNS), which is all // this pattern reads. $body = isset( $row['body'] ) ? $row['body'] : ( isset( $row['body_head'] ) ? $row['body_head'] : '' ); return 1 === preg_match( '/\A' . self::PREAMBLE . '<(!doctype|html|body)\b/is', (string) $body ); } /** * Whether the email was a multipart body (e.g. multipart/alternative, with an HTML and a * plain-text part side by side). * * @param array $row Stored row. * @return bool */ public static function is_multipart( array $row ) { return 0 === stripos( ltrim( self::content_type( $row ) ), 'multipart/' ); } /** * The document for one row. * * @param array $row Stored row, with its body. * @param string $view 'preview', 'text' or 'source'. * @return string */ public static function document( array $row, $view ) { $body = (string) $row['body']; if ( '' === $body ) { return self::plain( '

' . esc_html__( 'The content of this email was not kept.', 'adminkeep' ) . '

' ); } $parts = self::is_multipart( $row ) ? MimeParts::parse( $body, self::content_type( $row ) ) : null; if ( null !== $parts ) { return self::multipart_document( $parts, $view ); } if ( 'preview' !== $view || ! self::is_html( $row ) ) { return self::plain( '
' . esc_html( $body ) . '
' ); } return self::with_base( $body ); } /** * One view of a multipart email. Each part is redacted again after decoding: the log's own * redaction ran on the encoded body at save time, where a quoted-printable "key=3D…" or a * base64 part hides the link it looks for. * * @param array $parts Decoded parts from MimeParts::parse(). * @param string $view 'preview', 'text' or 'source'. * @return string */ private static function multipart_document( array $parts, $view ) { $html = null === $parts['html'] ? null : MailRedactor::redact( $parts['html'] ); $text = null === $parts['text'] ? null : MailRedactor::redact( $parts['text'] ); if ( 'text' === $view ) { return null === $text ? self::plain( '

' . esc_html__( 'This email has no plain-text version.', 'adminkeep' ) . '

' ) : self::plain( '
' . esc_html( $text ) . '
' ); } if ( null === $html ) { return self::plain( '
' . esc_html( (string) $text ) . '
' ); } return 'source' === $view ? self::plain( '
' . esc_html( $html ) . '
' ) : self::with_base( $html ); } /** * A minimal document around escaped content. * * @param string $inner Already-escaped markup. * @return string */ private static function plain( $inner ) { return '' . self::head_tags() . '' . $inner . ''; } /** * What every preview document opens with, ahead of anything the email itself says. * * The three tags repeat the response headers that matter for a document shown through * srcdoc, where no header arrives: the content policy, no DNS prefetch for a link's host, and * no referrer. makes links inert: the sandbox does not allow popups, so * a link that would open a new window does nothing, and the administrator cannot be walked * off to a stranger's page inside the frame. * * @return string */ private static function head_tags() { return '' . '' . '' . ''; } /** * The stored HTML with head_tags() in front of it. * * In front, not after the email's own : a content policy in a only binds what * the parser reads after it, and only counts inside the head. Placed first, the browser opens * the head for these tags itself, and whatever the stored markup does next (an image before * its , a inside a comment) comes too late to dodge the policy. The one thing * allowed ahead of them is the email's own doctype, so the email keeps its rendering mode. * * Older templates open with a byte-order mark, an XML prolog or a comment before that doctype. * None of it renders, and left in place it would push the doctype behind the tags added here, * where the browser ignores it. So that preamble is dropped and the doctype goes first. Only * those inert pieces are skipped (self::PREAMBLE): a comment that never closes, markup between * two comments, or anything else ahead of the doctype means no match, and the tags added here * go first as before. If the pattern engine gives up on a huge body, preg_match() returns * false, which takes the same safe path. * * @param string $html Stored body. * @return string */ private static function with_base( $html ) { if ( 1 === preg_match( '/\A' . self::PREAMBLE . '(]*+>)/is', $html, $doctype ) ) { return $doctype[1] . self::head_tags() . substr( $html, strlen( $doctype[0] ) ); } return self::head_tags() . $html; } }