What happens after your email API says accepted
By Flavio Copes
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:
- the response returned by the send API
- 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:
- the provider accepts the email
- the provider emits a delivery event
- 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:
submittingattempts older than a few minutessubmission_unknownattemptsprocessingattempts 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_unknownattempts - 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: