null, 'text' => null, ); self::walk( (string) $body, (string) $content_type, $found, 0 ); return null === $found['html'] && null === $found['text'] ? null : $found; } /** * Collect the first text/html and text/plain parts of one multipart level. * * @param string $body Body of this level. * @param string $content_type Content-Type of this level. * @param array $found Parts found so far, filled in place. * @param int $depth Current nesting depth. * @return void */ private static function walk( $body, $content_type, array &$found, $depth ) { if ( $depth > self::MAX_DEPTH || 0 !== stripos( ltrim( $content_type ), 'multipart/' ) ) { return; } $boundary = self::param( $content_type, 'boundary' ); if ( '' === $boundary ) { return; } $body = str_replace( "\r\n", "\n", $body ); // A plain substring split on "\n--boundary" also matches inside a nested part whose own // boundary happens to start with this one (outer "b1", nested "b1x"): "\n--b1x" contains // "\n--b1". The lookahead accepts a split only where a real boundary line follows — "--" // then optional trailing spaces/tabs then end of line for a closing line, or just the // trailing spaces/tabs and end of line for an opening one — so "\n--b1x" is left alone and // stays part of whichever section it is actually in. $sections = preg_split( '/\n--' . preg_quote( $boundary, '/' ) . '(?=--[ \t]*(?:\n|\z)|[ \t]*(?:\n|\z))/', "\n" . $body ); // The first section is the preamble before the first boundary; everything after a // closing "--boundary--" is the epilogue. Neither is a part. array_shift( $sections ); foreach ( $sections as $section ) { if ( 0 === strpos( $section, '--' ) ) { break; } $split = preg_split( "/\n\n/", ltrim( $section, " \t" ), 2 ); $head = isset( $split[0] ) ? $split[0] : ''; $content = isset( $split[1] ) ? $split[1] : ''; $headers = self::headers( $head ); // An attachment is not something to render inline; skip it so a renderable // alternative underneath or beside it is still found. $disposition = isset( $headers['content-disposition'] ) ? $headers['content-disposition'] : ''; if ( 0 === stripos( $disposition, 'attachment' ) ) { continue; } $type = isset( $headers['content-type'] ) ? $headers['content-type'] : 'text/plain'; if ( 0 === stripos( $type, 'multipart/' ) ) { self::walk( $content, $type, $found, $depth + 1 ); continue; } // RFC 2046 says the last renderable alternative should win; real mail almost always // carries one HTML part and one plain-text part, so taking the first found of each is // deliberate and simpler. $key = 0 === stripos( $type, 'text/html' ) ? 'html' : ( 0 === stripos( $type, 'text/plain' ) ? 'text' : '' ); if ( '' === $key || null !== $found[ $key ] ) { continue; } $encoding = isset( $headers['content-transfer-encoding'] ) ? strtolower( trim( $headers['content-transfer-encoding'] ) ) : ''; // The log's own truncation marker can sit inside the last part of a cut body. Left in // place it is more content to decode: its letters pass base64's character filter and // come out the other side as junk bytes tacked onto the real content. $content = rtrim( str_replace( MailRedactor::CUT_MARK, '', $content ), "\n" ); $decoded = self::to_utf8( self::decode( $content, $encoding ), self::param( $type, 'charset' ) ); // A boundary collision or a body truncated right after a boundary line can produce a // headerless section with nothing real in it; never let that claim the slot a genuine // part would otherwise fill. if ( '' === trim( $decoded ) ) { continue; } $found[ $key ] = $decoded; } } /** * A part's headers, lower-case name => value, with folded lines joined. * * @param string $head Header block. * @return array */ private static function headers( $head ) { $out = array(); foreach ( explode( "\n", preg_replace( "/\n[ \t]+/", ' ', $head ) ) as $line ) { if ( false === strpos( $line, ':' ) ) { continue; } list( $name, $value ) = explode( ':', $line, 2 ); $out[ strtolower( trim( $name ) ) ] = trim( $value ); } return $out; } /** * One parameter of a header value, e.g. boundary or charset, unquoted. * * @param string $value Header value. * @param string $name Parameter name. * @return string */ private static function param( $value, $name ) { if ( 1 !== preg_match( '/;\s*' . preg_quote( $name, '/' ) . '\s*=\s*("([^"]*)"|[^;\s]+)/i', $value, $m ) ) { return ''; } return isset( $m[2] ) && '' !== $m[2] ? $m[2] : trim( $m[1], '"' ); } /** * Undo the transfer encoding. A truncated base64 tail decodes as far as it is whole. * * @param string $content Encoded content. * @param string $encoding Content-Transfer-Encoding, lower case. * @return string */ private static function decode( $content, $encoding ) { if ( 'quoted-printable' === $encoding ) { return quoted_printable_decode( $content ); } if ( 'base64' === $encoding ) { $clean = preg_replace( '/[^A-Za-z0-9+\/=]/', '', $content ); $clean = substr( $clean, 0, strlen( $clean ) - strlen( $clean ) % 4 ); return (string) base64_decode( $clean ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding a MIME part for display, not obfuscation. } return $content; } /** * Convert a part to UTF-8 when it declares another charset and mbstring can do it. * * @param string $text Decoded text. * @param string $charset Declared charset. * @return string */ private static function to_utf8( $text, $charset ) { if ( '' !== $charset && 'utf-8' !== strtolower( $charset ) && function_exists( 'mb_convert_encoding' ) ) { try { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- An unknown charset name warns; the text is then shown as it came. $converted = @mb_convert_encoding( $text, 'UTF-8', $charset ); } catch ( \ValueError $e ) { // PHP 8.x throws ValueError for an unrecognised charset name instead of warning; PHP // 7.4 has no ValueError class, but catching an unknown class name is valid PHP. $converted = false; } if ( is_string( $converted ) ) { $text = $converted; } } return self::scrub_utf8( $text ); } /** * Repair a part left with an invalid trailing byte sequence. * * The log truncates a stored body at a fixed byte length, not a character boundary, so a * base64 or quoted-printable part can decode to bytes that end mid multi-byte UTF-8 * character (e.g. a lone lead byte). WordPress's esc_html()/esc_attr() call * wp_check_invalid_utf8() before display, which returns an empty string for the *entire* * value when even one sequence is invalid — so an unrepaired truncated part would render * blank instead of showing the text up to the cut. * * @param string $text Text that should be UTF-8, but may end mid-character. * @return string */ private static function scrub_utf8( $text ) { if ( function_exists( 'mb_check_encoding' ) && mb_check_encoding( $text, 'UTF-8' ) ) { return $text; } if ( function_exists( 'mb_convert_encoding' ) ) { // Converting UTF-8 to itself still passes every byte through mbstring's decoder, // which substitutes an invalid sequence rather than leaving it in place. $clean = @mb_convert_encoding( $text, 'UTF-8', 'UTF-8' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Scrubbing a truncated tail; failure falls through to the checks below. if ( is_string( $clean ) ) { return $clean; } } if ( function_exists( 'wp_check_invalid_utf8' ) ) { $clean = wp_check_invalid_utf8( $text, true ); if ( '' !== $clean || '' === $text ) { return $clean; } } // Neither mbstring nor wp_check_invalid_utf8() could repair it: drop bytes off the end // until what is left passes preg_match's UTF-8 mode, rather than show nothing at all. while ( '' !== $text && 1 !== @preg_match( '/^./us', $text ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- preg_match's /u mode warns on invalid UTF-8 by design; that is exactly the case being repaired here. $text = substr( $text, 0, -1 ); } return $text; } }