# API · Messages & threads

{/* // READ + REPLY + FORWARD + 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`.
**Pagination shape:** Message and thread lists return a `{ items, total, next_cursor? }` page. `next_cursor` is opaque:
  pass it back unchanged as `?cursor=`. It is omitted when no more results remain. Do not decode it
  or send it as an `offset`. New cursor-paginated agent collections (e.g. inbox lists) use the one
  [`{ object: "list", data, has_more, next_cursor }` envelope](https://docs.extrovert.dev/api/overview/#list-pagination--cursors)
  instead.
**The {inbox_id} path slot:** `{inbox_id}` is the inbox's opaque id (`pmbx_…`); its email **address** is accepted there as a
  within-project alias (URL-encode it). The authenticated key fixes the active project for every
  message and thread operation.

## The message shape

Every message endpoint returns this canonical object:

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

## `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. |

```bash frame="terminal"
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages?unread=true" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"
```

```json
{
  "items": [ { "id": "msg_8Tz", "subject": "Verify your email", "seen": false } ],
  "total": 1
}
```

## `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.

```bash frame="terminal"
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

The owning inbox is resolved from the opaque id; ownership is re-checked before any read. Returns the
full [message shape](#the-message-shape), including source and extracted body variants.

```bash frame="terminal"
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

Streams the full RFC822 `.eml` bytes (`Content-Type: message/rfc822`).

```bash frame="terminal"
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

Updates the message's `seen` state and returns the updated message.

```bash frame="terminal"
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 }'
```
**Reply and forward go through review too:** Reply and forward are governed by the inbox's
  [`effective_review_policy`](https://docs.extrovert.dev/api/inboxes/#effective_review_policy-know-before-you-send) exactly as
  `send` is. There is no unsupervised outbound verb on the agent plane. Under `require_review` (where
  every account starts) a reply or forward **without an `intent` is rejected `422 intent_required`**,
  and one **with** an intent answers `202 queued_for_review`. Forward is included deliberately: it is
  an outbound message to new recipients that quotes an inbound thread, so it must use the same policy.

## `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.

```bash frame="terminal"
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

```json
{
  "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`](https://docs.extrovert.dev/api/inboxes/#post-v1inboxesinbox_idsend-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.

```bash frame="terminal"
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)

```json
{
  "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

Under a policy that permits it, the legacy shape plus the additive `review_id`:

```json
{ "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](https://docs.extrovert.dev/concepts/deliverability-and-limits/).

## `POST /v1/inboxes/{inbox_id}/messages/{id}/forward`: forward

Re-sends an existing message to new recipients, preserving the original content.

### Request

```json
{
  "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)

```json
{
  "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

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

## 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)

```bash frame="terminal"
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/threads" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"
```

```json
{
  "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

Runs the mail server's indexed full-text search across message bodies and common headers, then returns
matching conversation threads. `q` is required.

```bash frame="terminal"
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

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.

```bash frame="terminal"
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

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`.

```bash frame="terminal"
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`.

```json
{
  "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

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

```bash frame="terminal"
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com/messages/msg_8Tz/attachments" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"
```

## Next

- [wait_for_email](https://docs.extrovert.dev/api/wait/): block for the next inbound message
- [Webhooks](https://docs.extrovert.dev/api/webhooks/): receive inbound delivery events
- [Inboxes](https://docs.extrovert.dev/api/inboxes/): create an inbox and send mail