CluPilotCloud/app/Services/Mail/MailParser.php

304 lines
11 KiB
PHP

<?php
namespace App\Services\Mail;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* A raw RFC 822 message, reduced to the four things the console shows.
*
* Sender, subject, the text a person wrote, and the NAMES of anything attached.
* Not a general MIME library and not trying to be one: everything it cannot
* make sense of is reported as unparseable so the caller can skip the message
* and leave it in the mailbox, where it can still be read by a mail client.
*
* What it handles, because real mail from real people contains it:
*
* - `=?UTF-8?B?…?=` and `=?…?Q?…?=` encoded headers (every German subject
* line with an umlaut in it);
* - quoted-printable and base64 bodies;
* - multipart/alternative — the text part wins over the HTML one, because a
* console is not a mail client and a person's own words are in the text;
* - multipart/mixed with attachments, whose headers are read for a name and a
* size while the content itself is thrown away unread.
*
* Charsets are converted to UTF-8, since that is what the database column is
* and a Windows-1252 umlaut stored raw is a broken character for ever.
*/
final class MailParser
{
/** How deep a nested multipart is followed before giving up. */
private const MAX_DEPTH = 4;
public static function parse(string $uid, string $raw): FetchedMail
{
[$headerBlock, $body] = self::split($raw);
$headers = self::headers($headerBlock);
$from = self::address($headers['from'] ?? '');
if ($from['email'] === '') {
throw new RuntimeException('A message with no sender is not one we can answer.');
}
$parts = self::parts($headers, $body);
return new FetchedMail(
uid: $uid,
// Falling back to the UID is deliberate: Message-ID is required by
// the standard and missing in practice often enough that dropping
// the mail would lose real questions. The UID is unique per
// mailbox, which is the same guarantee for our purposes.
messageId: trim($headers['message-id'] ?? '') !== ''
? trim($headers['message-id'])
: 'uid-'.$uid,
fromEmail: $from['email'],
fromName: $from['name'],
subject: self::decodeHeader($headers['subject'] ?? ''),
body: $parts['text'],
receivedAt: self::date($headers['date'] ?? null),
attachments: $parts['attachments'],
);
}
/** @return array{0: string, 1: string} */
private static function split(string $raw): array
{
$break = strpos($raw, "\r\n\r\n");
if ($break === false) {
$break = strpos($raw, "\n\n");
if ($break === false) {
throw new RuntimeException('No header/body boundary.');
}
return [substr($raw, 0, $break), substr($raw, $break + 2)];
}
return [substr($raw, 0, $break), substr($raw, $break + 4)];
}
/**
* Header lines, unfolded and lower-cased by name.
*
* @return array<string, string>
*/
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<string, string> $headers
* @return array{text: string, attachments: array<int, array{name: string, bytes: int}>}
*/
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<string, string> $headers
* @return array{text: string, attachments: array<int, array{name: string, bytes: int}>}
*/
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<string, string> $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<string, string> $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();
}
}
}