The mail endpoints let an app read the mailboxes somebody has access to. They are
part of the [app API](/docs/api/apps/overview) 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.

## 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`, `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 drafts and your own replies read as one
exchange.

`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:jamie@example.com`                | 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](/docs/api/mail/update) |
| Put it back where it came from | [Restore](/docs/api/mail/restore)        |
| Erase for good, out of the bin | [Erase](/docs/api/mail/erase)            |
| File under a label             | [Label](/docs/api/mail/label)            |
| Hand the exchange to somebody  | [Assign](/docs/api/mail/assign)          |
| Say it has been dealt with     | [Answered](/docs/api/mail/answered)      |
| Write and send                 | [Send](/docs/api/mail/send)              |
| Park it unsent                 | [Save a draft](/docs/api/mail/draft)     |

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](/docs/api/mail/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](/docs/api/apps/realtime) 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.