Skip to Content
Business logicEmail delivery

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:

StatusMeaning
sentAccepted and on its way
queuedAccepted, waiting to go
scheduledAccepted, 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:

ReasonWhat happened
recipient rejectedA specific address was refused — blacklisted, unsubscribed or invalid
api errorMandrill refused the call itself: a dead key, a template that will not render
transport errorThe request never completed — network, timeout, DNS
no recipients acceptedThe list was empty, most often a member with no email address on file
unrecognised responseMandrill 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. The email.send_failed log 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:

VerdictMeaning
validThe signature matched — logged as a heartbeat
invalidIt did not match — logged at error with the URL we signed against
missingNo header arrived
unconfiguredThe 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:

VariablePurpose
MANDRILL_WEBHOOK_URLThe URL to sign. Copy it verbatim from Mandrill → Webhooks
MANDRILL_WEBHOOK_KEYThe webhook’s key — not the Mandrill API key
MANDRILL_WEBHOOK_ENFORCE_SIGNATURESet 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

  1. Search the logs for email.send_failed with the member id. If it is there, the send was refused — the reason field says why, and there will be no mandrill_logs row.
  2. If there is no such line, the send was accepted. Check mandrill_logs for the delivery events — a bounced or soft-bounced status means the address is real but the mail did not land.
  3. 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.
Last updated on