mail: zentrale Bounce-Suppression mit Versand-Gate und Circuit Breaker (Roadmap 1b.6, Schritte 1–2)
email_suppressions/email_bounces als Zustand und Ereignis-Log; ein MessageSending-Listener streicht gesperrte Empfänger aus JEDER ausgehenden Mail und setzt den Return-Path. Zweiter Aufnahmepfad: SMTP-Ablehnungen im Worker — 5.1.x sperrt den Empfänger, Relay-Ablehnungen zählen für den Circuit Breaker, der den Versand anhält statt gesunde Adressen zu sperren. X-Pk-Ref-Header auf den Mails, API-Meldungen fallen auf die Firmen-E-Mail zurück, der Bounce-Import schreibt in die zentrale Liste. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
87f95fb7a7
commit
0a658bb34b
25 changed files with 1312 additions and 4 deletions
64
dev/2026-09-01/bounce-mails-2026-09-15.md
Normal file
64
dev/2026-09-01/bounce-mails-2026-09-15.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
VERP im Vollausbau (eine eigene Adresse pro Empfänger) brauchst du nicht. Ein einzelnes Bounce-Postfach plus ein eigener Header reichen.
|
||||
|
||||
## Der Aufbau
|
||||
|
||||
**1. Return-Path trennen**
|
||||
|
||||
Envelope-Sender auf `bounce@presseecho.de`, `From` bleibt `redaktion@`. Dann landen Rückläufer in einem eigenen Postfach statt in deinem Posteingang. Das ist eine Zeile, global über einen `MessageSending`-Listener:
|
||||
|
||||
```php
|
||||
$event->message->returnPath('bounce@presseecho.de');
|
||||
```
|
||||
|
||||
**2. Eigenen Header mitschicken**
|
||||
|
||||
```php
|
||||
$event->message->getHeaders()->addTextHeader('X-Pk-Ref', "pm:{$pm->id}:contact:{$contact->id}");
|
||||
```
|
||||
|
||||
Das ist der eigentliche Trick. Ein RFC-3464-DSN besteht aus drei Teilen, und der dritte trägt die Header der Originalnachricht, damit der Absender den Bounce dem Versand zuordnen kann. Dein Header kommt also zurück. Du weißt damit exakt, welche PM an welchen Kontakt ging, ohne aus dem Bounce-Fließtext Rückschlüsse ziehen zu müssen. Der Nutzen von VERP, ohne dessen Adressverwaltung.
|
||||
|
||||
**3. Abholen: `webklex/laravel-imap`**
|
||||
|
||||
Das ist der De-facto-Standard und wird aktiv gepflegt (letztes Update Juli 2026). Es bringt sowohl die native php-imap-Erweiterung als auch ein eigenes IMAP-Protokoll mit, du bist also nicht darauf angewiesen, dass Ploi dir die Extension kompiliert. IDLE brauchst du nicht, ein Scheduler alle zehn Minuten auf `UNSEEN` reicht bei zwei bis drei Mails am Tag.
|
||||
|
||||
**4. Parsen**
|
||||
|
||||
Kein Regex auf den Body. Der mittlere Teil `message/delivery-status` enthält die Felder strukturiert, Parser müssen nicht raten. Du brauchst genau vier Felder:
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|---|---|
|
||||
| `Final-Recipient` | `rfc822; user@example.com` — die Adresse |
|
||||
| `Action` | `failed` = Bounce, `delayed` = temporär, `delivered`/`relayed` = ignorieren |
|
||||
| `Status` | `5.1.1` — Klasse 5 permanent, Klasse 4 temporär |
|
||||
| `Diagnostic-Code` | `smtp; 550 ...` — nur für dein Log |
|
||||
|
||||
Feldnamen sind case-insensitive, `Status:` und `status:` sind beide gültig. Maßgeblich ist `Action`, nicht der Statuscode allein.
|
||||
|
||||
## Zu den Paketen
|
||||
|
||||
Fertige Bounce-Parser für PHP gibt es, aber keiner davon ist etwas, worauf ich ein Produktivsystem stellen würde:
|
||||
|
||||
| Paket | Einschätzung |
|
||||
|---|---|
|
||||
| `cfortune/PHP-Bounce-Handler` | Parst multipart/report nach RFC 1892 und 1894 — das sind die Vorgänger von 3461/3464. Stammt aus der phpclasses-Ära, kein Composer-Paket, keine Namespaces. Als Nachschlagewerk für Statuscodes brauchbar, als Fundament nicht. |
|
||||
| `galata90/php-mail-bounce-handler` | Prüft IMAP-Postfach oder eml-Dateien und versucht bei fehlerhaften Bounces heuristisch den Status zu ermitteln. Näher dran, aber ebenfalls wenig gepflegt und setzt für den IMAP-Modus die php_imap-Extension voraus. |
|
||||
| `molnix/bounced-mail-manager` | Liest IMAP, erkennt Bounces, arbeitet mit eigenen Custom-Headern in ausgehenden Mails — konzeptionell genau dein Ansatz. Aber ein Ein-Personen-Paket mit sehr geringer Verbreitung. Für einen Solo-Betreiber ist eine Abhängigkeit, die niemand außer dir benutzt, selbst ein Wartungsrisiko. |
|
||||
|
||||
Mein Vorschlag: `webklex/laravel-imap` für den Transport, dazu ein eigener DSN-Parser von etwa 100 bis 150 Zeilen. Sobald das MIME sauber zerlegt ist, ist der DSN-Teil banal — Teil mit Content-Type `message/delivery-status` suchen, an Leerzeilen in Recipient-Blöcke splitten, `Key: Value` einlesen. Die genannten Pakete nimmst du als Testkorpus, nicht als Dependency; die eml-Sammlungen darin sind für deine Tests mehr wert als der Code.
|
||||
|
||||
## Zweiter Pfad, der ohne IMAP läuft
|
||||
|
||||
Nicht jeder Fehlschlag erzeugt eine Rückläufermail. Manche Server weisen direkt während der SMTP-Verbindung mit 5xx ab, statt einen DSN zu erzeugen — die Behandlung muss beide Wege abdecken. Bei dir wirft Symfony dann eine `TransportException` und der Queue-Job schlägt fehl. Wenn du deine Mails über die Queue verschickst (was du solltest), fängst du in `failed()` einen Teil der Fälle sofort ab, ohne dass IMAP beteiligt ist. Das ist billiger als der IMAP-Pfad und sollte zuerst gebaut werden.
|
||||
|
||||
## Was du im Parser nicht vergessen darfst
|
||||
|
||||
**Zustellbestätigungen aussortieren.** Ein DSN mit `Action: delivered` oder `relayed` ist kein Bounce. Wer nur nach `Final-Recipient` greift, blockt Adressen, die funktioniert haben.
|
||||
|
||||
**Nicht-DSNs nicht raten lassen.** Handgeschriebene Ablehnungen und alte MTAs liefern Freitext. Was der Parser nicht sicher zerlegt, kommt in einen Ordner `Unparsed` und in eine Admin-Liste. Zwei Mails pro Woche manuell einordnen ist akzeptabel, eine falsch geblockte Adresse nicht.
|
||||
|
||||
**Idempotenz über `Message-ID`.** Wenn der Job zwischen Verarbeitung und Verschieben abbricht, liest der nächste Lauf dieselbe Mail erneut. Bounce-Zähler brauchen deshalb eine Unique-Prüfung auf die Message-ID des Rückläufers, sonst zählst du einen Soft Bounce dreimal und sperrst zu früh.
|
||||
|
||||
**Der Circuit Breaker aus dem letzten Durchgang bleibt Pflicht** — bei Eigenversand über Hetzner sogar dringender als bei einem Dienstleister, weil dort ein IP-Reputationsproblem sichtbar wird und du es hier selbst merken musst.
|
||||
|
||||
Ein Punkt noch, den du kennen solltest, auch wenn die Entscheidung steht: Beim Selbstversand ohne Dienstleister ist der Bounce-Parser nicht dein Hauptrisiko, sondern der Anteil an Mails, die stumm im Spam-Ordner verschwinden. Dafür siehst du weder Bounce noch Exception. Wenn du dir später Sichtbarkeit darauf verschaffen willst, ist ein DMARC-Report-Postfach der billigste Einstieg — gleicher IMAP-Mechanismus, anderes Format.
|
||||
|
|
@ -1004,3 +1004,54 @@ Land, Logo und Pressekontakte. Neu:
|
|||
die Mail), sonst `organic`.
|
||||
|
||||
Tests: `tests/Feature/NewsroomProfileTest.php` (4).
|
||||
|
||||
## 1b.6 — Bounce-Suppression (Schritte 1 und 2, 15.09.2026)
|
||||
|
||||
Grundlage ist das Konzept `bounce-mails-2026-09-15.md` (eigenes Postfach als
|
||||
Return-Path, `X-Pk-Ref`-Header, IMAP-Abholung mit eigenem DSN-Parser,
|
||||
SMTP-Ablehnung als zweiter Pfad, Circuit Breaker). Zwei Korrekturen daran:
|
||||
Der Return-Path muss auf der **From-Domain** liegen (`bounce@pressekonto.com`,
|
||||
nicht presseecho.de — SPF-Ausrichtung für DMARC), und nicht jede 5xx ist ein
|
||||
Empfängerproblem: 554-Spam-Pattern des Relays sperrt sonst gesunde Adressen.
|
||||
|
||||
**Schritt 1 — Liste und Gate**
|
||||
|
||||
- `email_bounces` (Ereignis-Log, idempotent über `message_id`) und
|
||||
`email_suppressions` (Zustand je Adresse: Grund, Zähler, `suppressed_at`,
|
||||
`released_at`). Hart → sofort gesperrt; weich → ab dem dritten in sieben
|
||||
Tagen (`config/bounces.php`). Freigabe setzt `released_at`, der nächste
|
||||
Bounce löscht sie wieder.
|
||||
- `EmailSuppressionService` — die eine Stelle, die „nicht erreichbar" heißt.
|
||||
- `GateOutgoingMail` am `MessageSending`-Event: streicht gesperrte Empfänger
|
||||
aus jeder ausgehenden Mail, verwirft sie ganz, wenn keiner bleibt (mit
|
||||
Eintrag im Aktivitätsverlauf, Typ „Mail-Sperre"), setzt den Return-Path
|
||||
(`MAIL_RETURN_PATH`). Eine Stelle für Magic Links, Benachrichtigungen,
|
||||
Newsletter und alles Künftige.
|
||||
- `X-Pk-Ref` auf Veröffentlichungs-, Ablehnungs- und Magic-Link-Mail
|
||||
(`pm:ID:company:ID`, `user:ID`).
|
||||
- API-Meldungen: Ist der Pressekontakt gesperrt, geht die Mail an die
|
||||
Firmen-E-Mail; ist die auch gesperrt, an niemanden — nie an den
|
||||
API-Einlieferer.
|
||||
- `newsletter:import-bounces` schreibt jetzt zuerst in die zentrale Liste —
|
||||
damit erreicht die Handliste auch Pressekontakte und Firmen ohne Konto.
|
||||
|
||||
**Schritt 2 — SMTP-Pfad und Circuit Breaker**
|
||||
|
||||
- `RecordSmtpFailure` am `JobFailed`-Event: `SmtpFailureClassifier` deutet
|
||||
die `TransportException`. `5.1.x` mit Adresse → harter Bounce, `4.x.x` →
|
||||
weicher, alles andere (5.7.x, 554, Verbindung) → Relay-Ablehnung.
|
||||
- `MailCircuitBreaker`: ab 5 Relay-Ablehnungen in 15 Minuten wird der
|
||||
Versand 30 Minuten angehalten, **ein** Betriebs-Alert. Im Worker schlagen
|
||||
Mails dann fehl (nachholbar mit `queue:retry all`), im Web-Kontext werden
|
||||
sie verworfen und protokolliert; Betriebs-Alerts gehen immer durch.
|
||||
`php artisan mail:circuit [--reset]` zeigt bzw. hebt die Pause auf.
|
||||
|
||||
Tests: `tests/Feature/EmailSuppressionTest.php` (11).
|
||||
|
||||
**Deploy:** `migrate --force`, `config:cache`, dann in der `.env`
|
||||
`MAIL_RETURN_PATH=bounce@pressekonto.com` — erst, wenn das Postfach existiert.
|
||||
Danach einmal `newsletter:import-bounces` laufen lassen, damit die bisherige
|
||||
Handliste in die zentrale Sperre kommt.
|
||||
|
||||
**Offen:** Schritt 3 (IMAP-Abholung, `webklex/laravel-imap`, freigegeben
|
||||
15.09.2026) und Schritt 4 (Markierung im Admin, Claim-Seite, Freigabe).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue