API / Messages & threads
Extrovert uses the mail server’s persistent conversation index, which follows standard email threading
headers as messages arrive. Replies stay in the same thread without requiring you to manage
In-Reply-To or References. Reads require mailbox:read; reply and forward require mailbox:send.
The message shape
Section titled “The message shape”Every message endpoint returns this canonical object:
{ "id": "msg_8Tz", "thread_id": "thr_9aB", "inbox": "agent7@extrovertmail.com", "direction": "inbound", "from": { "name": "Acme", "email": "no-reply@acme.test" }, "to": [{ "email": "agent7@extrovertmail.com" }], "subject": "Verify your email", "text": "Your code is 492013...", "html": "<p>Your code is <strong>492013</strong>...</p>", "extracted_text": "Your code is 492013...", "extracted_html": "<p>Your code is <strong>492013</strong>...</p>", "date": "Wed, 18 Jun 2026 18:05:42 +0000", "message_id": "<abc@acme.test>", "folder": "INBOX", "seen": false}direction is derived relative to the owning inbox; seen reflects the native IMAP \Seen flag (our
label-free read state).
Body handling preserves MIME truth:
textis the decodedtext/plainMIME part, ornullwhen none existed. It is never derived from HTML.htmlis the decodedtext/htmlMIME part, ornullfor text-only mail. It is never invented from text.extracted_text/extracted_htmlare best-effort newly authored content with quoted history and signatures removed from their corresponding source alternative. They are nullable, heuristic derivatives, not ground truth. They never replacetextorhtml.- The raw, unmodified RFC822 message remains available from the
/rawendpoint. HTML is not sanitized on the API wire; sanitize it before browser rendering.
GET /v1/inboxes/{inbox_id}/messages: list
Section titled “GET /v1/inboxes/{inbox_id}/messages: list”| Query | Type | Notes |
|---|---|---|
folder | string | IMAP folder (default INBOX). |
from / to / subject | string | Header substring filters. |
unread | true | Unread-only when set to true. |
limit | int | Page size (default 25). |
cursor | string | Opaque value from a previous next_cursor; pass it back unchanged. |
offset | int | Optional direct numeric offset for legacy callers. Do not put a cursor here. |
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages?unread=true" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"{ "items": [ { "id": "msg_8Tz", "subject": "Verify your email", "seen": false } ], "total": 1}GET /v1/inboxes/{inbox_id}/messages/search: search
Section titled “GET /v1/inboxes/{inbox_id}/messages/search: search”Full-text search scoped to one inbox. q is required. The response uses the same page shape as list.
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages/search?q=invoice" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"GET /v1/messages/{id}: get one
Section titled “GET /v1/messages/{id}: get one”The owning inbox is resolved from the opaque id; the connection’s read access and resource boundary (or the legacy key’s ownership ceiling) are re-checked before any read. Returns the full message shape, including source and extracted body variants.
curl -sS "$EXTROVERT_API_BASE_URL/v1/messages/msg_8Tz" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"GET /v1/inboxes/{inbox_id}/messages/{id}/raw: raw source
Section titled “GET /v1/inboxes/{inbox_id}/messages/{id}/raw: raw source”Streams the full RFC822 .eml bytes (Content-Type: message/rfc822).
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages/msg_8Tz/raw" \ -H "Authorization: Bearer $EXTROVERT_API_KEY" -o message.emlPATCH /v1/inboxes/{inbox_id}/messages/{id}: mark read or unread
Section titled “PATCH /v1/inboxes/{inbox_id}/messages/{id}: mark read or unread”Updates the message’s seen state and returns the updated message.
curl -sS -X PATCH "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages/msg_8Tz" \ -H "Authorization: Bearer $EXTROVERT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "read": true }'POST /v1/inboxes/{inbox_id}/reply: reply in-thread
Section titled “POST /v1/inboxes/{inbox_id}/reply: reply in-thread”Select the parent with exactly one of thread_id or message_id; recipients, subject, and
threading headers are derived server-side.
Read the entire oldest-first conversation and current writing rules before composing. Reply with
thread_id and the thread read’s context_version as required expected_context_version.
An explicit message_id must also identify the newest message in its conversation. The optional
expected_last_message_id is an additional check, not a replacement for the full context version.
Check existing reviews by inbox and thread_id before composing; omit the composer filter so other
accessible composers’ pending responses are visible. See the workflow.
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/threads/thr_9aB" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"
curl -sS -X POST "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/reply" \ -H "Authorization: Bearer $EXTROVERT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "thread_id": "thr_9aB", "expected_context_version": "CONTEXT_VERSION_FROM_THREAD_READ", "composition_token": "TOKEN_FROM_RULES_READ", "expected_last_message_id": "msg_8Tz", "text": "On it; thanks.", "intent": { "summary": "Acknowledge Acme and confirm we are proceeding" } }'Request
Section titled “Request”{ "message_id": "msg_8Tz", // or "thread_id": "thr_9aB" "expected_context_version": "CONTEXT_VERSION_FROM_THREAD_READ", // required for either selector "composition_token": "TOKEN_FROM_RULES_READ", "expected_last_message_id": "msg_8Tz", // optional additional guard "text": "On it; thanks.", "intent": { // required under require_review "summary": "Acknowledge Acme's verification email and confirm we're proceeding" }, "html": "<p>On it; thanks.</p>", // optional "cc": [], // optional "bcc": [], // optional "reply_to": "agent7@extrovertmail.com", // optional "reply_all": false, // optional; include all original recipients "attachments": [] // optional; carried through review to delivery}mode, intent, category_id, category_confidence, and composition_token work as they do on
send. There is no body alias here; the reply
body has always been text.
expected_context_version validates all observed conversation source messages. Missing context returns
422 reply_context_required; changed context or an older explicit parent returns
409 reply_context_changed. Reread all messages and reconsider the draft before retrying. The same
guard applies when revising replies and again before approval dispatch. It is optimistic validation,
not a lock that prevents new mail arriving during delivery. New API sends with raw reply headers
return 422 reply_workflow_required; use this canonical reply workflow.
curl -sS -X POST "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/reply" \ -H "Authorization: Bearer $EXTROVERT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message_id": "msg_8Tz", "expected_context_version": "CONTEXT_VERSION_FROM_THREAD_READ", "composition_token": "TOKEN_FROM_RULES_READ", "text": "On it; thanks.", "intent": { "summary": "Acknowledge Acme'"'"'s verification email and confirm we'"'"'re proceeding" } }'Response 202: queued (the default)
Section titled “Response 202: queued (the default)”{ "kind": "queued_for_review", "review": { "id": "rr_8Tz4kP", "state": "needs_review", "effective_mode": "review" }}The reply’s Re: subject, recipients and threading headers are resolved at submission, so the human
reviews the exact envelope that will go out, including the resolved subject and recipients.
Response 202: released immediately
Section titled “Response 202: released immediately”Under a policy that permits it, the legacy shape plus the additive review_id:
{ "message_id": "msg_5dRk", "thread_id": "thr_9aB", "review_id": "rr_8Tz4kP" }The reply’s In-Reply-To and References are set server-side from the parent, so it lands in the same
conversation. Like any send, a reply counts against the inbox’s
24h send-rate limit.
POST /v1/inboxes/{inbox_id}/messages/{id}/forward: forward
Section titled “POST /v1/inboxes/{inbox_id}/messages/{id}/forward: forward”Re-sends an existing message to new recipients, preserving the original content.
Request
Section titled “Request”{ "to": ["ops@acme.test"], // at least one recipient required "cc": [], // optional; screened by the same pre-flight as `to` "bcc": [], // optional; never rendered as a header "text": "FYI. See below.", // optional note prepended to the quoted parent "intent": { // required under require_review "summary": "Forward Acme's verification thread to ops so they can finish the account setup" }}mode, intent, category_id, category_confidence, and composition_token work here as they do
on send. html is
accepted and ignored: the forwarded content is a plain-text quote of the parent, and emitting an
HTML alternative would show HTML-capable clients the note without the forwarded thread.
Response 202: queued (the default)
Section titled “Response 202: queued (the default)”{ "kind": "queued_for_review", "review": { "id": "rr_4mQ2", "state": "needs_review", "effective_mode": "review" }}The forward’s subject and quoted parent body are materialized when submitted, so the human reviews the
same content the recipient will receive. If the reviewer edits the quote, the edit is what goes
out. A forward is not threaded to the parent: the delivered message carries no In-Reply-To, and its
thread_id is derived at send time.
Response 202: released immediately
Section titled “Response 202: released immediately”{ "message_id": "msg_7Gh", "thread_id": "thr_5kW", "review_id": "rr_4mQ2" }Conversation pagination
Section titled “Conversation pagination”Thread pages group before pagination. total counts distinct conversations; a conversation never
occupies several rows because several messages matched. Search may match any eligible member while
the summary and count describe the full conversation. Pages are live rather than frozen snapshots;
refresh from the first page to see conversations that moved above the current cursor.
Submission status
Section titled “Submission status”GET /v1/projects/{project_id}/inboxes/{inbox_id}/submissions/{submission_id}
Section titled “GET /v1/projects/{project_id}/inboxes/{inbox_id}/submissions/{submission_id}”Requires mailbox:read and access to the inbox within the connection grant or legacy key ceiling. The bare alias is
GET /v1/inboxes/{inbox_id}/submissions/{submission_id}. Neither endpoint sends mail.
Accepted send/reply responses add submission_id, nullable sent_message_id, sent_copy_status, and
per-state transport counts. These fields are optional against older servers and for historical mail.
Use sent_message_id for message-detail routes; the legacy message_id can instead contain an RFC
header value when the Sent copy is unavailable.
{ "submission_id": "sub_example", "inbox": "agent7@extrovertmail.com", "sent_message_id": "msg_7Gh", "sent_copy_status": "stored", "transport": { "waiting_for_parent": 1 }, "recipients": [{ "recipient": "peer@example.test", "state": "waiting_for_parent" }], "created_at": "2026-09-05T10:00:00Z", "updated_at": "2026-09-05T10:00:01Z"}Recipient states are queued, waiting_for_parent, transmitting, accepted, failed,
dependency_failed, and unknown. Absent count keys mean zero. accepted means accepted for onward
delivery, not confirmed arrival. waiting_for_parent continues automatically once the earlier
message’s delivery identity is ready. unknown needs reconciliation; do not resend with a new key.
sent_copy_status is pending, stored, or unavailable; archive failure never triggers a second send.
In TypeScript use client.projects.inboxes.getSubmission(projectId, inboxId, submissionId),
client.submissions.get(inboxId, submissionId), or inbox.getSubmission(submissionId).
In MCP use get_submission.
Safe retries
Section titled “Safe retries”Send, reply, and forward accept Idempotency-Key (up to 256 characters). Keep the same key and exact
request when retrying one logical send. Successful responses replay for 24 hours from the original
request, including across midnight. A different request with the same key returns
409 idempotency_conflict.
An in-progress or uncertain request returns 503 unavailable with Retry-After; it does not start
another send. Uncertain operations do not automatically expire or become safe to resend. Read the
submission or conversation to reconcile first. Do not rotate the key or edit the request to bypass
this protection. Validation failures confirmed before dispatch can be corrected and retried.
Threads
Section titled “Threads”A thread is the canonical conversation object: { id, inbox_id, subject, participants, message_count, last_message_at, snippet, unread, last_message_id, last_message_has_attachments }. id is an opaque,
server-issued selector; never derive it from the subject. unread reports whether the latest message
is unread. List and search return a { items, total, next_cursor? } page.
GET /v1/inboxes/{inbox_id}/threads: list (newest-first)
Section titled “GET /v1/inboxes/{inbox_id}/threads: list (newest-first)”curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/threads" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"{ "items": [ { "id": "thr_9aB", "inbox_id": "agent7@extrovertmail.com", "subject": "Verify your email", "participants": ["no-reply@acme.test", "agent7@extrovertmail.com"], "message_count": 2, "last_message_at": "Wed, 18 Jun 2026 18:06:10 +0000", "snippet": "Your code is 492013...", "unread": true, "last_message_id": "msg_8Tz", "last_message_has_attachments": false } ], "total": 1}Results are ordered by most recent thread activity. Use limit (1 to 100) and pass each opaque
next_cursor back as cursor to continue without rebuilding a position from timestamps or ids.
GET /v1/inboxes/{inbox_id}/threads/search: search threads
Section titled “GET /v1/inboxes/{inbox_id}/threads/search: search threads”Runs the mail server’s indexed full-text search across message bodies and common headers, then returns
matching conversation threads. q is required.
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/threads/search?q=verify" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"Search is keyword-based, scoped to the inbox, newest-first, and uses the same limit / cursor
pagination contract as thread listing.
GET /v1/inboxes/{inbox_id}/threads/{id}: get one thread
Section titled “GET /v1/inboxes/{inbox_id}/threads/{id}: get one thread”Returns the complete thread plus its messages (oldest-first). Detail is capped at 1,000 messages; an
exceptionally large thread returns 413 thread_too_large instead of a silently truncated conversation.
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/threads/thr_9aB" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"For concise reading, prefer each message’s extracted_text when it is non-null, but retain text as
the source-faithful fallback and for verification. Extraction is heuristic: it can omit signatures or
history, and an HTML-only message can have extracted_html while extracted_text remains null.
Delete and batch update
Section titled “Delete and batch update”Message deletion moves a message to Trash by default. ?expunge=true permanently removes it. A
message already in Trash is always expunged. Deleting a message requires mailbox:read plus
mailbox:create or mailbox:delete.
curl -sS -X DELETE \ "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b/messages/msg_8Tz" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"PATCH /v1/inboxes/{inbox_id}/messages/batch marks messages read or unread and can move them to a
folder. The response splits message ids into updated and failed, so one invalid id does not fail
the whole batch. Folder moves require mailbox:create or mailbox:delete in addition to
mailbox:read.
{ "ids": ["msg_8Tz", "msg_9Ua"], "read": true, "folder": "Archive"}Delete a full thread with DELETE /v1/inboxes/{inbox_id}/threads/{id}. By default, messages outside
Trash move to Trash and messages already in Trash are permanently removed. With ?expunge=true, all
messages in the thread are permanently removed. Treat the thread id as opaque and confirm the owning
inbox before expunging.
Attachments
Section titled “Attachments”| Method & path | Does |
|---|---|
GET /v1/inboxes/{inbox_id}/messages/{id}/attachments | List attachment metadata: { items: [{ id, filename, content_type, size }], total }. |
GET /v1/inboxes/{inbox_id}/messages/{id}/attachments/{attId} | Download one attachment’s bytes (Content-Type + Content-Disposition set). |
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages/msg_8Tz/attachments" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"- wait_for_email: block for the next inbound message
- Webhooks: receive inbound delivery events
- Inboxes: create an inbox and send mail