View as markdown ↓

Drafts & human-in-the-loop

A draft is a message prepared but not yet delivered. Drafts power two things: an agent staging work for later, and the human-in-the-loop (HITL) approval gate.

Voluntary vs. forced drafts

  • Voluntary — you explicitly create a draft with POST /api/v1/drafts. It sits in status draft until you send it (or a human touches it). A voluntary draft is never forced at creation time, even on a mailbox that requires approval.

  • Forced — you call POST /api/v1/send on a mailbox whose require_approval is on. Instead of delivering, Postfleet creates a draft in status pending_approval and returns 202:

    { "draft_id": "d_123...", "status": "pending_approval" }
    

    This is not a failure and must not be retried — the email is delivered once a human approves it. Whether a mailbox requires approval is a dashboard (control-plane) setting; it is read fresh at send time, and a forced draft stays forced even if the setting is later turned off.

Draft endpoints

All draft endpoints need an api/mcp key. Reads need can_read; writes need can_send.

Create — POST /api/v1/drafts

Same envelope fields as POST /api/v1/send (mailbox_id + text required; to/subject optional, or pass reply_to_message_id to draft a threaded reply). Returns 201:

{ "id": "d_123...", "status": "draft" }

List — GET /api/v1/drafts?mailbox_id=…

Returns the open drafts (status draft, pending_approval, or sending), newest first:

{ "drafts": [ { "id": "d_123...", "mailbox_id": "b1a2...", "status": "draft",
  "to": "person@example.com", "subject": "…", "text": "…",
  "reply_to_message_id": null, "requires_human": false,
  "thread_id": "t_…", "created_at": "…", "updated_at": "…" } ] }

Get — GET /api/v1/drafts/:id

Returns that same public draft shape. Missing, cross-account, cross-mailbox (for a bound key), and tombstoned drafts all collapse to a byte-identical 404 {"error": "draft not found"}.

Edit — PATCH /api/v1/drafts/:id

Merges to/subject/text over the stored draft (an omitted field is unchanged) and re-validates through the single-recipient guard. The reply context is immutable: sending reply_to_message_id returns 400. If the draft is no longer editable (it moved to sending or a terminal state), the guarded update loses and returns:

{ "code": "approval_conflict", "error": "draft is no longer editable" }

with status 409.

Delete — DELETE /api/v1/drafts/:id

204 on success. If the draft can no longer be deleted (already sending/terminal), 409 {"code": "approval_conflict", "error": "draft can no longer be deleted"}. Missing/cross-scope is 404.

Send — POST /api/v1/drafts/:id/send

Voluntary send of your own draft. The mailbox's current require_approval decides the path:

  • Already pending_approval → idempotent 202 {"draft_id": "…", "status": "pending_approval"}.

  • require_approval on → the draft is promoted into the human queue, 202 pending.

  • require_approval off → the draft is delivered through the shared send pipeline. On success, 201:

    { "id": "m_123...", "draft_id": "d_123...", "thread_id": "t_…",
      "provider_message_id": "prov_…" }
    

A lost transition (concurrent send/delete/approval) returns 409 {"code": "approval_conflict", "error": "draft can no longer be sent"}. A rejected/failed delivery reverts the draft to draft and surfaces the reason; a provider-unknown outcome leaves the draft in sending (see limitations).

content_hash optimistic concurrency (at approval)

When a human approves a pending draft in the dashboard, they approve the specific content they reviewed. The approve action carries the content_hash (a versioned v1: SHA-256 over {to, subject, text, in_reply_to}) of the rendering they saw. If the draft was edited between render and approval, the hash no longer matches and the guarded approval loses — surfaced as approval_conflict. This prevents a last-second edit from being approved unseen.

Note: content_hash is internal — it is not part of the public draft shape and API callers do not supply it. Editing a draft (PATCH) recomputes it automatically, which is what invalidates any in-flight approval.

Tombstones — "the draft disappears after send"

Once a draft is sent (or rejected, or deleted), it is tombstoned: it drops out of GET /api/v1/drafts and returns 404 from GET /api/v1/drafts/:id. So after a successful send, the message lives on under /api/v1/messages and the draft is gone. Don't treat a 404 on a previously-sent draft as an error — it's the expected end state.

MCP draft tools

  • create_draft — stage a draft (returns {id, status:"draft"}).
  • list_drafts — list open drafts in a mailbox.
  • send_draft — send a staged draft by id. On a require_approval mailbox this returns the 202 {draft_id, status:"pending_approval"} outcome — not a failure, do not retry; check list_drafts.

See MCP setup.

Current limitations

  • No client_id idempotency on POST /api/v1/drafts. The endpoint accepts a client_id but does not de-duplicate creation on it — a retried create can produce duplicate drafts. (Idempotency applies to sending, not to draft creation.)
  • No audit trail on draft create/edit/delete. Only the approval/rejection steps are audited; POST/PATCH/DELETE /api/v1/drafts write no audit event.
  • A provider-unknown draft stuck in sending has no redrive. If a draft send hits an ambiguous provider outcome it stays in sending (reconciled server-side) and there is no dashboard or API button to force-retry it.