# Messages

> Read a message in Pidgeon, act on it, undo that, and search across every address a key can reach. Bodies, headers and the reply without its quote.

Reading a message is a `GET`. Everything a person does to one — archiving it,
marking it spam, putting it back — is a state change the API makes as well,
and every one of them can be undone.

## Changing a conversation

Two fields you can set with a key: `is_starred` is about the mailbox, and
`folder` is where the conversation lives.

```bash
curl https://pidgeon.ai/api/v1/messages/bbaf186b-75b2-4123-a150-3c6e7a0c8d17 \
-X PATCH \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "is_starred": true, "folder": "archive" }'
```

```ts
const { undo } = await messagesUpdate({
path: { id: 'bbaf186b-75b2-4123-a150-3c6e7a0c8d17' },
body: {
  is_starred: true,
  folder: 'archive',
},
});
```

```python
httpx.patch(
  "https://pidgeon.ai/api/v1/messages/bbaf186b-75b2-4123-a150-3c6e7a0c8d17",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={"is_starred": True, "folder": "archive"},
)
```

### `is_read` is about a reader, and a key is not one

There is a third field, and setting it with an API key is **refused**. Read
state belongs to a person: the unread count answers "has somebody looked at
this", and software looking at a message is not that. So `is_read` is
readable — it tells you whether anybody has read it — and not writable by a
credential with no person behind it.

That is a deliberate refusal rather than a missing feature. It used to
succeed and do nothing, which is worse: your next read would say the message
was still unread and the bug would look like yours.

What you probably want instead:

- **`folder: "archive"`** if the point is "I have handled this". It is
  durable, it is what a person would do, and it moves the conversation out of
  the inbox for everybody.
- **[`GET /mailboxes/changes`](https://pidgeon.ai/docs/mailbox.md)** if the point is "do not show
  me this twice". A cursor is the right way to track what you have already
  processed, and it survives your process restarting.

Everything here is **thread-scoped**: archiving one message of a conversation
and leaving the rest in the inbox is never what anyone means. The four
folders you can move a conversation *to* are `inbox`, `archive`, `spam` and
`trash` — the ones that are places. Starred is a view over a flag, which is
why it is `is_starred` and not a destination.

`POST /messages/actions` applies one action to up to 200 conversations, which
is the same cap the dashboard uses.

## Undo

Unusual for an API to have, and the argument is that software makes mistakes
at a speed a person does not. An agent that archived the wrong forty
conversations should be able to say so.

```bash
curl https://pidgeon.ai/api/v1/messages/undo \
-X POST \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "undo": [ ... ] }'
```

```ts
const { undo } = await messagesAct({ body: { message_ids: ids, action: 'archive' } });

// Changed your mind.
await messagesUndo({ body: { undo } });
```

```python
httpx.post(
  "https://pidgeon.ai/api/v1/messages/undo",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={"undo": undo},
)
```

The `undo` value is the rows as they were before. Hand it back; do not read
it. Access to the messages it names is re-checked, so the worst a tampered
one can do is set flags on mail you can already set flags on.

## Search

Full-text, ranked by relevance. A result carries the folder **and the
mailbox** it was found in — the whole reason you are searching is that you
do not know where it lives. `identity` is the address and `identity_id` is
the id the attachment and label endpoints take, so a hit can be rendered and
acted on without a second call.

**`identity_id` is optional.** Leave it out and the search covers every
mailbox the key can reach, which is usually the question: *where did that
invoice go* is rarely about one address. Name one and it searches exactly
that one.

```bash
curl "https://pidgeon.ai/api/v1/messages/search?identity_id=0c939bd7-2d69-4150-a2ac-de994f0e4887&q=invoice&from=marcus&is_unread=true" \
-H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const { data } = await messagesSearch({
query: {
  identity_id: '0c939bd7-2d69-4150-a2ac-de994f0e4887',
  q: 'invoice',
  from: 'marcus',
  is_unread: 'true',
},
});
```

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

Not paged: page two of a relevance ranking is a place nobody goes. Narrow the
query instead — which is what the filters are for.

`label_id` names one label, and a label belongs to one address — so when you
are searching across mailboxes, filter by a label that exists on the one you
mean. The dashboard's `label:` takes a *name* and resolves it to every label
with that name you can reach, which is the thing a person means by it.

### Narrowing it

| Parameter        | Means                                                  |
| ---------------- | ------------------------------------------------------ |
| `in`             | One folder, **including Trash**, which `q` alone skips |
| `from`           | Substring of the sender's address *or* display name    |
| `to`             | Substring of any recipient                             |
| `has_attachment` | Whether there is at least one file                     |
| `is_unread`      | Whether a person has read it                           |
| `is_starred`     | Starred                                                |
| `label_id`       | One label, by id from `GET /labels`                    |

Everything is optional except having **something**: a filter with no words is
a legitimate search — "everything unread from Marcus" is a question — and
neither words nor filters is not, so that is a `400` rather than your whole
mailbox.

`in` is the only way to look in Trash. A plain search skips it on purpose,
because finding something you threw away is not usually what you meant.

`label_id` is an id rather than a name, and it is the same parameter
`GET /mailboxes/{folder}` takes. A label can be renamed; a stored query that
quietly started matching a different pile would be a bug nobody could see. An
id that names nothing on that mailbox is a `400` rather than an empty page —
a wrong id is a bug in your code, and an empty list would let you ship it.

These are the same seven things the mail client's search box understands as
`from:marcus is:unread label:Invoices`. They are parsed into these parameters
before anything leaves the browser, which is why the API takes fields rather
than a syntax you would have to learn. `label:` is the one that differs: the
box takes the name you see and resolves it, because that is what a person
types.
