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>
5.4 KiB
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:
$event->message->returnPath('bounce@presseecho.de');
2. Eigenen Header mitschicken
$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.