diff --git a/app/Console/Commands/FetchMailBounces.php b/app/Console/Commands/FetchMailBounces.php index 5bb46f18..9f180622 100644 --- a/app/Console/Commands/FetchMailBounces.php +++ b/app/Console/Commands/FetchMailBounces.php @@ -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) { diff --git a/app/Services/Mail/BounceMailProcessor.php b/app/Services/Mail/BounceMailProcessor.php index 419a1fd8..1c0adf69 100644 --- a/app/Services/Mail/BounceMailProcessor.php +++ b/app/Services/Mail/BounceMailProcessor.php @@ -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)'; diff --git a/app/Services/Mail/DsnParser.php b/app/Services/Mail/DsnParser.php index 94df3867..89db934c 100644 --- a/app/Services/Mail/DsnParser.php +++ b/app/Services/Mail/DsnParser.php @@ -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 $headers + * @return list + */ + 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) { diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 7e6d0ecf..3733321d 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -1154,3 +1154,55 @@ läuft `public/vendor/livewire` bei jedem Livewire-Update wieder aus dem Takt), `optimize:clear`, `config:cache`, `view:cache`, `queue:restart`. Vorher auf dem Server `git checkout composer.lock`, damit der Pull nicht an der handgeänderten Datei hängen bleibt. + +## 1b.6 — Erster Blick ins echte Postfach (15.09.2026) + +Zugang zu `bounce@pressekonto.com` steht; 151 Rückläufer aus `service@` +hinübergeschoben. Der Trockenlauf meldete zunächst **0**, weil alle das +Gelesen-Flag mitgebracht hatten und der Abruf nur UNSEEN holte. + +Befund über alle 151 (Dry-Run mit dem Parser): + +- 147 klassische DSNs (Postfix/All-Inkl, Exchange, Gmail) — sauber gelesen. +- 2 × `message/global-delivery-status` (RFC 6533, SmarterMail) — bisher + „nicht lesbar". +- 2 × Freitext des eigenen All-Inkl-Relays („anti spam system … list of + non existent recipients", Header `X-KASMail-Error`) — mit + `Auto-Submitted: auto-replied` und deshalb bisher als **Abwesenheit + ignoriert**. Genau die Adressen wären nie gesperrt worden. +- Statuscodes: 74 × 5.4.4 (Domain ohne MX, davon 66 × dieselbe Adresse), + 24 × 5.0.0, 13 × 5.1.1, 8 × 5.1.10, 8 × 5.7.1, Rest 5.2.x/4.x. +- 37 eindeutige Adressen. + +Vier Änderungen (Commit im Anschluss): + +1. Abruf holt **alles in der INBOX**, nicht nur Ungelesenes — das + Verschieben in `Processed/Ignored/Unparsed` ist ohnehin die Zustandsmarke. +2. Parser versteht RFC 6533 (`message/global-delivery-status`, + `message/global-headers`). +3. Parser liest das All-Inkl-Freitextformat — nur bei `X-KASMail-Error` + und dem wörtlichen Einleitungssatz. MAILER-DAEMON/„Undelivered" wird + nie mehr als Abwesenheitsnotiz gewertet. +4. Einstufung: `failed` mit 5.7.x (Policy, „Relay access denied"), 5.2.2/ + 5.2.5 (Postfach voll) oder SMTP 552 im Diagnosetext zählt **weich** + statt hart — die Adresse existiert, ihr Server nimmt nur nichts an. + 16 von 147 im Bestand. + +Danach: 151/151 gelesen, 0 ignoriert, 0 nicht lesbar. Nicht scharf von +hier gelaufen — das würde die Mails im Live-Postfach verschieben und die +Sperren nur in die Dev-DB schreiben. Der scharfe Lauf gehört auf den +Server nach dem Deploy (`php artisan mail:fetch-bounces`), der Scheduler +holt danach alle zehn Minuten nach. + +Fixtures: `global-delivery-status.eml`, `kas-antispam.eml`, +`policy-5.7.1.eml`; Tests in `BounceMailProcessingTest` (+3, 9). + +### Nebenbefund: Dev-DB von der Testsuite geleert + +Um 15:16 lag ein `bootstrap/cache/config.php`; der überlagert die +phpunit.xml-Umgebung (SQLite :memory:), und die Suite um 15:20 lief mit +`RefreshDatabase` gegen die MySQL-Dev-DB `pressekonto` — die Bestandskopie +ist weg (alle Tabellen neu angelegt 15:21:58–15:22:11). `composer run +test` räumt den Cache vorher weg, `php artisan test` direkt nicht. +`tests/TestCase.php` bricht jetzt hart ab, wenn die Verbindung nicht +SQLite :memory: ist. Die Kopie muss neu eingespielt werden. diff --git a/tests/Feature/BounceMailProcessingTest.php b/tests/Feature/BounceMailProcessingTest.php index 04b0e4f4..c50ec585 100644 --- a/tests/Feature/BounceMailProcessingTest.php +++ b/tests/Feature/BounceMailProcessingTest.php @@ -77,6 +77,61 @@ test('free-text bounces are not guessed but parked for a human, auto-replies are ->and(ActivityEvent::query()->where('type', ActivityType::MailSuppressed->value)->where('description', 'like', '%nicht lesbar%')->exists())->toBeTrue(); }); +test('the utf-8 dsn variant of rfc 6533 is read like a classic report', function () { + /** @var TestCase $this */ + // SmarterMail liefert `message/global-delivery-status` und die + // Original-Header als `message/global-headers` (Bestand 15.09.2026). + $report = app(DsnParser::class)->parse(bounceFixture('global-delivery-status')); + + expect($report)->not->toBeNull() + ->and($report->recipients)->toHaveCount(1) + ->and($report->recipients[0]['email'])->toBe('weiterleitung@example.net') + ->and($report->recipients[0]['status'])->toBe('5.0.0') + ->and($report->reference)->toBe('user:4711'); + + $result = app(BounceMailProcessor::class)->process(bounceFixture('global-delivery-status')); + + expect($result['folder'])->toBe(BounceMailProcessor::Processed) + ->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard); +}); + +test('the free-text notice of our own relay is read and never mistaken for an auto-reply', function () { + /** @var TestCase $this */ + // All-Inkl schickt „Auto-Submitted: auto-replied" mit — als Abwesenheit + // ignoriert, wären das genau die Adressen, die nie gesperrt würden. + expect(app(DsnParser::class)->looksLikeAutoReply(bounceFixture('kas-antispam')))->toBeFalse(); + + $result = app(BounceMailProcessor::class)->process(bounceFixture('kas-antispam')); + $suppressions = app(EmailSuppressionService::class); + + expect($result['folder'])->toBe(BounceMailProcessor::Processed) + ->and($result['bounces'])->toBe(2) + ->and($suppressions->isSuppressed('presse@example.net'))->toBeTrue() + ->and($suppressions->isSuppressed('info@example.org'))->toBeTrue(); +}); + +test('policy rejections and full mailboxes count soft, not hard', function () { + /** @var TestCase $this */ + // 5.7.1 „Relay access denied": die Adresse existiert, ihr Server nimmt + // nur nichts an — erst der Schwellwert sperrt. + $result = app(BounceMailProcessor::class)->process(bounceFixture('policy-5.7.1')); + + expect($result['folder'])->toBe(BounceMailProcessor::Processed) + ->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft) + ->and(EmailBounce::query()->first()->reference)->toBe('pm:99:company:7') + ->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse(); + + $quota = str_replace( + ['Status: 5.7.1', '554 5.7.1 : Relay access denied', '<20260915120000.ABC@'], + ['Status: 5.0.0', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'], + bounceFixture('policy-5.7.1'), + ); + app(BounceMailProcessor::class)->process($quota); + + expect(EmailBounce::query()->count())->toBe(2) + ->and(EmailBounce::query()->where('kind', EmailBounceKind::Hard->value)->count())->toBe(0); +}); + test('the fetch command does nothing without a configured mailbox', function () { /** @var TestCase $this */ config()->set('imap.accounts.bounces.host', null); diff --git a/tests/Fixtures/bounces/global-delivery-status.eml b/tests/Fixtures/bounces/global-delivery-status.eml new file mode 100644 index 00000000..47550849 --- /dev/null +++ b/tests/Fixtures/bounces/global-delivery-status.eml @@ -0,0 +1,53 @@ +Return-Path: <> +From: System Administrator +To: empfang@example.org +Subject: Failed: E-Mail-Adresse =?utf-8?b?YmVzdMOkdGlnZW4gZsO8cg==?= Ihr pressekonto +Date: Tue, 15 Sep 2026 07:58:48 -0400 +Message-ID: +MIME-Version: 1.0 +Content-Type: multipart/report; + boundary=SmarterMail_NextPart_bea868a207e2438d93f1a587865b9442; + report-type=delivery-status + +--SmarterMail_NextPart_bea868a207e2438d93f1a587865b9442 +Content-Type: multipart/alternative; + boundary=SmarterMail_NextPart_c2a1d6d5a7f547e881ec08ee079fab60 + +--SmarterMail_NextPart_c2a1d6d5a7f547e881ec08ee079fab60 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Delivery has failed to these recipients: + +weiterleitung@example.net + +Remote Server returned: '550 Sender has been throttled'= + +--SmarterMail_NextPart_c2a1d6d5a7f547e881ec08ee079fab60-- + +--SmarterMail_NextPart_bea868a207e2438d93f1a587865b9442 +Content-Type: message/global-delivery-status; charset=utf-8 +Content-Transfer-Encoding: 7bit + +Reporting-MTA: dns; mx.example.org + +Original-Recipient: rfc822; weiterleitung@example.net +Final-Recipient: rfc822; weiterleitung@example.net +Action: failed +Status: 5.0.0 + +--SmarterMail_NextPart_bea868a207e2438d93f1a587865b9442 +Content-Type: message/global-headers; charset=utf-8 +Content-Transfer-Encoding: 8bit + +Return-Path: +From: "Service | pressekonto.com" +To: empfang@example.org +Subject: E-Mail-Adresse bestätigen für Ihr pressekonto +Message-Id: +X-Pk-Ref: user:4711 +MIME-Version: 1.0 +Date: Tue, 15 Sep 2026 13:58:45 +0200 +Content-Type: text/html; charset=utf-8 + +--SmarterMail_NextPart_bea868a207e2438d93f1a587865b9442-- diff --git a/tests/Fixtures/bounces/kas-antispam.eml b/tests/Fixtures/bounces/kas-antispam.eml new file mode 100644 index 00000000..8dde4d52 --- /dev/null +++ b/tests/Fixtures/bounces/kas-antispam.eml @@ -0,0 +1,28 @@ +Return-Path: <> +X-Original-To: bounce@pressekonto.com +From: MAILER-DAEMON@dd19238.kasserver.com (Mail Delivery System) +To: bounce@pressekonto.com +Auto-Submitted: auto-replied +Subject: Undelivered Mail Returned to Sender +Message-Id: <20260902102204.844FE5F41C65@dd19238.kasserver.com> +Date: Wed, 2 Sep 2026 12:22:04 +0200 (CEST) +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 8bit +X-KASMail-Error: CFA07A22C8072A03 +X-KasLoop: m0805a0b + + + +This is the anti spam system at host dd19238.kasserver.com. + +I´m sorry to have to inform you that your message could not be delivered to one or more recipients. + +The recipient mailprovider bounced Mails to one or more recipients you tried to contact as not existent in the recent past. + +Please note the following list of non existent recipients: + +presse@example.net +info@example.org + +Please check the addresses and try again. diff --git a/tests/Fixtures/bounces/policy-5.7.1.eml b/tests/Fixtures/bounces/policy-5.7.1.eml new file mode 100644 index 00000000..ebaec785 --- /dev/null +++ b/tests/Fixtures/bounces/policy-5.7.1.eml @@ -0,0 +1,41 @@ +Return-Path: <> +From: MAILER-DAEMON@dd19238.kasserver.com (Mail Delivery System) +To: bounce@pressekonto.com +Subject: Undelivered Mail Returned to Sender +Message-Id: <20260915120000.ABC@dd19238.kasserver.com> +Date: Tue, 15 Sep 2026 14:00:00 +0200 (CEST) +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="ABC123.1789473600/dd19238.kasserver.com" + +--ABC123.1789473600/dd19238.kasserver.com +Content-Description: Notification +Content-Type: text/plain; charset=utf-8 + +This is the mail system at host dd19238.kasserver.com. + +--ABC123.1789473600/dd19238.kasserver.com +Content-Description: Delivery report +Content-Type: message/delivery-status + +Reporting-MTA: dns; dd19238.kasserver.com + +Final-Recipient: rfc822; pr@example.net +Original-Recipient: rfc822;pr@example.net +Action: failed +Status: 5.7.1 +Remote-MTA: dns; mx.example.net +Diagnostic-Code: smtp; 554 5.7.1 : Relay access denied + +--ABC123.1789473600/dd19238.kasserver.com +Content-Description: Undelivered Message Headers +Content-Type: text/rfc822-headers + +Return-Path: +From: "Service | pressekonto.com" +To: pr@example.net +Subject: Ihre Pressemitteilung ist online +X-Pk-Ref: pm:99:company:7 +Message-Id: + +--ABC123.1789473600/dd19238.kasserver.com-- diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2f..f9a75660 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,8 +3,25 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use RuntimeException; abstract class TestCase extends BaseTestCase { - // + /** + * Schutz vor einem gecachten `bootstrap/cache/config.php`: Der überlagert + * die phpunit.xml-Umgebung, die Suite läuft dann gegen die Dev-Datenbank + * aus der .env — und `RefreshDatabase` leert sie (passiert am + * 15.09.2026, 3 GB Bestandskopie weg). Lieber laut abbrechen. + */ + protected function setUp(): void + { + parent::setUp(); + + if (config('database.default') !== 'sqlite' || config('database.connections.sqlite.database') !== ':memory:') { + throw new RuntimeException(sprintf( + 'Tests laufen gegen „%s“ statt SQLite :memory: — vermutlich liegt ein Config-Cache. `php artisan config:clear` und erneut starten.', + config('database.default'), + )); + } + } }