Skip to the documentation
API

Threads

Read and organise mail.

GETapi.openemail.uk/threads

Runs any of the 7 calls on this page against your workspace, with your own key.

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.
detectedAtstring
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.
rawRetainedboolean
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.
partsobject[]
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[].indexnumber
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[].attachmentIdstring
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.
formatWhat arrivedBody
pgp-mimeA PGP/MIME envelope: multipart/encrypted with protocol=application/pgp-encrypted.Sealed
pgp-inlineArmor 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-encryptedAn S/MIME pkcs7-mime part with smime-type=enveloped-data, or one with no smime-type at all.Sealed
pgp-signedA detached PGP signature beside the message: multipart/signed with protocol=application/pgp-signature.Readable
smime-signedA 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".

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

EndpointDoes
POST /threads/{id}/trashMoves to Bin, clearing INBOX, SPAM, SNOOZED and ARCHIVE together.
POST /threads/{id}/snoozeBody { "wakeAt": "…" }. Hides it and schedules its return.
POST /threads/{id}/unsnoozeBrings 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.