---
title: "Threads"
description: "Every operation in this group: what it accepts, what it returns and the errors it can answer with."
url: "https://openemail.uk/docs/api/reference/threads"
area: "API"
category: "Reference"
---

# Threads

Every operation in this group: what it accepts, what it returns and the errors it can answer with.

## Operations

### `GET /threads`

List threads in a folder

Reads the local index. Passing `query` searches that same index.

Plain words must all appear, and each one matches loosely: case, accents and separators are ignored and part of a longer word counts, so `min` and `ben jamin` both find "Benjamin". A quoted phrase is matched as written apart from case and accents, so its separators have to line up: `"ben jamin"` does not find "Ben-Jamin", while `"quarterly invoice"` finds "Quarterly invoice". When nothing matches exactly, close spellings are returned instead, so `benjimin` finds "Benjamin": a plain word, or the value of `from:`, `to:`, `cc:`, `subject:`, `body:`, `filename:` or `label:`, may differ from the start of a word by one typo (a changed, missing, extra or swapped letter) when it has four to seven letters and by two when it has eight or more, while a quoted phrase, a word containing a digit, a shorter word and an excluded word still match exactly, and the pages that follow keep matching the same way. Filler words are dropped from a list of plain words when something else is left to search for, so `mail server` searches for `server` alone and `emails from john` searches for `john`. The filler is `a`, `an`, `the`, `and`, `or`, `of`, `to`, `from`, `for`, `about`, `with`, `in`, `on`, `at`, `by`, `my`, `me`, `all`, `any`, `some`, `show`, `find`, `get`, `email`, `emails`, `mail`, `mails`, `message`, `messages`, `thread` and `threads`, and it is kept when dropping it would leave nothing but a single letter.

Operators narrow the search: `from:`, `to:`, `cc:`, `subject:`, `body:`, `label:`, `filename:`, `in:` (`inbox`, `sent`, `drafts`, `spam`, `trash`, `archive`, `snoozed`, `anywhere` or a label name), `is:` (`unread`, `read`, `starred`, `important`, `snoozed`, `muted`, `draft`, `sent`), `has:` (`attachment`, `pdf`, `image`, `video`, `audio`, `document`, `spreadsheet`, `presentation`, `userlabels`, `nouserlabels`), `after:YYYY/MM/DD`, `before:YYYY/MM/DD`, `newer_than:7d` and `older_than:1y` (units `h`, `d`, `w`, `m`, `y`). Combine them with `OR`, `AND`, `NOT`, parentheses, braces for a set of alternatives (`{stripe paddle}`) and a leading `-` to exclude. Recipients are stored as one list without roles and never hold a Bcc, so `cc:` reads the same field as `to:` and `bcc:` matches nothing of its own. `from:me` is mail you sent, and `to:me` is mail carrying one of your own addresses, aliases included, among its recipients or as the address it was delivered to.

A value the search cannot use is ignored rather than narrowing, so a typo in a value widens the result instead of emptying it. Ignored: `category:`, `larger:`, `smaller:`, `size:`, `messagesize:`, `list:`, `rfc822msgid:`, `received:`, `sent:`, the category words (`is:primary`, `is:personal`, `is:social`, `is:promotions`, `is:updates`, `is:forums`, `is:reservations`, `is:purchases`), a `has:` word that names none of the kinds above, an `importance:` other than `high` or `low`, a date that cannot be read, and a duration whose unit is not `h`, `d`, `w`, `m` or `y`. An operator name it does not know, `project:` for instance, is searched as plain text instead.

Words and the `from:`, `to:`, `cc:`, `subject:` and `body:` operators read only the newest message on each thread: its sender, its recipients, its subject and the first 4,000 characters of its body. `filename:` and `has:` read every attachment on the whole conversation, and `label:`, `in:` and `is:` read the whole conversation. A plain word also matches the name of any attachment on the conversation, whichever message carried it. A message that arrived encrypted has no body text to match. `folder` still applies unless the query names one with `in:`, or with an `is:` that is a folder such as `is:sent`, and `in:anywhere` searches every folder, on its own as well as beside other terms. A drafts listing is the exception: `GET /drafts` and `GET /threads?folder=draft` stay in drafts whatever the query names.

Dates read the newest activity on the thread, in UTC. `after:` includes the day it names and `before:` excludes it. A date can be written `YYYY/MM/DD`, `YYYY-MM-DD`, `YYYYMMDD`, as a bare year, or as epoch seconds or milliseconds; A short date reads day first (`16/09/2026`), unless the second number cannot be a month (`09/16/2026`), and a number above 12 settles it either way. A day that does not exist is rejected and ignored.

`nextPageToken` is opaque. Pass back what you were given, never construct one.

Requires the `threads:read` scope.

- Scopes: `threads:read`.

**Query parameters**

- `folder` (`string`, default `"inbox"`): Label the threads must carry, case insensitive. Defaults to `inbox`, and `bin` is read as `trash`.
- `query` (`string`): Mailbox search. Plain words must all appear and match loosely, ignoring case, accents and separators, against the newest message on each thread and the name of any attachment on it, while a quoted phrase has to appear as written and filler words such as `the` or `emails` are dropped when something else is left to search for. Operators such as `from:ada`, `label:Invoices`, `is:unread`, `has:pdf` and `newer_than:7d` narrow it, and naming a folder with `in:` replaces `folder`, so `in:anywhere` searches them all. When nothing matches exactly, close spellings are returned instead, so `benjimin` finds "Benjamin". A value the search cannot use is ignored rather than narrowing.
- `labelIds` (`string`): Comma-separated label ids. A thread must carry every one of them and sit in `folder`, which is how the app filters a folder by a label. To list a label's threads whichever folder they are in, pass its id as `folder` instead: `folder=USER_BIG_CLIENTS`.
- `sort` (`string`, one of `"newest"`, `"oldest"`, `"sender"`, `"subject"`, default `"newest"`): The four orders of the thread list: `newest`, `oldest`, `sender` and `subject`. `sender` and `subject` are alphabetical, newest first within one sender or subject. Threads that arrived in the same instant are ordered by id, so every order pages to the end without skipping or repeating one.
- `dateFrom` (`string`, format `date-time`): Keeps threads whose newest message arrived at or after this instant. ISO 8601 with a time and an offset, such as `2026-09-01T00:00:00Z`.
- `dateTo` (`string`, format `date-time`): Keeps threads whose newest message arrived at or before this instant. Both ends are included, and `dateFrom` after `dateTo` is a 422.
- `fromContacts` (`string`, one of `"true"`, `"false"`): `true` keeps only threads whose newest message came from a saved contact, the From contacts filter of the thread list. A key limited to particular addresses reads the contacts its owner saved, and an app acting for a member reads the contacts that member saved.
- `limit` (`integer`, at least 1, at most 100): Threads per page, a whole number from 1 to 100. Defaults to 25.
- `pageToken` (`string`): The previous page's `nextPageToken`, passed back as it came. Send the same `sort`, dates and filters with it.

**Returns**

- `200`: A page of threads.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.list()`](https://openemail.uk/docs/sdk/reference/threads#list), [`threads.listAll()`](https://openemail.uk/docs/sdk/reference/threads#listAll), [`threads.iterate()`](https://openemail.uk/docs/sdk/reference/threads#iterate); CLI [`openemail threads list`](https://openemail.uk/docs/cli/reference/threads#threads-list); MCP [`listThreads`](https://openemail.uk/docs/mcp/tools/reading#listThreads).

### `GET /threads/{id}`

Retrieve a thread

Every message in it, not only the most recent.

The messages are passed through as the mailbox stored them rather than projected onto a field list, and this document describes only `encryption`, which is the one field whose absence a caller cannot safely guess at. A message that arrived encrypted carries it and has an EMPTY BODY; read `encryption.format` before concluding a message was empty, and note that the two `*-signed` formats are ordinary readable mail.

Requires the `threads:read` scope.

- Scopes: `threads:read`.

**Path parameters**

- `id` (`string`, required): Thread id, as returned by `list` or carried on a message.

**Returns**

- `200` `Thread`: The thread, with every message on it.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.get()`](https://openemail.uk/docs/sdk/reference/threads#get); CLI [`openemail threads get`](https://openemail.uk/docs/cli/reference/threads#threads-get); MCP [`getThread`](https://openemail.uk/docs/mcp/tools/reading#getThread).

### `PATCH /threads/{id}`

Mark read/unread and change labels

Applies and removes labels, and sets read state, on one conversation. Label ids come from `GET /labels`; the system ids `INBOX`, `ARCHIVE`, `STARRED`, `IMPORTANT`, `SPAM` and `UNREAD` are taken in any case, so archiving is `addLabelIds: ["ARCHIVE"]` with `removeLabelIds: ["INBOX"]`. An id in `addLabelIds` that names no label is a 422 `label_not_found`, and nothing on the thread changes: create the label with `POST /labels` first. An unknown id in `removeLabelIds` is not an error, since the thread does not carry it. `TRASH`, `SNOOZED` and `DRAFT` are refused with `label_not_directly_settable`; use the trash and snooze endpoints.

Requires the `threads:write` scope.

- Scopes: `threads:write`.

**Path parameters**

- `id` (`string`, required): Thread id.

**Request body**

- `read` (`boolean`): `true` removes `UNREAD` and `false` adds it.
- `addLabelIds` (`string[]`, up to 50 items): Label ids to put on the thread. Each must name a label.
- `removeLabelIds` (`string[]`, up to 50 items): Label ids to take off the thread.

**Returns**

- `200`: Applied.

**Errors**

- `422`: `label_not_found` for an id in `addLabelIds` that names no label, or `label_not_directly_settable` for `TRASH`, `SNOOZED` or `DRAFT`.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.update()`](https://openemail.uk/docs/sdk/reference/threads#update); CLI [`openemail threads update`](https://openemail.uk/docs/cli/reference/threads#threads-update); MCP [`markThreadsRead`](https://openemail.uk/docs/mcp/tools/organising#markThreadsRead), [`markThreadsUnread`](https://openemail.uk/docs/mcp/tools/organising#markThreadsUnread), [`modifyLabels`](https://openemail.uk/docs/mcp/tools/organising#modifyLabels), [`restoreThreads`](https://openemail.uk/docs/mcp/tools/organising#restoreThreads).

### `DELETE /threads/{id}`

Delete a thread for good

Deletes every message in the thread, with its attachments, and cannot be undone, which is what Delete from Bin does in the app. It works on a thread in any folder, so move it to the Bin with `POST /threads/{id}/trash` when you only mean to throw it away.

Requires the `threads:write` scope.

- Scopes: `threads:write`.

**Path parameters**

- `id` (`string`, required): The thread, as `GET /threads` returns it.

**Returns**

- `200` `DeletedThread`: Gone for good.

**Errors**

- `500`: `thread_delete_failed`: the thread could not be deleted, and nothing was removed.
- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.delete()`](https://openemail.uk/docs/sdk/reference/threads#delete); CLI [`openemail threads delete`](https://openemail.uk/docs/cli/reference/threads#threads-delete); MCP [`deleteThreadsForever`](https://openemail.uk/docs/mcp/tools/organising#deleteThreadsForever).

### `POST /threads/{id}/trash`

Move a thread to the Bin

Clears INBOX, SPAM, SNOOZED and ARCHIVE together, which is what the app does. Adding the `TRASH` label by hand through PATCH is refused, because doing only half of it leaves the thread listed in the Bin AND its old folder.

Requires the `threads:write` scope.

- Scopes: `threads:write`.

**Path parameters**

- `id` (`string`, required): Thread id.

**Returns**

- `200`: Trashed.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.trash()`](https://openemail.uk/docs/sdk/reference/threads#trash); CLI [`openemail threads trash`](https://openemail.uk/docs/cli/reference/threads#threads-trash); MCP [`trashThreads`](https://openemail.uk/docs/mcp/tools/organising#trashThreads).

### `POST /threads/{id}/snooze`

Snooze a thread

Hides it and schedules its return. Both halves happen together: the label hides it, a stored wake time brings it back, and a thread snoozed by label alone never returns.

Requires the `threads:write` scope.

- Scopes: `threads:write`.

**Path parameters**

- `id` (`string`, required): Thread id.

**Request body**

- `wakeAt` (`string`, required, format `date-time`)

**Returns**

- `200`: Snoozed.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.snooze()`](https://openemail.uk/docs/sdk/reference/threads#snooze); CLI [`openemail threads snooze`](https://openemail.uk/docs/cli/reference/threads#threads-snooze); MCP [`snoozeThreads`](https://openemail.uk/docs/mcp/tools/organising#snoozeThreads).

### `POST /threads/{id}/unsnooze`

Unsnooze a thread

Brings it back now and cancels the scheduled return.

Requires the `threads:write` scope.

- Scopes: `threads:write`.

**Path parameters**

- `id` (`string`, required): Thread id.

**Returns**

- `200`: Back in the inbox.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.unsnooze()`](https://openemail.uk/docs/sdk/reference/threads#unsnooze); CLI [`openemail threads unsnooze`](https://openemail.uk/docs/cli/reference/threads#threads-unsnooze); MCP [`unsnoozeThreads`](https://openemail.uk/docs/mcp/tools/organising#unsnoozeThreads).

### `GET /threads/{id}/messages/{messageId}/attachments`

A message's attachments

`content` is base64, and an empty string where the stored bytes could not be found.

This is the DISPLAY list. Of the parts named by a message's `encryption.parts`, the `ciphertext` part IS listed and downloads like any other file (it is the message, and downloading it is the only way an API client reads this mail). The PGP/MIME `version` part and any detached `signature` are held out deliberately, because they rendered as junk attachments and there is nothing a caller can do with them. Both keep their ids in `encryption.parts`, which correlates the two views, and this endpoint does not return them.

Requires the `threads:read` scope.

- Scopes: `threads:read`.

**Path parameters**

- `id` (`string`, required): Thread id the message belongs to.
- `messageId` (`string`, required): Message id from that thread's `messages`.

**Returns**

- `200`: Attachments.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.listAttachments()`](https://openemail.uk/docs/sdk/reference/threads#listAttachments); CLI [`openemail threads list-attachments`](https://openemail.uk/docs/cli/reference/threads#threads-list-attachments).

### `GET /threads/counts`

Count the mail in each folder

What the sidebar of the app shows: how many conversations each folder holds and how many of them are unread, and how many inbox conversations arrived at each address. `unread` is a pseudo-folder whose count is the unread conversations in the inbox. A key or an app limited to particular addresses counts only the mail that arrived at them, and `address` narrows the counts to one address, never wider than that.

Requires the `threads:read` scope.

- Scopes: `threads:read`.

**Query parameters**

- `address` (`string`, 3 to 320 characters): Count only the mail delivered to this address. One the key does not reach counts nothing rather than failing.

**Returns**

- `200` `MailboxCounts`: The counts.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.counts()`](https://openemail.uk/docs/sdk/reference/threads#counts); CLI [`openemail threads counts`](https://openemail.uk/docs/cli/reference/threads#threads-counts); MCP [`getMailboxCounts`](https://openemail.uk/docs/mcp/tools/reading#getMailboxCounts).

### `GET /threads/{id}/summary`

Read the summary of a thread

The short AI summary the reading pane shows above a thread. It is written once and kept, and written again when a new message arrives, so reading it is cheap. `state` is `ready` with the summary, `pending` while one is being written (ask again in a few seconds), or `none` when there is nothing to summarise, such as a thread holding a message that arrived encrypted. Writing a summary spends one of the workspace's AI actions.

Requires the `threads:read` scope.

- Scopes: `threads:read`.

**Path parameters**

- `id` (`string`, required): The thread, as `GET /threads` returns it.

**Returns**

- `200` `ThreadSummary`: The summary, or the state it is in.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.summary()`](https://openemail.uk/docs/sdk/reference/threads#summary); CLI [`openemail threads summary`](https://openemail.uk/docs/cli/reference/threads#threads-summary); MCP [`getThreadSummary`](https://openemail.uk/docs/mcp/tools/reading#getThreadSummary).

### `POST /threads/{id}/restore`

Take a thread out of the Bin

Puts a thread back in the inbox, out of the Bin and out of Spam, which is what Restore from Bin and Move to inbox do in the app. It is the undo of `POST /threads/{id}/trash`, and calling it again changes nothing.

Requires the `threads:write` scope.

- Scopes: `threads:write`.

**Path parameters**

- `id` (`string`, required): The thread, as `GET /threads` returns it.

**Returns**

- `200` `RestoredThread`: Back in the inbox.

**Errors**

- `500`: `thread_update_failed`: the labels could not be written, and nothing on the thread changed.
- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`threads.restore()`](https://openemail.uk/docs/sdk/reference/threads#restore); CLI [`openemail threads restore`](https://openemail.uk/docs/cli/reference/threads#threads-restore); MCP [`modifyLabels`](https://openemail.uk/docs/mcp/tools/organising#modifyLabels), [`restoreThreads`](https://openemail.uk/docs/mcp/tools/organising#restoreThreads).

### Objects

#### `DeletedThread`

`object`

- `object` (`string`, one of `"thread"`)
- `id` (`string`)
- `deleted` (`boolean`, one of `true`)

#### `MailboxCounts`

`object`

- `object` (`string`, required, one of `"mailbox_counts"`)
- `folders` (`object[]`, required)
  - `label` (`string`, required, one of `"inbox"`, `"sent"`, `"spam"`, `"archive"`, `"trash"`, `"snoozed"`, `"unread"`): The folder, as its label id in lower case.
  - `count` (`integer`, required): Conversations in the folder.
  - `unread` (`integer`, required): How many of them are unread.
- `addresses` (`object[]`, required)
  - `address` (`string`, required, nullable): The address the mail arrived at, or null for mail that recorded none.
  - `count` (`integer`, required): Inbox conversations delivered to it.

#### `MessageEncryption`

`object`

What kind of encryption this message arrived carrying, read off its top-level `Content-Type` when it was ingested.

ABSENCE MEANS NOBODY LOOKED. The field is omitted on every message stored before detection existed. It never means "checked, and found none". A client reading a missing `encryption` as "this was plaintext" is asserting something no part of this system measured.

It is a statement about the ENVELOPE and not a verification. A message can be seen to be sealed without being opened, and those are different claims: nothing here says a signature checked out, says who holds a key, or licenses a padlock in a user interface.

SIGNED IS NOT SEALED, and that distinction is the whole reason `format` is an enum rather than a flag. For the three sealed formats the message's body fields are empty or hold PGP armor, and an empty body on such a message means "we cannot read this" rather than "there was nothing here". For the two signed formats the body is ordinary text and every consumer keeps working on it.

- `format` (`string`, required, one of `"pgp-mime"`, `"pgp-signed"`, `"pgp-inline"`, `"smime-encrypted"`, `"smime-signed"`): Which of the five envelope shapes was recognised. THREE of them mean the body is unreadable (`pgp-mime`, `pgp-inline` and `smime-encrypted`), and the other two, `pgp-signed` and `smime-signed`, mean the body arrived in the clear beside a detached signature and is read exactly like any other message. Branch on this value; branching on the mere presence of the object treats readable mail as unreadable.
- `detectedAt` (`string`, required): When the classification was made, on our clock at ingest. It dates OUR READING of the envelope and says nothing about when the message was encrypted or signed, or by whom.
- `rawRetained` (`boolean`, default `false`): Whether the original RFC822 bytes were kept, so that something holding the key could open the message later. Today it is always false. Nothing retains the raw message yet. It is published now so a client can start reading it rather than needing a second pass over every stored message the day that changes.
- `parts` (`object[]`): The envelope parts, each named by the id its bytes were written under. They are deliberately NOT in the message's attachment list (a PGP/MIME message would otherwise render two junk chips), so a sealed message commonly reports no attachments at all, and that emptiness is not evidence that nothing was attached. These ids are a CORRELATION KEY and not a handle: `GET /threads/{id}/messages/{messageId}/attachments` serves the display list, so it does not return them, and no other path in this API returns them either. `index` is the index into the original MIME parts.
  - `index` (`integer`, required)
  - `attachmentId` (`string`, required)
  - `role` (`string`, required, one of `"version"`, `"ciphertext"`, `"signature"`)

#### `RestoredThread`

`object`

- `object` (`string`, one of `"thread"`)
- `id` (`string`)
- `restored` (`boolean`, one of `true`)

#### `Thread`

`object`

- `object` (`string`, one of `"thread"`)
- `id` (`string`): The id that was asked for. Echoed back rather than read off the result. The mailbox answers a thread as messages, labels and counts, with no id of its own.
- `messages` (`ThreadMessage[]`): Every message on the thread, oldest first, not only the most recent.
- `labels` (`object[]`): The folders and labels the thread sits in. Backend-shaped like the messages are, so only the two fields every backend agrees on are described.
  - `id` (`string`)
  - `name` (`string`)
- `messageCount` (`integer`): The length of `messages`, counted here.
- `hasUnread` (`boolean`)
- `totalReplies` (`integer`)
- `deliveredTo` (`string`, nullable): The workspace address the thread belongs to, lower-cased: the address its first message was delivered to, with any `+tag` removed, or the address it was sent from when the thread began with a send. Later messages joining the thread do not change it. Null when no address was recorded.

#### `ThreadMessage`

`object`

A message, in whatever shape the mailbox stored it. The fields are deliberately not enumerated here. `encryption` is the one property this API describes, because it is the one whose absence cannot be guessed at safely.

- `encryption` (`MessageEncryption`)

#### `ThreadSummary`

`object`

- `object` (`string`, required, one of `"thread_summary"`)
- `threadId` (`string`, required)
- `state` (`string`, required, one of `"ready"`, `"pending"`, `"none"`)
- `summary` (`string`, required, nullable): One or two sentences, present when `state` is `ready`.
