# Threads API

A **thread** groups messages that share a `thread_id` — typically a reply chain
keyed by a stripped RFC Message-Id (angle brackets removed). Standalone messages
that have no thread of their own appear as single-message threads keyed by the
message's own UUID. Every thread is scoped to exactly one mailbox.

The `id` you get back from the list endpoint is the **thread key**. It is either a
stripped Message-Id (e.g. `abc123@mail.replylayer.ai`) or a message UUID string.
Because a thread key can contain characters like `@`, always **URL-encode** it
before passing it to `GET /v1/threads/:id`.

This page is the human reference. The machine-readable contract for the same
operations ships as an OpenAPI document at
[`/docs/openapi.json`](/docs/openapi.json).

## At a glance

| Operation | Method + path | Auth | Classification |
|---|---|---|---|
| List threads | `GET /v1/mailboxes/:id/threads` | Bearer admin/agent or session | read-only |
| Read a thread | `GET /v1/threads/:id` | Bearer admin/agent or session | read-only |
| Continue a thread | `POST /v1/messages/send` (thread mode) | Bearer admin/agent or session | send-triggering, human-review-possible |
| Mark a thread read | `POST /v1/mailboxes/:id/threads/:thread_id/read` | Bearer admin/agent or session | mutating |
| Star / unstar a thread | `PATCH /v1/mailboxes/:id/threads/:thread_id/star` | Bearer admin/agent or session | mutating |

Agent-role keys are scoped to their bound mailboxes. How an unbound access is
masked depends on the read shape — see the per-operation notes below.

## GET /v1/mailboxes/:id/threads — list threads

**Classification: read-only.** Returns one row per thread, ordered by
`last_message_at` descending with the thread key ascending as a deterministic
tie-breaker. `:id` accepts a mailbox **name or UUID**.

**Auth:** Bearer admin key, Bearer agent key (scoped), or dashboard session.

**Query params:**

| Param | Meaning |
|---|---|
| `limit` | Max threads to return. Default `50`, capped at `200`. |
| `before_ts` | Cursor: return threads whose `last_message_at` is **strictly less than** this ISO timestamp. Pass the previous page's last thread's `last_message_at` to page backward. |
| `since_ts` | Return threads whose `last_message_at` is **strictly greater than** this ISO timestamp. Use it for delta polling ("only new mail since I last looked"). Combined with `before_ts`, returns threads strictly between the two. |
| `has_inbound` | `true` returns only threads with at least one inbound message (the "Inbox" semantic — sent-only threads are excluded). Default: no filter. |
| `starred` | `true` / `false` filters to starred (or unstarred) threads. |
| `include_firewall_blocked` | `true` includes messages that were held by the inbound firewall in the thread aggregation. Default excluded. |

Both `before_ts` and `since_ts` use strict inequality, so a thread whose
`last_message_at` exactly equals the cursor can be skipped at a page boundary.
Organic traffic almost never produces millisecond-exact ties; heavy test seeding
can.

Unlike the flat messages list, the thread endpoints **exclude deleted messages**
from every aggregate (counts, participants, subject, timestamps). A deleted
message never contributes to a thread.

**Response (200):**

```json
{
  "threads": [
    {
      "id": "abc123@mail.replylayer.ai",
      "subject": "Contract question",
      "first_message_at": "2026-04-10T12:00:00.000Z",
      "last_message_at": "2026-04-14T15:30:00.000Z",
      "message_count": 4,
      "unread_count": 1,
      "starred": false,
      "participants": ["Alice <alice@example.com>", "bot@mail.replylayer.ai"],
      "scan": { "verdict": "quarantined", "categories": [], "findings": [] },
      "body_preview": "Thanks — could you confirm the renewal date before we…",
      "has_attachment": true
    }
  ]
}
```

- `participants` is the deduplicated union of senders and recipients across the
  thread's non-deleted messages.
- `unread_count` counts inbound messages that have not been marked read.
- `scan`, `body_preview`, and `has_attachment` are additive row enrichments (an
  older API may omit all three):
  - **`scan`** is the **worst-across-thread** verdict summary, or `null` when no
    member has a scan result. Held delivery states are folded in, so an older
    blocked or quarantined message is never hidden by a newer clean reply. See
    the [message verdict vocabulary](/agents/messages) for the meaning of
    `verdict`.
  - **`body_preview`** is a short (≤200 char) preview of the thread's **latest**
    message, redacted according to the mailbox's PII delivery mode; `null` when
    the latest body is empty or unavailable.
  - **`has_attachment`** is `true` when any non-deleted member carries at least
    one attachment.

**Pagination:** page forward by passing the last row's `last_message_at` as the
next request's `before_ts`. A page shorter than `limit` is the last page.

**Errors:** `404 NOT_FOUND` (mailbox not found, wrong account, or — for an agent
key passing a mailbox **name** it isn't bound to — masked as not-found). An agent
key passing an unbound mailbox **UUID** gets `403`, which leaks no existence
because the scope check runs before any lookup. Full remediation for every code is
in the [error reference](/agents/errors).

## GET /v1/threads/:id — read a thread

**Classification: read-only.** Returns the full message sequence for one thread in
chronological order (oldest first). `:id` is the URL-encoded thread key from the
list endpoint.

**Auth:** Bearer admin key, Bearer agent key (scoped), or dashboard session.

**Query params:**

| Param | Meaning |
|---|---|
| `view` | `summary` (default) or `verbose` scan rendering. |
| `body_format` | `text` (default) or `html`. HTML is available to dashboard **session** callers only; a Bearer/agent caller requesting `html` gets `400 BODY_FORMAT_HTML_SESSION_ONLY`. |
| `include_firewall_blocked` | `true` includes firewall-held messages in the returned `messages` array. Default excluded. |
| `mailbox` | **Optional mailbox scope** (name or UUID). A thread key is not globally unique, so the same key can legitimately exist in two of your mailboxes. When that happens the account-wide lookup returns `404` (the single-mailbox invariant); pass `mailbox` to resolve the collision to the one you mean. Omitted → account-wide. |

**Access control — three layers:**

1. **Row-level isolation** — only messages belonging to the authenticated account
   are ever visible.
2. **Single-mailbox invariant** — a valid thread lives in exactly one mailbox; a
   result that would span mailboxes is rejected as `404`. Pass `mailbox` to scope
   past a cross-mailbox key collision.
3. **Mailbox scope** — an agent key bound to mailbox A that asks for a thread in
   mailbox B receives `404` (not `403`), so unbound keys cannot enumerate threads
   across mailboxes. Passing an unknown or unbound `mailbox` also returns `404`.

**Response (200):**

```json
{
  "id": "abc123@mail.replylayer.ai",
  "mailbox_id": "uuid",
  "mailbox_name": "support",
  "subject": "Contract question",
  "message_count": 3,
  "messages": [
    {
      "id": "uuid",
      "direction": "outbound",
      "state": "delivered",
      "sender": "bot@mail.replylayer.ai",
      "recipient": "alice@example.com",
      "subject": "Contract question",
      "mailbox_name": "support",
      "body": {
        "format": "text",
        "content": "Hi Alice, ...",
        "char_count": 13,
        "returned_char_count": 13,
        "truncated": false
      },
      "attachments": [],
      "scan": { "verdict": "clean", "categories": [], "findings": [] },
      "thread_id": "abc123@mail.replylayer.ai",
      "in_reply_to": null,
      "read_at": null,
      "starred": false,
      "dashboard_url": "https://app.replylayer.ai/messages?id=uuid",
      "created_at": "2026-04-10T12:00:00.000Z"
    }
  ]
}
```

Each item in `messages` has the **same shape** as a single-message detail read —
safe-view only, no raw body fields — including the inbound `agent_safety_context`,
`sender_authentication`, and (on outbound rows) `email_effect` objects, plus each
attachment's `preview_status` / `preview_kind` / `preview_reason_code` /
`preview_char_count` / `preview_page_count` / `preview_truncated` /
`preview_generated_at` projection. See the [Messages API](/docs/api/messages) for
the full per-message field reference. `dashboard_url` is a deep link into the web
inbox, or `null` when web deep-linking is not configured for your account.

**Reading a thread is side-effect-free for read state** — it does **not** mark any
message read. Mark messages read explicitly with the thread mark-read operation
below (or the per-message read endpoint on the [Messages API](/docs/api/messages)).
Each message returned is recorded as a `safe_view` entry in your content-access
audit trail.

**Errors:** `404 NOT_FOUND` (thread not found, cross-account, or — for an agent
key — a thread in an unbound mailbox); `400` for an invalid `body_format` or an
agent HTML request. See the [error reference](/agents/errors).

## POST /v1/messages/send — continue a thread (thread mode)

**Classification: send-triggering, human-review-possible.** There is no separate
"thread send" endpoint — you continue a conversation by calling the standard send
(or reply, or draft-create) endpoint in **thread mode**, which you enter by
supplying a `thread_id`. This is the sanctioned way to follow up on a conversation
whose latest message is your **own** outbound: the plain reply endpoint refuses an
outbound target (`400 VALIDATION_ERROR`), because thread mode keys on the thread,
not on a target message's direction.

**Auth:** Bearer admin key, Bearer agent key (scoped), or dashboard session.

**Request (thread continuation):**

```json
{
  "thread_id": "abc123@mail.replylayer.ai",
  "body": "Just following up on this."
}
```

When `thread_id` is present:

- **`from_mailbox` and `subject` are derived from the thread.** `subject` becomes
  `Re: <thread subject>` unless you pass an override. A `from_mailbox` that
  doesn't own the thread → `404`.
- **`to` is an optional participant selector**, resolved against the thread's real,
  visible inbound history (messages that were never delivered — firewall-held,
  still scanning, received-but-unprocessed, or deleted — never count):
  - exactly one visible inbound participant and no `to` → that participant is used;
  - more than one and no `to` → `422 AMBIGUOUS_THREAD_RECIPIENT` (pass `to`);
  - zero visible inbound participants → `422 THREAD_HAS_NO_INBOUND_RECIPIENT`;
  - a `to` that is not a visible inbound participant →
    `422 RECIPIENT_NOT_IN_THREAD` (this prevents smuggling a brand-new recipient
    past a contained mailbox).
- **Mailbox resolution:** a thread key is not unique, so if the thread spans more
  than one mailbox you can reach, and you omit `from_mailbox`, the send returns
  `409 AMBIGUOUS_THREAD_MAILBOX` (pass `from_mailbox`). A thread that only exists
  in a mailbox your agent key can't see returns `404` (never `403` — existence is
  not leaked).

`thread_id` may be a thread key (a stripped Message-Id) or the root message's
UUID.

**Recipient containment interaction.** In a mailbox running recipient containment
(allowlist mode), a thread-mode send to a participant who is **not** on the
allowlist is admitted only when the mailbox permits thread replies and the
recipient is a visible inbound participant of that thread — and **no standing
allowlist entry is written**. When thread replies are disabled for the mailbox, an
un-allowlisted participant is rejected with `403 RECIPIENT_NOT_ON_ALLOWLIST`. See
the [recipient allowlist guide](/docs/guides/recipient-allowlist) for the
thread-reply bypass, and the [send-gate reference](/agents/send-gates) for the
full ordered gate stack (suppression, agent containment, strict-recipient,
deliverability).

**Response (200):** identical to a non-thread send — `message_id`, `status`
(`sent` / `quarantined` / `blocked` / `pending_review`), `scan`, `hold_context`,
`email_effect`, `daily_limit`, `sends_remaining`, and the outbound-HTML
`html_sanitized` / `removed_categories` fields. Thread continuations reuse the
`reply` webhook origin — no new event type. Branch on `email_effect.effect_status`
to know what actually happened in one read; see
[send outcomes](/agents/send-outcomes) and the full send contract on the
[Messages API](/docs/api/messages).

**Human-review note.** When the source mailbox holds outbound messages for human review,
the response `status` is `pending_review` and the send awaits an explicit
approve/deny rather than going on the wire. This is why the operation is classified
**human-review-possible**.

**Thread-mode error codes (this operation's own):**

| Code | Status | Meaning |
|---|---|---|
| `AMBIGUOUS_THREAD_MAILBOX` | 409 | `thread_id` spans multiple accessible mailboxes; pass `from_mailbox`. |
| `AMBIGUOUS_THREAD_RECIPIENT` | 422 | Thread has more than one inbound participant; pass `to`. |
| `RECIPIENT_NOT_IN_THREAD` | 422 | The `to` you passed is not a visible inbound participant. |
| `THREAD_HAS_NO_INBOUND_RECIPIENT` | 422 | The thread has no visible inbound participant to reply to. |

These stack on top of the standard send gates (`RECIPIENT_SUPPRESSED`,
`RECIPIENT_AGENT_CONTAINED`, `RATE_LIMITED`, the outbound-HTML codes, and more).
Full remediation is in the [error reference](/agents/errors).

Thread mode is also available on `POST /v1/messages/:id/reply` and on draft
creation (`POST /v1/drafts`) with the same `thread_id` semantics and error codes;
on a draft, the recipient gate re-runs authoritatively at send time, so a thread
that later loses its inbound fails closed.

## POST /v1/mailboxes/:id/threads/:thread_id/read — mark a thread read

**Classification: mutating.** Marks every visible inbound unread message in the
thread as read, in one transaction. `:id` is the mailbox name or UUID;
`:thread_id` is the URL-encoded thread key.

**Auth:** Bearer admin key, Bearer agent key (scoped, masked as `404` when
unbound), or dashboard session.

**Request:** empty body.

**Response (200):**

```json
{
  "thread_id": "abc123@mail.replylayer.ai",
  "marked_count": 3
}
```

`marked_count` is the number of rows newly stamped by this call — it excludes
already-read, outbound, deleted, and firewall-held messages. A thread whose
messages are all already read returns `200` with `marked_count: 0` (the thread
still exists, so it is **not** a `404`).

**Errors:** `404 Mailbox not found` (mailbox absent or agent key not bound to it)
and `404 Thread not found` (mailbox exists but the thread doesn't) are distinct
so callers can disambiguate. See the [error reference](/agents/errors).

## PATCH /v1/mailboxes/:id/threads/:thread_id/star — star or unstar a thread

**Classification: mutating.** Sets or clears the star flag on every visible
message in the thread. `:id` is the mailbox name or UUID; `:thread_id` is the
URL-encoded thread key.

**Auth:** Bearer admin key, Bearer agent key (scoped, masked as `404` when
unbound), or dashboard session.

**Request:**

```json
{ "starred": true }
```

**Response (200):**

```json
{ "thread_id": "abc123@mail.replylayer.ai", "starred": true, "updated_count": 3 }
```

`updated_count` is the number of message rows updated.

**Errors:** `404 NOT_FOUND` (mailbox not found, thread not found, or agent key not
bound to the mailbox). See the [error reference](/agents/errors).

## SDK example

The TypeScript and Python SDKs mirror these operations method-for-method. In
TypeScript:

```ts
import { ReplyLayer } from '@replylayer/sdk';
const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });

// List threads with at least one inbound message.
const page = await rl.threads.list('support', { limit: 50, has_inbound: true });
for (const t of page.data) {
  console.log(t.id, t.subject, `unread=${t.unread_count}`, t.scan?.verdict ?? 'no-scan');
}

// Read the full sequence for one thread (URL-encoding is handled for you).
const thread = await rl.threads.get(page.data[0].id, { view: 'summary' });

// Mark it read, then star it.
await rl.threads.markRead('support', thread.id);
await rl.threads.setStarred('support', thread.id, true);

// Continue the conversation — thread mode derives mailbox + subject.
await rl.messages.send({ thread_id: thread.id, body: 'Following up on this.' });
```

The full machine-readable schema for every operation on this page is served at
[`/docs/openapi.json`](/docs/openapi.json).
