Skip to content
FLAVIO COPES
flaviocopes.com

Track transactional email delivery

By

Track accepted, delivered, deferred, bounced, and rejected email states by correlating provider events with your own delivery records.

~~~

An email API accepting a message does not mean the email was delivered.

It means the provider accepted responsibility for trying.

Delivery can later be deferred, rejected, bounced, or reported as spam. If email is part of a signup or password-reset flow, we need to track those later states.

Create the delivery record first

Start a local delivery attempt before calling the provider:

INSERT INTO email_deliveries (
  id,
  subscriber_id,
  status
) VALUES (?, ?, 'submitting');

Then send the email.

If the provider accepts it, store the returned 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 = ?;

Do not overwrite the subscriber with one delivery status. A subscriber can have several attempts.

Keep each attempt as its own row.

A timeout or connection reset is different. The provider may have accepted the email even though your application never received the response. Mark that attempt as submission_unknown, then reconcile it through the provider’s idempotency key, message search, or event stream before sending again.

Normalize the provider message ID

Email systems sometimes wrap message IDs in angle brackets:

<0198abc@example.com>

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 delivery event look unrelated.

Validate incoming events

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

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

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 terminal = body.payload?.terminal
  const eventAt = body.metadata?.eventTimestamp

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

  return {
    eventId,
    messageId,
    status: body.type.replace('cf.email.sending.message.', ''),
    eventAt,
    terminal
  }
}

Keep a fixed allowlist of event types.

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

Handle the correlation race

There is a small race:

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

Steps 2 and 3 can arrive in the wrong order.

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

Do not discard it immediately:

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 before acknowledging it. Keep the provider event ID and payload so the event can be replayed after fixing the correlation problem.

Deduplicate event side effects

The queue can deliver the same provider event more than once.

Store each provider event ID with a unique constraint:

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

Start one transaction by inserting this event row with ON CONFLICT DO NOTHING. If no row was inserted, acknowledge the duplicate and stop. Only then apply the delivery update and insert alerts into an outbox in that same transaction.

Status updates are naturally repeatable, but alerts, suppressions, and other side effects are not. Publish those from the outbox after the transaction commits.

Ignore older events

Delivery events can also arrive out of order.

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, a delayed deferred event could replace a newer delivered event.

Separate terminal and non-terminal states

I use these states:

submitting
processing
deferred
delivered
bounced
failed
rejected
complained

submitting and processing belong to our application.

The others come from the delivery system.

Store the provider’s terminal flag too. A deferred event is commonly non-terminal, but the consumer should preserve the provider’s explicit flag instead of deriving finality from the status label.

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'
  )
  AND (
    subscriber.status = 'pending'
    OR delivery.status = 'complained'
  );

This turns delivery telemetry into an operations queue.

A failed attempt is no longer actionable after the subscriber later confirms. A complaint remains important even for a confirmed subscriber.

The distinction matters: accepted tells us our API call worked. Delivered tells us the recipient’s mail system accepted the message.

~~~

Related posts about cloudflare: