IntegrationsGENERALPROTOCOL-SPECIFICCLOUD-SPECIFIC

Email and Notifications

The integration everyone builds first and treats casually: an external provider, at-least-once delivery, and a side effect that cannot be taken back.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

Why is sending an email harder than calling an API that sends an email?

The requirement

When an order is placed, the customer receives a confirmation. Once. With the right details. Even if our service restarts mid-request.

The obvious build

Call the provider's SDK from the order handler right after saving the order. One line, and the customer gets their email.

Why it breaks

The provider is slow. Checkout now waits on an email provider, and its latency and availability are in the path of taking money (Calling Something You Do Not Control).

How it breaks in production
  • The provider is slow. Checkout now waits on an email provider, and its latency and availability are in the path of taking money (Calling Something You Do Not Control).
  • The send is inside the transaction, so a database lock is held across a network call to a third party (External Calls Inside a Transaction).
  • The send is outside the transaction and the transaction rolls back, so the customer has a confirmation for an order that does not exist (The Dual Write Problem).
  • The send times out, the code retries, and the customer receives two confirmations — an effect that cannot be undone and that they will notice (Retries).
  • The address is invalid. The provider accepts the request and bounces asynchronously, so your success log is wrong and nobody learns the customer never received anything.
  • A staging deploy points at production credentials and real customers receive test emails.
  • Enough bounces accumulate that the provider throttles or suspends the account, and every email stops — including password resets.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • Sending is a side effect you cannot retract. Unlike a database write, there is no compensating transaction for an email a customer has already read, which is why duplicates matter more here than almost anywhere else.
  • The provider's API call is acceptance, not delivery. A 202 means the message was queued by the provider; delivery, bounce, spam-folder placement and complaint all happen later and are reported through a separate channel (Inbound Webhooks).
  • Delivery is inherently asynchronous and multi-stage: your queue, the provider's queue, the receiving mail server, the recipient's spam filter, the inbox. You have visibility into the first two and influence over none of the rest.
  • Because the send is asynchronous and at-least-once, deduplication is your responsibility: a stable key per logical message, checked before sending, is what prevents redelivery from becoming a second email (Job Idempotency).
  • Bounces and complaints are reputation-affecting. Continuing to send to addresses that hard-bounced degrades your sender reputation for every recipient, which is why a suppression list is a functional requirement and not a nicety.
  • Notifications generalise the same shape across channels — email, SMS, push, in-app — with different costs, different failure reporting and different regulatory rules, over one preference and suppression model.
  • Some messages are transactional (a receipt, a password reset: expected, individually triggered, exempt from most marketing rules) and some are marketing (bulk, requiring consent and unsubscribe). Treating them identically is both a deliverability mistake and a legal one.

From event to inbox, and where you stop being able to see

The pipeline below has a visibility cliff in the middle. Everything up to provider acceptance is yours to instrument; everything after it is reported back to you asynchronously, if at all. Most teams instrument only up to the cliff and describe that as delivery.

The step worth designing carefully is the claim. Whether it happens before or after the provider call determines whether your failure mode is a lost message or a duplicate one, and that is a product decision that differs by message type.

One order confirmation
  1. 1
    Fact committed

    The order exists, and an outbox row records that a confirmation is owed.

    fails by Sending from the handler instead, so a rollback leaves a confirmation for a nonexistent order (The Dual Write Problem).

  2. 2
    Consumer picks it up

    A worker reads the intent.

    fails by Consumer dead; nothing errors and no email is ever sent (Queue Backlog).

  3. 3
    Suppression check

    Is this recipient bounced, complained or unsubscribed?

    fails by Checked at enqueue rather than at send, so a fresh unsubscribe is ignored.

  4. 4
    Claim the message key

    Atomic conditional insert on (template, recipient, entity).

    fails by Claiming after the send, or a non-atomic check, so a redelivery sends twice.

  5. 5
    Render

    Template plus data becomes the final body.

    fails by Null field renders a raw placeholder; a template error fails after the claim, losing the message.

  6. 6
    Provider call

    Provider accepts and returns an id.

    fails by Timeout with unknown outcome; 429 treated as a generic error (Retries).

  7. 7
    Provider delivers

    SMTP conversation with the recipient's server.

    fails by Bounce, greylisting, spam classification. Reported later, or not at all.

  8. 8
    Delivery webhook

    Provider reports delivered / bounced / complained.

    fails by Endpoint not implemented, so acceptance is mistaken for delivery (Inbound Webhooks).

Steps one to six are observable to you. Seven and eight are the ones that decide whether the customer actually got it.

Claim, then send

DATABASE-SPECIFICRelies on a unique constraint making the insert atomic. Any store with a conditional put on a key provides the same guarantee; a store without one cannot express this claim at all and needs an external lock, which introduces its own failure modes.

The whole duplicate problem reduces to the order of two operations and whether the first is atomic. A read-then-write check is not a claim: two workers can both read "not sent" and both send.

Note the deliberate choice in the failure path. If the provider call fails ambiguously, the claim stays in place and the message is not retried automatically — because a second confirmation email is worse than a missing one that support can resend. For a one-time security code the calculus may differ, and that difference should be explicit in the code rather than emergent from it.

Sending an order confirmation from a worker
Send, then record
const already = await db.sent.find({ orderId, template })
if (already) return                       // read-then-write: two workers both pass

await mailer.send({ to: user.email, template, data })
await db.sent.insert({ orderId, template })
// crash between send and insert -> the retry sends again
// and the customer has two confirmations
Claim atomically, then send
const key = `${tenantId}:${template}:${orderId}`

// atomic: unique constraint decides the winner.
// A concurrent worker or a redelivery loses here.
const claim = await db.notifications.insertIfAbsent({
  key, status: 'claimed', claimedAt: now(),
})
if (!claim) return                        // someone else owns this message

if (await suppression.blocks(user.email)) {   // checked at SEND time
  return db.notifications.update(key, { status: 'suppressed' })
}

try {
  const res = await mailer.send({
    to: user.email, template, data,
    idempotencyKey: key,                  // if the provider supports it, use it
  })
  await db.notifications.update(key, { status: 'accepted', providerId: res.id })
} catch (err) {
  // Deliberate: the claim STAYS. An ambiguous failure may mean it was
  // sent. For a receipt, a missing email support can resend beats a
  // duplicate the customer already read. Record it and surface it.
  await db.notifications.update(key, { status: 'unknown', error: String(err) })
  metrics.increment('notify.ambiguous')
}

The conditional insert makes the claim a single atomic operation, so concurrency and redelivery cannot both pass it — which the read-then-write version permits precisely during the retry storm it needs to survive. Keeping the claim after an ambiguous failure encodes a product decision about which failure the customer would rather have, instead of leaving it to whatever the retry policy happens to do.

What actually goes wrong

Notification incidents are rarely subtle. They are duplicates, silence, or a flood of stale messages — and every one of them is visible to customers, which is why they generate support volume out of proportion to their technical severity.

Notification failures and their responses
TriggerSymptomCauseResponse
Worker crashes after send, before recordingCustomer receives two identical confirmations.Record-after-send, or a non-atomic check.Atomic claim before the provider call; keep the claim on ambiguous failure.
Consumer stopped overnightNo emails at all. No errors. Discovered by a customer.Alerting on error rate rather than on progress (Queue Backlog).Alert on oldest-unsent age; synthetic send to a monitored mailbox.
Provider outage for two hoursBacklog builds, then floods on recovery: shipping notices for delivered orders.No staleness policy on the backlog.Drop or collapse notifications past a staleness threshold; rate-shape the drain (Backpressure).
Hard bounces ignoredSender reputation drops; provider throttles the account; password resets stop.No suppression list.Consume bounce webhooks; suppress permanently on hard bounce (Inbound Webhooks).
Template renders a null fieldEmail arrives with a blank section or a literal placeholder.Rendering unvalidated against the data shape.Validate rendered output; fail the job rather than sending broken content (The Three Validations).
Staging points at production credentialsReal customers receive test emails.Shared configuration; nothing structurally prevents it.Separate credentials, provider sandbox mode, plus a recipient allowlist outside production (Validate at Startup, Fail Loudly).
Bulk campaign hits the provider limit429s; part of the campaign never sends.Bursting rather than shaping.Leaky-bucket shaping on the outbound path; honour Retry-After (Rate Limit Algorithms).
Unverified bounce webhook endpointLegitimate customers stop receiving mail.Anyone can post suppression entries.Verify the provider's signature on every webhook (Webhook Signature Verification).

How to build it

Most important first.

  • Never send from the request path. Commit the order, record the intent, and let a consumer do the sending (The Transactional Outbox, Background Jobs).
  • Give every logical message a stable idempotency key — something like tenant, template, recipient and the triggering entity id — and claim it atomically before calling the provider (Idempotency Keys).
  • Claim before sending, not after. If the process dies between claim and send the message is lost; if it dies between send and claim the message is duplicated. Losing a receipt is recoverable by resending on request; sending two is not retractable.
  • Maintain a suppression list — hard bounces, complaints, unsubscribes, and explicit preferences — and check it at send time, not at enqueue time, because it may change in between.
  • Consume the provider's bounce, complaint and delivery webhooks, verify their signatures, and treat them as the real delivery signal (Webhook Signature Verification).
  • Render templates from data, validate the rendered output, and store what you sent. "What exactly did the customer receive" is a support question you will be asked.
  • Make non-production environments incapable of reaching real recipients — separate credentials, a provider sandbox mode, plus a recipient allowlist as a second layer (Configuration: Separating Code From Environment, Validate at Startup, Fail Loudly).
  • Respect the provider's own rate limits and shape outbound volume rather than bursting a bulk send at them (Rate Limit Algorithms).
  • Separate transactional from marketing traffic — ideally on different sending identities — so a bulk campaign's complaint rate cannot block password resets.

What can go wrong

Failure modes
  • Duplicate sends from job retries, redeliveries or a user double-clicking. The single most common and most visible failure (Duplicate Detection).
  • A dead-letter queue full of unsent notifications that nobody reads, so the failure is a silent absence (Dead-Letter Queues).
  • Provider outage with no fallback, no backlog handling, and no plan for the flood when it returns (Queue Backlog).
  • Suppression list ignored, causing repeated sends to dead addresses and a reputation decline that affects everything.
  • Template rendering failure at send time — a null field in an edge-case order — producing an email with a blank section or a raw placeholder.
  • The queue drained after an incident, sending hours of stale notifications at once: "your order has shipped" for orders already delivered.
  • Rate limited by the provider mid-campaign, and the retry logic treats 429 as a generic error (Rate Limiting).
  • A single sending identity shared by marketing and transactional mail, so one campaign's complaint rate blocks account recovery for everyone.
What can race
  • Two workers claiming the same notification concurrently — the claim must be an atomic conditional insert, not a read-then-write (Atomic Operations).
  • A user double-submitting an action that triggers a notification, producing two intents with different ids for one logical message unless the key derives from the entity rather than the request (Idempotency Keys).
  • A suppression entry being written while a send for that recipient is in flight, so the check passed a moment before the unsubscribe.
  • A retry after a provider timeout overlapping the original request, where the provider may have accepted both (Timeouts).
Security
  • Email content routinely contains personal data, order details and one-time links. Do not log rendered bodies, and be deliberate about how long you retain them (Secrets in Logs).
  • Password reset and magic-link emails are authentication material. The token belongs to a single-use, short-lived, server-side record, and a duplicate send must not create a second valid token (Token Authentication and the Revocation Problem).
  • Recipient addresses must come from server-side state, never from the request body. An endpoint that sends "to the address provided" is an open relay for phishing with your domain's reputation (The Trust Boundary).
  • Template injection: user-controlled content rendered into HTML email must be escaped, or one user's display name becomes markup in another user's inbox (Parse, Validate, Authorize, Process in Security).
  • Enumeration through notification behaviour: a password reset that responds differently for existing and non-existent accounts is an account oracle regardless of what the email says.
  • Provider webhooks must be signature-verified. An unverified bounce endpoint lets anyone add arbitrary addresses to your suppression list and silently deny mail to real customers (Webhook Signature Verification).
Misreads
  • "The API returned 200, so the email was sent." It was accepted for delivery. Bounce, spam placement and silent discard all happen afterwards and are reported separately.
  • "Email is simple." It is a non-retractable side effect delivered by a third party through a chain you cannot observe, with a reputation system attached. It is one of the harder integrations to do properly.
  • "Retry until it succeeds." Every retry risks a duplicate, and a duplicate is visible to the customer in a way a duplicate log line is not.
  • "Duplicates are not a big deal." They are the most visible bug your product can ship. Two order confirmations generate support contacts; two payment receipts generate alarm.
  • "We will queue it, so it is reliable." A queue makes it durable. Delivery still depends on the provider, the recipient's server and their spam filter (At-Least-Once Delivery).
  • "Marketing and transactional mail are the same pipeline." Different consent rules, different unsubscribe obligations, and shared sending reputation — which is how a campaign takes down password resets.

Operating it

How you see it in production
  • The funnel as separate metrics: enqueued, claimed, accepted by provider, delivered, bounced, complained, opened. Most teams measure only the third and call it delivery.
  • Bounce and complaint rate as a monitored signal with a threshold, because providers act on these and their action is abrupt.
  • Time from triggering event to provider acceptance, which is the part you control and the part that grows first when the consumer falls behind (Depth Is Not an Emergency; Age Is in Performance).
  • Duplicate-suppressed counter. Non-zero is healthy — it means the dedupe key is doing its job; a spike means an upstream retry loop (Retries).
  • Per-template send volume and failure rate, so one broken template is visible without reading logs.
  • A synthetic send to a monitored mailbox that verifies actual arrival, which is the only check that covers the hops after the provider (Health Checks: Startup, Readiness, Liveness).
What changes at 10x and 100x
  • At 10x, a bulk send becomes a rate-shaping problem: the provider has limits and bursting into them is how a campaign becomes an incident (Rate Limit Algorithms).
  • Per-message cost makes volume an economic decision, so preference management and digesting are cost controls as much as user-experience features.
  • At 100x, template rendering and personalisation become real compute, and rendering during the send loop caps throughput (What Serialization Costs).
  • Backlog after an outage needs deliberate handling: send everything and users get a flood of stale messages, drop everything and they get nothing. Discarding notifications past a staleness threshold is usually the right call and must be a decision, not an accident.
  • Multiple channels multiply the problem: the same event fanning out to email, push and SMS needs one preference model and one dedupe key across all of them.
What this costs
  • Asynchronous sending is correct and means the user does not know the email is on its way. A UI that says "sent" when it means "queued" is a small, common lie.
  • Claiming before sending accepts rare loss to avoid duplicates. The opposite ordering trades duplicates for reliability, and for a receipt that is the wrong trade — while for a one-time security code it may be the right one.
  • A suppression list is a correctness and reputation requirement and will occasionally suppress a legitimate recipient who fixed their mailbox.
  • Multiple providers give failover and double the integration surface, the template quirks and the webhook handling.
  • Storing what you sent answers support questions and stores personal data with a retention obligation attached.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALAsynchronous sending, deduplication, suppression and delivery-versus-acceptance apply to every provider and every channel.
  • PROTOCOL-SPECIFICEmail is store-and-forward with asynchronous bounces, so failure arrives minutes later through a webhook. Push notifications report invalid device tokens synchronously and require per-platform handling; SMS reports delivery through carrier receipts with wildly varying reliability and carries per-message cost and regulatory constraints email does not.
  • CLOUD-SPECIFICProviders differ in ways that change the design: which delivery events they report, whether they offer native idempotency on send, how suppression lists are managed and shared across sending identities, and how sender reputation is scoped. Treat a provider migration as a re-integration rather than a configuration change.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — a side effect in an external system that cannot be rolled back, and what "exactly once" can and cannot mean when it leaves your boundary.