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>
This commit is contained in:
Kevin Adametz 2026-09-15 15:27:09 +00:00
parent 6e159ecf06
commit 4cefdf7f5c
9 changed files with 323 additions and 15 deletions

View file

@ -20,6 +20,12 @@ use Illuminate\Support\Carbon;
* 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
{
@ -36,18 +42,14 @@ class DsnParser
foreach ($parts as $part) {
$type = strtolower((string) ($part['headers']['content-type'] ?? ''));
if ($status === null && str_starts_with($type, 'message/delivery-status')) {
if ($status === null && (str_starts_with($type, 'message/delivery-status') || str_starts_with($type, 'message/global-delivery-status'))) {
$status = $part['body'];
} elseif ($original === null && (str_starts_with($type, 'message/rfc822') || str_starts_with($type, 'text/rfc822-headers'))) {
} elseif ($original === null && preg_match('#^(message/rfc822|message/global|text/rfc822-headers)#', $type) === 1) {
$original = $part['body'];
}
}
if ($status === null) {
return null;
}
$recipients = $this->recipientsFrom($status);
$recipients = $status !== null ? $this->recipientsFrom($status) : $this->recipientsFromKasNotice($headers, $topBody);
if ($recipients === []) {
return null;
@ -76,6 +78,14 @@ class DsnParser
$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'])
@ -183,6 +193,39 @@ class DsnParser
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) {