# Update flags, labels and keywords for every message a search selects

Applies one patch to every message `POST /search/query` would return for the same `filter` — the way to act on a whole result set without naming its messages. The filter grammar, and the set it selects, are that route's own.

SEND BACK THE `excludeRoles` YOU SEARCHED WITH. It narrows the selection here exactly as it narrows the results there, and it is not part of the filter — so a call that omits it patches the Trash and Junk mail the search left out.

THE CEILING. `idMax` is a message id (a ULID, so ordered by arrival) resolved on the first call and echoed back: mail that arrives while the gesture runs has a later id and is never touched. Send it back unchanged on every continuation.

IT MAY ANSWER A CURSOR, exactly as `PATCH /labels/{label}/messages` does: the call spends a bounded budget, and a non-null `nextAfter` means call again with `after` set to it, the same `idMax`, the same filter and the same patch. Unlike that route it reports no `remaining` — measure progress against the search's own `total`.

A FULL-TEXT filter is bounded: one that matches more than 5,000 messages is refused with `too_large`, having written nothing, rather than applied to some of them. Narrow the search. A filter is REQUIRED — this route will not patch a whole mailbox by omission.

## `PATCH /api/v1/mailboxes/{mailboxId}/search/query`

### Path parameters

- `mailboxId` (string, required) — Mailbox ULID identifier

### Request body (optional)

`application/json`

- `after` (string, min 1 chars) — Continuation cursor: pass the previous call's `nextAfter`.
- `excludeRoles` ("inbox" | "sent" | "drafts" | "trash" | "archive" | "junk"[]) — System label roles whose mail this query must not return — e.g. `["trash", "junk"]` for an All-Mail view. A message carrying ANY label with one of these roles is excluded, whatever else it carries. Says in one argument what a filter would need a label lookup and one bound value per label to say; a role no label answers to is simply not excluded.
- `filter` (EmailSearchFilter) — An RFC 8621 §4.4 FilterCondition, or a boolean tree of them ({operator: AND|OR|NOT, conditions: [...]}). Bounded, and refused (never truncated) past the bound: the tree at most 32 levels deep and 512 nodes, and at most 64 bound values in total (an inMailboxOtherThan element costs one each).
  - _option 1: EmailSearchFilterOperator_
    - `conditions` (EmailSearchFilter[], required) — Nested filters: each is itself an EmailSearchFilter (condition or operator)
      - _recursive: EmailSearchFilter_
    - `operator` ("AND" | "OR" | "NOT", required) — How the nested conditions combine (RFC 8620 §5.5 FilterOperator)
  - _option 2: EmailSearchCondition_
    - `after` (string) — Received at or after this RFC 8620 UTCDate (e.g. 2026-08-01T00:00:00Z)
    - `allInThreadHaveKeyword` (string) — Every live message in the conversation carries this keyword
    - `bcc` (string) — Substring of the Bcc addresses (max 50 bytes)
    - `before` (string) — Received strictly before this RFC 8620 UTCDate (e.g. 2026-08-01T00:00:00Z), the one place this API departs from epoch seconds
    - `body` (string) — Full-text match over the body parts
    - `cc` (string) — Substring of the Cc addresses (max 50 bytes)
    - `from` (string) — Substring of the From addresses (max 50 bytes)
    - `hasAttachment` (boolean) — Whether the message has file attachments
    - `hasKeyword` (string) — The message carries this keyword or flag (e.g. $seen, $flagged, $forwarded)
    - `header` (string[], min 1 items, max 2 items) — [name] = the header is present; [name, value] = the header contains value. Indexed names only: subject, message-id, in-reply-to, from, to, cc, list-id
    - `inMailbox` (string) — Only messages in this Mailbox, as L<labelId>: the `id` from GET /mailboxes/{mailboxId}/labels prefixed with L (INBOX is usually L1)
    - `inMailboxOtherThan` (string[]) — Only messages in at least one Mailbox NOT listed (same L<labelId> ids)
    - `maxSize` (integer) — Size in bytes is strictly below this
    - `minSize` (integer) — Size in bytes is at least this
    - `noneInThreadHaveKeyword` (string) — No live message in the conversation carries this keyword
    - `notKeyword` (string) — The message does NOT carry this keyword
    - `someInThreadHaveKeyword` (string) — At least one live message in the conversation carries this keyword
    - `subject` (string) — Full-text match over the Subject header
    - `text` (string) — Full-text match over the whole message (subject and bodies)
    - `to` (string) — Substring of the To addresses (max 50 bytes)
- `flagsClear` ("seen" | "answered" | "flagged" | "draft" | "deleted"[]) — Flags to remove
- `flagsSet` ("seen" | "answered" | "flagged" | "draft" | "deleted"[]) — Flags to set
- `idMax` (string, min 1 chars) — ULID ceiling — the selection is every match with id <= this. Omitted on a FIRST call; REQUIRED once `after` is given.
- `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. Never raises it.
- `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.

### Responses

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

`application/json` → QueryMessagesUpdateResult

- `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) — Messages the query selected in this call
- `expunged` (integer, required) — Messages whose last label this patch removed: moved to the Recently-deleted tier, restorable until purge. Counted here rather than in `updated`.
- `failed` (integer, required) — Per-message refusals. Not an error: the rest of the call applied.
- `idMax` (string, required) — The ULID ceiling this call worked under. Send it back UNCHANGED on a continuation: it is what keeps the selection the one the search showed, and re-resolving it would let mail that arrived since drift into the gesture.
- `nextAfter` (string | null, required) — Continuation cursor: call again with `after` set to this, the same `idMax`, the same filter and the same patch. `null` means the selection is drained. There is no `remaining` count — counting what is left of a filter is an aggregate over the whole filtered set on every call. Measure progress against the search's own `total`.
- `unchanged` (integer, required) — Selected messages the patch provably would not have changed, so they were skipped
- `updated` (integer, required) — Messages the patch changed

**400** — validation_failed, filter_required, patch_required, idmax_required (after without idMax), too_large (a full-text filter over the cap), unsupported_filter, invalid_filter, unknown_label (a name in the patch), or invalid_keyword

`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 not found or not accessible to this principal

`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}/search/query' \
  -H 'Authorization: Bearer oek_…' \
  -H 'Content-Type: application/json' \
  -d '{}'
```
