*/ private static function headers(string $block): array { // Unfold first: a long Subject is legally split across lines with the // continuation indented, and reading it line by line would cut it. $unfolded = preg_replace('/\r?\n[ \t]+/', ' ', $block) ?? $block; $headers = []; foreach (preg_split('/\r?\n/', $unfolded) ?: [] as $line) { $colon = strpos($line, ':'); if ($colon === false) { continue; } $name = strtolower(trim(substr($line, 0, $colon))); // First wins. A message with two Subject headers is malformed, and // the second one is the interesting one to an attacker. if (! array_key_exists($name, $headers)) { $headers[$name] = trim(substr($line, $colon + 1)); } } return $headers; } /** @return array{email: string, name: ?string} */ private static function address(string $value): array { $value = self::decodeHeader($value); if (preg_match('/<([^>]+)>/', $value, $m) === 1) { $name = trim(str_replace('"', '', substr($value, 0, strpos($value, '<')))); return ['email' => strtolower(trim($m[1])), 'name' => $name === '' ? null : $name]; } return ['email' => strtolower(trim($value)), 'name' => null]; } /** RFC 2047: `=?charset?B|Q?text?=`, possibly several times in one line. */ private static function decodeHeader(string $value): string { $decoded = preg_replace_callback( '/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/', function (array $m): string { $text = strtoupper($m[2]) === 'B' ? (base64_decode($m[3], true) ?: '') // Underscore is a space in Q encoding — the one way it // differs from quoted-printable, and the reason a decoded // subject otherwise reads "Ihre_Frage". : quoted_printable_decode(str_replace('_', ' ', $m[3])); return self::toUtf8($text, $m[1]); }, $value, ); return trim($decoded ?? $value); } /** * The readable text, and what was attached. * * @param array $headers * @return array{text: string, attachments: array} */ private static function parts(array $headers, string $body, int $depth = 0): array { $contentType = strtolower($headers['content-type'] ?? 'text/plain'); if (str_starts_with($contentType, 'multipart/') && $depth < self::MAX_DEPTH) { return self::multipart($headers, $body, $contentType, $depth); } // An attachment at the top level: a bare file mailed on its own. $name = self::attachmentName($headers); if ($name !== null) { return ['text' => '', 'attachments' => [['name' => $name, 'bytes' => strlen($body)]]]; } return ['text' => self::text($headers, $body, $contentType), 'attachments' => []]; } /** * @param array $headers * @return array{text: string, attachments: array} */ private static function multipart(array $headers, string $body, string $contentType, int $depth): array { if (preg_match('/boundary="?([^";]+)"?/i', $headers['content-type'] ?? '', $m) !== 1) { throw new RuntimeException('A multipart message with no boundary.'); } $chunks = explode('--'.$m[1], $body); $text = ''; $html = ''; $attachments = []; foreach ($chunks as $chunk) { $chunk = ltrim($chunk, "\r\n"); if ($chunk === '' || str_starts_with($chunk, '--')) { continue; // the preamble and the closing delimiter } try { [$partHeaderBlock, $partBody] = self::split($chunk); } catch (RuntimeException) { continue; } $partHeaders = self::headers($partHeaderBlock); $partType = strtolower($partHeaders['content-type'] ?? 'text/plain'); $name = self::attachmentName($partHeaders); if ($name !== null) { // The name and the size, and then we stop reading. Keeping the // file itself would make this application a store of whatever // strangers choose to send it. $attachments[] = ['name' => $name, 'bytes' => strlen($partBody)]; continue; } if (str_starts_with($partType, 'multipart/')) { $nested = self::parts($partHeaders, $partBody, $depth + 1); $text = $text !== '' ? $text : $nested['text']; $attachments = array_merge($attachments, $nested['attachments']); continue; } $decoded = self::text($partHeaders, $partBody, $partType); if (str_starts_with($partType, 'text/plain') && $text === '') { $text = $decoded; } elseif (str_starts_with($partType, 'text/html') && $html === '') { $html = $decoded; } } // Plain wins. A console is not a mail client, and what a person wrote is // in the text part; the HTML one is the same words wrapped in markup. return ['text' => $text !== '' ? $text : $html, 'attachments' => $attachments]; } /** @param array $headers */ private static function attachmentName(array $headers): ?string { $disposition = $headers['content-disposition'] ?? ''; if (preg_match('/filename="?([^";]+)"?/i', $disposition, $m) === 1) { return self::decodeHeader($m[1]); } // Some senders put the name only on the type, with no disposition. if (preg_match('/name="?([^";]+)"?/i', $headers['content-type'] ?? '', $m) === 1) { return self::decodeHeader($m[1]); } return str_starts_with(strtolower($disposition), 'attachment') ? 'unbenannt' : null; } /** @param array $headers */ private static function text(array $headers, string $body, string $contentType): string { $encoding = strtolower(trim($headers['content-transfer-encoding'] ?? '7bit')); $decoded = match ($encoding) { 'base64' => base64_decode(preg_replace('/\s+/', '', $body) ?? '', true) ?: '', 'quoted-printable' => quoted_printable_decode($body), default => $body, }; $charset = preg_match('/charset="?([^";]+)"?/i', $contentType, $m) === 1 ? $m[1] : 'UTF-8'; $decoded = self::toUtf8($decoded, $charset); if (str_starts_with($contentType, 'text/html')) { // Not a renderer: the tags come out, the words stay. The console // shows this as text and escapes it on the way to the page. $decoded = html_entity_decode(strip_tags($decoded), ENT_QUOTES | ENT_HTML5, 'UTF-8'); } return trim($decoded); } private static function toUtf8(string $text, string $charset): string { $charset = strtoupper(trim($charset)); if ($charset === '' || $charset === 'UTF-8' || $charset === 'US-ASCII') { return $text; } $converted = @mb_convert_encoding($text, 'UTF-8', $charset); return $converted === false ? $text : $converted; } private static function date(?string $value): Carbon { if ($value === null || trim($value) === '') { return Carbon::now(); } try { return Carbon::parse($value); } catch (\Throwable) { // An unreadable Date header is not a reason to lose the message — // and "when we read it" is closer to the truth than a guess. return Carbon::now(); } } }