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.
| Prefix | Names |
|---|---|
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.
Search
?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.
| Operator | Matches |
|---|---|
from:[email protected] | Sender address or name |
to:support | Anybody in the to or cc lists |
subject:invoice | The subject line |
label:payouts | A label of this environment, by name |
has:attachment | Messages with a file attached |
is:read, is:unread | Whether it has been opened |
is:starred | Starred messages |
is:answered, is:unanswered | Whether the conversation has been replied to |
in:trash, in:spam | Go to a folder the search would otherwise skip |
after:2026-08-01, before:2026-09-01 | Date 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.
| What | Where |
|---|---|
| Star, read, move to a folder | PATCH a message |
| Put it back where it came from | Restore |
| Erase for good, out of the bin | Erase |
| File under a label | Label |
| Hand the exchange to somebody | Assign |
| Say it has been dealt with | Answered |
| Write and send | Send |
| Park it unsent | Save a draft |
| Put a file on it | Add a file |
| Proofread or improve the draft | Writing help |
| Refuse mail from its sender | Block |
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.