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 71d9639491
commit fddfd8a61c
9 changed files with 323 additions and 15 deletions

View file

@ -12,13 +12,16 @@ use Webklex\PHPIMAP\Message;
/**
* Holt Rückläufer aus dem Bounce-Postfach (Roadmap 1b.6, Konzept
* `bounce-mails-2026-09-15.md`). Alle zehn Minuten auf UNSEEN — IDLE braucht
* es bei zwei bis drei Mails am Tag nicht.
* `bounce-mails-2026-09-15.md`). Alle zehn Minuten — IDLE braucht es bei
* zwei bis drei Mails am Tag nicht.
*
* Jede Mail wird nach dem Lesen in einen Ordner verschoben: `Processed`,
* `Ignored` oder `Unparsed`. Verschieben statt Flag: Bricht der Lauf
* dazwischen ab, liest der nächste dieselbe Mail erneut — die Sperre bleibt
* dank Message-ID-Unique trotzdem einmalig.
* dank Message-ID-Unique trotzdem einmalig. Deshalb zählt auch alles in
* der INBOX, nicht nur Ungelesenes: Was ein Mensch dort angesehen oder aus
* `service@` hinübergeschoben hat, trägt das Gelesen-Flag schon und würde
* sonst nie verarbeitet.
*/
class FetchMailBounces extends Command
{
@ -53,7 +56,7 @@ class FetchMailBounces extends Command
throw new \RuntimeException('INBOX nicht gefunden.');
}
$messages = $inbox->query()->whereUnseen()->setFetchBody(true)->limit(max(1, (int) $this->option('limit')))->get();
$messages = $inbox->query()->all()->setFetchBody(true)->limit(max(1, (int) $this->option('limit')))->get();
/** @var Message $message */
foreach ($messages as $message) {

View file

@ -65,9 +65,7 @@ class BounceMailProcessor
continue;
}
// 4.x.x mit `failed` ist ein weicher Fehler, den der MTA
// aufgegeben hat — zählt weich, nicht hart.
if ($kind === EmailBounceKind::Hard && str_starts_with((string) $recipient['status'], '4.')) {
if ($kind === EmailBounceKind::Hard && $this->isTransient($recipient['status'], $recipient['diagnostic'])) {
$kind = EmailBounceKind::Soft;
}
@ -95,6 +93,24 @@ class BounceMailProcessor
return ['folder' => $bounces > 0 ? self::Processed : self::Ignored, 'bounces' => $bounces, 'subject' => $subject];
}
/**
* `failed` heißt nicht immer „Adresse existiert nicht". Weich zählen
* (Sperre erst nach dem Schwellwert): 4.x.x, das der MTA aufgegeben hat;
* volles Postfach (5.2.2, 5.2.5, SMTP 552); Policy-Ablehnungen 5.7.x
* („Relay access denied", Spamfilter der Gegenseite) — die Adresse gibt
* es, ihr Server nimmt gerade nur nichts an. Bestand vom 15.09.2026:
* 16 von 147 DSNs fielen in diese Gruppe.
*/
private function isTransient(?string $status, ?string $diagnostic): bool
{
$status = (string) $status;
return str_starts_with($status, '4.')
|| str_starts_with($status, '5.7.')
|| in_array($status, ['5.2.2', '5.2.5'], true)
|| preg_match('/^\s*(4\d\d|552)\b/', (string) $diagnostic) === 1;
}
private function subject(string $raw): string
{
return preg_match('/^Subject:\s*(.*)$/mi', $raw, $m) === 1 ? mb_substr(trim($m[1]), 0, 200) : '(ohne Betreff)';

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) {