presseportale/app/Services/Mail/DsnParser.php
Kevin Adametz fddfd8a61c mail: Bounce-Abruf liest RFC 6533, All-Inkl-Freitext und ganze INBOX
Befund aus 151 echten Rücklaeufern: 2 SmarterMail-DSNs
(message/global-delivery-status) und 2 Freitext-Hinweise des eigenen
Relays (X-KASMail-Error, formal auto-replied) fielen durch. Policy- und
Quota-Codes zaehlen weich statt hart. Abruf ohne UNSEEN-Filter, weil
das Verschieben die Zustandsmarke ist.

Dazu Schutz in tests/TestCase.php: Suite bricht ab, wenn ein Config-Cache
sie auf die Dev-Datenbank umlenkt.

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

241 lines
9.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.
*
* Zwei bewusste Ausnahmen: die UTF-8-Variante nach RFC 6533
* (`message/global-delivery-status`, z. B. SmarterMail) und das
* Freitextformat des eigenen All-Inkl-Relays („anti spam system at host …
* list of non existent recipients"), das mit `X-KASMail-Error` eindeutig
* erkennbar ist — 2 von 151 Rückläufern im Bestand vom 15.09.2026.
*/
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') || str_starts_with($type, 'message/global-delivery-status'))) {
$status = $part['body'];
} elseif ($original === null && preg_match('#^(message/rfc822|message/global|text/rfc822-headers)#', $type) === 1) {
$original = $part['body'];
}
}
$recipients = $status !== null ? $this->recipientsFrom($status) : $this->recipientsFromKasNotice($headers, $topBody);
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'] ?? ''));
$from = strtolower((string) ($headers['from'] ?? ''));
// Ein Zustellsystem antwortet formal auch „auto-replied" — sein
// Rückläufer ist aber nie eine Abwesenheitsnotiz.
if (preg_match('/mailer-daemon|postmaster@/', $from) === 1
|| preg_match('/undeliver|returned to sender|delivery (status|failure)|unzustellbar/', $subject) === 1) {
return false;
}
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;
}
/**
* Freitext-Rückläufer des All-Inkl-Relays: kein DSN, aber ein festes
* Format mit Adressliste. Nur bei gesetztem `X-KASMail-Error` und dem
* wörtlichen Einleitungssatz — alles andere bleibt Sache eines Menschen.
*
* @param array<string, string> $headers
* @return list<array{email: string, action: string, status: string|null, diagnostic: string|null}>
*/
private function recipientsFromKasNotice(array $headers, string $body): array
{
if (! isset($headers['x-kasmail-error'])
|| preg_match('/list of non existent recipients:\s*\n(.+?)(?:\n\s*\n|\z)/is', $body, $m) !== 1) {
return [];
}
$recipients = [];
foreach (preg_split('/\s+/', trim($m[1])) ?: [] as $candidate) {
$email = EmailSuppressionService::normalise($candidate);
if ($email !== null) {
$recipients[] = [
'email' => $email,
'action' => 'failed',
'status' => '5.1.1',
'diagnostic' => 'All-Inkl anti spam: recipient reported as non existent',
];
}
}
return $recipients;
}
private function date(?string $value): ?Carbon
{
if ($value === null) {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
}