# Gmail simulator

A stateful Gmail API v1 simulator — messages, threads, drafts, labels and their counts, the history feed, search and mailbox settings, for one mailbox, with nothing delivered.

`gmail-twin` is a local, stateful simulator of the Gmail API v1 for one mailbox — messages inserted, imported, sent, read in every format, labelled, trashed and deleted, threads, drafts, labels with their counts, the history feed, `q` search on `messages.list` and `threads.list`, the profile, and the filter, IMAP, POP, language, vacation, send-as and forwarding settings — built from the API's Discovery document (revision `20261005`) converted to OpenAPI. No message is ever delivered.

## Quickstart

You need an API key from [Account → API keys](https://www.rystic.ai/account/api-keys) ([how](https://www.rystic.ai/docs/licensing.md#get-an-api-key)).

```bash
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…                                                    # your API key
rystic run gmail-twin -d --port 0 --scenario gmail_busy_inbox         # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version
```

Expect `"service_hosts":["gmail.googleapis.com"]`. The examples below use `localhost:8080`; use the port `rystic run` printed. On Enterprise, `rystic login ./rystic-license.json` takes the place of the key ([license files](https://www.rystic.ai/docs/licensing.md#enterprise-license-files)).

Read the inbox, then insert a message and read it back:

```bash
curl -s -H 'Authorization: Bearer ya29.placeholder' 'localhost:8080/gmail/v1/users/me/messages?labelIds=INBOX&maxResults=3'
# -> {"messages":[{"id":"19a4c0d1e2f31007","threadId":"19a4c0d1e2f30004"},...],"nextPageToken":"19a4c0d1e2f31004","resultSizeEstimate":6}

RAW=$(printf 'From: marcus.bell@northwind.example\r\nTo: ada.lovelace@example.com\r\nSubject: Hello twin\r\n\r\nFirst message.\r\n' | base64 | tr '+/' '-_' | tr -d '\n')
curl -s -H 'Authorization: Bearer ya29.placeholder' -H 'Content-Type: application/json' \
  -d "{\"raw\":\"$RAW\",\"labelIds\":[\"INBOX\",\"UNREAD\"]}" localhost:8080/gmail/v1/users/me/messages
# -> {"id":"0000000000000001","labelIds":["INBOX","UNREAD"],"threadId":"0000000000000001"}
curl -s -H 'Authorization: Bearer ya29.placeholder' 'localhost:8080/gmail/v1/users/me/messages/0000000000000001?format=metadata&metadataHeaders=Subject'
# -> {...,"headers":[{"name":"Subject","value":"Hello twin"}],...,"snippet":"First message.",...}
```

## Point your client at it

Google's client libraries build every URL on `https://gmail.googleapis.com`, uploads included. **A request left unpointed goes to the real Gmail.**

Node (`@googleapis/gmail` or `googleapis`): hand the client a fetch that rewrites Google's origin to the twin's and refuses anything else, so media uploads move too.

```js
import { auth, gmail as makeGmail } from "@googleapis/gmail";
const twin = "http://localhost:8080";
const user = new auth.OAuth2();
user.setCredentials({ access_token: "ya29.placeholder" });
const gmail = makeGmail({
  version: "v1",
  auth: user,
  fetchImplementation: (url, init) => {
    const raw = String(url instanceof Request ? url.url : url);
    const target = raw.replace(/^https:\/\/gmail\.googleapis\.com/, twin);
    if (!target.startsWith(twin + "/")) throw new Error(`refusing ${target}`);
    return fetch(target, init);
  },
});
const message = "From: ada.lovelace@example.com\r\nTo: marcus.bell@northwind.example\r\nSubject: Hello\r\n\r\nSent through the twin.\r\n";
await gmail.users.messages.send({ userId: "me", requestBody: { raw: Buffer.from(message).toString("base64url") } });
```

Python (`google-api-python-client`): build from the bundled Discovery document with its `rootUrl` rewritten, which moves the upload URLs too; no request reaches Google, the document included.

```python
import json
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build_from_document
from googleapiclient.discovery_cache import get_static_doc

doc = json.loads(get_static_doc("gmail", "v1"))
doc["rootUrl"] = "http://localhost:8080/"
gmail = build_from_document(doc, credentials=Credentials(token="ya29.placeholder"))
gmail.users().messages().list(userId="me", labelIds=["INBOX"]).execute()
```

## Plans

Every plan ([plans](https://www.rystic.ai/docs/licensing.md#plans)) serves all 63 Gmail routes, every world and every control route but `/_rystic/network` and `/_rystic/ws-drop`, which are Pro and Enterprise ([routes by plan](https://www.rystic.ai/docs/control-plane.md#routes-by-plan)). Without `--scenario` the twin boots one **empty mailbox** — Gmail's 14 system labels, its primary send-as address and default settings, no mail — and `POST /_rystic/reset` wipes it back to that.

## Auth

Gmail reads an OAuth 2 access token as `Authorization: Bearer ya29.…`. The twin accepts any bearer value; no Google Cloud project, OAuth client or token is involved. A request with no `Authorization` header, an empty bearer value or another scheme is refused as Gmail refuses a missing credential: 401 `required` "Login Required." at location `Authorization`, except on `history.list`. Every request speaks as the mailbox's one user: `userId` takes `me` or the mailbox's own address (`getProfile`'s `emailAddress`). Any other value answers 403 `forbidden` before any record is looked up — "Invalid user id specified in request/Delegation denied" when it is not an email address, "Delegation denied for" followed by the mailbox's own address when it names another mailbox — except on `history.list`.

## Coverage

The twin serves 63 operations on one mailbox. Messages: insert, import and send, each as base64url `raw` in JSON or as an upload (`uploadType=media` or `multipart` on `/upload/gmail/v1`); read in `format` `full`, `metadata`, `minimal` or `raw`; list by `labelIds` and `q`; modify, trash, untrash and delete, one at a time or in a batch; and download an attachment by the `attachmentId` its part names. Threads: list by `labelIds` and `q`, read, modify, trash, untrash and delete. `q` takes Gmail's search operators — words, quoted phrases, `from:`, `to:`, `cc:`, `bcc:`, `subject:`, `label:`, `category:`, `is:`, `in:`, `has:attachment`, `filename:`, `larger:`, `smaller:`, the date operators, `-`, `OR` and `{…}` — and both lists leave trash and spam out unless `includeSpamTrash=true` or the query names them. Drafts: create, list, read, update, send and delete, by JSON or upload. Labels: every mailbox holds Gmail's 14 system labels; labels are listed, created, read, updated, patched and deleted, with `messagesTotal`, `messagesUnread`, `threadsTotal` and `threadsUnread` current as soon as a write answers. `history.list` lists `messagesAdded`, `messagesDeleted`, `labelsAdded` and `labelsRemoved` after a `startHistoryId`, filtered by `historyTypes` and `labelId`. `getProfile` answers the mailbox's address, `historyId` and live message and thread totals. Settings: filters listed, created, read and deleted; IMAP, POP, language and vacation read and replaced; auto-forwarding read; the mailbox's send-as addresses listed, read and patched, and the primary one updated; its forwarding addresses listed and read. A message joins a thread only by the `threadId` you send. Every error is Gmail's JSON envelope, and an unknown id answers 404 `notFound`.

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs gmail; do not edit) -->

<!-- coverage-claim -->
63 routes across 14 resources, counted from the sim's literal registrations.

| Resource | Operations |
|---|---|
| Auto-forwarding settings (1) | `GET /gmail/v1/users/{userId}/settings/autoForwarding` |
| Draft (7) | `GET, POST /gmail/v1/users/{userId}/drafts`; `GET, PUT, DELETE /gmail/v1/users/{userId}/drafts/{id}`; `POST /upload/gmail/v1/users/{userId}/drafts?uploadType=media`; `PUT /upload/gmail/v1/users/{userId}/drafts/{id}?uploadType=media` |
| Filter (4) | `GET, POST /gmail/v1/users/{userId}/settings/filters`; `GET, DELETE /gmail/v1/users/{userId}/settings/filters/{id}` |
| Forwarding address (2) | `GET /gmail/v1/users/{userId}/settings/forwardingAddresses`; `GET /gmail/v1/users/{userId}/settings/forwardingAddresses/{forwardingEmail}` |
| IMAP settings (2) | `GET, PUT /gmail/v1/users/{userId}/settings/imap` |
| Label (6) | `GET, POST /gmail/v1/users/{userId}/labels`; `GET, PUT, PATCH, DELETE /gmail/v1/users/{userId}/labels/{id}` |
| Language settings (2) | `GET, PUT /gmail/v1/users/{userId}/settings/language` |
| Message (24) | `POST /gmail/v1/users/{userId}/drafts/send`; `GET /gmail/v1/users/{userId}/history`; `GET, POST /gmail/v1/users/{userId}/messages`; `POST /gmail/v1/users/{userId}/messages/batchDelete`; `POST /gmail/v1/users/{userId}/messages/batchModify`; `POST /gmail/v1/users/{userId}/messages/import`; `POST /gmail/v1/users/{userId}/messages/send`; `GET, DELETE /gmail/v1/users/{userId}/messages/{id}`; `POST /gmail/v1/users/{userId}/messages/{id}/modify`; `POST /gmail/v1/users/{userId}/messages/{id}/trash`; `POST /gmail/v1/users/{userId}/messages/{id}/untrash`; `GET /gmail/v1/users/{userId}/messages/{messageId}/attachments/{id}`; `POST /gmail/v1/users/{userId}/threads/{id}/modify`; `POST /gmail/v1/users/{userId}/threads/{id}/trash`; `POST /gmail/v1/users/{userId}/threads/{id}/untrash`; `POST /upload/gmail/v1/users/{userId}/drafts/send?uploadType=media`; `POST /upload/gmail/v1/users/{userId}/drafts/send?uploadType=multipart`; `PUT /upload/gmail/v1/users/{userId}/drafts/{id}?uploadType=multipart`; `POST /upload/gmail/v1/users/{userId}/messages?uploadType=media`; `POST /upload/gmail/v1/users/{userId}/messages?uploadType=multipart`; `POST /upload/gmail/v1/users/{userId}/messages/import?uploadType=media`; `POST /upload/gmail/v1/users/{userId}/messages/import?uploadType=multipart` |
| POP settings (2) | `GET, PUT /gmail/v1/users/{userId}/settings/pop` |
| Profile (1) | `GET /gmail/v1/users/{userId}/profile` |
| Send-as alias (4) | `GET /gmail/v1/users/{userId}/settings/sendAs`; `GET, PUT, PATCH /gmail/v1/users/{userId}/settings/sendAs/{sendAsEmail}` |
| Thread (6) | `GET /gmail/v1/users/{userId}/threads`; `GET, DELETE /gmail/v1/users/{userId}/threads/{id}`; `POST /upload/gmail/v1/users/{userId}/drafts?uploadType=multipart`; `POST /upload/gmail/v1/users/{userId}/messages/send?uploadType=media`; `POST /upload/gmail/v1/users/{userId}/messages/send?uploadType=multipart` |
| Vacation responder settings (2) | `GET, PUT /gmail/v1/users/{userId}/settings/vacation` |

<!-- END generated: served-operations -->

Measured against live Gmail: the newest full-suite round, of 2026-10-25 (stamp `20261025T212918Z`), matched 635 of the suite's 652 probes (97.4%), none inconclusive, against a target of 95%.

## Control plane

Every route under `/_rystic/` answers on every plan but `/_rystic/network` and `/_rystic/ws-drop`, which are Pro and Enterprise ([Plans](#plans)); [routes by plan](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) lists them.

## Scenarios

- `gmail_busy_inbox`: Ada Lovelace's lived-in mailbox — nine messages in six threads, one carrying a PDF and a CSV attachment with real bodies, filed under the system labels and the user labels `Receipts`, `Travel` and `Team/Standup`, whose counts match the mail, and one promotion in `TRASH`.
- `gmail_draft_bench`: Grace Hopper's three unsent drafts — an empty compose, a reply inside a live thread carrying an attachment, and one from her second send-as address — each backed by its own `DRAFT` message, so draft read, update and send have a real target.
- `gmail_filter_desk`: the Operations Desk's four standing filters and the four messages they routed — billing and reports filed out of the inbox, an alert marked `IMPORTANT`, a newsletter dropped to `TRASH` — so every label a filter action names resolves to a seeded label and seeded mail.
- `gmail_vacation_relay`: Sam Rivera's out-of-office thread, with two forwarding addresses and three send-as addresses, one of each still pending verification.
- `gmail_fresh_mailbox`: a just-provisioned account — the full system label set with every count at zero, one send-as address, and no message, thread, draft or filter.

## Seeding state

`POST /_rystic/seed` takes a register map — `state_message` (keyed by message id, its whole message in `raw`), `state_thread`, `state_label`, `state_draft`, `state_attachment`, `state_filter`, `state_send_as`, `state_forwarding_address` and the settings registers — and merges it into the running mailbox; [Worlds](https://www.rystic.ai/docs/concepts/worlds.md) covers seed files, `GET /_rystic/registers` and the seed gate. The twin renders a seeded message's payload, headers and snippet from its `raw`:

```bash
curl -X POST localhost:8080/_rystic/seed -d '{
  "state_message": {"19ffffffffff0001": {"id": "19ffffffffff0001", "threadId": "19ffffffffff0001",
    "labelIds": ["INBOX", "UNREAD"], "internalDate": "1769904000000",
    "raw": "RnJvbTogYUBleGFtcGxlLmNvbQ0KU3ViamVjdDogU2VlZGVkDQoNCkhlbGxvLg0K"}},
  "state_thread": {"19ffffffffff0001": {"id": "19ffffffffff0001"}}
}'
# -> 204; the message then reads back with Subject "Seeded" and snippet "Hello."
```

## Determinism & reset

Message, thread, draft and label ids are sequences the reset rewinds, so the first message inserted after a reset has the same id every time, and the clock starts at a pinned instant — `2026-01-01T00:00:00Z` empty, `2026-02-01T00:00:00Z` in every world — and moves one millisecond for each message written, never on a read and never by wall time. Two identical runs from the same reset give the same messages, byte for byte apart from `historyId`, which keeps counting across a reset. [Reset and determinism](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) covers reset, the clock and the RNG.

## Egress

No message leaves the twin. Every send that Gmail would deliver records one event in `GET /_rystic/egress`, the same on every route (`messages.send` as JSON, media or multipart upload, `drafts.send` as JSON or multipart upload): effect `eff_send_mail`, type `message.sent`, and a payload holding the sent message's `messageId` and `threadId`, its `from` and `subject`, the addresses its headers name in `to`, `cc` and `bcc` (each a list of bare addresses), and its `raw` bytes as `messages.get` with `format=raw` answers them. A refused send records nothing, and so does a `drafts.send` by media upload, which Gmail always refuses. Loading a world records nothing either.

## Limitations

- There are no rate or sending limits: never a 403 `rateLimitExceeded` or `userRateLimitExceeded`, a 429, or a send blocked for the day. An unserved route answers Gmail's JSON 404 `notFound`, where Gmail answers its HTML 404 page.
- Nothing is delivered, and nothing is classified beyond the `CATEGORY_PERSONAL` an import gets, as on Gmail: no spam, importance or other category label is added.
- Every id is the twin's own, and its shape differs by the route that made it; treat ids and page tokens as opaque. One process holds one mailbox: run several on different ports for parallel, isolated users.

Left out on purpose: `watch` and `stop`, which publish to a Cloud Pub/Sub topic, so poll `history.list` instead; the batch endpoint and resumable uploads; the methods only a domain-wide-delegated service account may call (delegates, creating, deleting and verifying send-as addresses, creating and deleting forwarding addresses, and updating auto-forwarding); and the `cse` and `smimeInfo` families.

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs gmail; do not edit) -->

**Known issues.**

- An import by upload (`uploadType=media` or `multipart`) answers a `labelIds` member beside its id, where Gmail answers the id alone; an import sent as JSON answers as Gmail does
- A shipped world's vacation, IMAP, POP and auto-forwarding settings read back as their defaults: `gmail_vacation_relay`'s auto-reply and forwarding never show; `PUT` a setting before reading it
- An upload (`uploadType=media` or `multipart`) over 1 MiB answers 413 `request body exceeds blob_cap_bytes 1048576`, where Gmail takes 35 MB; send the message as base64url `raw` in JSON instead
- A bearer value that is no access token answers as a valid one, where Gmail answers 401 `authError` "Invalid Credentials"
- `historyId` keeps counting across `POST /_rystic/reset`, so two identical runs from a reset answer different `historyId` values
- A thread's `historyId` is not the mailbox's: a seeded thread's runs ahead of `getProfile`'s, a sent reply drops it to `1`, and a message change leaves it as it was
- An insert by media upload answers a `snippet` member beside `id`, `threadId` and `labelIds`, where Gmail answers those three alone
- `history.list` runs none of the route's refusals: a request with no credential, or with a `Basic` one, answers the feed where every other route answers 401 `required`, and any `userId`, malformed or naming another mailbox, reads as `me` where the other routes answer 403 `forbidden`; send a bearer token and `me` or the mailbox's own address
- An imported message (`messages.import`, JSON or upload) joins no thread: `threads.list` leaves it out, `threads.get` on its `threadId` answers 404 `notFound` and `getProfile`'s `threadsTotal` does not count it; read it with `messages.get`, or insert it instead

**Not served.** 28 of the 91 operations in the published spec are not served.

- users, 1 of 3 served: `POST /gmail/v1/users/{userId}/stop`, `POST /gmail/v1/users/{userId}/watch`
- users.settings, 9 of 10 served: `PUT /gmail/v1/users/{userId}/settings/autoForwarding`
- users.settings.forwardingAddresses, 2 of 4 served: `POST /gmail/v1/users/{userId}/settings/forwardingAddresses`, `DELETE /gmail/v1/users/{userId}/settings/forwardingAddresses/{forwardingEmail}`
- users.settings.sendAs, 4 of 7 served: `POST /gmail/v1/users/{userId}/settings/sendAs`, `DELETE /gmail/v1/users/{userId}/settings/sendAs/{sendAsEmail}`, `POST /gmail/v1/users/{userId}/settings/sendAs/{sendAsEmail}/verify`
- Not served at all: users.settings.cse.identities (5), users.settings.cse.keypairs (6), users.settings.delegates (4), users.settings.sendAs.smimeInfo (5)

**Not measured.** The fidelity suite runs 652 probes, and 153 documented branches have none. 34 need a credential the measuring account does not hold (401 ×4, 403 ×30). The other 119:

| Operation | Status | Why |
|---|---:|---|
| `DELETE /gmail/v1/users/{userId}/drafts/{id}` | 400 | a well-formed request does not provoke it |
| `DELETE /gmail/v1/users/{userId}/drafts/{id}` | 429 | no request a client sends provokes it |
| `DELETE /gmail/v1/users/{userId}/drafts/{id}` | 500 | no request a client sends provokes it |
| `DELETE /gmail/v1/users/{userId}/labels/{id}` | 400 | a well-formed request does not provoke it |
| `DELETE /gmail/v1/users/{userId}/labels/{id}` | 429 | no request a client sends provokes it |
| `DELETE /gmail/v1/users/{userId}/labels/{id}` | 500 | no request a client sends provokes it |
| `DELETE /gmail/v1/users/{userId}/messages/{id}` | 429 | no request a client sends provokes it |
| `DELETE /gmail/v1/users/{userId}/messages/{id}` | 500 | no request a client sends provokes it |
| `DELETE /gmail/v1/users/{userId}/settings/filters/{id}` | 400 | a well-formed request does not provoke it |
| `DELETE /gmail/v1/users/{userId}/settings/filters/{id}` | 429 | no request a client sends provokes it |
| `DELETE /gmail/v1/users/{userId}/settings/filters/{id}` | 500 | no request a client sends provokes it |
| `GET /gmail/v1/users/{userId}/drafts/{id}` | 429 | no request a client sends provokes it |
| `GET /gmail/v1/users/{userId}/drafts/{id}` | 500 | no request a client sends provokes it |
| `GET /gmail/v1/users/{userId}/history` | 400 | a well-formed request does not provoke it |
| `GET /gmail/v1/users/{userId}/history` | 429 | no request a client sends provokes it |
| `GET /gmail/v1/users/{userId}/history` | 500 | no request a client sends provokes it |
| `GET /gmail/v1/users/{userId}/labels` | 400 | a well-formed request does not provoke it |
| `GET /gmail/v1/users/{userId}/labels/{id}` | 400 | a well-formed request does not provoke it |
| `GET /gmail/v1/users/{userId}/labels/{id}` | 429 | no request a client sends provokes it |
| `GET /gmail/v1/users/{userId}/labels/{id}` | 500 | no request a client sends provokes it |
| … and 99 more | | |

**Not modeled.** What the model's operations state they leave out:

- The google.rpc.ErrorInfo detail on the 401 missing-credential answer names the internal RPC method; its value for drafts.delete is unverified, so /error/details is ignored in comparison. (`DELETE /gmail/v1/users/{userId}/drafts/{id}`)
- Whether deleting a draft whose message is the only one in its thread also removes the thread record is undocumented in the bundle. (`DELETE /gmail/v1/users/{userId}/drafts/{id}`)
- Whether drafts.delete answers 400 for a malformed draft id is unverified: the BadRequest 'invalidArgument' (Invalid id value) and 'invalid' (enum value) examples are measured on messages.get, so the 400 branch here carries no executable recipe. (`DELETE /gmail/v1/users/{userId}/drafts/{id}`)
- The 204 answer carries no body, and the act is a DELETE, so no settle declaration is made here: the post-delete 404 on labels.get is left to that reader's own probe rather than a settle on this branch. (`DELETE /gmail/v1/users/{userId}/labels/{id}`)
- The 401 answer to a present but invalid credential (reason authError, message 'Invalid Credentials') differs from the missing-credential answer modeled here; it is not declared as its own branch because no guard distinguishes the two cases without credential_state. (`DELETE /gmail/v1/users/{userId}/labels/{id}`)
- The exact 400 condition and message for a malformed label id is unmeasured: the BadRequest examples are from messages.get, so br_400_invalid_id carries no probe recipe and its message is compared by shape. (`DELETE /gmail/v1/users/{userId}/labels/{id}`)
- Whether deleting a SYSTEM label (INBOX, UNREAD, TRASH, …) is refused, and with which status and reason, is not stated in the bundle: no branch or guard is declared for it. (`DELETE /gmail/v1/users/{userId}/labels/{id}`)
- Whether the delete also touches thread-level label state beyond the per-message labelIds is not stated; state_thread declares no labelIds field, so no thread write is declared. (`DELETE /gmail/v1/users/{userId}/labels/{id}`)
- The 429 body: G-ERR prints no JSON sample, so only the error envelope's shape is claimed and no message text is asserted. (`DELETE /gmail/v1/users/{userId}/messages/{id}`)
- The quota and policy 403 variants (dailyLimitExceeded, rateLimitExceeded, userRateLimitExceeded, domainPolicy) share status 403 with the delegation refusal and carry no body discriminator the model can key on; only the delegation refusal is modeled as a branch. (`DELETE /gmail/v1/users/{userId}/messages/{id}`)
- Whether deleting a message changes its thread record (snippet, historyId) or removes a thread whose last message was deleted: no chunk in this bundle states it. (`DELETE /gmail/v1/users/{userId}/messages/{id}`)
- Whether the fields partial-response selector or prettyPrint alters a 204 answer (which carries no body) or the error envelopes. (`DELETE /gmail/v1/users/{userId}/messages/{id}`)
- Whether the message's attachments (state_attachment) are removed with it; the message id reference on state_attachment declares no x_on_delete in the state model. (`DELETE /gmail/v1/users/{userId}/messages/{id}`)
- The 400 branch's trigger on this route is undocumented: the BadRequest examples in the bundle are messages.get and labels.create captures, so no recipe is sealed for a filters.delete 400 and the branch stays unprobed. (`DELETE /gmail/v1/users/{userId}/settings/filters/{id}`)
- The 401 envelope's error.details[0].metadata.method names the RPC behind the route; the only captured sample is getProfile's, so the detail is declared ignore rather than guessed for filters.delete. (`DELETE /gmail/v1/users/{userId}/settings/filters/{id}`)
- The 403 branch conflates the measured delegation refusal (a userId naming another mailbox) with the documented quota, rate and domain-policy refusals; no chunk here distinguishes their bodies on this route, and none of them is provokable with the run's own credential. (`DELETE /gmail/v1/users/{userId}/settings/filters/{id}`)
- The 429 body carries no JSON sample in the evidence: its reason and message strings are unmodeled. (`DELETE /gmail/v1/users/{userId}/settings/filters/{id}`)
- Whether a repeat delete of an already deleted filter answers 404 rather than 204 is not stated in the bundle; the existence guard asserts the 404 reading, which the post_state records as a claim to be measured. (`DELETE /gmail/v1/users/{userId}/settings/filters/{id}`)
- 401 with reason authError ('Invalid Credentials'): a credential refusal the run's valid bearer cannot provoke, so it carries no recipe and is left out of the branch list. (`DELETE /gmail/v1/users/{userId}/threads/{id}`)
- 403 usage-limit refusals (dailyLimitExceeded, rateLimitExceeded, userRateLimitExceeded, domainPolicy): account- and project-wide ambient conditions, not provokable from a probe. (`DELETE /gmail/v1/users/{userId}/threads/{id}`)
- … and 250 more

<!-- END generated: known-gaps -->

Release notes: [Changelog](https://www.rystic.ai/docs/changelog.md#gmail)

---

Source: https://www.rystic.ai/docs/simulators/gmail · Markdown: https://www.rystic.ai/docs/simulators/gmail.md
