# Mailboxes and folders

> Where mail lives in Pidgeon, how folders and read state work, and how to stay in step with a mailbox without polling it.

Every address with an inbox has one. A mailbox is folders with counts and a
page of conversations in each — the shape a person sees, read and written by
the API rather than only by the client.

## The folders

Ten: `inbox`, `starred`, `sent`, `drafts`, `scheduled`, `snoozed`, `archive`,
`spam`, `trash` and `unauthenticated`. `identity_id` is required — there is
no merged view across addresses here, because two folders joined together are
a folder nobody has. For a list of conversations across every address, use
[`GET /threads`](https://pidgeon.ai/docs/threads.md), which is workspace-wide and whose rows now
carry the folder, the sender and a preview.

**Four of them are destinations.** `folder` on a conversation may be set to
`inbox`, `archive`, `spam` or `trash`. The rest are consequences of
something: `starred` is a flag, `sent` and `drafts` are what the message is,
and `scheduled` and `snoozed` hold a conversation until a time arrives.
Snoozing and scheduling are dashboard actions and have no API of their own —
you can read both folders, and a conversation leaves them when its time comes
rather than because you moved it.

**Spam is never in `inbox`, and the inbox page says how much there is.** It
carries `held_back: { spam, how_to_include }`, where `spam` is the number of
unread messages filed there. An empty inbox with `spam` above zero means the
mail came and the classifier distrusted it: read `spam` next.

**`unauthenticated` is a view over the other nine**, not a tenth place. Mail
in it says it is from somebody and failed to prove it — SPF, DKIM or DMARC —
and the same message is also in Inbox or Spam, wherever the classifier put
it. Nothing moves anything in or out. Read
[`authentication`](https://pidgeon.ai/docs/messages.md) on the message for what the sending
domain's own records actually said.

```bash
curl "https://pidgeon.ai/api/v1/mailboxes?identity_id=0c939bd7-2d69-4150-a2ac-de994f0e4887" \
-H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const { data: folders } = await mailboxesList({
query: { identity_id: '0c939bd7-2d69-4150-a2ac-de994f0e4887' },
});
```

```python
httpx.get(
  "https://pidgeon.ai/api/v1/mailboxes",
  params={"identity_id": "0c939bd7-2d69-4150-a2ac-de994f0e4887"},
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
)
```

`total` and `unread` are `null` rather than `0` where the product does not
count that folder. Zero would be a claim; null says we do not count it.

## One folder, a page at a time

A row is a **conversation** — the newest message in each thread — not a
message. It carries no body: a list of forty conversations does not want
forty bodies. `GET /messages?thread_id=` opens one.

```bash
curl "https://pidgeon.ai/api/v1/mailboxes/inbox?identity_id=0c939bd7-2d69-4150-a2ac-de994f0e4887&limit=50" \
-H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const inbox = {
path: { folder: 'inbox' },
query: { identity_id: '0c939bd7-2d69-4150-a2ac-de994f0e4887' },
};

const { data: page } = await mailboxesListFolder(inbox);

if (page.has_more) {
const { data: next } = await mailboxesListFolder({
  ...inbox,
  query: { ...inbox.query, cursor: page.next_cursor! },
});
}
```

```python
httpx.get(
  "https://pidgeon.ai/api/v1/mailboxes/inbox",
  params={"identity_id": "0c939bd7-2d69-4150-a2ac-de994f0e4887", "limit": 50},
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
)
```

**`cursor` is opaque, and it is not an offset.** It carries a sort key and an
id together, because several messages can arrive in the same millisecond and
a cursor on the timestamp alone repeats or drops rows at a page boundary. An
offset would be worse than either: rows shift underneath it and a client
syncing an inbox silently loses mail. Pass back what `next_cursor` gave you;
do not construct one.

A cursor we did not write is page one, not an error. It arrives in a URL, and
a URL is a thing people edit.

## Staying in step

An agent that watches a mailbox goes away and comes back, and the question it
has is not "what is in the inbox" but **"what has happened since I last
looked"**. Paging the folder and diffing it against what you remember is the
answer without this endpoint, and it is not one anybody should have to write.

```bash
curl "https://pidgeon.ai/api/v1/mailboxes/changes?identity_id=0c939bd7-2d69-4150-a2ac-de994f0e4887&since=$CURSOR" \
-H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
// First run: page the folder, and keep the sync cursor it hands back.
const inbox = {
path: { folder: 'inbox' },
query: { identity_id: '0c939bd7-2d69-4150-a2ac-de994f0e4887' },
};

let { data: page } = await mailboxesListFolder(inbox);
while (page.has_more) {
({ data: page } = await mailboxesListFolder({
  ...inbox,
  query: { ...inbox.query, cursor: page.next_cursor! },
}));
}

let since = page.sync_cursor;

// Every run after that.
const { data: delta, error } = await mailboxesChanges({
query: { identity_id: '0c939bd7-2d69-4150-a2ac-de994f0e4887', since },
});

if (error?.error.code === 'resync') {
// Too far behind. Page the folder again, from the top.
} else if (delta) {
for (const change of delta.data) {
  if (change.deleted) forget(change.id);
  else upsert(change);
}
since = delta.next_cursor!;
}
```

```python
httpx.get(
  "https://pidgeon.ai/api/v1/mailboxes/changes",
  params={"identity_id": "0c939bd7-2d69-4150-a2ac-de994f0e4887", "since": cursor},
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
)
```

**The first cursor comes off a listing**, not from this endpoint, and
`sync_cursor` sits beside `data` on every page of `GET /mailboxes/{folder}`.
It is not a page cursor: the two are different reads and each refuses the
other, with a sentence saying which you handed it.

**Per mailbox, not per folder.** A message moving from the inbox to the
archive is one event; asking per folder would deliver it as a disappearance
in one poll and an appearance in another, and would mean nine polls where one
does. Each row carries `folder`, so you learn where it went.

**Arrivals are in it.** New mail is the most important thing that happens to
a mailbox, and a client polling the listing for arrivals *and* this for
changes would be polling twice for one job.

**A deletion carries the id and nothing else.** There is nothing else left —
the message is gone, and keeping a record of what it said would make "delete"
mean "delete most of it".

**`since` is not a page cursor.** Both are opaque, and they walk different
orders: `cursor` pages a folder newest-first, `since` walks change time.
Passing one where the other belongs is refused rather than answered, because
the wrong answer would be a convincing one.

**`410 resync` means start again.** We keep the record of what was deleted for
thirty days — the same window raw messages get, because a record that a
message was deleted should not outlive the message. A cursor older than that
cannot be answered completely, and an empty delta would be indistinguishable
from "nothing changed".

## Taking all of it with you

`GET /v1/export` streams every message your key can read as one mbox file —
the format Apple Mail, Thunderbird and most archivers import. Pass
`identity_id` for one address. Each message carries an `X-Pidgeon-Mailbox`
header, so a file with several addresses in it can be split again, and
originals come back byte for byte while storage still holds them. Four
exports an hour per workspace.

```bash
curl https://pidgeon.ai/api/v1/export \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-o pidgeon.mbox
```
