> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Email delivery lifecycle: bounces, complaints, and the retry queue

> How email moves from provider acceptance to a mailbox outcome — the queued/sent/delivered/bounced/complained status map, the Resend webhook events that drive it, the durable retry queue that recovers terminal send failures, and how to read submitted_no_receipt on a channel with no DLR.

# Email delivery lifecycle

Email shares the core delivery state machine described in [delivery lifecycle](/concepts/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:

| Status                              | Meaning                                                                                                                                                                                                                      | Terminal? |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
| `queued`                            | Row accepted and placed on the send queue.                                                                                                                                                                                   | No        |
| `sent`                              | Resend accepted the submission. Provider-side acceptance only — the recipient's mail server has not yet confirmed.                                                                                                           | No        |
| `delivered`                         | The recipient's mail server accepted the message, confirmed by a delivery event.                                                                                                                                             | Yes       |
| `bounced` (surfaced as `failed`)    | The receiving server rejected the message — a hard bounce, or a soft bounce whose in-call recovery was exhausted.                                                                                                            | Yes       |
| `complained` (surfaced as `failed`) | The recipient filed a spam complaint. The address is added to the email suppression list so future sends are short-circuited before dispatch.                                                                                | Yes       |
| `submitted_no_receipt`              | The submission was accepted and no delivery event arrived within 60 minutes — the wire-intermediate sentinel, not a delivery. See [How `submitted_no_receipt` applies to email](#how-submitted_no_receipt-applies-to-email). | No        |

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](#the-email-retry-queue-end-to-end).

## Resend webhook mapping

Resend emits lifecycle webhooks that Orbit normalizes into the status map above:

| Resend event                    | Orbit status           | Notes                                                                                                                               |
| ------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `email.sent`                    | `sent`                 | Provider accepted the submission.                                                                                                   |
| `email.delivery_delayed`        | `sent` (held)          | The recipient server deferred; the row stays at `sent` while Resend retries at the provider layer.                                  |
| `email.delivered`               | `delivered`            | Recipient server accepted. This is the delivery confirmation.                                                                       |
| `email.bounced`                 | `failed`               | Hard bounces terminalize immediately; soft bounces hold at `sent` with `soft_bounced_at` until recovery exhausts, then terminalize. |
| `email.complained`              | `failed` + suppression | A spam complaint; also writes `complained_at` / `complaint_type` and suppresses the address.                                        |
| `email.opened`, `email.clicked` | (no status change)     | Engagement only — stamps metadata, never advances status.                                                                           |
| `email.failed`                  | `failed`               | Provider-side terminal send failure after Resend's own retry budget — distinct from a recipient-side bounce.                        |
| `email.suppressed`              | `failed`               | The recipient was on a suppression list and the send was short-circuited.                                                           |

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:

```
attempt 1 ──(5 min)──▶ attempt 2 ──(30 min)──▶ attempt 3 ──(2 h)──▶
attempt 4 ──(12 h)──▶ attempt 5 ──(24 h)──▶ exhausted
```

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](/concepts/async-processing-model).

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

* [Delivery lifecycle](/concepts/delivery-lifecycle) — the channel-agnostic state machine this page extends
* [How Orbit processes work asynchronously](/concepts/async-processing-model) — the queue backbone the email-retry queue runs on
* [Consent and suppression model](/concepts/consent-and-suppression-model) — how complaints and bounces feed suppression
