What happens after your email API says accepted

By

Follow one transactional email through provider acceptance, SMTP delivery, queues, duplicate and out-of-order events, reconciliation, bounces, and complaints.

~~~

An email API accepting a message does not mean the email arrived.

It means the provider accepted responsibility for trying.

The provider may deliver it immediately. It may queue it. The recipient’s mail server may defer it, reject it, or accept it. A later complaint can turn an apparently successful delivery into a serious problem.

If email is part of signup, password recovery, billing, or account security, one sendEmail() result is not enough.

We need to follow what happens afterward.

The path of one email

A transactional email crosses several systems:

sequenceDiagram
  participant App
  participant Provider
  participant Queue
  participant MailServer as Recipient mail server
  participant Consumer as Event consumer
  participant DB as Application database

  App->>DB: Create delivery attempt
  App->>Provider: Submit email
  Provider-->>App: Accepted + message ID
  App->>DB: Store message ID
  Provider->>MailServer: SMTP delivery attempt
  MailServer-->>Provider: Delivered, deferred, or rejected
  Provider->>Queue: Publish lifecycle event
  Queue->>Consumer: Deliver event
  Consumer->>DB: Append event and update delivery

Every arrow can fail.

Some operations can happen twice. Some arrive out of order. A connection can disappear after the other side completed its work.

This is a small distributed system hiding behind one welcome email.

Accepted, queued, and delivered are different

An email provider can return several states from the initial API call.

Cloudflare Email Service, for example, can report recipients as:

  • delivered immediately
  • queued for later delivery
  • permanent bounces

It also returns a provider message ID.

The message ID is the correlation key between the synchronous submission and later delivery events.

Do not turn all successful HTTP responses into delivered.

A 200 response says the API call succeeded. It does not describe the final SMTP conversation with the recipient’s mail server.

Create the local attempt first

Start a delivery record before calling the provider:

INSERT INTO email_deliveries (
  id,
  subscriber_id,
  purpose,
  status,
  created_at
) VALUES (?, ?, 'confirm_signup', 'submitting', CURRENT_TIMESTAMP);

This gives the attempt a local identity before the network call.

If the provider accepts the message, store its message ID:

UPDATE email_deliveries
SET
  message_id = ?,
  status = 'processing',
  updated_at = CURRENT_TIMESTAMP
WHERE id = ?;

If the provider explicitly rejects the request, mark the attempt as failed:

UPDATE email_deliveries
SET
  status = 'failed',
  terminal = 1,
  detail = ?,
  updated_at = CURRENT_TIMESTAMP
WHERE id = ?;

Keep each attempt as its own row.

Do not store one delivery status directly on the subscriber. One subscriber can receive several messages and several attempts for the same purpose.

A timeout creates an unknown state

Suppose the application submits the message and the connection resets.

The provider may have accepted the email. The application never received the message ID.

This is not an ordinary failure.

Use a separate state:

submission_unknown

Then reconcile through one of these mechanisms:

  • a provider idempotency key
  • a provider message search API
  • a later lifecycle event
  • an application-generated message header preserved by the provider

Do not immediately send another email.

The first one may already be travelling to the recipient. Retrying a password-reset email is annoying. Retrying a receipt or alert can be misleading. Retrying a large notification batch can be expensive.

Unknown is a real state. Store it.

Normalize the provider message ID

Email systems sometimes wrap message IDs in angle brackets:

<[email protected]>

The send response and delivery event might not use the same formatting.

Normalize before storing or comparing:

function normalizeMessageId(value) {
  const id = value.trim()

  if (id.startsWith('<') && id.endsWith('>')) {
    return id.slice(1, -1).trim()
  }

  return id
}

A tiny mismatch here makes every event look unrelated.

Also store the provider name. Message IDs are not guaranteed to be globally unique across every provider you may use later.

Delivery events form a second input

After submission, the provider continues working.

Cloudflare Email Sending publishes lifecycle events such as:

message.delivered
message.deferred
message.bounced
message.failed
message.rejected
message.complained

The provider sends these events to a Cloudflare Queue through an event subscription.

Your application now has two separate inputs:

  1. the response returned by the send API
  2. asynchronous lifecycle events

They describe the same email but travel through different paths.

The database joins them through the provider message ID.

Validate every incoming event

Do not trust a queue message merely because it came from your infrastructure.

Check the event type, source, domain, message ID, event ID, timestamp, and terminal flag:

function parseDeliveryEvent(body, expectedDomain) {
  if (!body || typeof body !== 'object') return null
  if (!knownTypes.includes(body.type)) return null
  if (body.source?.type !== 'email.sending') return null
  if (body.source?.domain !== expectedDomain) return null

  const messageId = normalizeMessageId(
    body.payload?.messageId ?? ''
  )
  const eventId = body.payload?.eventId
  const eventAt = body.metadata?.eventTimestamp

  if (!messageId) return null
  if (typeof eventId !== 'string' || !eventId) return null
  if (typeof body.payload?.terminal !== 'boolean') return null
  if (!Number.isFinite(Date.parse(eventAt))) return null

  return {
    eventId,
    messageId,
    type: body.type,
    eventAt,
    terminal: body.payload.terminal,
    payload: body.payload
  }
}

Keep a fixed allowlist of event types.

This prevents a new or malformed provider event from quietly becoming an application state.

Preserve the original payload too. Normalized columns make queries easy. The raw event helps when the provider adds fields or an incident needs investigation.

The correlation race

There is a small race:

  1. the provider accepts the email
  2. the provider emits a delivery event
  3. the application stores the provider message ID

Steps 2 and 3 can happen in the wrong order.

The queue consumer receives a valid event but cannot find its delivery row.

Do not discard it:

const matched = await applyDeliveryEvent(db, event)

if (!matched) {
  if (message.attempts < 3) {
    message.retry({ delaySeconds: 10 })
    return
  }

  await storeOrphanDeliveryEvent(db, event)
}

message.ack()

This retry is not for a failed database.

It gives the original request time to save the correlation key.

After the final attempt, store the unmatched event in an email_delivery_orphans table or send it to a dead letter queue. Keep enough data to replay it after fixing the correlation problem.

Acknowledging and forgetting it would turn a temporary race into permanent missing history.

Queue delivery can happen more than once

A queue may deliver the same event again after a consumer timeout, crash, or failed acknowledgement.

The provider may also publish a duplicate.

Store every provider event ID with a unique constraint:

CREATE TABLE email_delivery_events (
  event_id TEXT PRIMARY KEY,
  delivery_id TEXT NOT NULL,
  message_id TEXT NOT NULL,
  type TEXT NOT NULL,
  event_at TEXT NOT NULL,
  payload TEXT NOT NULL
);

Start one transaction by inserting the event with ON CONFLICT DO NOTHING.

If no row was inserted, acknowledge the duplicate and stop.

Only the first copy can update the delivery or create side effects.

Status assignments are often naturally repeatable. Alerts, suppression changes, and follow-up emails are not.

Put side effects in an outbox

A bounced or complained event may need to:

  • update the delivery
  • suppress the recipient
  • create an admin alert
  • notify another system

Do not update the database and then publish an alert as two unrelated operations.

The database update may commit while publishing fails.

Write an outbox record in the same transaction:

INSERT INTO email_outbox (
  id,
  type,
  delivery_id,
  payload
) VALUES (?, 'delivery_failed', ?, ?);

A separate worker publishes unsent outbox records and marks them complete.

The transaction makes one promise: either the delivery update and its required side effect both become durable, or neither does.

Events can arrive out of order

The provider may emit:

deferred at 10:01
delivered at 10:02

The queue may deliver delivered first and deferred later.

Only apply an event when it is at least as recent as the stored one:

UPDATE email_deliveries
SET
  status = ?,
  terminal = ?,
  event_at = ?,
  updated_at = CURRENT_TIMESTAMP
WHERE id = ?
  AND (event_at IS NULL OR event_at <= ?);

Without this condition, an old deferred event can replace a newer delivered state.

Still insert the older event into email_delivery_events. It belongs in the history even when it should not become the current state.

The event log and the current projection serve different purposes.

Separate terminal and non-terminal states

I use these application states:

submitting
submission_unknown
processing
deferred
delivered
bounced
failed
rejected
complained

submitting, submission_unknown, and processing belong to our application.

The others reflect delivery events.

Store the provider’s terminal flag instead of deriving finality only from the status name.

A deferred event is normally non-terminal because the provider will retry. A bounce is normally terminal. The event schema is the authoritative description for that delivery attempt.

Do not let an old non-terminal event reopen a terminal attempt.

A state machine makes the rules visible

The common path looks like this:

submitting → processing → delivered

               deferred

         delivered | bounced

Other paths include:

submitting → failed
submitting → submission_unknown
processing → rejected
delivered → complained

The last transition may look strange.

delivered means the recipient mail server accepted the message. It does not mean the person wanted it. A later complaint is a new and more serious fact.

Define allowed transitions and test them.

Do not accept every newer event merely because its timestamp is later.

Delivered does not mean inboxed or read

A delivered event usually means the recipient’s mail server returned a successful SMTP response such as 250.

It does not prove:

  • the message reached the inbox instead of spam
  • the recipient saw it
  • the recipient opened it
  • the recipient clicked anything

Open tracking introduces another set of limitations and privacy concerns. Many email clients block or proxy tracking pixels.

Keep the claim precise:

Delivered means the recipient’s mail system accepted the message.

That is still far more useful than knowing only that our API request worked.

Bounces and complaints change future sending

A hard bounce commonly means the address does not exist or cannot receive mail permanently.

A complaint means the recipient reported the message as spam.

Both should affect more than one delivery row.

Maintain recipient-level suppression state:

active
suppressed_bounce
suppressed_complaint

Before submitting a new transactional message, check the suppression state.

The provider may maintain its own suppression list too. Keep local state because the application still needs to explain why it did not attempt a message.

Be careful with temporary failures. A deferred event does not automatically mean the address should be suppressed.

Reconciliation catches missing events

Even with retries and dead letter queues, add a reconciliation job.

It should look for:

  • submitting attempts older than a few minutes
  • submission_unknown attempts
  • processing attempts with no lifecycle event after an expected period
  • orphan events that now have a matching message ID
  • outbox records that have not been published

The job can query provider logs or APIs when available.

Reconciliation is not a substitute for event processing. It is the safety net for gaps between systems.

Show failures that require action

The useful admin view is not a table of every email event.

Show the latest failed attempt for each subscriber:

WITH ranked AS (
  SELECT
    deliveries.*,
    ROW_NUMBER() OVER (
      PARTITION BY subscriber_id
      ORDER BY created_at DESC, id DESC
    ) AS position
  FROM email_deliveries AS deliveries
)
SELECT *
FROM ranked AS delivery
JOIN subscribers AS subscriber
  ON subscriber.id = delivery.subscriber_id
WHERE position = 1
  AND delivery.status IN (
    'bounced',
    'failed',
    'rejected',
    'complained',
    'submission_unknown'
  );

Add the purpose, last error, event time, and a link to the event history.

This turns delivery telemetry into an operations queue.

A failure may stop being actionable after a later successful attempt. A complaint remains important even after other successful deliveries.

The complete design

The system needs more than an email_status column:

flowchart LR
  A["Application request"] --> D[("Delivery attempts")]
  A --> P["Email provider"]
  P --> Q["Lifecycle event queue"]
  Q --> C["Consumer"]
  C --> E[("Immutable events")]
  C --> D
  C --> O[("Outbox")]
  C --> R[("Orphan events")]
  O --> N["Alerts and side effects"]
  J["Reconciliation job"] --> D
  J --> R
  J --> P

The pieces have different jobs:

  • delivery attempts answer what we currently believe
  • events preserve what the provider reported
  • orphans preserve facts we cannot correlate yet
  • outbox records make side effects durable
  • reconciliation finds missing or stuck work

What I would monitor

I would alert on:

  • rising hard-bounce rate
  • any complaint for low-volume transactional email
  • old submission_unknown attempts
  • growing orphan-event count
  • growing dead letter queue
  • delayed outbox records
  • processing attempts with no terminal event
  • sudden increases in provider rejection

The aggregate rates reveal reputation or configuration problems. The individual records help a person fix one account.

The deeper lesson

The send API answers one narrow question:

Did the provider accept this submission request?

The application needs a larger answer:

What happened to this delivery attempt, and do we need to act?

We get there by storing the attempt before the network call, preserving unknown outcomes, correlating later events, deduplicating them, rejecting stale state changes, and reconciling gaps.

Accepted tells us our API call worked.

Delivered tells us the recipient’s mail system accepted the message.

Those are two different facts. A reliable application stores both.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cloudflare: