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:
parent
71d9639491
commit
fddfd8a61c
9 changed files with 323 additions and 15 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue