---
title: "Threads"
description: "`threads.list`, `listAll`, `iterate`, `get`, `update`, `trash`, `snooze`, `unsnooze` and `listAttachments`."
url: "https://openemail.uk/docs/sdk/threads"
area: "SDK"
category: "Mailbox"
---

# Threads

`threads.list`, `listAll`, `iterate`, `get`, `update`, `trash`, `snooze`, `unsnooze` and `listAttachments`.

## Reading

**read-threads.ts**

```
const page = await openemail.threads.list({
  folder: 'inbox',
  query: 'from:ada',
  labelIds: ['INBOX', 'IMPORTANT'],
  limit: 25,
})

const next = page.nextCursor
  ? await openemail.threads.list({ folder: 'inbox', cursor: page.nextCursor })
  : null

const thread = await openemail.threads.get('thread_…')
console.log(thread.messageCount, thread.hasUnread, thread.totalReplies)
```

> The API pages threads with a `pageToken`. The client hands it to you as `nextCursor` and takes it back as `cursor`, like every other list, and `listAll` and `iterate` follow it for you. It is opaque: pass back what you were given and never build one.

## Organising

**organise-threads.ts**

```
await openemail.threads.update('thread_…', {
  read: true,
  addLabelIds: ['Done'],
  removeLabelIds: ['INBOX'],
})

await openemail.threads.trash('thread_…')
await openemail.threads.snooze('thread_…', new Date(Date.now() + 86_400_000))
await openemail.threads.unsnooze('thread_…')
```

Read state IS a label on every backend here, so it travels with the label lists and the ordering is deterministic when you set both. At least one of the three fields must be present.

## Attachments on a message

**attachments.ts**

```
const files = await openemail.threads.listAttachments('thread_…', 'message_…')

for (const file of files) {
  console.log(file.filename, file.contentType, file.size)
  if (file.content) await save(file.filename, Buffer.from(file.content, 'base64'))
}
```

> `content` is base64, and an empty string when the stored bytes could not be found, so check its length before decoding. The ciphertext of an encrypted message IS in this list and downloads like any other file; the PGP/MIME version part and any detached signature are not. They keep their ids in `encryption.parts` and nothing more.

## A message that arrived encrypted

This SDK neither encrypts nor decrypts: it cannot open a message somebody else encrypted, and it cannot send an encrypted one. The send request is refused if it carries an encryption marker, because a client with no key has no business asserting one. Keys generated in the OpenEmail app live in the browser that made them and reach nothing here, and when that browser opens a sealed message the plaintext stays in it, and the stored message this call reads is still ciphertext. What `threads.get` gives you is the envelope, recognised. A message that arrived PGP- or S/MIME-wrapped carries an `encryption` object, so an empty `decodedBody` stops being the only thing you are handed, and `encryption` is the one field on `MessageResource` with a real type, because it is the one whose absence you cannot survive guessing at.

**encrypted-mail.ts**

```
import { isSealed, openemail } from '@openemail/sdk'

const thread = await openemail.threads.get('thread_…')

for (const message of thread.messages) {
  if (!message.encryption) continue
  if (!isSealed(message)) continue

  console.warn('cannot read this one:', message.encryption.format)
}
```

> Branch with `isSealed`, never on the presence of the field. Two of the five formats, `pgp-signed` and `smime-signed`, describe a body that arrived in the CLEAR beside a detached signature, so gating on presence hides mail nobody needed to hide, and the user cannot see it or explain it. `isSealed` ships for exactly that reason: the server states the sealed set once, and a third copy written out of the union is the copy that drifts.

> Absence is not plaintext. `encryption` is missing on every message stored before detection shipped, and on anything that reached the mailbox by a path where the detector never ran. It records that nobody looked, a fact about our coverage rather than about the mail, and nothing backfills it.

## Where these differ from the rest

- Each entry in `ThreadResource.messages` is a `MessageResource`, a `Record<string, unknown>` with exactly one named field on it. Typing the rest would be the client asserting a normalisation nobody performs, and `encryption` is named anyway because a client that cannot branch on it reads a sealed message as an empty one.
- A request that cannot be served faithfully is a 422 `capability_unsupported`, not a response that looks right and is quietly wrong.

## Parameters: threads.list (ThreadListOptions)

- `folder` (string): Which folder to list. The server defaults it to `inbox`, so omitting it narrows the listing rather than widening it to everything. It applies to a `query` search as well, unless the query names a folder itself with `in:` or a folder `is:` such as `is:sent`.
- `query` (string): The mailbox search syntax. Plain words must all appear, and each 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 `"ben jamin"` does not find "Ben-Jamin", and filler words are dropped when something else is left to search for. Narrow with operators such as `from:ada`, `label:Invoices`, `is:unread`, `has:pdf`, `before:2026/01/31` and `older_than:1y`, and combine them with `OR`, parentheses and a leading `-`; a value the search cannot use is ignored rather than narrowing. Words and the `from:`, `to:`, `cc:`, `subject:` and `body:` operators read the latest message's sender, recipients, subject and the first 4,000 characters of its body with markup stripped, while `filename:` and `has:` read every attachment on the whole conversation and labels and folders read the whole conversation. It narrows the same index the unfiltered listing reads. Sealed messages store no body text, so only their sender, recipients and subject can match.
- `labelIds` (string | string[]): Restrict the listing to threads carrying these labels. The endpoint takes a comma-separated string and the client joins an array into one for you; there is no limit on how many you name.
- `limit` (number): How many threads to return, from 1 to 100. Omitted, the handler uses 25. The default lives in the handler rather than the schema, so an absent value and an explicit 25 behave alike.
- `cursor` (string): The previous page's `nextCursor`, passed back verbatim. It is the API's `pageToken` under the name every other list uses, and it is opaque, so never construct or edit one.

## Response: Page<ThreadSummaryResource>

- `items` (ThreadSummaryResource[]): One entry per thread in this page, lifted out of the API’s `data` envelope. Each entry is only an object marker and an id. The listing carries no subject, snippet, participants or labels, so anything more means calling `threads.get` on the threads you want.
- `items[].id` (string): The thread's id, to hand to `threads.get`, `threads.update` and the rest unchanged. It is the same id whether the row came from a filtered listing or from a `query` search.
- `hasMore` (boolean): Whether there is a further page, derived from `nextCursor` where the API does not state it.
- `nextCursor` (string | null): The API's `nextPageToken`, to send back as `cursor` for the following page, or null when there is no further page. An empty token is normalised to null, so a falsy check and a null check agree.
