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 statusdraftuntil 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/sendon a mailbox whoserequire_approvalis on. Instead of delivering, Postfleet creates a draft in statuspending_approvaland returns202:{ "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→ idempotent202 {"draft_id": "…", "status": "pending_approval"}.require_approvalon → the draft is promoted into the human queue,202pending.require_approvaloff → 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 arequire_approvalmailbox this returns the202 {draft_id, status:"pending_approval"}outcome — not a failure, do not retry; checklist_drafts.
See MCP setup.
Current limitations
- No
client_ididempotency onPOST /api/v1/drafts. The endpoint accepts aclient_idbut 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/draftswrite no audit event. - A provider-
unknowndraft stuck insendinghas no redrive. If a draft send hits an ambiguous provider outcome it stays insending(reconciled server-side) and there is no dashboard or API button to force-retry it.