Kai Ole Hartwig
Kubernetes-Migration geplant?

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 …

Nutze es nicht wenn …

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:

SchrittWas passiert
Fehlergrund festhaltenDie Exception wird als ErrorDetailsStamp an die Nachricht geschrieben.
WiederholenBis zu drei Versuche, nach 30, 60 und 120 Sekunden, jeweils mit 10 % Jitter, damit nicht alle Mails gleichzeitig den Mailserver treffen.
ParkenNach 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=60

Mails, 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

  1. Paket installieren: composer require koh/typo3-async:^1.0. Ohne weiteren Schritt ändert sich nichts, TYPO3 verschickt weiter im Request.
  2. 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.
  3. MESSENGER_ASYNC=1 in 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

TeilAufgabe
AsyncMailerDekorator um MailerInterface: rendert, kopiert, legt in die Queue – oder reicht durch, wenn der Schalter aus ist
koh_async_mailDoctrine-Transport, Queue mail
koh_async_failedDoctrine-Transport, Queue failed für Nachrichten ohne Versuche
SendEmailHandlerVersand im Consumer über den inneren Mailer
Drei Worker-ListenerFehlergrund, 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" }

Grenzen und Roadmap

Was sich ändert, wenn eine Mail in der Queue liegt

Geplant

Häufige Fallstricke

Nächster Schritt

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.

K8s-Setup besprechen →

Oder direkt schreiben: mail@ole-hartwig.eu