Skip to the documentation
API

Threads

Read and organise mail.

GET/threads

Runs any of 17 calls on your workspace.

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

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.
POST /threads/{id}/restoreTakes 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.

EndpointDoes
GET /sender-categoriesLists 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.

EndpointDoes
GET /follow-upsLists 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-upsBody { "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