Track transactional email delivery
By Flavio Copes
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:
- the provider accepts the email
- the provider emits a delivery event
- 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: