presseportale/app/Services/Mail/DsnParser.php
Kevin Adametz f9c97ccf2f mail: Rückläufer aus dem Bounce-Postfach lesen (Roadmap 1b.6, Schritt 3)
webklex/laravel-imap als Transport, eigener DSN-Parser nach RFC 3464 auf
der rohen Nachricht: Final-Recipient, Action, Status, Diagnostic-Code und
unser X-Pk-Ref aus der Original-Nachricht. Maßgeblich ist Action —
Zustellbestätigungen sperren niemanden, Freitext-Rückläufer wandern nach
Unparsed vor einen Menschen, Abwesenheitsnotizen nach Ignored.
mail:fetch-bounces alle zehn Minuten; ohne Konto tut es nichts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 13:55:30 +00:00

198 lines
7.1 KiB
PHP

<?php
namespace App\Services\Mail;
use Illuminate\Support\Carbon;
/**
* Liest Zustellberichte (RFC 3464, `multipart/report; report-type=
* delivery-status`) aus der rohen Nachricht — ohne zu raten.
*
* Ein DSN hat drei Teile: lesbarer Text, `message/delivery-status` mit den
* Feldern je Empfänger, und die Original-Nachricht (oder deren Header). Der
* Parser braucht aus dem zweiten Teil genau vier Felder (`Final-Recipient`,
* `Action`, `Status`, `Diagnostic-Code`) und aus dem dritten unseren
* `X-Pk-Ref`. Maßgeblich ist `Action`: `failed` und `delayed` sind Bounces,
* `delivered`/`relayed`/`expanded` sind Zustellbestätigungen — wer nur nach
* `Final-Recipient` greift, sperrt Adressen, die funktioniert haben.
*
* Alles, was kein `message/delivery-status` trägt (handgeschriebene
* Ablehnungen, alte MTAs mit Freitext), liefert null. Das wandert in den
* Ordner `Unparsed` und vor einen Menschen — zwei Mails pro Woche von Hand
* sind akzeptabel, eine falsch gesperrte Adresse nicht.
*/
class DsnParser
{
public function parse(string $raw): ?DsnReport
{
$raw = str_replace("\r\n", "\n", $raw);
[$topHeaders, $topBody] = $this->splitHeadersAndBody($raw);
$headers = $this->parseHeaders($topHeaders);
$parts = $this->allParts($topBody, $headers['content-type'] ?? '');
$status = null;
$original = null;
foreach ($parts as $part) {
$type = strtolower((string) ($part['headers']['content-type'] ?? ''));
if ($status === null && str_starts_with($type, 'message/delivery-status')) {
$status = $part['body'];
} elseif ($original === null && (str_starts_with($type, 'message/rfc822') || str_starts_with($type, 'text/rfc822-headers'))) {
$original = $part['body'];
}
}
if ($status === null) {
return null;
}
$recipients = $this->recipientsFrom($status);
if ($recipients === []) {
return null;
}
$referenceHeader = strtolower((string) config('bounces.reference_header', 'X-Pk-Ref'));
$originalHeaders = $original !== null ? $this->parseHeaders($this->splitHeadersAndBody(ltrim($original, "\n"))[0]) : [];
return new DsnReport(
messageId: trim((string) ($headers['message-id'] ?? ''), '<> ') ?: 'sha1:'.sha1($raw),
recipients: $recipients,
reference: $originalHeaders[$referenceHeader] ?? null,
receivedAt: $this->date($headers['date'] ?? null),
);
}
/**
* Grobe Erkennung ohne Parsen: für die Entscheidung, ob eine unlesbare
* Mail eine Abwesenheitsnotiz ist (ignorieren) oder ein Rückläufer, den
* ein Mensch ansehen sollte.
*/
public function looksLikeAutoReply(string $raw): bool
{
$raw = str_replace("\r\n", "\n", $raw);
$headers = $this->parseHeaders($this->splitHeadersAndBody($raw)[0]);
$autoSubmitted = strtolower((string) ($headers['auto-submitted'] ?? ''));
$subject = strtolower((string) ($headers['subject'] ?? ''));
return ($autoSubmitted !== '' && $autoSubmitted !== 'no')
|| isset($headers['x-autoreply'])
|| isset($headers['x-autorespond'])
|| preg_match('/abwesen|out of office|autoreply|automatische antwort|auto-reply/i', $subject) === 1;
}
/**
* @return array{0: string, 1: string}
*/
private function splitHeadersAndBody(string $raw): array
{
$pos = strpos($raw, "\n\n");
return $pos === false ? [$raw, ''] : [substr($raw, 0, $pos), substr($raw, $pos + 2)];
}
/**
* Header mit Fortsetzungszeilen zusammengefaltet, Namen kleingeschrieben.
* Bei Mehrfach-Headern gewinnt der erste.
*
* @return array<string, string>
*/
private function parseHeaders(string $block): array
{
$unfolded = preg_replace('/\n[ \t]+/', ' ', $block) ?? $block;
$headers = [];
foreach (explode("\n", $unfolded) as $line) {
if (preg_match('/^([A-Za-z0-9\-]+):\s*(.*)$/', $line, $m) === 1) {
$headers[strtolower($m[1])] ??= trim($m[2]);
}
}
return $headers;
}
/**
* Alle MIME-Teile, rekursiv über verschachtelte Multiparts.
*
* @return list<array{headers: array<string, string>, body: string}>
*/
private function allParts(string $body, string $contentType): array
{
if (preg_match('/boundary="?([^";\s]+)"?/i', $contentType, $m) !== 1) {
return [];
}
$boundary = $m[1];
$segments = preg_split('/^--'.preg_quote($boundary, '/').'(--)?[ \t]*$/m', $body) ?: [];
$parts = [];
// Erstes Segment ist der Präambel-Text vor dem ersten Boundary,
// letztes der Epilog nach dem Schluss-Boundary.
foreach (array_slice($segments, 1, -1) as $segment) {
[$headerBlock, $partBody] = $this->splitHeadersAndBody(ltrim($segment, "\n"));
$partHeaders = $this->parseHeaders($headerBlock);
$parts[] = ['headers' => $partHeaders, 'body' => $partBody];
if (str_starts_with(strtolower((string) ($partHeaders['content-type'] ?? '')), 'multipart/')) {
array_push($parts, ...$this->allParts($partBody, $partHeaders['content-type']));
}
}
return $parts;
}
/**
* Der `message/delivery-status`-Teil: ein Block je Nachricht, dann je
* Empfänger ein Block, durch Leerzeilen getrennt. Feldnamen sind
* case-insensitiv.
*
* @return list<array{email: string, action: string, status: string|null, diagnostic: string|null}>
*/
private function recipientsFrom(string $status): array
{
$recipients = [];
foreach (preg_split('/\n{2,}/', trim($status)) ?: [] as $block) {
$fields = $this->parseHeaders($block);
$recipient = $fields['final-recipient'] ?? $fields['original-recipient'] ?? null;
if ($recipient === null) {
continue;
}
// „rfc822; user@example.com" — der Adresstyp vor dem Semikolon.
$email = EmailSuppressionService::normalise(trim((string) preg_replace('/^[^;]*;\s*/', '', $recipient)));
$action = strtolower(trim((string) ($fields['action'] ?? '')));
if ($email === null || $action === '') {
continue;
}
$diagnostic = isset($fields['diagnostic-code']) ? trim((string) preg_replace('/^[^;]*;\s*/', '', $fields['diagnostic-code'])) : null;
$recipients[] = [
'email' => $email,
'action' => $action,
'status' => isset($fields['status']) && preg_match('/^\d\.\d{1,3}\.\d{1,3}$/', trim($fields['status'])) === 1 ? trim($fields['status']) : null,
'diagnostic' => $diagnostic !== '' ? $diagnostic : null,
];
}
return $recipients;
}
private function date(?string $value): ?Carbon
{
if ($value === null) {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
}