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

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

View file

@ -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.

View file

@ -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 <pr@example.net>: 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);

View file

@ -0,0 +1,53 @@
Return-Path: <>
From: System Administrator <noreply@example.org>
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: <smartermail-4dafebf3@example.org>
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: <bounce@pressekonto.com>
From: "Service | pressekonto.com" <service@pressekonto.com>
To: empfang@example.org
Subject: E-Mail-Adresse bestätigen für Ihr pressekonto
Message-Id: <e080bfba8cd885ac98a1979708debe2c@pressekonto.com>
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--

View file

@ -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.

View file

@ -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 <pr@example.net>: Relay access denied
--ABC123.1789473600/dd19238.kasserver.com
Content-Description: Undelivered Message Headers
Content-Type: text/rfc822-headers
Return-Path: <bounce@pressekonto.com>
From: "Service | pressekonto.com" <service@pressekonto.com>
To: pr@example.net
Subject: Ihre Pressemitteilung ist online
X-Pk-Ref: pm:99:company:7
Message-Id: <abc@pressekonto.com>
--ABC123.1789473600/dd19238.kasserver.com--

View file

@ -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'),
));
}
}
}