Reading mail

.md

How the mailbox endpoints are shaped, and why the unit is a message rather than a thread.

The mail endpoints let an app read the mailboxes somebody has access to. They are part of the app API and need a user token, never an API key: a mailbox belongs to people, and which people may open it is a decision the environment already made.

Every one of them needs the mail:read scope on the device and the view_mailbox permission on the account. Without the permission you get 403 with mailbox_not_permitted, whatever scopes the device holds.

Ids

Every id is a string with a prefix that says what it names, such as msg_h3k8q2w7n4r9t5vx. Send them back exactly as you got them, in a path, a query and a body alike.

PrefixNames
msg_A message
thr_A conversation, as thread_id on a message
mbx_A mailbox, as address_id on a message
lbl_A label
mnote_A note
att_An attachment
sig_A signature
blk_A blocked sender
usr_A person: an assignee, the author of a note, somebody who read it

Treat an id as an opaque string. Do not read anything into the characters after the prefix, and do not check their length.

A number in their place never finds the row it used to name. In a path it answers 404, like an id that does not exist. In the mailbox filter it is ignored like any mailbox you cannot read, and in the label filter it answers 404. In a JSON body every id takes a string, so a number there is refused with validation_failed.

The unit is a message

A list is one row per message, not one row per conversation, and opening a message opens the exchange around it.

That is not an oversight. A row is a thing you act on, and a row standing for twelve messages makes every button on it act on twelve: filing one message would file the replies sitting in Sent along with it. So the list stays flat, and each row says which place it takes in its conversation with position and count.

Opening one still gives you the whole exchange, because reading is where the grouping earns its keep.

Folders

inbox, archive, drafts, sent, spam and trash. A conversation is read inside one world at a time: the bin shows only what is in the bin, spam only what is in spam, and everywhere else the inbox, the archive, the drafts and your own replies read as one exchange.

archive is for mail worth keeping and not worth reading. Nothing in it counts towards unread, so a nightly report filed there by a rule never asks anybody for attention, and nothing in it is ever cleaned up on age the way spam and the bin are.

elsewhere on an opened conversation says what it has in the folders that view does not reach, so half an exchange never looks like the whole of it.

The list spans folders when you narrow it another way

Ask for a folder and you get that folder. Ask for starred, a label, assigned=me or a search, and the list reaches across the inbox, the drafts and what was sent, because none of those are questions about one column. The bin and spam stay out of all four; in:trash and in:spam are how you go there.

?q= takes the same query language as the web client, and the operators are English in every language because they are syntax rather than prose.

OperatorMatches
from:[email protected]Sender address or name
to:supportAnybody in the to or cc lists
subject:invoiceThe subject line
label:payoutsA label of this environment, by name
has:attachmentMessages with a file attached
is:read, is:unreadWhether it has been opened
is:starredStarred messages
is:answered, is:unansweredWhether the conversation has been replied to
in:trash, in:spamGo to a folder the search would otherwise skip
after:2026-08-01, before:2026-09-01Date range
"exact phrase"Words that have to appear together

Anything else is text. before:soon searches for the words before:soon rather than quietly dropping the filter, because a search that silently answers a different question is worse than one that finds nothing.

Writing

Everything that changes something needs mail:write on the device, which also grants mail:read.

WhatWhere
Star, read, move to a folderPATCH a message
Put it back where it came fromRestore
Erase for good, out of the binErase
File under a labelLabel
Hand the exchange to somebodyAssign
Say it has been dealt withAnswered
Write and sendSend
Park it unsentSave a draft
Put a file on itAdd a file
Proofread or improve the draftWriting help
Refuse mail from its senderBlock

The ones that take conversation: true apply to the whole exchange rather than the one message. Which messages that is comes from the id in the path, never from a list in the body, so a request can never reach further than the conversation it names.

Assignment and the answered mark have no such option: they live on the exchange because an exchange has one owner, and a per-message version would be a way to give two people halves of the same job.

Reading marks as read

Opening a conversation marks every message in it as read and records who read it, the same as in the browser. There is no way to read a message without that happening, on purpose: a badge that disagrees between somebody's phone and their laptop is a badge they stop believing.

Coming back after a while

Do not fetch the mailbox again. Sync changes hands back what moved and what disappeared since your last call, which over a mobile connection is the difference between an app that opens and one that spins.

While the app is on screen, a websocket tells it about mail as it lands. Neither replaces the other: a socket only works while it is connected.

HTML bodies

html on a message is the sender's markup after our sanitiser has been through it. It is safe to render, and it is the only form the body is ever handed out in.

Render it in a web view with JavaScript disabled, and set the view's base URL to the base_url on the conversation. Inline images are signed relative links, so they resolve against that host and nowhere else. Do not give the view access to your token.

quoted is the conversation the message carries along underneath it, split off so a reply can be read without reading the whole thread again. Fold it away behind a control.