Skip to the documentation
TypeScript

Threads

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

Reading

read-threads.ts
const page = await nextmail.threads.list({  folder: 'inbox',  query: 'from:ada',  labelIds: ['INBOX', 'IMPORTANT'],  limit: 25,}) const next = page.nextCursor  ? await nextmail.threads.list({ folder: 'inbox', cursor: page.nextCursor })  : null const thread = await nextmail.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.

sort-threads.ts
const lastWeek = await nextmail.threads.listAll({  sort: 'oldest',  dateFrom: new Date(Date.now() - 7 * 86_400_000),  dateTo: new Date(),  fromContacts: true,}) for await (const thread of nextmail.threads.iterate({ sort: 'sender' })) {  console.log(thread.id)}

sort, dateFrom, dateTo and fromContacts are the thread list’s own controls. sort is newest, oldest, sender or subject, the dates take a Date or an ISO 8601 string and both ends are included, and fromContacts keeps mail whose newest message came from a saved contact. Every order pages to the end without skipping or repeating a thread. With semantic: true the plain words of query match by meaning instead, best match first, and sort does not apply.

Organising

organise-threads.ts
await nextmail.threads.update('thread_…', {  read: true,  addLabelIds: ['USER_DONE'],  removeLabelIds: ['INBOX'],}) await nextmail.threads.trash('thread_…')await nextmail.threads.snooze('thread_…', new Date(Date.now() + 86_400_000))await nextmail.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.

addLabelIds takes ids from labels.list and the system ids such as ARCHIVE and STARRED. An id that names no label is refused with a 422 label_not_found rather than created, so make the label with labels.create first. threads.list({ folder: 'USER_DONE' }) lists every thread carrying a label, whichever folder it is in.

Attachments on a message

attachments.ts
const files = await nextmail.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 Nextmail 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, nextmail } from '@nextmail/sdk' const thread = await nextmail.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)

folderstring
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`.
querystring
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. 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. 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. A plain word also matches the name of any attachment on the conversation, whichever message carried it.
labelIdsstring | 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.
limitnumber
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.
cursorstring
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.
categoryInboxCategory
Keeps only the threads sorted into one inbox tab: `primary`, `promotions`, `updates`, `social` or `forums`. It applies while `folder` is `inbox`, and `primary` is every inbox thread in no other tab. In `query`, `category:promotions` filters the same way in any folder.

Response: Page<ThreadSummaryResource>

itemsThreadSummaryResource[]
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[].idstring
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.
hasMoreboolean
Whether there is a further page, derived from `nextCursor` where the API does not state it.
nextCursorstring | 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.

Suggested replies

threads.replySuggestions(id) resolves the replies the reading pane suggests under the latest message: up to three, each a label of a few words and a body ready to send. They are kept until another message arrives, and state is pending while they are being written and none when the message needs no reply.

reply-suggestions.ts
const { state, suggestions } = await nextmail.threads.replySuggestions('thread_…') if (state === 'ready') {  for (const suggestion of suggestions) console.log(suggestion.label, suggestion.body)}

Mute and inbox tabs

mute-and-tabs.ts
await nextmail.threads.mute('thread_…')await nextmail.threads.unmute('thread_…') const promotions = await nextmail.threads.list({ category: 'promotions' }) await nextmail.threads.setSenderCategory('news@example.com', 'promotions')const choices = await nextmail.threads.listSenderCategories()await nextmail.threads.clearSenderCategory('news@example.com')

threads.mute moves a thread from the inbox to the archive, and from then on new mail in it arrives archived, unread and without a notification, for everyone on the workspace. threads.unmute takes the mute off and leaves the thread where it is. Both resolve with { object, id, muted }.

setSenderCategory saves the inbox tab for one sender and moves their inbox threads into it, listSenderCategories resolves the saved choices as a plain array, and clearSenderCategory hands the sender back to the sorting rules. A workspace holds at most 2,000 choices, and the call past that is a 422 sender_category_limit_reached.

Reminders

reminders.ts
await nextmail.emails.send({ ...message, followUpAt: 'P3D' }) const reminder = await nextmail.followUps.set({  threadId: 'thread_…',  remindAt: new Date(Date.now() + 3 * 86_400_000),}) const waiting = await nextmail.followUps.list({ status: 'waiting' })await nextmail.followUps.cancel(reminder.id)

followUps.set asks to be reminded about a thread if nobody replies by remindAt, a Date or an ISO 8601 string, and followUpAt on emails.send does the same as the mail goes out. A real reply closes the reminder by itself. Otherwise the thread returns to the top of the inbox, unread, when the time comes.

A thread holds one waiting reminder per person, so setting another, or sending again into the thread with followUpAt, replaces the one that was waiting. A reminder whose thread sits in the Bin or in Spam when it comes due is cancelled. followUps.list resolves a plain array, newest first, and takes status, threadId and limit. A time in the past, or more than 365 days out, is a 422 invalid_follow_up.

Out of office

out-of-office.ts
const reply = await nextmail.outOfOffice.set('ada@example.com', {  startsAt: new Date('2026-12-22T00:00:00Z'),  endsAt: new Date('2027-01-04T08:00:00Z'),  subject: 'Away until 4 January',  message: 'I am away until 4 January and will answer when I am back.',}) const replies = await nextmail.outOfOffice.list()await nextmail.outOfOffice.clear('ada@example.com')

outOfOffice.set saves the automatic reply of one address, or of a catch-all written *@example.com, and replaces whatever was saved. message is the only required field. Each sender is answered once for the whole away period, and newsletters, automatic mail, bounces and blocked senders are never answered.

outOfOffice.list resolves every reply saved in the workspace, or with address exactly one row, which reads enabled: false when that address never saved one. active says whether mail arriving now is answered. outOfOffice.clear removes a reply, and enabled: false on set pauses one and keeps its text. These calls need settings:read and settings:write.