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; ownership is 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.
For an agent handling a conversation, read the thread first, reason over its oldest-first messages,
then reply with thread_id. The server resolves the newest parent at submission, so the agent does not
have to infer context from quoted text. Pass the thread’s last_message_id as
expected_last_message_id when the reply must fail rather than use context that changed since the read.
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_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_last_message_id": "msg_8Tz", // optional; valid with thread_id "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_last_message_id is an optimistic submission-time guard, not a delivery lock. If the thread
has already advanced, the server returns 409; fetch the thread again and reconsider the reply. New
mail can still arrive after a reply is accepted.
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", "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" }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