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

# Threads

Read and organise mail.

**Also documents:** `GET /threads`, `GET /threads/{id}`, `PATCH /threads/{id}`, `POST /threads/{id}/trash`, `POST /threads/{id}/snooze`, `POST /threads/{id}/unsnooze`, `GET /threads/{id}/messages/{messageId}/attachments`

## 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". 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 value the search cannot use is ignored rather than narrowing, so a typo in a value widens the result instead of emptying it: `category:`, `larger:`, `smaller:`, `size:`, `messagesize:`, `list:`, `rfc822msgid:`, `received:`, `sent:`, the category words such as `is:promotions`, 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.

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

OpenEmail 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 OpenEmail 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 an OpenEmail tab.

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://openemail.uk/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://openemail.uk/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.

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

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

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