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.

Passcodes are the exception: the member is told

For most emails a rejection is something only the logs record — the member finds out by not receiving something they may not have been expecting. A passcode is different: someone is sitting at the login screen waiting for it.

So when a passcode email is rejected, the login response says so instead of claiming success, and the member sees a panel explaining that their email provider refused the message. They are then offered whatever ways back in their account supports:

  • a verified phone → the code by SMS instead;
  • security questions → answer them to sign in directly (see below);
  • neither → told plainly there is no second channel, and pointed at support.

The panel is not only for a bounce. Three failures leave a member with nothing usable on the screen in front of them, and all three answer the same question — what can I do now — so it is answered once in handle2FAAuthentication:

  • the email bounced, deliveryFailed;
  • the send limit refused a code, four in ten minutes, deliveryBlocked;
  • the code was guessed wrong five times, so it was cancelled.

Failures that leave a working form — a mistyped digit, an expired code — are deliberately excluded. They stay a toast over the entry screen, because replacing it with a panel would take away the retry the member actually needs. That is what the stuck flag marks.

Which options appear depends on whether the server would accept them, not on what the member has configured:

  • SMS only when the send limiter would allow another code. The phone path goes through the same limiter, so offering it otherwise is a button that fails the same way — including on a fourth consecutive bounce, where the send counter is already spent.
  • Security questions only while a bounce is still in window, since that is what the answer endpoints require. A member who reached the send limit by resending mail that was arriving fine has no bounce on record and is not offered them.

The send-limit case matters more than its numbers suggest. A member whose email bounces reaches it fastest — their code never arrives, so they retry — and attempt only resets on a completed login, which they cannot make. Before this, that response carried no options at all, so a member who had already earned the security-answer fallback held an entitlement with no way to reach it.

“Has security questions” means rows holding a non-empty answer, not rows. Until TUN-873 the profile editor stored '' for a blank field and replaced a member’s answers by deleting all of them and re-inserting, unwrapped — so a failure mid-save wiped them silently, and two saves that interleaved left contradictory duplicates. Verification requires every stored row to match, so either one offered members a fallback they could never complete. The writer is now a single transaction keyed on (member_id, question_id), blanks are dropped rather than stored, and migration 0012 cleaned up the rows that predate it.

Because answers are stored hashed, the editor cannot show a member what they already set — every answer box is blank on load. A blank therefore means leave this question alone, not remove it: only a question the member did not submit at all is deleted. Without that, editing one answer silently deleted the two the member had left untouched.

Signing in with security questions

A member with security questions set can answer them to complete the login — no password reset. It reuses the username and password they just entered and, on a correct set of answers, issues a session exactly as a passcode or authenticator success does. It is a second factor on top of the password, not a password-free way in.

It is a genuine fallback, not a standing alternative: it is only available for a short window (15 minutes) after a real email bounce, stamped on the member’s passcode row as members_passcode.bounce_at, and cleared on first use. Issuing a new code clears it too, so the marker only ever reflects the most recent send — a member who has since received a code cannot reach the fallback. Outside that window the endpoints refuse, so answers can never substitute for the emailed code except in the exact case the panel exists for. Admins are excluded — they authenticate with an authenticator app, never an emailed passcode, so the fallback does not apply to them.

Earlier this path only led to a password reset, which looped a member back to the same failing 2FA step; it now issues a session directly, which is why it is offered.

A completed answer login counts as a completed login for the send limiter too, so it clears attempt and the unconsumed code — without that a member who got in this way came back already at the limit, and could never clear it, because the only thing that does is the passcode login their broken email prevents.

Measuring it

Four events, logger.info and so structured JSON in Datadog:

EventMeans
login.recovery_offeredoptions were put in front of the member — carries fallbacks
security_answers.screen_shownthey reached the questions
security_answers.acceptedthey got in
security_answers.rejectedwrong answers

Count unique member_id, not events — a member who retries produces several, and the send limiter makes that the common case. Offered against shown is how many see the option and do not take it; shown against accepted is whether people can actually remember their answers.

These only fire for members who hit a wall, so they are not a count of broken addresses. For that, query mandrill_logs — it holds the real address and years of history, where the error logs mask the address and Datadog keeps about a week.

The distinction between a transient rejection and a permanent one matters to the copy: a soft-bounce invites a retry, because the mailbox may simply be full or the mail server briefly unavailable. Anything else does not.

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