# Drafts API

A **draft** is a message in `state='draft'`. It lives in the same store as any
other message, under the same authentication and tenant isolation, and moves
through a deliberate **scan-then-review-then-send** flow:

- **Create / update** run the scanner (and PII detection) **synchronously**, so a
  verdict is attached to the row *before* you commit to sending.
- **Send** re-runs the scanner **authoritatively** — policy and engine may have
  changed since create — plus the full send-time gate stack. The create-time
  verdict is a preview; the send-time verdict is authority.

Drafts are excluded from the default message list; pass `?status=draft` to a
message-list read to opt them back in, or use the dedicated endpoints below.

This page is the human reference for the drafts resource. The machine-readable
contract for every operation ships as an OpenAPI document at
[/docs/openapi.json](/docs/openapi.json).

## Authentication

Every operation accepts one of:

- **Bearer admin key** — full account access.
- **Bearer agent key** — must be scoped to the draft's mailbox. A key bound to a
  different mailbox sees `404 NOT_FOUND` on reads and is rejected on writes.
- **Session cookie** — the dashboard.

One operation is stricter: **`POST /v1/drafts/:id/release-and-send`** is
**admin key or session only** — agent keys get `403 INSUFFICIENT_SCOPE`. This is
noted again on that operation.

## Operation classification

Each operation below carries a classification tag:

| Tag | Meaning |
|---|---|
| **read-only** | Returns data; no state change, no delivery. |
| **mutating** | Creates or changes a draft row; never puts email on the wire. |
| **send-triggering** | Can dispatch an email (subject to the send-time gates). |
| **human-review-possible** | May route to human review (`pending_review`) instead of sending. |

For the full error-code catalog and remediation, see
[/agents/errors](/agents/errors). For the message state machine and the
`scan.verdict` / `worst_decision` vocabulary, see
[/agents/messages](/agents/messages). For why a send is held or blocked at the
gate stack, see [/agents/send-gates](/agents/send-gates).

---

## Create a draft — `POST /v1/drafts`

**Classification: mutating.** Creating a draft never sends email; the scanner
runs to produce a preview verdict on the row.

**Auth:** admin key, session, or agent key bound to the target mailbox.

### Request

```json
{
  "mailbox_id": "support",
  "to": "user@example.com",
  "subject": "Re: your invoice",
  "body": "Thanks for your question.",
  "html": "<p>Thanks for your question.</p>",
  "in_reply_to_message_id": "uuid-of-original-message",
  "thread_id": "thread-uuid",
  "send_at": "2026-05-05T16:00:00Z",
  "attachment_ids": ["att-handle-uuid"]
}
```

- **`mailbox_id`** — a mailbox name or UUID.
- **`to`**, **`subject`**, **`body`** — the recipient, subject, and text body.
  `subject` is CRLF-stripped; `body` is length-capped (same validation as an
  immediate send).
- **`html`** (optional) — an HTML body. It is run through the outbound HTML
  sanitization gate at create time: active/unsafe constructs (scripts, forms,
  event handlers, and similar) are **rejected** with
  `400 OUTBOUND_HTML_ACTIVE_CONTENT_REJECTED` (`details.categories` names what was
  found), and a body that fails to parse or exceeds 1,000,000 bytes fails closed
  with `400 OUTBOUND_HTML_SANITIZE_FAILED` — both **before** any storage write.
  The frozen sanitized deliverable is persisted, but (unlike the immediate send
  and reply endpoints) the draft response does **not** echo `html_sanitized` /
  `removed_categories`.
- **`in_reply_to_message_id`** (optional) — reply to a received message. Derives
  `thread_id` and `in_reply_to`, and auto-prefixes `Re:` on the subject if absent.
  The original must belong to the same account and mailbox. Replying to an
  **outbound** message is rejected (`400 VALIDATION_ERROR`) — continue an outbound
  conversation with a thread-mode send instead.
- **`thread_id`** (optional) — continue an existing thread. When present,
  `mailbox_id` and `subject` are **derived** from the thread and `to` is an
  optional participant selector. Mutually exclusive with
  `in_reply_to_message_id`. The resolved `thread_id` is persisted; the recipient
  gate is re-checked authoritatively at send time (see
  [/agents/send-gates](/agents/send-gates)).
- **`send_at`** (optional) — schedule the draft for future dispatch. ISO-8601 with
  an **explicit** offset (`Z` or `±HH:MM`); a naive timestamp is rejected with
  `400 TIMEZONE_REQUIRED`, under 60 seconds out with `SEND_AT_TOO_SOON`, and beyond
  the scheduling horizon with `SEND_AT_TOO_FAR`. See the
  [scheduled-send guide](/docs/guides/scheduled-send).
- **`attachment_ids`** (optional) — handles staged via the attachments API,
  **held** at create time and **consumed at dispatch**. Each must be scoped to
  this draft's mailbox. A draft **cannot be both scheduled and attachment-bearing**
  — `send_at` + `attachment_ids` together is rejected with
  `400 ATTACHMENTS_REQUIRE_SYNC_SEND`. For the staging lifecycle, caps, and
  resolver error set, see [/agents/attachments](/agents/attachments).

### Request headers

- **`Idempotency-Key`** (optional) — a stable, client-chosen key for retry-safe
  draft creation. A same-key retry replays the prior draft **while it is still a
  draft**; if that draft was since sent or deleted, the replay returns
  `409 IDEMPOTENCY_KEY_DRAFT_CONSUMED`. The key is namespaced to drafts: reusing a
  key already spent on an immediate send/reply returns
  `409 IDEMPOTENCY_KEY_BOUND_TO_IMMEDIATE_SEND`. Keys are permanent (no expiry).

### Response `201`

```json
{
  "id": "uuid",
  "mailbox_id": "uuid",
  "mailbox_name": "support",
  "state": "draft",
  "sender": "support@mail.replylayer.ai",
  "recipient": "user@example.com",
  "subject": "Re: your invoice",
  "body": {
    "format": "text",
    "content": "Thanks for your question.",
    "char_count": 25,
    "returned_char_count": 25,
    "truncated": false
  },
  "scan": { "verdict": "clean", "categories": [], "findings": [] },
  "worst_decision": "allow",
  "thread_id": "original-uuid",
  "in_reply_to": "<orig@mail.replylayer.ai>",
  "subaddress_instance_id": null,
  "subaddress_mode": null,
  "send_at": "2026-05-05T16:00:00.000Z",
  "original_send_at": "2026-05-05T16:00:00.000Z",
  "send_attempts": 0,
  "last_dispatch_error_code": null,
  "last_dispatch_attempt_at": null,
  "compose_held_at": null,
  "compose_held_decision": null,
  "releasable": false,
  "created_at": "2026-04-17T00:00:00Z",
  "updated_at": "2026-04-17T00:00:00Z",
  "attachments": []
}
```

- **`scan`** is the create-time preview verdict (a `ScanSummary`: `verdict` +
  `categories[]` + `findings[]`). **`worst_decision`** is the aggregated decision.
  See [/agents/messages](/agents/messages) for the vocabulary.
- **`mailbox_name`** is the owning mailbox's label, so a caller holding a bare
  `mailbox_id` UUID can read it directly.
- **`attachments[]`** is a safe, read-only view of the held handles — `filename`,
  `content_type`, `size`, `upload_id`, and `policy_action` only (no storage
  pointers or malware signatures).

### Errors (important)

`400` validation, `TIMEZONE_REQUIRED`, `SEND_AT_TOO_SOON`, `SEND_AT_TOO_FAR`,
`OUTBOUND_HTML_ACTIVE_CONTENT_REJECTED`, `OUTBOUND_HTML_SANITIZE_FAILED`,
`ATTACHMENTS_REQUIRE_SYNC_SEND` · `403` mailbox access denied, account suspended,
`STORAGE_QUOTA_EXCEEDED` · `404` mailbox or original message not found · `409`
`IDEMPOTENCY_KEY_BOUND_TO_IMMEDIATE_SEND`, `IDEMPOTENCY_KEY_DRAFT_CONSUMED` ·
`429` `SCHEDULED_SEND_QUOTA_EXCEEDED` (too many pending scheduled drafts). Full
catalog: [/agents/errors](/agents/errors).

> **Debounce auto-save.** A client that recreates or patches a draft on every
> keystroke runs a scan + PII detection per request. Debounce edits (for example,
> 500 ms after the last keystroke) rather than firing per character.

---

## Get a draft — `GET /v1/drafts/:id`

**Classification: read-only.**

**Auth:** admin key, session, or agent key bound to the draft's mailbox (a key
bound to a different mailbox sees `404 NOT_FOUND`).

Applies the mailbox's PII mode at read time — a `redacted` mailbox returns
plaintext with `<TYPE>` tags in place of detected PII.

**Response `200`:** the same shape as the create response. Returns `404` if the
draft was deleted or already sent.

---

## List drafts — `GET /v1/mailboxes/:id/drafts`

**Classification: read-only.**

**Auth:** admin key, session, or agent key bound to the mailbox.

### Query params

- **`limit`** — default 50, max 200.
- **`before`** — a UUID cursor (rows created before this one).

### Response `200`

```json
{ "drafts": [ /* DraftSummary[] */ ], "has_more": true }
```

Each summary carries `id`, `mailbox_id`, `mailbox_name`, `state`, `sender`,
`recipient`, `subject`, `worst_decision`, `send_at`, `original_send_at`,
`send_attempts`, `last_dispatch_error_code`, `last_dispatch_attempt_at`,
`created_at`, `updated_at`. The scheduling fields are `null` on drafts that were
never scheduled.

`has_more` is `true` iff at least one more draft exists beyond the returned page.
Use it — not `drafts.length === limit` — as the pagination terminator: a page with
exactly `limit` rows and nothing beyond it reports `has_more: false`.

> **`last_dispatch_error_code`** is the raw error code thrown by a failed send
> attempt (e.g. `RECIPIENT_NOT_ON_ALLOWLIST`, `DRAFT_REJECTED_BY_RESCAN`) — free
> text for diagnosis, not a closed enum. The normalized reason code lives on the
> `message.dispatch_failed` webhook (see [/agents/webhooks](/agents/webhooks)).

---

## Update a draft — `PATCH /v1/drafts/:id`

**Classification: mutating.** A patch re-runs the scanner + PII detection and
refreshes the cached verdict and body; it never sends.

**Auth:** admin key, session, or agent key bound to the draft's mailbox.

### Request

Any non-empty subset of `{ to, subject, body, html, send_at,
subaddress_instance_id, subaddress_mode, attachment_ids }`. At least one field is
required (an empty patch is `400`).

`html` is re-sanitized on patch, with the same `OUTBOUND_HTML_ACTIVE_CONTENT_REJECTED`
/ `OUTBOUND_HTML_SANITIZE_FAILED` codes as create. Several fields are **three-way
sigils** — an explicit value sets, an explicit `null` clears, and omission leaves
the field unchanged:

- **`attachment_ids`** (`string[] | null`) — an array **replaces** the held
  attachment set; `null` **clears** it; omission leaves it as-is. Handles are held
  now and consumed at dispatch. Setting `send_at` on an attachment-bearing draft
  (or adding attachments to a scheduled draft) is rejected with
  `400 ATTACHMENTS_REQUIRE_SYNC_SEND`. See [/agents/attachments](/agents/attachments).
- **`send_at`** (`string | null`) — a string sets or reschedules (same validation
  as create; a reschedule resets `send_attempts` to 0); `null` cancels the
  schedule and clears the scheduling fields; omission leaves it as-is. See the
  [scheduled-send guide](/docs/guides/scheduled-send).
- **`subaddress_instance_id`** / **`subaddress_mode`** (`string | null`) — set or
  clear the per-send sub-address routing hint. See the
  [sub-addressing guide](/docs/guides/sub-addressing).

**Response `200`:** the updated draft (same shape as create).

### Errors (important)

`400` empty patch, validation, the two `OUTBOUND_HTML_*` codes, the scheduling
codes · `403` `STORAGE_QUOTA_EXCEEDED` · `404` draft not found · `429`
`SCHEDULED_SEND_QUOTA_EXCEEDED` (on a `null`→set transition). Full catalog:
[/agents/errors](/agents/errors).

---

## Send a draft — `POST /v1/drafts/:id/send`

**Classification: send-triggering, human-review-possible.**

**Auth:** admin key, session, or agent key bound to the draft's mailbox.

Re-runs the scanner **authoritatively** against the mailbox's current policy, then
runs the full send-time gate stack (suppression, recipient policy, reply-loop,
budget, content-similarity, domain health, account/mailbox status, and — where
outbound delivery validation is enforced — recipient MX). The recipient gate
re-runs with the draft's persisted `thread_id`, so a thread that lost its inbound
participant (or a mailbox whose thread-reply bypass was turned off) fails closed.
For the decision tree, see [/agents/send-gates](/agents/send-gates).

**Request:** empty body (`{}`). The call is idempotent — a duplicate call on an
already-sent draft returns `409 DRAFT_ALREADY_SENT` (`details.current_state` names
what it became).

### Response `200`

```json
{
  "message_id": "uuid",
  "status": "sent",
  "warning": null,
  "daily_limit": 500,
  "sends_remaining": 499,
  "scan": { "verdict": "clean", "categories": [], "findings": [] },
  "hold_context": null
}
```

- **`status`** is `sent` on success. On a mailbox configured to route all outbound
  through human review, it is instead **`pending_review`** — the draft moves to
  `state='pending_review'` and awaits an approve/deny decision, and the daily
  budget is reserved (not double-charged) at that point.
- **`scan`** is the send-time scanner verdict. **`hold_context`** is the policy /
  human-review reason, and is non-null only when the delivery `status` diverges from
  `scan.verdict` because a mailbox review policy promoted or demoted the scanner's
  decision — `{ trigger_source, summary_reasons[] }`, else `null`.
- A send-time **content hold does not return `200`** — a scanner `block` or
  `quarantine` returns `409 DRAFT_REJECTED_BY_RESCAN` (below).

### Asynchronous send (optimistic-ack) & polling

This is the **only** asynchronous send path (the immediate send/reply endpoints
are always synchronous and ignore the header). The async branch is
**deployment-config-gated**, so **detect it from the response — do not assume it
was taken**:

1. Send the draft with the request header **`Prefer: respond-async`**.
2. **Branch on the status code:**
   - **`202`** with response header `Preference-Applied: respond-async` — the async
     branch ran. The body is minimal (no verdict yet; the scan and any human-review
     decision run later in the dispatch consumer):

     ```json
     {
       "message_id": "uuid",
       "status": "queued_for_dispatch",
       "daily_limit": 500,
       "sends_remaining": 487
     }
     ```
   - **`200`** — the async branch was not available; you got a normal synchronous
     draft-send response with the verdict inline. Handle it and you are done.
3. **After a `202`, poll `GET /v1/messages/:id`** (using `message_id`) until the
   row reaches a terminal state — one of `available`, `delivered`, `bounced`,
   `pending_review`, or `draft` — reading `scan` and the governed send effect on
   the row. Branch as you would on a synchronous send:
   - **`available`** — accepted for delivery; delivery events later advance it to
     `delivered` or `bounced`.
   - **`delivered` / `bounced`** — terminal, and **may arrive before `available`**
     (a loop that waits only for `available` can hang).
   - **`pending_review`** — held by the mailbox review policy; awaits approve/deny.
   - **`draft`** — **rolled back**: a scanner block/quarantine or a dispatch
     failure refunded the budget and returned the row to `draft`. The verdict is on
     the row's `scan`; the matching webhook fires. On this path a content hold
     surfaces as this `draft` rollback, not as a `quarantined`/`blocked` state.

   Or subscribe to the lifecycle webhooks instead of polling — the same terminal
   transitions emit their matching events (see [/agents/webhooks](/agents/webhooks)).
4. **Constraint:** attachment-bearing drafts are **sync-only** — sending with
   `attachment_ids` under `Prefer: respond-async` returns
   `400 ATTACHMENTS_REQUIRE_SYNC_SEND`.

The state machine and terminal-state semantics live at
[/agents/messages](/agents/messages).

### Errors (important)

- **`403 RECIPIENT_SUPPRESSED`** — the recipient is on the do-not-contact list
  (`details.reason: 'suppressed'`).
- **`403 ACCOUNT_SUSPENDED` / `MAILBOX_SUSPENDED` / `DOMAIN_SUSPENDED` /
  `SANDBOX_TRIAL_EXPIRED` / `STORAGE_QUOTA_EXCEEDED`** — a send-time gate rejected
  the request.
- **`404 NOT_FOUND`** — draft not found, or its mailbox was deleted between create
  and send.
- **`409 DRAFT_REJECTED_BY_RESCAN`** — the send-time scan returned `block` or
  `quarantine`. `details.worst_decision` carries the verdict, `details.scan` the
  vendor-neutral scan summary, and **`details.releasable`** is `true` for a
  releasable `quarantine` (send it anyway via release-and-send, below) and `false`
  for a terminal `block`. `details.hold_context` is the policy/human-review reason (or
  `null`). The draft stays in `draft`; the cached scan summary is refreshed. Edit
  and retry. Example:

  ```json
  {
    "error": "Draft rejected by send-time scan",
    "code": "DRAFT_REJECTED_BY_RESCAN",
    "details": {
      "worst_decision": "quarantine",
      "releasable": true,
      "scan": { "verdict": "clean", "categories": [], "findings": [] },
      "hold_context": {
        "trigger_source": "mailbox_policy",
        "summary_reasons": ["Held for review by your mailbox policy; the review queue is unavailable on your current plan."]
      }
    }
  }
  ```
- **`409 DRAFT_ALREADY_SENT`** — the draft already left `draft` state.
- **`429 RATE_LIMITED`** — daily send budget exhausted; carries
  `details: { daily_limit, sends_remaining: 0, reset_at }`. Budget limits are
  documented at [/docs/limits](/docs/limits).
- **`429 REPLY_LOOP_DETECTED`** — the reply-loop guard fired.
- **`503 DOMAIN_UNHEALTHY`** — the sending domain's health check is failing.

Full catalog and remediation: [/agents/errors](/agents/errors).

---

## Release and send a held draft — `POST /v1/drafts/:id/release-and-send`

**Classification: send-triggering.** This is the human "send anyway" override for
a draft that was **held** by a send-time scan (a prior
`409 DRAFT_REJECTED_BY_RESCAN` with `releasable: true`).

**Auth: admin key or session cookie only** — agent keys receive
`403 INSUFFICIENT_SCOPE`. Loosening a scanner hold is a human decision by design.

The route **skips the content rescan** (it honors the scanner verdict cached at
hold time) but still runs every non-content dispatch gate — suppression,
allowlist, budget, domain health, account/mailbox status, and enforced MX
validation.

### Request

```json
{
  "release_source": "compose_inline",
  "override_reason": "Manually reviewed; content is legitimate."
}
```

- **`release_source`** (required) — `"compose_inline"` or `"quarantine_page"`, for
  audit attribution.
- **`override_reason`** (optional, ≤500 chars) — recorded on the audit row.

### Response `200`

```json
{ "status": "sent", "message_id": "uuid" }
```

### Errors (important)

- **`403 INSUFFICIENT_SCOPE`** — an agent key was used.
- **`403 BLOCK_TERMINAL`** — the held decision was a terminal `block`; the draft
  must be edited before it can be sent.
- **`404 NOT_FOUND`** — draft not found.
- **`409 DRAFT_WRONG_STATE`** — the draft is not in `state='draft'`.
- **`409 DRAFT_NOT_HELD` / `DRAFT_EDITED_SINCE_HOLD`** — the draft was not held (or
  a subsequent edit cleared the hold marker); re-send via
  `POST /v1/drafts/:id/send` to get a fresh verdict first.
- **`409 DRAFT_HELD_DECISION_MISSING`** — a legacy row with a hold marker but no
  recorded decision; re-send to refresh.
- **`409 ALREADY_RELEASED`** — a concurrent release already moved the draft out of
  `draft`.
- **`422 RECIPIENT_UNDELIVERABLE` / `RECIPIENT_ADDRESS_INVALID` / `RECIPIENT_DOMAIN_TYPO_SUSPECTED` /
  `RECIPIENT_ROLE_ADDRESS` / `RECIPIENT_DISPOSABLE_ADDRESS`** — enforced recipient
  verification confirmed a recipient-quality violation (this route shares the same
  send-time gate as `POST /v1/drafts/:id/send`).
- **`429 RATE_LIMITED`** — two shapes: (a) daily budget exhausted
  (`details: { daily_limit, sends_remaining: 0, reset_at }`); or (b) a new-sender
  warm-up hold on the shared sending pool
  (`details: { reason: 'new_account_warmup', retry_after_seconds }`). Under (b) the
  draft **stays held** — no state change — so retry after `retry_after_seconds` or
  verify your own sending domain to lift the warm-up.
- **`503 DOMAIN_UNHEALTHY`** — the sending domain's health check is failing.

Full catalog: [/agents/errors](/agents/errors).

---

## Delete a draft — `DELETE /v1/drafts/:id`

**Classification: mutating.**

**Auth:** admin key, session, or agent key bound to the draft's mailbox.

Soft-deletes the draft (`state='deleted'`) and returns **`204`**. Subsequent GET /
PATCH / send calls on it return `404`.

If the draft had a schedule set, deletion additionally emits a
`message.schedule_cancelled` webhook (`reason: draft_deleted`). Deleting an
unscheduled draft emits nothing beyond the internal delete record. If a scheduled
dispatch wins the race and sends before the delete lands, the delete is still
idempotent (returns `204`) but no cancellation event fires — subscribers already
saw the terminal dispatch event.
