Skip to content

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.

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:

  • text is the decoded text/plain MIME part, or null when none existed. It is never derived from HTML.
  • html is the decoded text/html MIME part, or null for text-only mail. It is never invented from text.
  • extracted_text / extracted_html are 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 replace text or html.
  • The raw, unmodified RFC822 message remains available from the /raw endpoint. HTML is not sanitized on the API wire; sanitize it before browser rendering.
QueryTypeNotes
folderstringIMAP folder (default INBOX).
from / to / subjectstringHeader substring filters.
unreadtrueUnread-only when set to true.
limitintPage size (default 25).
cursorstringOpaque value from a previous next_cursor; pass it back unchanged.
offsetintOptional direct numeric offset for legacy callers. Do not put a cursor here.
Terminal window
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
}
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.

Terminal window
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages/search?q=invoice" \
-H "Authorization: Bearer $EXTROVERT_API_KEY"

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.

Terminal window
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).

Terminal window
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages/msg_8Tz/raw" \
-H "Authorization: Bearer $EXTROVERT_API_KEY" -o message.eml

PATCH /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.

Terminal window
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.

Terminal window
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" }
}'
{
"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.

Terminal window
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" }
}'
{
"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.

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.

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

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

{ "message_id": "msg_7Gh", "thread_id": "thr_5kW", "review_id": "rr_4mQ2" }

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.

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.

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.

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)”
Terminal window
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.

Terminal window
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.

Terminal window
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.

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.

Terminal window
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.

Method & pathDoes
GET /v1/inboxes/{inbox_id}/messages/{id}/attachmentsList 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).
Terminal window
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages/msg_8Tz/attachments" \
-H "Authorization: Bearer $EXTROVERT_API_KEY"