Skip to main content

Email delivery lifecycle

Email shares the core delivery state machine described in delivery lifecycle, but it runs that machine on a different provider (Resend, not a carrier network), adds two failure outcomes SMS never sees (bounced and complained), and carries a durable retry queue that turns a terminal send failure into scheduled re-attempts. This page covers the email-specific parts of that lifecycle end to end. Read it before you branch integration code on email outcomes or report on email delivery rates.

Why email needs its own lifecycle

The shared lifecycle’s per-channel caveat for email is a single bullet — “adds bounced” — because the full answer is structural: provider acceptance is not mailbox delivery, and email makes that gap visible in a way SMS does not. When you POST /messages/email and the row reaches sent, Resend has accepted the message. The recipient’s mail server has not necessarily accepted it yet — it can greylist, defer, or reject outright. Between “Resend accepted” and “the recipient’s mail server said yes,” several things can happen that the SMS lifecycle never models:
  • The receiving server can hard-bounce (the address does not exist) or soft-bounce (mailbox full, temporary deferral) — terminal and recoverable outcomes that share the bounce word.
  • The recipient can complain — mark the message as spam — which is a terminal outcome for that address but a different failure class than a bounce, and drives suppression, not retry.
  • The send itself can fail transiently (a provider 5xx, a network fault) or terminally (a 4xx, an unverified domain), and only the durable retry queue decides whether the message is lost or merely delayed.
That is why email needs its own map: the same word (sent) covers a different promise than on SMPP-backed channels, and the failure vocabulary is richer.

Status map

An email passes through the same happy-path states as any channel — queued → sending → sent → delivered — and then splits into email-specific outcomes: Two email statuses look different from the raw provider event, and the distinction matters for metrics: a bounce and a complaint both write the row as failed, but the underlying event type is preserved — email.bounced versus email.complained — and the complaint additionally stamps metadata.complained_at and metadata.complaint_type on the row. A soft bounce that has not exhausted its recovery holds at sent with a metadata.soft_bounced_at marker, staying out of failed until that recovery is exhausted — so a transient deferral is never counted as a terminal bounce. Engagement events — email.opened and email.clicked — never advance the status at all. They stamp metadata.opened_at / metadata.clicked_at on top of whatever lifecycle position the row already holds, because inbound security appliances (Proofpoint, Mimecast, Microsoft Safe Links) routinely pre-fetch pixels and follow tracked links while scanning a message at the recipient’s gateway — before the message is delivered, and even when it ultimately bounces. Engagement is never proof of delivery. For system-level emails that fail every retry — an invite, billing alert, or operator notification the platform sends on your behalf — exhaustion is observability-grade: a durable audit entry is written under the email_delivery_failed action so the audit trail carries every permanently dropped email in one query, and an operator alert fires. Your outbound message sends follow the same escalation discipline through the email-retry queue.

Resend webhook mapping

Resend emits lifecycle webhooks that Orbit normalizes into the status map above: Treat sent as “in flight” and reserve delivered for the recipient-side confirmation — that is the boundary the bounced/complained split makes explicit.

The email-retry queue end to end

The send path is escalation-first: a Resend API call retries transient errors in-call with backoff before it ever touches a queue. Only when every in-call attempt fails does the send escalate to the durable email-retry queue — the envelope (from/to/subject/body plus an idempotency key) is persisted to Redis with its own attempt counter, so a process restart loses nothing and a duplicate enqueue dedups on a deterministic job id. From there, a dedicated worker re-runs the send on a fixed schedule:
The schedule is bespoke — five attempts with delays of 5 minutes, 30 minutes, 2 hours, 12 hours, and 24 hours — rather than the queue library’s automatic retry budget, so the delays operators see in the dashboard match the schedule the system actually runs. A successful re-attempt resolves the job and the email lands. On exhaustion, the queue writes the email_delivery_failed audit row and fires an operator alert, so a permanently dropped email is never silently lost. Failed jobs are retained for seven days and remain inspectable in the queue-health panel; the general mechanics behind that retention are in how Orbit processes work asynchronously. The same escalation shape applies whether the failing call came from your POST /messages/email request or from a platform-generated system email — the queue is shared, and the idempotency key the original call used rides every re-attempt so Resend can dedup a send that actually succeeded during the initial outage window.

How submitted_no_receipt applies to email

Email has no delivery-receipt (DLR) equivalent. An SMPP carrier either returns a DLR or does not; Resend returns webhook events, and on a broken webhook path — an unconfigured endpoint secret, endpoint drift, a dropped notification — no event lands at all. The row would sit at sent forever. The same fallback that covers SMPP channels covers email, on email’s own clock: after a 60-minute grace window (twice the SMS window, because a slow recipient MTA greylisting for 30 minutes is normal), a still-sent email is promoted to submitted_no_receipt. Analytics counts the sentinel toward the delivered rate — the channel’s delivery KPI does not drag on a provider hiccup — but the row is explicitly not terminal: a genuine late email.delivered still overwrites it, and an hourly reconciler resolves each sentinel row against Resend’s authoritative per-message state (a true bounce or complaint heals to failed; a confirmed delivery heals to delivered). Two email-specific guards keep that fallback honest:
  • A row carrying soft_bounced_at is never promoted — it is a negative receipt, not a missing one, and it stays at sent until recovery exhausts or a real delivery lands.
  • An engagement marker (opened_at / clicked_at) is never treated as proof of delivery — gateway scanners pre-fetch pixels on messages that bounce afterward, so engagement-only rows resolve through the sentinel plus reconciler path, not a direct delivered promotion.

Pitfalls

  1. Treating sent as delivery. sent means Resend accepted the submission, nothing more. The recipient’s server can still bounce it, and on a broken webhook path it stays at sent until the 60-minute sentinel promotion. Gate any “email arrived” logic on delivered, and treat submitted_no_receipt as outcome-unknown, not success.
  2. Counting complaints against the delivered rate. A complaint is terminal for that address — the row goes to failed and the address is suppressed — but it is a different failure class than a bounce. Blend the two and a sender-reputation problem reads as a routing problem. Report complaints separately; the complaint path stores complaint_type on the row precisely so you can split them.
  3. Prematurely hard-failing soft bounces. A soft bounce holds the row at sent with soft_bounced_at while recovery runs. If you mirror statuses into your own datastore, do not flip such a row to your terminal bucket — a later email.delivered legitimately overwrites it.
  4. Trusting engagement as delivery proof. An opened_at stamp can come from a gateway scanner on a message that hard-bounced minutes later. Branch delivery logic on the lifecycle status, and read engagement from metadata as a separate signal.
  5. Dropping submitted_no_receipt into the failed bucket. Same sentinel trap as the shared lifecycle, with an email-specific twist: the hourly reconciler will heal the row to its true terminal state, so rows parked in the sentinel are still resolving. Report the sentinel rate separately — a rising share points at the webhook path, not at your recipients.

See also