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).

This page is the single source for the state machine and the verdict vocabulary. Related contracts live elsewhere: the send-gate decision tree, the error-code catalog, the per-field trust taxonomy (sender_authentication, agent_safety_context), the webhook event catalog, and tier 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

StateDirectionMeaningHold?
draftoutboundComposed 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.
receivedinboundRaw message stored, not yet processed.
scanningbothBeing processed by the scanner. Transient.
availablebothInbound: safe view ready, delivered to the agent. Outbound: accepted for delivery.
quarantinedbothScanner flagged; held for review. Releasable.yes
pending_reviewoutbound (primary)Held for an explicit human approve/deny — a mailbox review policy or a scanner require_human_approval decision. Releasable.yes
blockedbothScanner rejected, or a review was denied. Terminal (except soft delete).
deliveredoutboundDelivery confirmed by the provider.
bouncedoutboundDelivery failed (hard bounce).
firewall_blockedinboundHeld by the inbound sender firewall (sender blocklist or an allowlist miss). Releasable — release re-enters scanning.yes
dispatchingoutboundTransient phase between an optimistic-ack 202 and the terminal outcome of an asynchronous send.
deletedbothSoft-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.

FromAllowed next statesTrigger
draftavailable, pending_review, deleted, dispatchingSync send (clean → available; review hold → pending_review); async send ack → dispatching; delete.
receivedscanningProcessing begins.
scanningavailable, quarantined, pending_review, blockedScan verdict resolves.
availabledelivered, bounced, deletedOutbound delivery confirmation; late/hard bounce; delete.
quarantinedavailable, blocked, deletedRelease → available; confirm block → blocked; delete.
pending_reviewavailable, blocked, deletedApprove → available (then dispatch); deny → blocked; delete. De-escalation to quarantined is intentionally illegal.
blockeddeletedDelete only.
deliveredbounced, deletedLate bounce; delete.
bounceddeletedDelete only.
firewall_blockedscanning, deletedFirewall release re-enters scanning (then the normal scanning → transitions apply); delete.
dispatchingavailable, pending_review, draft, deletedAsync 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.
deletedTerminal.

Releasable holds vs terminal outcomes

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

Hold stateRelease actionConfirm/reject action
quarantinedPOST /v1/messages/:id/releaseavailable (inbound readable / outbound sent)POST /v1/messages/:id/blockblocked
pending_reviewPOST /v1/messages/:id/approveavailable then dispatchPOST /v1/messages/:id/denyblocked
firewall_blockedPOST /v1/messages/:id/firewall-releasescanning (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 and mutation endpoints in /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.verdictUnderlying decisionMeaningReleasable?
cleanallowNo adverse finding.— (deliverable)
warningallow_with_warningPassive advisory (e.g. PII noticed). Delivered as-is; the warning is API/operator-facing, never injected into the email.— (deliverable)
review_requiredrequire_human_approvalContent the scanner wants a human to approve.hold
quarantinedquarantineReleasable scanner hold.yes
blockedblockTerminal 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_injectionjailbreak, instruction_injection
  • harmful_contenttoxicity, violence, sexual_content, hate_speech, harassment, self_harm, profanity
  • secret_detectedsecret_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.

The full decision tree for how a send resolves to a state is owned by /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 onlybody is the sole body field; raw MIME is never exposed. body is a projection:

{
  "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. 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_statuspending | 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.

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:

{ "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".