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.

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:

TagMeaning
read-onlyReturns data; no state change, no delivery.
mutatingCreates or changes a draft row; never puts email on the wire.
send-triggeringCan dispatch an email (subject to the send-time gates).
human-review-possibleMay route to human review (pending_review) instead of sending.

For the full error-code catalog and remediation, see /agents/errors. For the message state machine and the scan.verdict / worst_decision vocabulary, see /agents/messages. For why a send is held or blocked at the gate stack, see /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

{
  "mailbox_id": "support",
  "to": "[email protected]",
  "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).
  • 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.
  • 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-bearingsend_at + attachment_ids together is rejected with 400 ATTACHMENTS_REQUIRE_SYNC_SEND. For the staging lifecycle, caps, and resolver error set, see /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

{
  "id": "uuid",
  "mailbox_id": "uuid",
  "mailbox_name": "support",
  "state": "draft",
  "sender": "[email protected]",
  "recipient": "[email protected]",
  "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": "<[email protected]>",
  "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 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.

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

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


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.
  • 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.
  • subaddress_instance_id / subaddress_mode (string | null) — set or clear the per-send sub-address routing hint. See the sub-addressing guide.

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.


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.

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

{
  "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):

      {
        "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.
    • draftrolled 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).

  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.

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:

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

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


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

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

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


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.