Kai Ole Hartwig
Planning a Kubernetes migration?

typo3-async — mails leave the request.

TYPO3 sends every mail inside the request: a form, a password reset or a backend action waits until the mail server has answered. If it is slow or briefly gone, the visitor waits or sees an error. typo3-async renders the mail where it is written, puts it on a queue and lets a consumer send it – with three retries and a failure queue, so no mail is lost.

  • Composer package:koh/typo3-async:^1.0
  • Extension key:koh_async
  • TYPO3: 14.3+ (Composer mode) · PHP: 8.5+ · MIT

TL;DR — Does this fit my setup?

Use it if …

Don't use it if …

Effort

One Composer package, one consumer process and one environment variable. No code in the application, no change to forms or templates: everything that uses TYPO3's mailer goes through the queue automatically.

How it works

Request (form, password reset, backend)
   │  MailerInterface::send()
   ▼
AsyncMailer            MESSENGER_ASYNC=1?
   │  no ──► inner mailer, as before
   │  yes
   │  render the FluidEmail in the request, copy it into a plain Email
   ▼
queue "mail"           (Doctrine transport, sys_messenger_messages)
   │
   ▼
messenger:consume koh_async_mail
   │
   ▼
SendEmailHandler ──► inner TYPO3 mailer ──► SMTP / SES / …

 

The AsyncMailer decorates TYPO3's MailerInterface. Since TYPO3 14 every mail goes through that service, so the decorator reaches all of them: form finishers, password reset, workspace notifications, your own extensions.

Why it renders in the request

A FluidEmail renders late, only when it is sent, and holds its view and the request to do so. Neither can be serialised, and in the consumer the site, the language and the links the mail was written for would be missing. So the decorator renders it in the request and queues a plain Email with both bodies and every attachment.

Why the consumer uses the inner mailer

The consumer runs with MESSENGER_ASYNC=1 as well. Through the decorated mailer, the mail would go straight back onto the queue. So the SendEmailHandler sends through the inner mailer, with TYPO3's own transport and its events.

The failure path — no mail is lost

TYPO3's messenger:consume brings no failure path of its own: if the handler throws, the message counts as done and is gone. For a mail that would mean a brief SMTP error swallows it. So the extension connects Symfony's own worker listeners to TYPO3's event dispatcher:

StepWhat happens
Record the reasonThe exception is written onto the message as an ErrorDetailsStamp.
RetryUp to three attempts, after 30, 60 and 120 seconds, each with 10 % jitter so that not every mail hits the mail server at once.
ParkAfter the third failure the message goes to the queue failed (transport koh_async_failed) instead of being deleted.

Inspecting and resending parked mails

Parked messages sit in sys_messenger_messages with queue_name = 'failed'; the ErrorDetailsStamp names the error. Once the cause is fixed, one run sends everything again:

 

vendor/bin/typo3 messenger:consume koh_async_failed --limit=100 --time-limit=60

Taking mails, webhooks or image processing out of the request? I help with queue design, running consumers and the integration into TYPO3 on Kubernetes. Get in touch →

Installation and activation

Three steps

  1. Install the package: composer require koh/typo3-async:^1.0. Nothing changes without the next steps; TYPO3 keeps sending in the request.
  2. Start a consumer that reads the queue: vendor/bin/typo3 messenger:consume koh_async_mail --time-limit=3600 --memory-limit=256M. It ends cleanly after an hour or at 256 MB and is restarted by the process manager.
  3. Set MESSENGER_ASYNC=1 in the environment – in the web process and in the consumer, and only where the consumer really runs.

Why the switch is an environment variable

A queue nobody reads does not delay mails, it keeps them. Whether a consumer runs is known to the platform that starts it, not to the code. So the platform switches both together: whoever starts the consumer sets the variable, and only then.

What the extension ships

PartJob
AsyncMailerDecorator around MailerInterface: renders, copies, queues – or passes through when the switch is off
koh_async_mailDoctrine transport, queue mail
koh_async_failedDoctrine transport, queue failed for messages without attempts left
SendEmailHandlerSends in the consumer through the inner mailer
Three worker listenersFailure reason, retry, park

Kubernetes: the consumer as a Deployment

The consumer is a second process from the same image as the website. As a Deployment with one replica it reads the queue continuously. When a run ends after --time-limit, only the container restarts; the pod stays.

 

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" }

Limits and roadmap

What changes when a mail sits in the queue

Planned

Common pitfalls

Next step

Taking mails out of the request?

If forms or password resets wait for a mail server, or mails get lost on an error, typo3-async helps. Talk to me about integration, running the consumer or extending it to further tasks.

Discuss the K8s setup →

Or email me directly: mail@ole-hartwig.eu