Email delivery
Every email the platform sends — passcodes, logbook notifications, currency
warnings, payment receipts — goes out through Mandrill. This page covers the
delivery mechanism itself. The content of individual emails belongs to the
feature that sends them; the templates live under
api/src/features/shared/email/templates/.
The question this page exists to answer is the one support gets most often: a member says they never received an email — where do I look?
Sending an email
EmailSender renders a template, builds the recipient list and hands the message
to Mandrill. The client is constructed on first use rather than at import time,
so a MANDRILL_API_KEY that arrives late in the boot sequence is not baked in as
undefined for the life of the process. A missing key raises immediately rather
than failing quietly later.
sendEmail() returns true only when Mandrill accepted every recipient. It
is not a promise that the mail arrived — only that it was taken for delivery.
When a send is rejected
This is the part that surprises people. The Mandrill client never throws.
@mailchimp/mailchimp_transactional ends every request with
.catch(error => error), so an invalid API key, an HTTP 500 or a dropped
connection all come back as a resolved value. A try/catch around the send
cannot see them.
Per-recipient rejections are quieter still. A blacklisted, unsubscribed or invalid address comes back HTTP 200, with the failure sitting inside the results array. Mandrill considers three states deliverable:
| Status | Meaning |
|---|---|
sent | Accepted and on its way |
queued | Accepted, waiting to go |
scheduled | Accepted, held for a future time |
Anything else — rejected, invalid — is a failed recipient. The response is
inspected explicitly and each failure is logged at error level as
email.send_failed, carrying the reject reason, the template, the module and the
member id. Recipient addresses are masked in every log line.
The failures fall into five kinds:
| Reason | What happened |
|---|---|
recipient rejected | A specific address was refused — blacklisted, unsubscribed or invalid |
api error | Mandrill refused the call itself: a dead key, a template that will not render |
transport error | The request never completed — network, timeout, DNS |
no recipients accepted | The list was empty, most often a member with no email address on file |
unrecognised response | Mandrill answered with something unexpected |
A rejected message leaves no row in
mandrill_logs. Webhook events are only produced for messages Mandrill actually accepted, so a rejection never generates one. Theemail.send_failedlog line is the only trace that the send was attempted. If a member is missing an email and the logs table has nothing, this is why — look for the log line, not the row.
Webhook events and mandrill_logs
For messages Mandrill did accept, it reports progress — delivered, opened,
clicked, bounced — by POSTing batches of events to /api/mandrill-webhook.
Those events populate mandrill_logs, which feeds two things: the admin Mandrill
logs page, and the daily health check that counts the previous day’s events by
status to decide whether email delivery is working.
The route acknowledges with a 200 before processing. That is deliberate:
Mandrill retries anything that is not a prompt 2xx, and a slow handler would earn
duplicate batches. It has a consequence worth understanding — once the 200 has
gone out, Mandrill considers the batch delivered and will never send it again, so
anything that fails after that point is lost for good.
Because of that, each event is stored in its own try/catch. One malformed or
unstorable event costs one event rather than the remainder of the batch, and is
logged with its mandrill_id, status, masked recipient, module and member id. A
payload that will not parse at all is logged and dropped deliberately.
Signature verification
The webhook sits outside the normal token check — Mandrill cannot present our
JWT — so for a long time nothing authenticated it at all. Anyone who knew the URL
could write rows into mandrill_logs with a chosen mandrill_id, status and
member id, skewing both the admin page and the daily health check.
Mandrill signs every call. X-Mandrill-Signature is a base64 HMAC-SHA1, keyed
with the webhook’s key, computed over the webhook URL exactly as registered in
Mandrill’s dashboard, followed by each POST parameter sorted by key.
That “exactly as registered” is the whole difficulty. If our copy of the URL
differs by one character — http for https, a trailing slash, a host the proxy
rewrites — every signature we compute is wrong, and enforcing on it would reject
every genuine call.
So the check is currently observational: the signature is computed and compared, and nothing is rejected. The verdict is one of four:
| Verdict | Meaning |
|---|---|
valid | The signature matched — logged as a heartbeat |
invalid | It did not match — logged at error with the URL we signed against |
missing | No header arrived |
unconfigured | The key or URL is not set, so no comparison was possible |
unconfigured is kept distinct from invalid on purpose. If the two collapsed
into one, a key that went astray in Infisical would be indistinguishable from a
forged request — and enforcement would reject all genuine traffic in the same
breath.
Three environment variables control it:
| Variable | Purpose |
|---|---|
MANDRILL_WEBHOOK_URL | The URL to sign. Copy it verbatim from Mandrill → Webhooks |
MANDRILL_WEBHOOK_KEY | The webhook’s key — not the Mandrill API key |
MANDRILL_WEBHOOK_ENFORCE_SIGNATURE | Set to the exact string true to start rejecting. Anything else is off |
When enforcement is switched on, invalid calls are answered with a 401. That is
checked before the 200 is sent, because a rejection decided after the
acknowledgement is not a rejection at all. A 401 is not a 2xx, so Mandrill
retries rather than treating the batch as delivered.
Where to look when an email is missing
- Search the logs for
email.send_failedwith the member id. If it is there, the send was refused — the reason field says why, and there will be nomandrill_logsrow. - If there is no such line, the send was accepted. Check
mandrill_logsfor the delivery events — abouncedorsoft-bouncedstatus means the address is real but the mail did not land. - If neither has anything, the code path that should have sent the email probably never ran. That is a question for the feature, not for delivery.