---
title: "Threads"
description: "Read and organise mail."
url: "https://nextmail.sh/docs/api/threads"
area: "API"
category: "Mailbox"
---

# Threads

Read and organise mail.

**Also documents:** `GET /threads`, `GET /threads/{id}`, `GET /threads/{id}/messages`, `GET /threads/{id}/messages/{messageId}`, `PATCH /threads/{id}`, `POST /threads/{id}/trash`, `POST /threads/{id}/snooze`, `POST /threads/{id}/unsnooze`, `GET /threads/{id}/messages/{messageId}/attachments`, `POST /threads/{id}/mute`, `POST /threads/{id}/unmute`, `GET /sender-categories`, `PUT /sender-categories/{email}`, `DELETE /sender-categories/{email}`, `GET /follow-ups`, `POST /follow-ups`, `DELETE /follow-ups/{id}`

## Listing

`GET /threads?folder=inbox`. Passing `query` searches the same local index. Plain words must all appear, and each matches loosely, ignoring case, accents and separators, so `min` finds "Benjamin". A quoted phrase is matched as written apart from case and accents, so `"ben jamin"` does not find "Ben-Jamin". 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 such as `the` or `emails` are dropped from a list of plain words when something else is left to search for. Operators such as `from:`, `to:`, `subject:`, `label:`, `is:unread`, `has:pdf`, `after:2026/01/31` and `newer_than:7d` narrow it, and `OR`, parentheses and a leading `-` combine them. 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.

Words and the `from:`, `to:`, `cc:`, `subject:` and `body:` operators read 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. `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 and stays in drafts whatever the query names. A plain word also matches the name of any attachment on the conversation, whichever message carried it.

A value the search cannot use is ignored rather than narrowing, so a typo in a value widens the result instead of emptying it: `larger:`, `smaller:`, `size:`, `messagesize:`, `list:`, `rfc822msgid:`, `received:`, `sent:`, a `has:` word naming no kind of attachment, an `importance:` other than `high` or `low`, an unreadable date 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. Dates read the newest activity on the thread, in UTC, with `after:` including the day it names and `before:` excluding it; write one as `YYYY/MM/DD`, `YYYY-MM-DD`, `YYYYMMDD`, a bare year, or epoch seconds or milliseconds.

> `nextPageToken` is opaque. Pass back exactly what you were given; never construct or edit one. Its shape is not part of the contract.

Labels narrow a listing two ways. `labelIds` takes comma-separated label ids and keeps the threads in `folder` that carry every one of them, which is how the app filters a folder by a label. A label id passed as `folder` itself, as in `GET /threads?folder=USER_INVOICES`, lists every thread carrying that label whichever folder it is in, Bin and Spam included.

Four more parameters are the thread list’s own controls. `sort` is `newest` (the default), `oldest`, `sender` or `subject`, and the last two are alphabetical, newest first within one sender or subject. `dateFrom` and `dateTo` are ISO 8601 instants, both included, read against the newest message on each thread, and `dateFrom` after `dateTo` is a 422. `fromContacts=true` keeps the threads whose newest message came from a saved contact. Every order pages to the end: threads that arrived in the same instant are ordered by id, so a page boundary between two of them never skips or repeats one. Send the same parameters with every `pageToken`.

`semantic=true` searches by meaning. The plain words of `query` are matched against what each thread is about rather than how it is spelt, in any language, so a description such as `invoices I still owe` finds mail about unpaid bills however it is worded. The closest threads come back best match first, together with every thread the words match as text, and a thread is kept only when it is clearly closer to the words than the rest of the mailbox, so a description nothing fits returns only the text matches. Operators and the other parameters still narrow it, `sort` does not apply, and a query with no plain words is an ordinary search. Meaning is read from the whole conversation, newest messages first and up to 12,000 characters, so an earlier message counts too, a conversation with an encrypted message is found by its text alone, and new mail can be found by meaning about a minute after it arrives. A workspace that turned search by meaning off in its settings (`semanticSearch: false`) gets a text search whatever `semantic` says. The SDKs take `semantic: true`, the CLI takes `--semantic`, and the MCP tool `listThreads` takes `semantic`.

Three operators read the states described further down this page. `is:muted` finds muted threads, `is:waiting` finds threads sent with a reminder that nobody has answered yet, and `category:promotions` finds the threads sorted into one inbox tab, with `primary`, `promotions`, `updates`, `social` or `forums` as its value. `is:promotions` and the other tab names do the same, and a `category:` that names no tab is ignored. `folder` still applies to all of them, so add `in:anywhere` to find a muted thread in the archive.

## Retrieving

`GET /threads/{id}` returns every message in the thread, not just the most recent one, along with its labels and whether anything in it is unread.

## Messages that arrived encrypted

This API neither encrypts nor decrypts. It cannot open a message somebody else encrypted, and it cannot send an encrypted one. A request carrying an encryption marker is refused with a 422, because the only surfaces that may set one are the ones holding the keys, and no API client holds a key. What it does is RECOGNISE a sealed envelope on the way in, off the top-level `Content-Type` and nothing more, and then say so on the message.

Nextmail itself holds keys now, and it is worth being exact about which half and where. A mailbox owner generates an OpenPGP identity in their browser and publishes the PUBLIC key to a directory other signed-in Nextmail senders can resolve. The private half is made in that browser, never sent here, and never recoverable, so nothing in this API can decrypt anything, and no support request, subpoena or backup of ours produces a key that could. The web app can now OPEN a PGP/MIME or inline-PGP message when the key is in the reader’s browser, but that decrypt happens in the tab and its plaintext is never written back: the stored message stays ciphertext, and no response from this API ever carries the opened text. The app can now seal a new message in the browser and send it: the composer encrypts to the recipients’ published keys and the mail goes out as PGP/MIME. This API still cannot seal anything, so the field below describes both mail somebody else encrypted and mail sealed in the Nextmail web app.

That is worth a field because of what the alternative was. A sealed message stores no readable body, so `decodedBody` comes back as `""`, the same bytes as a message that genuinely had no content. `encryption` is what lets you tell the two apart before you act on one, and it is a statement about the envelope rather than a verification: seeing that a message is sealed is not the same as having opened it.

**Response**

```
{
    "object": "thread",
    "id": "thread_2f9b…",
    "messages": [
      {
        "id": "msg_7c41…",
        "subject": "Q3 numbers",
        "decodedBody": "",
        "encryption": {
          "format": "pgp-mime",
          "detectedAt": "2026-08-30T09:14:22.117Z",
          "rawRetained": false,
          "parts": [
            { "index": 0, "attachmentId": "msg_7c41…-0", "role": "version" },
            { "index": 1, "attachmentId": "msg_7c41…-1", "role": "ciphertext" }
          ]
        }
      }
    ]
  }
```

**encryption**

- `format` ('pgp-mime' | 'pgp-signed' | 'pgp-inline' | 'smime-encrypted' | 'smime-signed'): Which envelope arrived. Read off the top-level `Content-Type` (its `protocol` parameter for PGP, its `smime-type` for S/MIME) or, for `pgp-inline`, off a body that opens with the PGP armor header. A `pkcs7-mime` part carrying no `smime-type` at all is read as `smime-encrypted`, which is what RFC 8551 makes it by default.
- `detectedAt` (string): ISO 8601, when the detector ran, which is when the message was ingested here. It says nothing about when the message was encrypted, or by whom.
- `rawRetained` (boolean): Whether the original RFC822 bytes were kept, so the message could be handed back whole. False on every message today, since nothing here retains raw mail yet. It is in the response now so that the day it changes is not also the day every stored message has to be migrated again.
- `parts` (object[]): The envelope parts this format uses. Present whenever `encryption` is, and empty when there are none to name: `pgp-inline` has no separate part at all, since its armor IS the body and arrives in `decodedBody`.
- `parts[].index` (number): Which MIME part of the original message this was, counted over the parts as they arrived rather than over `attachments`. The two lists differ, which is the entire reason this is recorded.
- `parts[].attachmentId` (string): The id this part carries in `attachments`, where it appears there at all: the message id with the part index appended. The `ciphertext` part is listed and downloads like any other file; `version` and `signature` are held out of the list, so their ids correlate the two views and nothing more. The attachments endpoint will not return them.
- `parts[].role` ('version' | 'ciphertext' | 'signature'): `version` is the PGP/MIME control part, `ciphertext` is the message, `signature` is a detached signature. Only `ciphertext` is worth fetching; the other two are protocol furniture that used to render as junk attachments and no longer do.

| format | What arrived | Body |
| --- | --- | --- |
| pgp-mime | A PGP/MIME envelope: `multipart/encrypted` with `protocol=application/pgp-encrypted`. | Sealed |
| pgp-inline | Armor in the body itself. Only ever read off the body text, so a reply that merely quotes an armored block is not mistaken for one. | Sealed |
| smime-encrypted | An S/MIME `pkcs7-mime` part with `smime-type=enveloped-data`, or one with no `smime-type` at all. | Sealed |
| pgp-signed | A detached PGP signature beside the message: `multipart/signed` with `protocol=application/pgp-signature`. | Readable |
| smime-signed | A detached S/MIME signature: a `pkcs7-signature` protocol, or `smime-type=signed-data`. | Readable |

> Signed is not sealed, and branching on the presence of `encryption` instead of on `format` gets that exactly backwards. A signature is a claim about who wrote the message, not a wrapper around it: the body of a signed message is in the clear and reads like any other. Treat `pgp-mime`, `pgp-inline` and `smime-encrypted` as unreadable, and the two signed formats as ordinary mail.

## What changes on a sealed message

Only the three sealed formats change anything, and the change happens at ingest rather than in this response. Everything that would have read the body stands down, instead of reading ciphertext and reporting a result it could not have got:

- Search over the body. The message is indexed with an empty body snippet, so it is still found by sender, subject, address and label, and not by anything inside it.
- The phishing scorer’s body pass. The verdict still arrives and says what it could not do: `risk.signals` carries `body-encrypted` and `risk.aiChecked` is false.
- The AI-authorship check, which declines rather than guesses: `aiWritten.level` is `unknown` and `aiWritten.skipped` is `encrypted`.
- Body conditions in rules. Envelope and header conditions run exactly as before; a rule that asked about the body is recorded as unevaluated rather than counted as a non-match, because "did not match" and "could not be read" are different answers.
- Calendar invite import. The invite is inside the ciphertext, and building an event out of the envelope would put a wrong entry on a real calendar.
- Thread summaries and embeddings, for the whole thread. One sealed reply is enough. A summary is a model’s reading of the plaintext stored as cleartext metadata, which is the one place in this pipeline where a body would leak into a store nobody thinks of as a body.

Everything that does not need the body is untouched:

- DMARC, DKIM and SPF. Those are read off `Authentication-Results`, which ciphertext does not hide, so an encrypted message still gets a real authentication verdict rather than none.
- Threading, spam filing and the blocklist: all envelope and header work.
- Attachments. The ciphertext part stays in `attachments`, named `encrypted-message.asc` when it arrives unnamed, and downloads through the endpoint below. It is exactly what the web app’s own reader fetches and decrypts in the browser; for an API client, which holds no key, that download stays the only way to read the mail. Open it in a client that has one.
- A signed message loses none of this. Every one of the checks above keeps running on it, and nothing is withheld, which is why the sealed list is a list of three formats and not of five.

> Absence of `encryption` is not a claim of plaintext. It means nobody looked: the message predates detection, or reached the mailbox by a path that does not run the detector. Nothing backfills it, so a field that says "we did not check" must never be read as "we checked and found none".

- [Encrypted mail in the app](https://nextmail.sh/docs/knowledge/trust/e2ee.md): What a reader sees on one of these, what the keys are, and what has to happen before a padlock goes green.
- [Transport encryption](https://nextmail.sh/docs/knowledge/trust/tls.md): What TLS does and does not tell you about a delivery.

## Marking and labelling

`PATCH /threads/{id}` takes `read`, `addLabelIds` and `removeLabelIds`. Read state is a label on every backend this product supports, so setting `read` and moving labels in one call keeps the ordering deterministic. User label ids come from `GET /labels`, and the system ids such as `ARCHIVE`, `STARRED` and `UNREAD` are taken in any case, so archiving is adding `ARCHIVE` and removing `INBOX`.

**PATCH**

```
{ "read": true, "addLabelIds": ["USER_INVOICES"] }
```

> `TRASH` and `SNOOZED` are refused here with `label_not_directly_settable`. Neither state is carried by its label alone (trashing also clears the folder labels, and a snooze needs a wake time stored beside it), so setting them by hand leaves a thread in a state the app never produces and cannot recover from. Use the endpoints below.

> An id in `addLabelIds` that names no label is refused with 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 cannot carry it.

## Trash and snooze

| Endpoint | Does |
| --- | --- |
| POST /threads/{id}/trash | Moves to Bin, clearing INBOX, SPAM, SNOOZED and ARCHIVE together. |
| POST /threads/{id}/snooze | Body `{ "wakeAt": "…" }`. Hides it and schedules its return. |
| POST /threads/{id}/unsnooze | Brings it back now, and cancels the scheduled return. |
| POST /threads/{id}/restore | Takes it back out of the Bin or Spam and puts it in the inbox. |
| DELETE /threads/{id} | Deletes it for good, every message and attachment. There is no undo. |

> Snooze writes two things: the label that hides the thread, and the entry that brings it back. Doing one without the other is exactly why these are endpoints rather than label edits.

## Attachments

`GET /threads/{id}/messages/{messageId}/attachments` returns each attachment with `filename`, `contentType`, `size` and `content` as base64. `content` is an empty string where the stored bytes could not be found, so check its length before decoding.

> An encrypted envelope is not all here. The ciphertext is (it is the message, and downloading it is the only way an API client reads this mail), but the PGP/MIME version part and any detached signature are held out of the list, 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; this endpoint does not return them.

## Suggested replies

`GET /threads/{id}/reply-suggestions` returns up to three short replies to the latest message, the ones the reading pane offers under it. Each has a `label`, a few words for a button, and a `body`, the reply itself in plain text, ready to send with `POST /emails` or to keep as a draft. They are written in the language of that message and in the voice of the mail the workspace sends.

> Suggestions are written once for each new message and kept, so asking again costs nothing until another message arrives. A thread whose latest message needs no reply, such as a newsletter or one your own reply ended, answers with `state` set to `none`, and so does every thread in a workspace that set `replySuggestions` to `false` in its settings.

## Mute

`POST /threads/{id}/mute` mutes a conversation, as Mute does in the app, and `POST /threads/{id}/unmute` takes the mute off. Neither takes a body, and both need `threads:write`. A muted thread in the inbox moves to the archive, and from then on new mail in it goes straight to the archive, unread, with no push, sound or desktop notification.

**Response**

```
{ "object": "thread", "id": "thr_7c1e4a90", "muted": true }
```

> Thread state belongs to the whole workspace, so a thread is muted for everyone on it, and mail addressed only to you does not bring it back. Unmuting leaves the thread where it is, and the next message arrives in the inbox. `MUTE` is also a label that `PATCH /threads/{id}` takes in `addLabelIds` and `removeLabelIds`, and the `email.received` webhook carries `muted: true` for mail that arrived in a muted thread.

## Inbox tabs

Mail arriving in the inbox is sorted into five tabs, `primary`, `promotions`, `updates`, `social` and `forums`, by plain rules that use no AI. `GET /threads?category=promotions` lists one tab, and `category` applies only while `folder` is `inbox`. `primary` is every inbox thread in no other tab.

The other four tabs are labels, `CATEGORY_PROMOTIONS`, `CATEGORY_UPDATES`, `CATEGORY_SOCIAL` and `CATEGORY_FORUMS`, and `PATCH /threads/{id}` adds and removes them like any system label. A thread sits in one tab, and its newest message decides which.

| Endpoint | Does |
| --- | --- |
| GET /sender-categories | Lists the senders whose mail always goes to one tab. Needs `threads:read`. |
| PUT /sender-categories/{email} | Body `{ "category": "promotions" }`. Saves the tab for one sender and moves their inbox threads into it, up to the newest 1,000. Needs `threads:write`. |
| DELETE /sender-categories/{email} | Removes the choice, so the rules sort new mail from that sender again. Threads already sorted stay where they are. |

> A choice saved for a sender wins over the rules. A workspace holds at most 2,000 of them, and the one past that is a 422 `sender_category_limit_reached`. They apply to every address in the workspace, so a key limited to particular addresses or domains can read them, and gets a 422 `capability_unsupported` when it tries to change one.

> Mail is sorted for the whole workspace, and showing the tabs is each person’s choice, saved on the account as `inboxTabs` with `PATCH /settings`. `primaryOnlyAlerts`, on by default, keeps pushes, sounds and desktop notifications to Primary mail while the tabs are on.

## Reminders

A reminder brings a thread back when nobody has replied. Set one as you send, with `followUpAt` on `POST /emails`, or on a thread you already sent mail in, with `POST /follow-ups`. If no real reply has arrived by then, the thread returns to the top of the inbox, unread, with the built-in `FOLLOW_UP` label and a push to your phones.

| Endpoint | Does |
| --- | --- |
| GET /follow-ups | Lists your reminders, newest first. `status` keeps `waiting`, `answered`, `due` or `cancelled`, `threadId` keeps one thread, and `limit` takes 1 to 200, with 100 when left out. |
| POST /follow-ups | Body `{ "threadId": "…", "remindAt": "…" }`. Sets the reminder of a thread, or moves the one already waiting on it. |
| DELETE /follow-ups/{id} | Cancels a waiting reminder and returns it. One that already ended is returned as it is. |

> A real reply closes a reminder by itself, and automatic replies, delivery reports and mail from your own sending address do not count as one. `remindAt` has to be in the future and at most 365 days out, or the call is a 422 `invalid_follow_up`. Reading needs `threads:read` and changing needs `threads:write`.

> An API key reads and sets the reminders of the workspace owner, and an app those of the person who connected it. Reminders are checked every 5 minutes, and one whose thread sits in the Bin or in Spam when it comes due is cancelled, so that thread is not brought back. The push has its own account setting, `phoneFollowUps`, on by default and changed with `PATCH /settings`.

> A thread holds one waiting reminder per person, so `POST /follow-ups`, or another send into the thread with `followUpAt`, replaces the one that was waiting. A reminder set with `followUpAt` attaches to the thread when the message actually goes out, so for a scheduled message `threadId` stays null, and `is:waiting` does not find the thread, until it is sent.

## Messages in a fixed shape

`GET /threads/{id}` returns each message the way the mailbox stored it. `GET /threads/{id}/messages` lists the same messages, oldest first, in one shape that only ever gains fields: `from`, `to`, `cc`, `bcc` and `replyTo` as `{ name, email }`, `date`, `messageId`, `inReplyTo` and `references`, the body as `text` and `html`, the `attachments` without their bytes, the `headers` and `encryption`. `GET /threads/{id}/messages/{messageId}` reads one of them. Both need `threads:read`.

**GET /threads/{id}/messages/{messageId}**

```
{
  "object": "message",
  "id": "msg_7c2e9a41b3d84f6e9a0c1d2e",
  "threadId": "thr_2b8e4c1a9d7f4e3b8a6c5d0e",
  "messageId": "<CAHk7pQ2x9LmZ4@mail.example.com>",
  "from": { "name": "Ada Lovelace", "email": "ada@example.com" },
  "to": [{ "name": null, "email": "billing@acme.com" }],
  "cc": [],
  "bcc": [],
  "replyTo": [],
  "subject": "The invoice for October",
  "date": "2026-10-14T09:12:31.000Z",
  "draft": false,
  "inReplyTo": null,
  "references": [],
  "text": "Hello, the invoice is attached.",
  "html": "<p>Hello, the invoice is attached.</p>",
  "attachments": [
    { "id": "msg_7c2e9a41b3d84f6e9a0c1d2e-1", "filename": "invoice.pdf", "mimeType": "application/pdf", "size": 48213 }
  ],
  "headers": [
    { "name": "From", "value": "Ada Lovelace <ada@example.com>" },
    { "name": "Message-ID", "value": "<CAHk7pQ2x9LmZ4@mail.example.com>" }
  ],
  "encryption": null
}
```

> `text` is the plain text body, or the text drawn from the HTML when the message came as HTML only, and `html` is null for a plain text message. `headers` holds the headers Nextmail keeps: From, To, Cc, Reply-To, Subject, Date, Message-ID, In-Reply-To, References and the two List-Unsubscribe headers. The rest of what a message arrived with is not kept. Unsent draft replies are in the list with `draft: true`.

## Reference

- [`GET /threads`](https://nextmail.sh/docs/api/reference/threads#get-threads): full reference
- [`GET /threads/{id}`](https://nextmail.sh/docs/api/reference/threads#get-threads-id): full reference
- [`GET /threads/{id}/messages`](https://nextmail.sh/docs/api/reference/threads#get-threads-id-messages): full reference
- [`GET /threads/{id}/messages/{messageId}`](https://nextmail.sh/docs/api/reference/threads#get-threads-id-messages-messageid): full reference
- [`PATCH /threads/{id}`](https://nextmail.sh/docs/api/reference/threads#patch-threads-id): full reference
- [`POST /threads/{id}/trash`](https://nextmail.sh/docs/api/reference/threads#post-threads-id-trash): full reference
- [`POST /threads/{id}/snooze`](https://nextmail.sh/docs/api/reference/threads#post-threads-id-snooze): full reference
- [`POST /threads/{id}/unsnooze`](https://nextmail.sh/docs/api/reference/threads#post-threads-id-unsnooze): full reference
- [`GET /threads/{id}/messages/{messageId}/attachments`](https://nextmail.sh/docs/api/reference/threads#get-threads-id-messages-messageid-attachments): full reference
- [`POST /threads/{id}/mute`](https://nextmail.sh/docs/api/reference/threads#post-threads-id-mute): full reference
- [`POST /threads/{id}/unmute`](https://nextmail.sh/docs/api/reference/threads#post-threads-id-unmute): full reference
- [`GET /sender-categories`](https://nextmail.sh/docs/api/reference/threads#get-sender-categories): full reference
- [`PUT /sender-categories/{email}`](https://nextmail.sh/docs/api/reference/threads#put-sender-categories-email): full reference
- [`DELETE /sender-categories/{email}`](https://nextmail.sh/docs/api/reference/threads#delete-sender-categories-email): full reference
- [`GET /follow-ups`](https://nextmail.sh/docs/api/reference/threads#get-follow-ups): full reference
- [`POST /follow-ups`](https://nextmail.sh/docs/api/reference/threads#post-follow-ups): full reference
- [`DELETE /follow-ups/{id}`](https://nextmail.sh/docs/api/reference/threads#delete-follow-ups-id): full reference
