# Message states & scan verdicts

Every message ReplyLayer stores — inbound mail your agent receives and outbound
mail it sends — carries two independent status channels:

- **`state`** — the delivery-lifecycle status. This is the **authoritative**
  signal for what actually happened to the message (readable, held, sent,
  bounced, deleted). Branch your control flow on `state`.
- **`scan.verdict`** — the scanner's *own* decision about the content. It
  explains **why** a message might be held, but it is **not** guaranteed to equal
  `state` (see [Verdict is not delivery state](#verdict-is-not-delivery-state)).

This page is the single source for the state machine and the verdict vocabulary.
Related contracts live elsewhere: the [send-gate decision tree](/agents/send-gates),
the [error-code catalog](/agents/errors), the [per-field trust taxonomy](/agents/security-model)
(`sender_authentication`, `agent_safety_context`), the
[webhook event catalog](/agents/webhooks), and [tier limits](/docs/limits).

## The mailbox model

A message always belongs to exactly one **mailbox**. Every mailbox-scoped path
accepts the mailbox's **name or UUID** interchangeably (`GET /v1/mailboxes/support/messages`
and `GET /v1/mailboxes/<uuid>/messages` are equivalent). Agent-role API keys are
bound to specific mailboxes; admin keys and dashboard sessions see all of them.

Scope masking differs by read shape so an out-of-scope key can't probe for
siblings:

- **Detail reads** (`GET /v1/messages/:id`) resolve then mask — an unbound or
  non-existent message returns **`404 NOT_FOUND`**.
- **Collection reads** (`GET /v1/mailboxes/:id/messages`, `.../messages/wait`,
  `.../threads`) check mailbox scope *before* any lookup — an unbound mailbox
  **UUID** returns **`403`** and an unbound mailbox **name** returns **`404`**.

## The lifecycle

```
inbound:   received → scanning → available            (delivered to the agent)
                              ↘ quarantined            (releasable hold)
                              ↘ pending_review         (human-approval hold)
                              ↘ blocked                (terminal reject)
           firewall_blocked → scanning → …             (inbound firewall hold)

outbound:  draft → dispatching → available → delivered (accepted, then confirmed)
                 ↘ pending_review                       (review hold)
                 → available → bounced                  (delivery failed)
                 ↘ quarantined / blocked                (scanner outcome)

any state → deleted                                     (soft delete)
```

### State reference

| State | Direction | Meaning | Hold? |
|---|---|---|---|
| `draft` | outbound | Composed but not yet sent. A send-time rescan rejection keeps the row in `draft` and updates the scan result in place — it never flips a draft straight to `quarantined`/`blocked`. | — |
| `received` | inbound | Raw message stored, not yet processed. | — |
| `scanning` | both | Being processed by the scanner. Transient. | — |
| `available` | both | Inbound: safe view ready, delivered to the agent. Outbound: accepted for delivery. | — |
| `quarantined` | both | Scanner flagged; held for review. **Releasable.** | yes |
| `pending_review` | outbound (primary) | Held for an explicit human approve/deny — a mailbox review policy or a scanner `require_human_approval` decision. **Releasable.** | yes |
| `blocked` | both | Scanner rejected, or a review was denied. **Terminal** (except soft delete). | — |
| `delivered` | outbound | Delivery confirmed by the provider. | — |
| `bounced` | outbound | Delivery failed (hard bounce). | — |
| `firewall_blocked` | inbound | Held by the inbound sender firewall (sender blocklist or an allowlist miss). **Releasable** — release re-enters `scanning`. | yes |
| `dispatching` | outbound | Transient phase between an optimistic-ack `202` and the terminal outcome of an asynchronous send. | — |
| `deleted` | both | Soft-deleted. **Terminal.** Stored bytes are purged; the row is retained for audit. | — |

### Transition table

A transition not listed here is rejected — the state machine is forward-only and
enforced server-side.

| From | Allowed next states | Trigger |
|---|---|---|
| `draft` | `available`, `pending_review`, `deleted`, `dispatching` | Sync send (clean → `available`; review hold → `pending_review`); async send ack → `dispatching`; delete. |
| `received` | `scanning` | Processing begins. |
| `scanning` | `available`, `quarantined`, `pending_review`, `blocked` | Scan verdict resolves. |
| `available` | `delivered`, `bounced`, `deleted` | Outbound delivery confirmation; late/hard bounce; delete. |
| `quarantined` | `available`, `blocked`, `deleted` | Release → `available`; confirm block → `blocked`; delete. |
| `pending_review` | `available`, `blocked`, `deleted` | Approve → `available` (then dispatch); deny → `blocked`; delete. De-escalation to `quarantined` is intentionally **illegal**. |
| `blocked` | `deleted` | Delete only. |
| `delivered` | `bounced`, `deleted` | Late bounce; delete. |
| `bounced` | `deleted` | Delete only. |
| `firewall_blocked` | `scanning`, `deleted` | Firewall release re-enters `scanning` (then the normal `scanning →` transitions apply); delete. |
| `dispatching` | `available`, `pending_review`, `draft`, `deleted` | Async dispatch resolves: provider-accepted → `available`; review hold → `pending_review`; scanner block/quarantine, provider rejection, or watchdog timeout **rolls back to `draft`** so you can edit and retry; delete. |
| `deleted` | — | Terminal. |

## Releasable holds vs terminal outcomes

Three states are **releasable holds** — a human (dashboard session or admin key)
can move them onward:

| Hold state | Release action | Confirm/reject action |
|---|---|---|
| `quarantined` | `POST /v1/messages/:id/release` → `available` (inbound readable / outbound sent) | `POST /v1/messages/:id/block` → `blocked` |
| `pending_review` | `POST /v1/messages/:id/approve` → `available` then dispatch | `POST /v1/messages/:id/deny` → `blocked` |
| `firewall_blocked` | `POST /v1/messages/:id/firewall-release` → `scanning` (evaluated afresh) | (report-and-block, see below) |

Contract notes an agent must respect:

- **A `quarantined` release reuses the recorded decision — the scanner is not
  consulted again.** It marks the message `available` (inbound) or dispatches it
  (outbound) using the decision already on record. Only a **firewall release**
  re-enters `scanning` for a fresh evaluation.
- `POST /v1/messages/:id/release` returns `409` if the message is **not**
  `quarantined`. Outbound release can still fail to `blocked` if a hard dispatch
  gate (recipient policy, deliverability) rejects the send.
- **`blocked`, `bounced`, and `deleted` are terminal** — the only move out of them
  is `deleted` (soft delete). There is no "unblock".
- `POST /v1/messages/:id/report` is an inbound-only atomic **report-and-block**:
  it blocks a held message and adds the sender to the account blocklist in one
  transaction. An outbound target returns **`422 REPORT_OUTBOUND_UNSUPPORTED`**.

Inbound containment (`firewall_blocked`) only occurs when the sender firewall is
enforced for your mailbox. See the full error semantics in
[/agents/errors](/agents/errors) and mutation endpoints in [/agents/cli](/agents/cli).

## Scan verdict vocabulary

`scan` is a vendor-neutral summary rendered from the persisted scan result:
`{ verdict, categories[], findings[] }` (nullable when no scan ran).

`scan.verdict` is one of five values, in increasing severity:

| `scan.verdict` | Underlying decision | Meaning | Releasable? |
|---|---|---|---|
| `clean` | `allow` | No adverse finding. | — (deliverable) |
| `warning` | `allow_with_warning` | Passive advisory (e.g. PII noticed). Delivered as-is; the warning is API/operator-facing, never injected into the email. | — (deliverable) |
| `review_required` | `require_human_approval` | Content the scanner wants a human to approve. | hold |
| `quarantined` | `quarantine` | Releasable scanner hold. | **yes** |
| `blocked` | `block` | Terminal scanner reject. | no |

**`blocked` strictly dominates `quarantined`.** When findings of different
severities co-occur, the worst wins — a message with any `block` finding renders
`scan.verdict: "blocked"` even if it also has a quarantine-level finding. The
ordering is `clean < warning < review_required < quarantined < blocked`.

### `scan.categories[]` and `scan.findings[]`

`categories[]` is a per-category rollup — one `{ category, decision }` row per
non-`allow` category (empty `[]` on a clean summary). `findings[]` carries the
per-finding detail. Both use these **vendor-neutral** enums:

**`category`:** `prompt_injection`, `function_call_risk`, `harmful_content`,
`liability_risk`, `pii`, `phishing_url`, `image_exfil`, `malware`,
`attachment_policy`, `mime_mismatch`, `attachment_type_mismatch`, `spam`,
`language_policy`, `recipient_policy`, `secret_detected`, `content_similarity`,
`delivery_warmup`, `scan_incomplete`.

**`subtype`** (optional; scoped to its category):

- `prompt_injection` → `jailbreak`, `instruction_injection`
- `harmful_content` → `toxicity`, `violence`, `sexual_content`, `hate_speech`,
  `harassment`, `self_harm`, `profanity`
- `secret_detected` → `secret_value` (a detected credential/secret, either
  direction), `outbound_confidentiality_leak`

A finding row can also carry: `reason` (anonymized, human-readable; may be
`null`), `pii_type`, `attachment_index` + `attachment_filename` (the attachment a
finding refers to, stamped at scan time so it reaches list/wait/send surfaces that
carry no `attachments[]` array), a `failure_class` discriminator, an optional
`retryable` boolean, and an optional `agent_instructions[]` array.

### Infrastructure error vs content judgment

`failure_class` distinguishes a real content decision from an infrastructure
failure:

- **`model_judgment`** — the scanner evaluated the content and reached a verdict.
  Fix the content and resend.
- **`inference_error`** — a scanner failed for an infrastructure reason (timeout,
  transport error, fallback exhaustion). The hold is **not** a content judgment.
  These findings carry **`retryable: true`** and their `reason` is a static
  fallback string. The correct agent action is **retry later**, not edit-content.

`agent_instructions[]` is stable, machine-readable handling guidance computed
**structurally** from the typed finding fields (never by parsing `reason`). It is
vendor-free, and is omitted on `allow` findings and on `inference_error` findings
(an infrastructure error is not a content judgment). Treat these strings as
authoritative handling guidance.

**`delivery_warmup`** (a new-sender warm-up hold on the shared sending pool) is a
deliberate release-after-review hold, **not** a content judgment: it renders as a
releasable `quarantine` with a customer-safe reason and `agent_instructions`
naming the verify-your-own-domain escape hatch. It is never an `inference_error`
and is distinct from `scan_incomplete`.

**Directional coverage.** Scanning is asymmetric by design: the secrets scanner
runs **outbound-only** (exfiltration prevention), while inbound scanning targets
agent-input threats (prompt-injection / jailbreak). An inbound secret is the
sender's own data, not an exfil risk, so it is delivered `clean`.

## Verdict is not delivery state

`scan.verdict` is the *scanner-decision* channel; `state` is the *delivery*
channel. They can diverge because a mailbox review policy can promote or demote
the scanner's decision:

- A **clean** scan can be held: `scan.verdict: "clean"` **and**
  `state: "pending_review"` when the mailbox holds every outbound send for human
  review.
- On an account without the review-queue entitlement, a scanner
  `require_human_approval` is downgrade-safely **demoted** to `quarantined`
  (never silently allowed).

**Always branch on `state`** (or the send response `status`) for the authoritative
delivery/releasable signal, not on `scan.verdict`. When they diverge, the read and
send responses carry a `hold_context` object explaining the policy hold:
`{ trigger_source: "mailbox_policy" | "scanner" | "both", summary_reasons: string[] }`,
and the `wait`/list/detail reads carry a `review_trigger_source`
(`"mailbox_policy" | "scanner" | "both" | null`). `hold_context` is `null` when the
scan verdict already explains the hold (a real scanner block/quarantine) or when
the message was sent. Outbound reads additionally carry `email_effect`
(`{ effect_status, releasable, terminal, retryable }`) — a one-field discriminator
over the send outcome; see [/agents/send-gates](/agents/send-gates).

The full decision tree for how a send resolves to a state is owned by
[/agents/send-gates](/agents/send-gates).

### Thread-level verdict

`GET /v1/threads/:id` and the thread list surface a **worst-across-thread**
`scan` summary that folds delivery state in: a `blocked` member controls the whole
thread verdict, a `quarantined`/`firewall_blocked` member reduces to `quarantined`,
and a clean-but-`pending_review` member reduces to `review_required` — so an older
held or blocking message is never hidden behind a newer clean reply.

## Reading a message

`GET /v1/messages/:id` returns a **safe view only** — `body` is the sole body
field; raw MIME is never exposed. `body` is a projection:

```json
{
  "format": "text",
  "content": "Hello, my order #1234 hasn't arrived...",
  "char_count": 42,
  "returned_char_count": 42,
  "truncated": false
}
```

- Long bodies are capped at the **body-delivery cap (20,000 characters by
  default)**; `body.truncated` is then `true` (no marker text is appended inside
  `content`).
- **Bearer (API-key) callers always get plaintext.** Requesting `?body_format=html`
  with a Bearer key returns **`400 BODY_FORMAT_HTML_SESSION_ONLY`**; sanitized HTML
  is a dashboard-session affordance.
- When the mailbox's `pii_mode` is `redacted`, each detected PII span in the body
  is replaced with a `<TYPE>` tag (e.g. `<EMAIL_ADDRESS>`). Subject lines and
  attachment filenames are **not** redacted.

Inbound reads also carry `agent_safety_context` (an always-untrusted-content
behavioural layer — `untrusted_content` is `true` even on a clean scan) and
`sender_authentication` (a domain-authenticity signal). These belong to the trust
taxonomy — see [/agents/security-model](/agents/security-model). An agent cannot
enable, grant, or loosen instruction trust for itself; any relaxation is
configured by a human via a dashboard session with fresh re-authentication.

Attachment rows carry a preview-status projection (`preview_status` ∈
`pending | ready | blocked | failed | null`, plus `preview_kind`,
`preview_char_count`, `preview_page_count`, `preview_truncated`,
`preview_generated_at`). Only fetch the preview when `preview_status` is `ready`.
See [/agents/attachments](/agents/attachments).

### Read state is explicit

`GET /v1/messages/:id` is **side-effect-free** — reading a message never changes
its `read_at`. Mark messages read deliberately:

- **`POST /v1/messages/:id/read`** — single message. Eligible only for
  **inbound, visible** rows (`direction='inbound'` and `state NOT IN ('deleted',
  'firewall_blocked')`). Outbound, deleted, and firewall-blocked targets return
  `200` with the row's existing `read_at` (usually `null`) as a no-op. Idempotent:
  the first mark pins the timestamp, and a second call returns the same value.
- **`POST /v1/mailboxes/:id/threads/:thread_id/read`** — bulk-mark every visible
  inbound unread message in a thread. Returns `{ thread_id, marked_count }`;
  `marked_count` is rows newly stamped this call (a fully-read thread returns
  `marked_count: 0`, not `404`).

### `body_preview`

The list (`GET /v1/mailboxes/:id/messages`) and `wait` reads return a compact
summary row with **`body_preview`** — the first 200 characters of the message
body, plaintext (never HTML), and PII-redacted under the mailbox's `pii_mode`.
Each row also carries `has_attachment` (boolean), `starred`, `read_at`,
`state`, and `mailbox_name`, so an agent can triage without a detail read.

## Waiting for new mail

`GET /v1/mailboxes/:id/messages/wait` is a long-poll for new messages. It holds
the connection open until a message arrives or the timeout elapses.

**Query params:**

- **`timeout`** — seconds to hold, **1–30, default 30** when omitted. A **numeric**
  value `> 30` or `< 1` returns **`400 VALIDATION_ERROR`** (fail-loud — an
  out-of-range hold is *not* silently clamped). An *omitted* **or non-numeric**
  `timeout` falls back to 30 — a non-parseable value is treated as absent, not
  rejected.
- **`since=<iso-datetime>`** — switches the endpoint into **monitoring-loop mode**:
  it surfaces only messages with `created_at > since`, in **chronological (ASC)**
  order. Pass the `created_at` of the last returned message as `since` on the next
  poll to advance the cursor. **Without `since`**, the endpoint returns the single
  **newest** matching message.

**On a hit**, `message` is a compact summary row (the same shape as the list row —
`id`, `direction`, `state`, `sender`, `recipient`, `subject`, `mailbox_name`,
`body_preview`, `starred`, `has_attachment`, `created_at`, `read_at`), plus
`review_trigger_source` for held rows.

**On timeout with no new message**, the response is `200` with:

```json
{ "message": null }
```

Reconnect immediately to continue the loop. Because a mailbox with human review enabled can hold a
clean message, **branch on `message.state`, not `scan.verdict`** — a row can be
`state: "pending_review"` with `scan.verdict: "clean"`.
