# Update flags, labels and keywords for every message in a folder, by label id

Keyed on the label's numeric `id` — as `GET /labels` and `POST /labels` report it — rather than on its name. The id survives a rename (it is what JMAP `Mailbox/set` and folder-scoped shares key on), so a client that renames folders should hold it: the by-name route's key changes as the operation succeeds. It also never contains a `/`, so a folder name that does need not be encoded into a path segment. Otherwise identical to the by-name operation.

This is the spelling a browser client needs: a nested folder IS the label called `Work/Projects`, and a name carrying a `/` cannot be a path segment.

## `PATCH /api/v1/mailboxes/{mailboxId}/labels/by-id/{labelId}/messages`

### Path parameters

- `mailboxId` (string, required) — Mailbox ULID identifier
- `labelId` (integer, required, > 0) — The label's numeric `id`, as `GET /labels` and `POST /labels` report it

### Request body (optional)

`application/json`

- `after` (integer <int64>, ≥ 0) — Continuation cursor: resume above this UID. Pass the previous call's `nextAfter`.
- `flagsClear` ("seen" | "answered" | "flagged" | "draft" | "deleted"[]) — Flags to remove
- `flagsSet` ("seen" | "answered" | "flagged" | "draft" | "deleted"[]) — Flags to set
- `hasFlags` ("seen" | "answered" | "flagged" | "draft" | "deleted"[]) — Narrow the selection to messages with ALL of these flags set
- `keywordsClear` (string[]) — Custom keywords to remove
- `keywordsExact` (string[]) — Exact replacement list of custom keywords
- `keywordsSet` (string[]) — Custom keywords to add
- `labelsAdd` (string[]) — Labels to add
- `labelsExact` (string[]) — Exact replacement list of labels
- `labelsRemove` (string[]) — Labels to remove
- `limit` (integer, ≥ 1) — Lower the per-call update budget, for a client that wants finer progress than the server's own bound gives. Never raises it.
- `notFlags` ("seen" | "answered" | "flagged" | "draft" | "deleted"[]) — Narrow the selection to messages with NONE of these set — `["seen"]` for the unread-only gesture a folder's unread badge implies
- `orphans` ("expunge" | "archive") — What to do if this write would leave the message with no label. 'expunge' (the default) sends the message to Recently deleted. 'archive' keeps the message live by attaching the Archive system label (attaching Archive needs no right beyond the grant's 't' for a scoped viewer). If the write removes Archive itself and would leave no labels, Archive is kept without error. A SCOPED viewer whose write lands the message in Archive has moved it out of their own scope: the answer is still `updated`, and the hydrated message carries an EMPTY `labels` list rather than naming a label the grant never included. `labelsAdded` omits Archive for the same reason.
- `uidMax` (integer <int64>, ≥ 1) — Inclusive UID ceiling — the selection is every member with uid <= this. Omitted on a FIRST call, the label's current highest UID is used and echoed back. REQUIRED once `after` is given, so a continuation cannot move its own ceiling and sweep in mail that arrived mid-gesture.
- `uidValidity` (integer <int64>) — Precondition: refuse with 409 uid_validity_mismatch unless the label still carries this epoch. A bump means the UIDs this selection was taken over no longer name the same messages.

### Responses

**200** — Counts for this call, and a cursor if the range is not drained

`application/json` → LabelMessagesUpdateResult

- `archived` (integer, required) — Messages whose last label this patch removed that fell back to the Archive label instead of being expunged. Unlike `expunged`, this is a SUBSET of `updated`, not an alternative to it: an archived message was still updated, so `examined` equals `updated + unchanged + expunged + failed` and adding `archived` to `updated` double counts. Only present when the patch carried `orphans: "archive"`.
- `examined` (integer, required) — Memberships this call looked at, filtered-out ones included
- `expunged` (integer, required) — Messages whose last label this patch removed: they moved to the Recently-deleted tier, restorable until purge. Counted here rather than in `updated`.
- `failed` (integer, required) — Per-message refusals — a keyword cap, or a row that left the label between this call's scan and its write. Not an error: the rest of the call applied.
- `nextAfter` (integer | null, required) — Continuation cursor: call again with `after` set to this, the same `uidMax`, and the same patch. `null` means the range is drained. Repeating a call is harmless — a patch that changes nothing writes nothing, and a filing patch's earlier pages have already left the range.
- `remaining` (integer, required) — Memberships left in the range this call did not reach, 0 when it finished. This counts what is UNEXAMINED, not what would match a flag filter — it is the figure a progress bar needs, and it is exact.
- `uidMax` (integer <int64>, required) — The resolved ceiling this call worked under. Send it back UNCHANGED on a continuation: it is what keeps the selection the one the user saw, and re-resolving it would let mail that arrived since drift into the gesture.
- `uidValidity` (integer <int64>, required) — UIDVALIDITY epoch of the label the patch ran against
- `unchanged` (integer, required) — Messages in the selection the patch provably would not have changed, so they were skipped
- `updated` (integer, required) — Messages the patch changed

**400** — As the by-name route

`application/json` → ErrorOrValidation

**401** — unauthorized: the bearer credential is missing, malformed, expired, or revoked. Carries a `WWW-Authenticate: Bearer` challenge

`application/json` → ErrorBody

**404** — Mailbox or label not found, or outside this principal's folder grant

`application/json` → ErrorBody

**409** — uid_validity_mismatch — the label's UIDVALIDITY is not the one given

`application/json` → ErrorBody

**413** — too_large: JSON request body exceeds 1 MiB or 500,000 values (object keys included), checked before parsing

`application/json` → ErrorBody

**503** — temporary_failure: the platform's storage could not answer just now; retry after the Retry-After window. Any operation can answer this. A write that got it may have been applied, so retry it with the same idempotency key where it takes one, or read the state back first (see Transient failures in the API description)

`application/json` → ErrorBody

### Example request

```bash
curl -X PATCH 'https://api.open.email/api/v1/mailboxes/{mailboxId}/labels/by-id/{labelId}/messages' \
  -H 'Authorization: Bearer oek_…' \
  -H 'Content-Type: application/json' \
  -d '{}'
```
