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 …
- a slow or unreliable mail server holds up forms, password resets or backend actions
- a mail must not be lost on an SMTP error
- you can keep one process running that reads the queue – a Kubernetes Deployment, a systemd service or a Supervisor program
Don't use it if …
- no consumer can run: a queue nobody reads does not delay mails, it keeps them
- you are looking for a mail spool that batches mails – this sends each mail on its own as soon as the consumer takes it
- you run PHP < 8.5 or TYPO3 < 14.3
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:
| Step | What happens |
|---|---|
| Record the reason | The exception is written onto the message as an ErrorDetailsStamp. |
| Retry | Up to three attempts, after 30, 60 and 120 seconds, each with 10 % jitter so that not every mail hits the mail server at once. |
| Park | After 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=60Taking 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
- Install the package:
composer require koh/typo3-async:^1.0. Nothing changes without the next steps; TYPO3 keeps sending in the request. - 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. - Set
MESSENGER_ASYNC=1in 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
| Part | Job |
|---|---|
AsyncMailer | Decorator around MailerInterface: renders, copies, queues – or passes through when the switch is off |
koh_async_mail | Doctrine transport, queue mail |
koh_async_failed | Doctrine transport, queue failed for messages without attempts left |
SendEmailHandler | Sends in the consumer through the inner mailer |
| Three worker listeners | Failure 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" }- Surge, not Recreate: two consumers for the length of a rollout are harmless, the Doctrine transport locks the row it takes. A gap would not be.
- Grace period: on SIGTERM the consumer finishes the message in hand and stops. 60 seconds cover a mail send with a round trip.
- The same environment: the consumer needs the same mail configuration (DSN, sender, credentials) as the website, or it sends with different settings.
- Alert: an alert on “no consumer available for five minutes” belongs with it. If none runs, mails pile up without anyone noticing.
Limits and roadmap
What changes when a mail sits in the queue
MailerInterface::getSentMessage()returnsnull: when the request ends, the mail has not been sent yet. The core only uses the value for logging.- A sending error no longer shows in the request, but in the consumer's log and as a parked message.
- The order of two mails is not guaranteed if one of them had to be retried.
Planned
- Image variants on upload: crops and sizes are produced by the consumer right after the upload, not on the first page view. Pays off above all on image-heavy sites and shops.
Common pitfalls
- Switch without consumer.
MESSENGER_ASYNC=1without a running consumer collects mails instead of sending them. Set both together, never one without the other. - Consumer with a different mail configuration. If the consumer lacks the sender or credentials, sending fails there – and the mails end up in the failure queue after three attempts.
- Empty sender address. TYPO3's default for
defaultMailFromAddressis empty and falls back to an address under the site domain. Many mail services refuse it unless it is verified. - Composer mode only. No
ext_emconf.php, no classic mode.
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.
Or email me directly: mail@ole-hartwig.eu