Simulators
Gmail simulator
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 (how).
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).
Read the inbox, then insert a message and read it back:
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.
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.
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) 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). 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.
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 |
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); 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 labelsReceipts,TravelandTeam/Standup, whose counts match the mail, and one promotion inTRASH.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 ownDRAFTmessage, 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 markedIMPORTANT, a newsletter dropped toTRASH— 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 covers seed files, GET /_rystic/registers and the seed gate. The twin renders a seeded message’s payload, headers and snippet from its raw:
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 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
rateLimitExceededoruserRateLimitExceeded, a 429, or a send blocked for the day. An unserved route answers Gmail’s JSON 404notFound, where Gmail answers its HTML 404 page. - Nothing is delivered, and nothing is classified beyond the
CATEGORY_PERSONALan 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.
Known issues.
- An import by upload (
uploadType=mediaormultipart) answers alabelIdsmember 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;PUTa setting before reading it - An upload (
uploadType=mediaormultipart) over 1 MiB answers 413request body exceeds blob_cap_bytes 1048576, where Gmail takes 35 MB; send the message as base64urlrawin JSON instead - A bearer value that is no access token answers as a valid one, where Gmail answers 401
authError“Invalid Credentials” historyIdkeeps counting acrossPOST /_rystic/reset, so two identical runs from a reset answer differenthistoryIdvalues- A thread’s
historyIdis not the mailbox’s: a seeded thread’s runs ahead ofgetProfile’s, a sent reply drops it to1, and a message change leaves it as it was - An insert by media upload answers a
snippetmember besideid,threadIdandlabelIds, where Gmail answers those three alone history.listruns none of the route’s refusals: a request with no credential, or with aBasicone, answers the feed where every other route answers 401required, and anyuserId, malformed or naming another mailbox, reads asmewhere the other routes answer 403forbidden; send a bearer token andmeor the mailbox’s own address- An imported message (
messages.import, JSON or upload) joins no thread:threads.listleaves it out,threads.geton itsthreadIdanswers 404notFoundandgetProfile’sthreadsTotaldoes not count it; read it withmessages.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
Release notes: Changelog