typo3-async — Mails verlassen den Request.
TYPO3 verschickt jede Mail im Request: Ein Formular, ein Passwort-Reset oder eine Backend-Aktion wartet, bis der Mailserver geantwortet hat. Ist er langsam oder kurz weg, wartet der Besucher oder sieht einen Fehler. typo3-async rendert die Mail dort, wo sie geschrieben wird, legt sie in eine Queue und lässt einen Consumer sie verschicken – mit drei Wiederholungen und einer Failure-Queue, damit keine Mail verloren geht.
- Composer-Paket:
koh/typo3-async:^1.0 - Extension-Key:
koh_async - TYPO3: 14.3+ (Composer-Mode) · PHP: 8.5+ · MIT
TL;DR — Passt das zu meinem Setup?
Nutze es wenn …
- ein langsamer oder unzuverlässiger Mailserver Formulare, Passwort-Resets oder Backend-Aktionen aufhält
- eine Mail bei einem SMTP-Fehler nicht verloren gehen darf
- du einen Prozess dauerhaft laufen lassen kannst, der die Queue liest – als Kubernetes-Deployment, systemd-Dienst oder Supervisor-Programm
Nutze es nicht wenn …
- kein Consumer laufen kann: Eine Queue, die niemand liest, verzögert Mails nicht, sie behält sie
- du einen Mail-Spool suchst, der Mails bündelt – das hier verschickt jede Mail einzeln, sobald der Consumer sie nimmt
- PHP < 8.5 oder TYPO3 < 14.3 im Einsatz ist
Aufwand
Ein Composer-Paket, ein Consumer-Prozess und eine Umgebungsvariable. Kein Code in der Anwendung, keine Änderung an Formularen oder Templates: Alles, was TYPO3s Mailer benutzt, geht automatisch über die Queue.
So funktioniert es
Request (Formular, Passwort-Reset, Backend)
│ MailerInterface::send()
▼
AsyncMailer MESSENGER_ASYNC=1?
│ nein ──► innerer Mailer, wie bisher
│ ja
│ FluidEmail im Request rendern, als einfache Email kopieren
▼
Queue "mail" (Doctrine-Transport, sys_messenger_messages)
│
▼
messenger:consume koh_async_mail
│
▼
SendEmailHandler ──► innerer TYPO3-Mailer ──► SMTP / SES / …
Der AsyncMailer dekoriert TYPO3s MailerInterface. Seit TYPO3 14 läuft jede Mail über diesen Dienst, deshalb erreicht der Dekorator alles: Formular-Finisher, Passwort-Reset, Workspace-Benachrichtigungen, eigene Extensions.
Warum im Request gerendert wird
Eine FluidEmail rendert spät, erst beim Versand, und hält dafür ihren View und den Request. Beides lässt sich nicht serialisieren, und im Consumer fehlten Site, Sprache und Links, für die die Mail geschrieben wurde. Der Dekorator rendert sie deshalb im Request und legt eine einfache Email mit beiden Bodys und allen Anhängen in die Queue.
Warum der Consumer den inneren Mailer nimmt
Der Consumer läuft ebenfalls mit MESSENGER_ASYNC=1. Ginge er über den dekorierten Mailer, läge die Mail sofort wieder in der Queue. Der SendEmailHandler verschickt deshalb über den inneren Mailer, mit TYPO3s eigenem Transport und seinen Events.
Der Fehlerpfad — keine Mail geht verloren
TYPO3s messenger:consume bringt keinen eigenen Fehlerpfad mit: Wirft der Handler eine Exception, gilt die Nachricht als erledigt und ist weg. Für eine Mail hieße das, dass ein kurzer SMTP-Fehler sie verschluckt. Die Extension hängt deshalb Symfonys eigene Worker-Listener an TYPO3s Event-Dispatcher:
| Schritt | Was passiert |
|---|---|
| Fehlergrund festhalten | Die Exception wird als ErrorDetailsStamp an die Nachricht geschrieben. |
| Wiederholen | Bis zu drei Versuche, nach 30, 60 und 120 Sekunden, jeweils mit 10 % Jitter, damit nicht alle Mails gleichzeitig den Mailserver treffen. |
| Parken | Nach dem dritten Fehlschlag landet die Nachricht in der Queue failed (Transport koh_async_failed), statt gelöscht zu werden. |
Geparkte Mails ansehen und erneut senden
Geparkte Nachrichten liegen in sys_messenger_messages mit queue_name = 'failed'; der ErrorDetailsStamp nennt den Fehler. Ist die Ursache behoben, schickt ein einmaliger Lauf alles erneut los:
vendor/bin/typo3 messenger:consume koh_async_failed --limit=100 --time-limit=60Mails, Webhooks oder Bildverarbeitung aus dem Request holen? Ich helfe bei Queue-Design, Consumer-Betrieb und der Integration in TYPO3 auf Kubernetes. Anfragen →
Installation und Aktivierung
Drei Schritte
- Paket installieren:
composer require koh/typo3-async:^1.0. Ohne weiteren Schritt ändert sich nichts, TYPO3 verschickt weiter im Request. - Einen Consumer starten, der die Queue liest:
vendor/bin/typo3 messenger:consume koh_async_mail --time-limit=3600 --memory-limit=256M. Er beendet sich nach einer Stunde oder bei 256 MB sauber und wird vom Prozessmanager neu gestartet. MESSENGER_ASYNC=1in der Umgebung setzen – im Web-Prozess und im Consumer, und nur dort, wo der Consumer wirklich läuft.
Warum der Schalter eine Umgebungsvariable ist
Eine Queue, die niemand liest, verzögert Mails nicht, sie behält sie. Ob ein Consumer läuft, weiß nur die Plattform, die ihn startet, nicht der Code. Deshalb schaltet die Plattform beides zusammen: wer den Consumer startet, setzt die Variable, und nur dann.
Was die Extension mitbringt
| Teil | Aufgabe |
|---|---|
AsyncMailer | Dekorator um MailerInterface: rendert, kopiert, legt in die Queue – oder reicht durch, wenn der Schalter aus ist |
koh_async_mail | Doctrine-Transport, Queue mail |
koh_async_failed | Doctrine-Transport, Queue failed für Nachrichten ohne Versuche |
SendEmailHandler | Versand im Consumer über den inneren Mailer |
| Drei Worker-Listener | Fehlergrund, Wiederholung, Parken |
Kubernetes: der Consumer als Deployment
Der Consumer ist ein zweiter Prozess aus demselben Image wie die Website. Als Deployment mit einer Replica liest er die Queue dauerhaft. Endet ein Lauf nach --time-limit, startet nur der Container neu, der Pod bleibt.
apiVersion: apps/v1
kind: Deployment
metadata:
name: consumer
spec:
replicas: 1
strategy:
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: consumer
image: registry.example.org/typo3-runtime@sha256:...
workingDir: /app
command: ["vendor/bin/typo3", "messenger:consume", "koh_async_mail",
"--time-limit=3600", "--memory-limit=256M"]
env:
- { name: MESSENGER_ASYNC, value: "1" }- Surge statt Recreate: Zwei Consumer für die Dauer eines Rollouts sind harmlos, der Doctrine-Transport sperrt die Zeile, die er nimmt. Eine Lücke wäre es nicht.
- Grace Period: Bei SIGTERM beendet der Consumer die Nachricht in Arbeit und hört dann auf. 60 Sekunden reichen für einen Mailversand mit Rückfrage.
- Dieselbe Umgebung: Der Consumer braucht dieselbe Mail-Konfiguration (DSN, Absender, Zugangsdaten) wie die Website, sonst verschickt er mit anderen Einstellungen.
- Alarm: Ein Alarm auf „kein Consumer verfügbar seit fünf Minuten“ gehört dazu. Läuft keiner, sammeln sich Mails, ohne dass jemand es merkt.
Grenzen und Roadmap
Was sich ändert, wenn eine Mail in der Queue liegt
MailerInterface::getSentMessage()gibtnullzurück: Beim Ende des Requests ist die Mail noch nicht verschickt. Der Core nutzt den Wert nur zum Loggen.- Ein Versandfehler erscheint nicht mehr im Request, sondern im Log des Consumers und als geparkte Nachricht.
- Die Reihenfolge zwischen zwei Mails ist nicht garantiert, wenn eine davon wiederholt werden musste.
Geplant
- Bildvarianten beim Upload erzeugen: Zuschnitte und Größen entstehen direkt nach dem Upload im Consumer, nicht beim ersten Seitenaufruf. Lohnt sich vor allem bei bildlastigen Seiten und Shops.
Häufige Fallstricke
- Schalter ohne Consumer.
MESSENGER_ASYNC=1ohne laufenden Consumer sammelt Mails, statt sie zu verschicken. Setze beides gemeinsam, nie das eine ohne das andere. - Consumer mit anderer Mail-Konfiguration. Fehlen dem Consumer Absender oder Zugangsdaten, scheitert der Versand dort – und die Mails landen nach drei Versuchen in der Failure-Queue.
- Absenderadresse leer. TYPO3s Standard für
defaultMailFromAddressist leer und fällt auf eine Adresse unter der Site-Domain zurück. Viele Mail-Dienste lehnen sie ab, wenn sie nicht verifiziert ist. - Nur Composer-Mode. Kein
ext_emconf.php, kein Classic-Mode.
Mails aus dem Request holen?
Wenn Formulare oder Passwort-Resets auf einen Mailserver warten oder bei einem Fehler Mails verloren gehen, hilft typo3-async. Sprich mich für Integration, Consumer-Betrieb oder eine Erweiterung auf weitere Aufgaben an.
Oder direkt schreiben: mail@ole-hartwig.eu