54 KiB
Gmail API capability map
What the Gmail REST API (v1) and its neighbours can and cannot do for a native desktop client (Tauri 2, Rust backend, React frontend) that wants HEY and Superhuman style workflows on top of a user's Gmail account. Everything below was checked against the live Google docs on 2026-09-03 unless marked "unverified" or "third-party".
One headline before the detail: the Gmail API usage limits changed on 1 May 2026. The
per-user rate limit is now 6,000 quota units per minute (100 per second, not the 250
per second most older write-ups quote), messages.get costs 20 units (not 5) and
threads.get costs 40 (not 10). Every quota figure in this document uses the new
table. Plan sync around it.
1. Auth and distribution
Scopes
| Scope | Unlocks | Class |
|---|---|---|
https://mail.google.com/ |
Everything, including permanent delete (messages.delete, batchDelete, threads.delete) and IMAP/SMTP via XOAUTH2. The only scope IMAP accepts. |
Restricted |
https://www.googleapis.com/auth/gmail.modify |
Read, list, modify labels, trash/untrash, send, drafts, insert/import, history, watch. Not permanent delete. | Restricted |
https://www.googleapis.com/auth/gmail.readonly |
Read and list only, including history and watch. | Restricted |
https://www.googleapis.com/auth/gmail.compose |
Drafts create/update/send and messages.send. No reading. |
Restricted |
https://www.googleapis.com/auth/gmail.send |
messages.send only. |
Sensitive |
https://www.googleapis.com/auth/gmail.insert |
messages.insert and messages.import. |
Restricted |
https://www.googleapis.com/auth/gmail.labels |
labels.* only. |
Non-sensitive |
https://www.googleapis.com/auth/gmail.metadata |
List and read headers and labels, never bodies. format=full and format=raw are refused, and messages.list refuses the q parameter under this scope. |
Restricted |
https://www.googleapis.com/auth/gmail.settings.basic |
Filters, vacation responder, IMAP/POP/language settings, update of the primary send-as (signature, display name). | Restricted |
https://www.googleapis.com/auth/gmail.settings.sharing |
Send-as alias create/delete/verify, forwarding addresses, auto-forwarding, delegates. Every one of these methods is documented as "only available to service account clients that have been delegated domain-wide authority", so a consumer desktop client cannot use them at all. | Restricted |
https://www.googleapis.com/auth/contacts.other.readonly |
People API otherContacts.list and otherContacts.search (the auto-collected "people you emailed" set that powers Gmail autocomplete). |
Sensitive (third-party classification, unverified against Google's master list) |
https://www.googleapis.com/auth/contacts.readonly |
People API people.connections.list, people.searchContacts. |
Sensitive (unverified, as above) |
https://www.googleapis.com/auth/calendar.events |
Calendar events.list, events.patch, events.import for RSVP. |
Sensitive (unverified) |
https://www.googleapis.com/auth/pubsub |
Only needed if the client itself pulls from a Pub/Sub subscription. See section 3. | Cloud scope, not a Gmail scope |
Minimum set for the product as described: gmail.modify (which covers gmail.labels),
gmail.settings.basic, contacts.other.readonly, contacts.readonly, and
calendar.events if you do RSVP. Add https://mail.google.com/ only if you use IMAP
or want permanent delete. Any Gmail scope beyond gmail.send and gmail.labels is
restricted, so there is no scope choice that avoids restricted-scope verification for a
real mail client.
What verification means in practice
Publishing status and user caps, quoted from Google's "Manage App Audience" page:
- Testing: "up to 100 test users listed in the OAuth consent screen". Every test user must be added by email address by the project owner. "Authorizations by a test user will expire seven days from the time of consent", and that includes the refresh token. The only exception is apps that request nothing beyond name, email and profile. A mail client in Testing therefore forces a full re-login every 7 days. Do not ship in Testing.
- In production, unverified: any Google account can be presented with the "unverified app" interstitial and click through. The project gets "100 new users in total" for its lifetime; the cap "cannot be reset". Refresh tokens do not have the 7-day expiry in this state.
- In production, verified: no cap, no interstitial (after brand verification the app name and logo show on the consent screen).
Verification tiers and timelines:
- Brand verification (name and logo only): "typically takes 2-3 business days".
- Sensitive scope verification: "typically takes 3-5 business days". Requires a domain verified in Search Console, a public homepage on that domain, a privacy policy hosted on the same domain and linked from the consent screen, an unlisted YouTube demo showing the consent flow with the client ID visible in the address bar and each scope in use, and a written justification per scope.
- Restricted scope verification: all of the above plus Limited Use compliance, and the process "can potentially take several weeks". Re-verification is annual.
- Security assessment (CASA, run by the App Defense Alliance): Google's wording is "Every app that requests access to Google users' restricted data and has the ability to access data from or through a third-party server must go through a security assessment from Google-empanelled security assessors." Assessments are assigned an assurance level (AL1 or AL2) and "All applications must be revalidated every year." Third-party pricing for the common Tier 2 / lab-verified scan is roughly USD 540 to 1,800 per year (TAC Security via switchlabs.dev, 2025 to 2026); older figures of USD 15,000 to 75,000 (Nylas, 2021) refer to the original pentest regime and are stale.
The open question that decides Margin's cost: a desktop client whose only server is
Google, with all mail data on the user's disk, does not obviously "access data from or
through a third-party server". Google publishes no explicit "local-only apps are exempt"
clause, so treat this as unverified and ask the verification team in writing before
you build a push relay (a relay that sees only the user's email address and a
historyId, as Mimestream's does, may or may not count). Mimestream states it
"applied for and completed Google's Restricted scope verification process" and that
"Mimestream stores user email data locally on the user's device", but does not say
whether it was assessed.
Exemptions Google lists for verification itself: "personal use (fewer than 100 users)", apps in development/testing/staging, service-account-only apps, and internal Workspace apps. None of these is a distribution strategy.
Bring your own OAuth client
The pattern: each user creates their own Google Cloud project, enables the Gmail API, configures an External consent screen, adds every scope, creates a "Desktop app" OAuth client, and pastes the client ID and secret into Margin. Each user is then the "developer" of a one-user app, which falls under the personal-use exemption and needs no verification. It works, and several open-source tools ship this way.
It is not realistic for non-technical friends. It is fifteen to twenty clicks across a console whose UI changes every few months, it needs the People API and Calendar API enabled separately, the consent screen must either stay in Testing (7-day re-login) or be pushed to production (scary interstitial, but stable tokens), and support requests will all be "the Google page looks different". Offer it as an escape hatch for the technical, not as the default.
Installed-app OAuth flow
Use the loopback flow: OAuth client type "Desktop app", redirect to
http://127.0.0.1:{port} (or http://[::1]:{port}) served by a one-shot listener in the
Rust backend, PKCE mandatory (verifier 43 to 128 unreserved characters, S256
challenge). Google states "Custom URI schemes are no longer supported due to the risk
of app impersonation" and the out-of-band copy-paste flow is gone. The client secret
of a Desktop client is not a secret; Google's own doc says installed apps "cannot keep
secrets", so embedding it in the binary is expected.
Token facts to design around:
- "refresh tokens are always returned for installed applications".
- "limit of 100 refresh tokens per Google Account per OAuth 2.0 client ID". The oldest is silently invalidated. Per-account, per-client, so a user on many machines is fine.
- A refresh token dies if unused for six months, if the user revokes it, or if "The
user changed passwords and the refresh token contains Gmail scopes". Password change
logs Margin out. Handle
invalid_grantby prompting re-auth, not by crashing sync. - Access tokens last about an hour. IMAP sessions authenticated with OAuth are "limited to about the validity period of the access token used (usually 1 hour)".
Store refresh tokens in the OS keychain (macOS Keychain, Windows Credential Manager, Secret Service on Linux), never in SQLite.
What a small team shipping to friends should do
Create one Cloud project and one Desktop OAuth client. Push the consent screen to
production unverified straight away and eat the interstitial: it costs 100 lifetime
users, which is plenty for a friends release and avoids the 7-day token expiry that
Testing imposes. In parallel, buy a domain, publish a homepage and privacy policy on
it, record the demo video, and submit restricted scope verification with the explicit
statement that the app has no server and stores all Google user data on the user's
device; ask whether a security assessment is required. Budget for one anyway (order of
USD 1,000 to 2,000 per year at the cheap end). Do not request https://mail.google.com/
unless IMAP is in the plan; the narrower the scope list the easier the review.
Sources: https://developers.google.com/workspace/gmail/api/auth/scopes, https://support.google.com/cloud/answer/15549945, https://support.google.com/cloud/answer/7454865, https://support.google.com/cloud/answer/13464323, https://support.google.com/cloud/answer/13465431, https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification, https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification, https://developers.google.com/identity/protocols/oauth2/native-app, https://developers.google.com/identity/protocols/oauth2 (refresh token expiration), https://developers.google.com/terms/api-services-user-data-policy, https://mimestream.com/trust/security-and-privacy, https://www.switchlabs.dev/post/casa-tier-2-tier-3-security-review-providers-pricing-and-the-cheapest-option (third-party), https://www.nylas.com/blog/google-oauth-app-verification/ (third-party, 2021).
2. Data model
Base URL is https://gmail.googleapis.com/gmail/v1/users/{userId}/...; use me for
userId. Uploads go to https://gmail.googleapis.com/upload/gmail/v1/....
Message
Fields: id ("The immutable ID of the message", a 16-hex-digit string), threadId,
labelIds[], snippet ("A short part of the message text", HTML-escaped, roughly a
sentence), historyId ("The ID of the last history record that modified this
message"), internalDate ("The internal message creation timestamp (epoch ms)", the
time Gmail received or created it, not the Date header; for insert/import you
choose internalDateSource=receivedTime|dateHeader), payload (parsed MIME tree),
sizeEstimate (bytes, approximate), raw (base64url RFC 2822 bytes, only with
format=raw).
format on messages.get and threads.get:
| format | Returns | Notes |
|---|---|---|
minimal |
id, threadId, labelIds, snippet, historyId, internalDate, sizeEstimate | No headers. Same 20 units as full. |
metadata |
minimal plus payload.headers |
Pass metadataHeaders=From&metadataHeaders=Subject... to restrict which headers come back. No body parts, no MIME tree. |
full |
Parsed MIME tree in payload |
Body data is base64url in payload.parts[].body.data, or referenced by attachmentId when large. |
raw |
Whole RFC 2822 message base64url in raw |
Best for archival and for feeding a real MIME parser (mail-parser or mailparse in Rust). |
MessagePart: partId, mimeType, filename (only present on attachments),
headers[] {name, value}, body {attachmentId, size, data}, parts[]. Body data
is base64url without padding issues in practice but always decode with a
URL-safe, padding-tolerant decoder. Large bodies and every real attachment arrive as
attachmentId and must be fetched separately with
GET .../messages/{messageId}/attachments/{id} (20 units, returns
MessagePartBody {attachmentId, size, data}). Inline images are ordinary parts whose
headers carry Content-ID: <foo> and Content-Disposition: inline; the HTML body
references them as src="cid:foo", and the client maps cid to the part and serves the
bytes to the webview. Attachment IDs are widely reported to change between get
calls, so store the partId/Content-ID path, not the attachment ID (unverified in
the docs, consistently observed).
Sort and display by internalDate. The Date header is the sender's claim and can be
hours off or absent.
Thread
{id, snippet, historyId, messages[]}. threads.get accepts the same format and
metadataHeaders and returns every message in the thread in one call for 40 units.
Threads cannot be created directly; Gmail assigns threadId on delivery, send,
insert or import. Threading rules are in section 4. Gmail's web UI splits a
conversation into a new thread with the same subject after 100 messages (third-party,
consistently reported, unverified in Google docs).
Labels
System labels the guide lists, and whether modify may add or remove them:
| Label | Applicable via API |
|---|---|
INBOX |
Yes (remove to archive) |
SPAM |
Yes |
TRASH |
Yes (prefer trash/untrash methods) |
UNREAD |
Yes |
STARRED |
Yes |
IMPORTANT |
Yes |
SENT |
No |
DRAFT |
No |
CATEGORY_PERSONAL, CATEGORY_SOCIAL, CATEGORY_PROMOTIONS, CATEGORY_UPDATES, CATEGORY_FORUMS |
Yes |
"The preceding list isn't exhaustive and other reserved label names exist" (CHAT is
one). Creating a user label with a reserved name returns "HTTP 400 - Invalid label
name". System label IDs equal their names; user label IDs look like Label_42 and
are immutable, while names can be changed with labels.patch, so key everything on
ID and re-list labels when history shows anything unexpected.
User label fields: name (a / in the name nests the label in the Gmail UI, so
Margin/Snoozed renders under Margin; the parent label should exist first),
messageListVisibility (show, hide), labelListVisibility (labelShow,
labelShowIfUnread, labelHide), color {textColor, backgroundColor} restricted to
a fixed palette of about a hundred hex values listed in the Label resource docs (any
other value is rejected), type (system, user), and read-only counts
messagesTotal, messagesUnread, threadsTotal, threadsUnread. Hard limit:
"Maximum labels per mailbox: 10,000". Name length limit is not documented (the web
UI enforces 225 characters, unverified). Labels cannot be applied to drafts.
Drafts
{id, message}. The draft id is stable across drafts.update; the inner
message.id changes on every update. drafts.send deletes the draft and returns a new
message with a new id and the SENT label. Draft messages only ever carry the
DRAFT label.
History and historyId
historyId appears on every message, thread, the profile (users.getProfile, 1
unit, returns emailAddress, messagesTotal, threadsTotal, historyId), and every
history.list and watch response. Google: "History IDs increase chronologically but
are not contiguous with random gaps in between valid IDs." A history record is
{id, messages[], messagesAdded[], messagesDeleted[], labelsAdded[], labelsRemoved[]}
where the added/removed entries carry {message: {id, threadId, labelIds?}, labelIds[]}.
"We recommend using the specific change-type fields instead of" messages[].
messagesDeleted means permanently deleted, not trashed; trash shows up as
labelsAdded with TRASH.
Stable IDs: message id, thread id, label id, draft id (until sent), historyId (as a
cursor). Not stable: the message id behind a draft, attachment ids (reported),
snippet text (can be regenerated), resultSizeEstimate (an estimate).
Sources: https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages, https://developers.google.com/workspace/gmail/api/reference/rest/v1/Format, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages.attachments/get, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.threads, https://developers.google.com/workspace/gmail/api/guides/labels, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.labels, https://developers.google.com/workspace/gmail/api/guides/drafts, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.history/list, https://support.cloudhq.net/how-does-gmail-decide-to-group-emails-into-conversations/ (third-party).
3. Sync strategy
Quota, the numbers that matter
"As of May 1, 2026, the usage limits for this API were updated."
| Limit | Value |
|---|---|
| Per minute per user per project | 6,000 quota units |
| Per minute per project | 1,200,000 quota units |
| Per day per project (billing threshold) | 80,000,000 quota units |
| Batch | 100 calls max per batch, "larger than 50 requests is not recommended", counted as n requests |
| Pricing | "All standard use of the Gmail API is available at no additional cost. Exceeding the quota request limits is planned to incur charges to your Google Cloud billing account later in 2026." |
Per-method costs (units):
| Method | Units | Method | Units |
|---|---|---|---|
messages.send, drafts.send, watch |
100 | messages.get, drafts.get, messages.attachments.get, messages.trash |
20 |
threads.get |
40 | threads.trash, threads.delete |
20 |
messages.batchModify, messages.batchDelete, stop |
50 | messages.insert, messages.import |
25 |
drafts.update |
15 | messages.delete, drafts.create, drafts.delete, threads.list, threads.modify |
10 |
messages.list, drafts.list, messages.modify, messages.untrash, labels.create/delete/update, settings.filters.create/delete, settings.updateVacation |
5 | history.list |
2 |
getProfile, labels.get, labels.list, settings.filters.get/list, settings.getVacation, settings.getAutoForwarding |
1 | settings.sendAs.create/update/verify, forwardingAddresses.create, delegates.create, updatePop |
100 |
Third-party pages dated 2026 still quote messages.get at 5 and threads.get at 10;
Google's page as read on 2026-09-03 says 20 and 40. There is also an undocumented
concurrency ceiling per mailbox (Unipile reports "50 concurrent in-flight requests
per mailbox"; Google's error page only says 429 can be "triggered by daily per-user
limits, bandwidth limits, or concurrent request limits" and "Per-user limits cannot
be increased").
What 6,000 units per minute buys: 300 messages.get per minute, or 150 threads.get,
or 60 sends. A mailbox with 20,000 messages costs 400,000 units to fetch metadata for
every message, which is 67 minutes at the ceiling; 100,000 messages is 5.6 hours.
Idle polling of history.list every 15 seconds is 8 units per minute, so polling is
free; hydration is what costs.
Initial full sync
getProfileand recordhistoryIdbefore you list anything, so the first partial sync covers everything that changed during the crawl.messages.list?maxResults=500&includeSpamTrash=true(5 units per page, ids and threadIds only, newest first) and page withpageTokenuntil exhausted. 20,000 messages is 40 pages, 200 units, a few seconds. Store ids, threadIds and a "needs hydration" flag.- Hydrate newest first in batches of 50
messages.get?format=metadatawithmetadataHeaderslimited to what the UI and the classifier need:From,To,Cc,Bcc,Reply-To,Subject,Date,Message-ID,In-Reply-To,References,List-Id,List-Unsubscribe,List-Unsubscribe-Post,Precedence,Authentication-Results,Content-Type. Throttle to roughly 280 gets per minute, retry 429 and 403userRateLimitExceededwith truncated exponential backoff starting at 1 second, and treat 404 as "deleted since listing". - For threads with three or more messages,
threads.get?format=metadata(40 units) is cheaper than per-message gets. Group step 2's ids by threadId and route. - Bodies: fetch
format=full(orraw) lazily on open and prefetch the last N days in the background. Cache decoded HTML and text in SQLite with an LRU cap. - Labels:
labels.list(1 unit) once, then again whenever history mentions a label id you do not know.
Order of hydration is a product decision: newest 30 days first, then the rest, and the UI must be usable while step 3 is running. Zero (Mail-0) reports initial sync "can take hours for large inboxes (10,000+ emails)"; with the new unit costs that is the norm, not the exception.
Partial sync
history.list?startHistoryId=X&maxResults=500 (2 units per page), optionally
historyTypes=messageAdded|messageDeleted|labelAdded|labelRemoved and labelId=.
Apply pages in order; the records are chronological by id. For each messagesAdded
run messages.get (the record includes labelIds, but not headers); for
labelsAdded/labelsRemoved update the local label set; for messagesDeleted
delete locally. A message can appear in messagesAdded and messagesDeleted in the
same window, and messages.get can 404 for a record you have not processed yet;
both are normal, not errors. When the final page has no nextPageToken, store the
response historyId as the new cursor. Never combine q with history; history is
mailbox-wide and has no query parameter, so every screening decision is made locally
on the record's labelIds and the fetched headers.
Google on validity: "A historyId is typically valid for at least a week, but in some
rare circumstances may be valid for only a few hours. If you receive an HTTP 404 error
response, your application should perform a full sync." The cheap recovery is: re-run
step 2 of the full sync (ids only, 5 units per 500), diff against the local set to
find adds and deletes, hydrate the adds, and refresh labelIds for the most recent N
days with format=minimal (still 20 units each; there is no cheaper label-only read
except threads.list, which returns only ids). Accept that label state for old mail
may be stale until the user opens it.
Polling cadence
Poll history.list every 10 to 15 seconds while the window is focused, 60 seconds in
the background, and immediately after any local write. Cost at 15 seconds is 5,760
units per user per day; 1,000 users idle-polling this way is 5.8 million units per
day against the 80 million project threshold. The budget goes to hydration and full
syncs, not polling.
Push via Pub/Sub, honestly
users.watch (100 units) with topicName=projects/{project}/topics/{topic} where the
project "must exactly match your Google developer project id (the one executing this
watch request)". You grant roles/pubsub.publisher on the topic to
[email protected]. Watches expire after 7 days and Google
says "We recommend calling watch once per day". The notification is only
{"emailAddress": "...", "historyId": "..."} and the rate is capped at "one event per
second" per user. On receipt you run the partial sync above.
Delivery needs a Pub/Sub subscription. A push subscription posts to an HTTPS endpoint,
which means a server. A pull subscription is possible from a desktop app in theory:
projects.subscriptions.pull needs the pubsub.subscriptions.consume permission
(roles/pubsub.subscriber) on the subscription and a token with
https://www.googleapis.com/auth/pubsub or cloud-platform. The problem is the
principal. The desktop app authenticates as the end user's Google account, which has
no IAM on your project. Your options are to grant every user individually (a server
or manual step), grant allAuthenticatedUsers (any Google account on earth could pull
and ack everyone's notifications from the shared subscription), or ship a service
account key in the binary (extractable, and Google will reject it in review). None is
acceptable. Conclusion: with no server there is no push. If you want push later, the
minimum is a small relay that owns the subscription and forwards {emailAddress, historyId} to connected clients; Mimestream runs exactly that (push.mimestream.com)
and stores only "Email address, APNs device token, Device identifier". That relay may
also drag you into the security assessment. Ship with polling.
IMAP as a complement
Gmail IMAP (imap.gmail.com:993, SMTP smtp.gmail.com:465 or 587) authenticates with
SASL XOAUTH2 (base64("user=" user "^Aauth=Bearer " token "^A^A")) using the
https://mail.google.com/ scope, which is restricted like the rest. Capabilities
include IDLE, CONDSTORE, MOVE, UIDPLUS, X-GM-EXT-1 and APPENDLIMIT=35651584;
there is no QRESYNC. The Gmail extensions expose X-GM-MSGID (64-bit message id),
X-GM-THRID (thread id), X-GM-LABELS (fetch, store and search labels) and
X-GM-RAW (full Gmail search syntax over IMAP). The REST id is the lowercase hex of
X-GM-MSGID and threadId the hex of X-GM-THRID (widely relied on, verify in a
spike before depending on it).
What IMAP does that REST cannot: bulk header fetch with no unit quota (one UID FETCH 1:* (ENVELOPE BODYSTRUCTURE X-GM-LABELS X-GM-THRID X-GM-MSGID) over [Gmail]/All Mail
returns tens of thousands of messages in minutes, limited only by the 2,500 MB per day
IMAP download bandwidth), and IDLE for near-instant new-mail notification without a
server (one connection per watched folder, sessions "limited to about 24 hours" and
OAuth sessions to the token lifetime, so reconnect hourly). What REST does that IMAP
cannot: history.list (IMAP has no mailbox-wide change log; CONDSTORE gives per-folder
MODSEQ only), filters, settings, send-as, vacation, drafts with stable ids, proper
SENT threading via threadId, snippets, sizeEstimate, category labels as first-class
ids, and batch HTTP. IMAP exceeding bandwidth suspends the account for "1 hour, but can
last up to 24 hours".
Reasonable hybrid: REST for everything the user touches and for the change log; IMAP
only as an optional accelerator for the initial backfill and for IDLE as a wake-up
signal that triggers history.list. That costs you the https://mail.google.com/
scope and a second protocol stack, so do it only if the initial sync time measured
with REST alone is unacceptable.
Local mirror
Mimestream keeps everything in a Core Data SQLite store on the Mac and syncs "directly with Google APIs, not through an intermediary service". Do the same: tables for accounts (email, historyId cursor, profile), labels (id, name, type, visibility, colour), messages (id, threadId, internalDate, sizeEstimate, snippet, labelIds as a JSON array or a join table, parsed headers as columns, hydration state), threads (id, latest internalDate, participant summary, derived flags), bodies (message id, html, text, fetched_at), attachments (message id, partId, filename, mimeType, size, content-id, cached path), an outbox, and an FTS5 table over subject, participants and body text. Everything in section 5 marked "client-side" also lives here.
Sources: https://developers.google.com/workspace/gmail/api/reference/quota, https://developers.google.com/workspace/gmail/api/guides/sync, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.history/list, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/list, https://developers.google.com/workspace/gmail/api/guides/batch, https://developers.google.com/workspace/gmail/api/guides/handle-errors, https://developers.google.com/workspace/gmail/api/guides/push, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users/watch, https://docs.cloud.google.com/pubsub/docs/access-control, https://docs.cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/pull, https://mimestream.com/trust/private-push, https://developers.google.com/workspace/gmail/imap/imap-extensions, https://developers.google.com/workspace/gmail/imap/xoauth2-protocol, https://developers.google.com/workspace/gmail/imap/imap-smtp, https://knowledge.workspace.google.com/admin/gmail/gmail-bandwidth-limits, https://gist.github.com/emersion/2c769bc1ed60a7b7945910d35b606801 (third-party capability dump), https://www.unipile.com/gmail-api-limits/ (third-party).
4. Writes
Labels and state
messages.modify(5 units):{addLabelIds[], removeLabelIds[]}.messages.batchModify(50 units): same body plusids[], "There is a limit of 1000 ids per request", empty response, no per-id error report.threads.modify(10 units): applies to every message in the thread. Note that "Messages in a thread might have labels that other messages in the same thread don't have", so thread-level views must aggregate.- Read/unread: remove/add
UNREAD. Star:STARRED. Importance:IMPORTANT. Archive: removeINBOX. Spam: addSPAM(removes from inbox in Gmail's view). Category move: remove oneCATEGORY_*, add another. - Trash:
messages.trash/threads.trash(20) anduntrash(5). Gmail purges trash after 30 days. Permanent delete:messages.delete(10),batchDelete(50),threads.delete(20); these needhttps://mail.google.com/(long-standing, unverified in this pass). - Mute: there is no mute label or method.
q=is:muteddoes find muted threads, so the state is readable but not writable. Emulate with aMargin/Mutedlabel and a client rule that stripsINBOXfrom new messages in muted threads as they arrive in history. While Margin is not running, Gmail web will show them in the inbox.
Sending
messages.send (100 units): body {raw, threadId?} where raw is the complete RFC
2822 message base64url-encoded. Simple JSON body is fine for small messages; for
anything with attachments use the upload URI with uploadType=multipart or
resumable. Discovery document maxSize for send is 36,700,160 bytes (35 MB) for the
whole encoded message; the Gmail web UI limit for attachments is 25 MB (unverified).
insert and import accept 157,286,400 bytes (150 MB). Consumer accounts are capped
at 500 messages per day and Workspace at 2,000 (Google's help page confirms the 500;
the 2,000 is third-party), counted across web, IMAP and API.
Threading rules for a reply, all three required: the threadId in the request, and
"The References and In-Reply-To headers must be set in compliance with the RFC 2822
standard", and "The Subject headers must match" (Gmail tolerates Re:, Fwd:, R:
and similar prefixes). Since the March 2019 change Gmail also requires that "an
incoming message's Reference header, if present, must reference IDs of previous
messages"; same subject and participants alone no longer thread. Practical recipe:
In-Reply-To: <parent Message-ID>, References: <parent References> <parent Message-ID>, subject Re: <original>, threadId of the parent. Changing the subject
starts a new thread on the recipient's side.
Other send facts: the From header must be the account's primary address or a
verified send-as alias, otherwise Gmail rewrites it to the primary (documented
behaviour of aliases, rewriting itself reported by developers, unverified in the
reference). labelIds in the send body should be assumed ignored; apply labels with
messages.modify on the returned id (unverified). Gmail generates a Message-ID if
you omit one; supplying your own lets you correlate the outbox with the sent copy.
The response contains the new id, threadId and labelIds (SENT). There is no
scheduled send and no undo send in the API; Gmail web's Undo Send is a client-side
hold of 5, 10, 20 or 30 seconds before the message leaves, which is exactly what
Margin should implement.
Drafts
drafts.create (10), drafts.update (15), drafts.get (20), drafts.list (5),
drafts.delete (10), drafts.send (100, "You can send as-is or provide updates by
including a new MIME message"). Put threadId inside message for reply drafts.
Drafts created via the API appear in Gmail web's Drafts folder, which is the only
roaming compose state you get.
Settings
settings.sendAs.list/get: enumerate the primary address and aliases withdisplayName,replyToAddress,signature(HTML, "added to new emails only", Gmail "will sanitize the HTML before saving it"),isPrimary,isDefault,treatAsAlias,verificationStatus(accepted,pending).sendAs.update/patchon the primary address works withgmail.settings.basic; "Addresses other than the primary address for the account can only be updated by service account clients that have been delegated domain-wide authority", andcreate,delete,verifyare service-account-only too. So: read aliases, send from them, edit the primary signature; nothing else.settings.getVacation/updateVacation(gmail.settings.basic):{enableAutoReply, responseSubject, responseBodyPlainText, responseBodyHtml, restrictToContacts, restrictToDomain, startTime, endTime}.- Filters (
gmail.settings.basic):settings.filters.create/get/list/delete, no update ("filters must be deleted and recreated"), "you can only create a maximum of 1,000 filters". Criteria:from,to(matches the local part, case-insensitive),subject,query(Gmail search syntax),negatedQuery,hasAttachment,excludeChats,sizewithsizeComparison(smaller,larger). Actions:addLabelIds[],removeLabelIds[],forward(needs a verified forwarding address, which you cannot create). The guide's examples useINBOXandUNREADinremoveLabelIds(skip inbox, mark read),TRASH,STARRED,IMPORTANTinaddLabelIds, and one user label per filter; the exact accepted set is not spelled out. "Filters only apply to specific messages and not the entire email thread", and they act on mail as it arrives; the web UI's "also apply filter to matching conversations" has no API equivalent, so backfilling is your job withbatchModify. - Forwarding addresses, auto-forwarding, delegates, POP/IMAP toggles: all service-account-only or admin territory. Delegates are Workspace-only ("up to 25 delegates and up to 10 delegators").
messages.import(25 units, 150 MB) runs "standard email delivery scanning and classification similar to receiving via SMTP" includingprocessForCalendar;messages.insertjust stores. Neither sends. Useful for migration, not for the features here.
Sources: https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/batchModify, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send, https://gmail.googleapis.com/$discovery/rest?version=v1 (maxSize values), https://developers.google.com/workspace/gmail/api/guides/sending, https://developers.google.com/workspace/gmail/api/guides/threads, https://workspaceupdates.googleblog.com/2019/03/threading-changes-in-gmail-conversation-view.html, https://developers.google.com/workspace/gmail/api/guides/drafts, https://developers.google.com/workspace/gmail/api/guides/alias_and_signature_settings, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.settings.sendAs/create, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.settings.sendAs/update, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.settings/updateVacation, https://developers.google.com/workspace/gmail/api/guides/filter_settings, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.settings.filters, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.settings.filters/create, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.settings.forwardingAddresses/create, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.settings/updateAutoForwarding, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.settings.delegates/create, https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/import, https://support.google.com/mail/answer/22839, https://support.google.com/mail/answer/2819488.
5. Feature feasibility matrix
"Native" means the Gmail API (or the named Google API) does it as a first-class operation. "Emulate" means Margin builds it from labels, filters, headers and local state. Anything emulated with local state does not roam to another machine or to Gmail web unless the label mirror carries it.
| Feature | Native in Gmail API | Emulate client-side (how) | Not possible |
|---|---|---|---|
| Screener (allow-list senders, hide unknown senders from inbox) | No. Filters cannot express "sender not in a list" beyond a single negatedQuery capped by query length. |
On every messagesAdded with INBOX, look the sender up in a local allow-list (contacts, prior correspondents, user decisions). Unknown: modify remove INBOX, add Margin/Screened out. Screen-in decision re-adds INBOX for the thread and records the sender. Only runs while Margin is running; Gmail web shows unscreened mail in Inbox in between. |
Server-side enforcement without a server. |
| Split inbox / Feed / Paper trail | Partial. CATEGORY_PROMOTIONS, CATEGORY_UPDATES, CATEGORY_SOCIAL, CATEGORY_FORUMS, CATEGORY_PERSONAL are applied by Gmail's classifier regardless of whether tabs are on (third-party), and are readable and settable. |
Per-sender routing stored locally and mirrored as Margin/Feed, Margin/Paper trail labels via modify; optionally a Gmail filter per stable sender (from: add label, remove INBOX) so Gmail web matches and mail arriving while Margin is closed is routed. Seed the initial bucket from CATEGORY_* and List-Id/Precedence: bulk headers; let the user override per sender. |
|
| Reply later pile | No. | Label Margin/Reply later plus local ordering and optional local reminders. Keep or drop INBOX as a product choice. |
|
| Set aside pile | No. | Label Margin/Set aside, remove INBOX. |
|
| Snooze | No. Gmail's own snooze is invisible except through q=in:snoozed and the eventual INBOX label add/remove in history; no snooze date is exposed (issue 287304309 is open). |
Remove INBOX, add Margin/Snoozed, store wake time in SQLite; a scheduler re-adds INBOX (and UNREAD if wanted). Fires late if Margin was closed. Do not touch messages the user snoozed in Gmail web; Mimestream documents a Gmail server bug where archiving previously-snoozed mail via the API leaves it showing in Inbox. |
Cross-device wake without your own sync. |
| Send later | No. Gmail web's Schedule send is not in the API. | Local outbox row with send_at; scheduler calls messages.send when due. Store the compose as a Gmail draft too so it survives a reinstall. App must be running at send time. |
Sending while the app is closed. |
| Undo send | No. | Hold the message in the outbox for N seconds (Gmail web offers 5, 10, 20, 30) before calling messages.send. After send it is gone. |
Recall after send. |
| Read receipts / open tracking | No. Gmail's read receipts are Workspace-only and admin-enabled (Disposition-Notification-To); consumer accounts cannot request or return them. |
Adding Disposition-Notification-To to raw costs nothing but only some recipients' clients honour it (unverified). Pixel tracking needs a server you run and is the same practice Margin blocks on receipt; recommend not building it. |
Open tracking without a server. |
| Spy pixel blocking | No. | Load bodies in the webview with remote content blocked by default, rewrite <img src=http...> to placeholders, allow per-sender or per-message, strip 1x1 and known tracker hosts, never send Referer. Optional proxy through your own server later. |
|
| Unsubscribe | No (Gmail web's button is UI only). | Fetch List-Unsubscribe and List-Unsubscribe-Post via metadataHeaders. If List-Unsubscribe-Post: List-Unsubscribe=One-Click is present, POST to the HTTPS URI with body List-Unsubscribe=One-Click (RFC 8058, no GET, no confirmation page). Else mailto: via messages.send, else open the HTTPS link. Pair with a from: filter to TRASH or Margin/Screened out for stragglers. |
|
| Block sender | Yes via filters: criteria.from, action.addLabelIds=[TRASH] (or removeLabelIds=[INBOX] plus a label). |
Backfill existing mail with messages.list?q=from:x and batchModify. |
|
| Snippets / templates | No. | Local SQLite; insert into compose. | |
| Sticky notes on threads | No. | Local SQLite keyed by threadId. Hidden drafts in the thread show up in Gmail web and labels cannot carry text, so there is no sane Gmail-side store. | Roaming without your own sync. |
| Rename thread subject | No; message headers are immutable. | Local display-name override keyed by threadId; replies still carry the real subject so threading holds. | Changing what Gmail web shows. |
| Merge threads | No; threadId is assigned by Gmail. |
Local mapping of several threadIds to one virtual thread; writes fan out to every underlying thread. | Merging in Gmail itself. |
| Contacts autocomplete | Yes via People API: otherContacts.list (contacts.other.readonly, readMask=names,emailAddresses, pageSize up to 1000, sync tokens expire after 7 days), people.connections.list (contacts.readonly, up to 1000), otherContacts.search and people.searchContacts (max 30 results, send an empty-query warm-up first). |
Also index From/To/Cc from synced headers locally; it is the best signal and needs no extra scope. | |
| Attachments browsing | Partial: q=has:attachment or filename:pdf returns ids. |
Index payload.parts[].filename/mimeType/size from format=full fetches into SQLite; fetch bytes on demand with attachments.get. |
|
| Calendar invite RSVP | Not in Gmail API. Calendar API: find the event with events.list?iCalUID=<UID from the text/calendar part> on primary (Gmail auto-adds invites depending on the user's "Add invitations to my calendar" setting, "From everyone" or "Only if the sender is known"), then events.patch with attendees[self].responseStatus in accepted, tentative, declined and sendUpdates=all; if absent, events.import with iCalUID, start, end. Needs calendar.events. |
Parse the text/calendar part (METHOD:REQUEST) yourself and render the card. A standards-only fallback is to email a METHOD:REPLY iMIP message to the organiser via messages.send, which needs no Calendar scope but does not update the user's own calendar. |
|
| Shared / team features | Out of scope. Delegation exists but is Workspace-only and service-account-only. | ||
| Multiple accounts | Yes: one OAuth grant per account with the same client ID; separate refresh tokens and sync cursors. | ||
| Unified inbox | No. | Merge locally across account databases; every write routes to the owning account. | |
| Search | Yes: messages.list?q= and threads.list?q= (5 or 10 units per page, full Gmail operator set: from:, to:, subject:, label:, category:, has:attachment, filename:, list:, newer_than:, larger:, rfc822msgid:, in:snoozed, is:muted, deliveredto:, AROUND, OR, -). Returns ids only; hydrate from the mirror. Not available under gmail.metadata. Cannot be combined with history.list. |
SQLite FTS5 over synced subject, participants and cached bodies for instant results; fall back to server q for anything not yet hydrated and for operators you have not implemented. Server search is a network round trip and returns ids in relevance-ish order, so results feel slower than local. |
|
| Keyboard triage | No. | Client. Batch consecutive label changes into batchModify calls. |
|
| Push notifications | Yes with users.watch plus Pub/Sub, but delivery requires a server or an unacceptable IAM grant (section 3). |
Poll history.list; optionally IMAP IDLE on INBOX as a wake-up. |
Push with no server. |
| Offline compose and queue | No. | Local outbox; drafts.create when online so the draft roams; messages.send on reconnect with backoff. |
|
| Categories tabs | Yes: read and set CATEGORY_*. Accuracy is good for big senders and mediocre for small newsletters; Promotions is a fair Feed seed, Updates is a fair Paper trail seed (receipts, notifications), Forums and Social less useful. Gmail may reclassify a sender after the user moves a few messages, which you observe as label churn in history. |
Treat as a prior; sender-level rules win. | |
| Importance markers | Yes: IMPORTANT label readable and settable; filters can removeLabelIds=[IMPORTANT]. |
||
| Muted threads | Readable via q=is:muted, not settable. |
Margin/Muted label plus client rule (section 4). |
Muting that Gmail itself enforces. |
| Labels and folders, archive, star, read state, trash, spam | Yes. | ||
| Signature and vacation responder | Yes for the primary address (sendAs.patch signature, updateVacation). |
Creating or verifying send-as aliases (service-account-only). | |
| Permanent delete | Yes, but only with https://mail.google.com/. |
Trash instead. |
Sources: https://developers.google.com/workspace/gmail/api/guides/labels, https://developers.google.com/workspace/gmail/api/guides/filter_settings, https://issuetracker.google.com/issues/287304309 (login required), https://mimestream.com/help/common-issues/archiving-snoozed-messages, https://support.google.com/mail/answer/9413651, https://www.rfc-editor.org/rfc/rfc8058, https://developers.google.com/people/api/rest/v1/otherContacts/list, https://developers.google.com/people/api/rest/v1/otherContacts/search, https://developers.google.com/people/api/rest/v1/people.connections/list, https://developers.google.com/people/api/rest/v1/people/searchContacts, https://developers.google.com/workspace/calendar/api/v3/reference/events, https://developers.google.com/workspace/calendar/api/v3/reference/events/list, https://developers.google.com/workspace/calendar/api/v3/reference/events/import, https://support.google.com/calendar/answer/13159188, https://support.google.com/mail/answer/7190 (search operators), https://support.google.com/mail/thread/231661001 (categories applied with tabs off, community thread).
6. Gotchas and war stories
- Quota changed on 1 May 2026. Any library, blog post or mental model built on 250
units per second per user and 5-unit
messages.getis off by a factor of 4 to 10. Budget hydration at 300 messages per minute per user and expect billing for overage "later in 2026". - Full sync is now the slow part. A 100,000 message mailbox is roughly 5 to 6 hours of metadata hydration at the ceiling. Hydrate newest first, show partial state, and consider IMAP for the backfill if that is unacceptable.
history.list404 is routine. A user who does not open Margin for a week or two will hit it; treat the ids-only re-list as a normal code path, not a disaster.- History can reference messages that no longer exist.
messages.get404 on amessagesAddedid means it was deleted in the same window. Log and move on. - Label IDs are not names.
Label_123is the key; names change; system labels are their own IDs. Re-list labels when history contains an unknown id. Do not create a label whose name collides with a reserved name (400). labelIdsonmessages.sendshould be assumed ignored;modifyafterwards.rawis base64url, not standard base64.+//versus-/_mistakes produce corrupt MIME that Gmail rejects with a 400 that says nothing useful.- 35 MB (36,700,160 bytes) is the hard
sendceiling for the whole encoded message, which after base64 expansion is about 25 MB of attachments. Fail early in the composer, not at send. - Messages are immutable. There is no way to change headers, subject, or body of a stored message. Every "edit" feature in section 5 is a local overlay.
messages.listpages at most 500 and returns only ids;resultSizeEstimateis an estimate and can be wildly wrong.threads.listlikewise.- Batch: 100 max, 50 recommended, each call metered separately, and 429 inside a batch
comes back per part, so parse every part's status. The batch path from the discovery
document is
batch/gmail/v1onwww.googleapis.com. - 429 and 403
userRateLimitExceeded/rateLimitExceededboth want truncated exponential backoff (min((2^n) + random_ms, 32 to 64 s)) and "Start retry periods at least one second after the error". Per-user limits "cannot be increased for any reason". Watch the undocumented concurrency ceiling: keep in-flight requests per mailbox low (single digits) and the batch size at 25 to 50. - Server search is slow-ish and eventually consistent: a message you just labelled may
not match
q=label:xfor a few seconds. Local FTS is the fast path. - Header decoding: RFC 2047 encoded-words (
=?UTF-8?B?...?=) in From, Subject and filenames, RFC 2231 parameter continuations inContent-Disposition, and charset-labelled bodies (iso-8859-1,windows-1252,gb2312) all show up daily. Use a real MIME parser onrawrather than trustingpayloadfor anything beyond headers. - HTML mail in a webview: sanitise (strip scripts, forms,
<meta http-equiv>, external CSS,javascript:URLs), isolate in an iframe or Tauri child webview with CSP, block remote images by default, rewritecid:to local resources, and expect broken layouts with dark mode. Practical dark-mode approach is per-message opt-in inversion or a light-background iframe; automatic colour inversion breaks a lot of newsletters. - Newsletter and bulk detection:
List-Id(RFC 2919),List-Unsubscribe,Precedence: bulk|list,Auto-Submitted,X-Mailerand aCATEGORY_PROMOTIONSorCATEGORY_UPDATESlabel together give a reliable "not a human" signal for the screener's default bucket. - Authentication results are only in raw headers. Fetch
Authentication-ResultsviametadataHeadersand parsespf=,dkim=,dmarc=from themx.google.comstanza. There is no API field for spam score or phishing verdict;SPAMlabel is all you get. - Gmail's classifier and your screener will disagree. A first-time human sender in Promotions is common (Gmail keys on content, you key on sender). Make the screener decision sender-based and use categories only to pick the default bucket.
- Threading edge cases: recipients and senders can see different threads for the same
messages; a changed subject starts a new thread; Gmail web splits at 100 messages;
mailing-list software that rewrites
Message-IDor stripsReferencesproduces orphan threads; a reply sent withoutthreadIdlands in a new thread even with correct headers. - Snoozed-in-Gmail messages that Margin archives can reappear in Gmail web's Inbox (server bug, Mimestream help page). Leave Gmail-snoozed mail alone.
- Password change revokes every refresh token with Gmail scopes. Expect
invalid_grantand re-prompt. - Testing-mode tokens die after 7 days. If someone reports "it logs me out weekly", the consent screen is still in Testing.
resultSizeEstimate,messagesTotaland label counts are estimates and lag. Never drive UI badges from them; count locally.- People API sync tokens expire after 7 days and "The first page of a full sync request has an additional quota" that returns 429 if hammered; do one full contacts sync per install, then incremental.
- Sending limits are per account across all clients: 500 per day consumer. A bulk "unsubscribe from 200 lists via mailto" action can burn a meaningful chunk of it.
Sources: https://developers.google.com/workspace/gmail/api/guides/handle-errors, https://developers.google.com/workspace/gmail/api/reference/quota, https://developers.google.com/workspace/gmail/api/guides/sync, https://developers.google.com/workspace/gmail/api/guides/batch, https://developers.google.com/workspace/gmail/api/guides/threads, https://mimestream.com/help/common-issues/archiving-snoozed-messages, https://developers.google.com/identity/protocols/oauth2, https://developers.google.com/people/api/rest/v1/otherContacts/list.
7. Recommendation
REST is the only transport for v1. IMAP comes later, if at all, for two narrow jobs:
bulk backfill when measured REST sync time is unacceptable, and IDLE as a wake-up
signal. history.list stays the single change log; IMAP has no equivalent.
Request gmail.modify, gmail.settings.basic, contacts.other.readonly,
contacts.readonly and calendar.events; no https://mail.google.com/ until IMAP is
real. Push the consent screen to production unverified for the friends release (100
lifetime users, stable tokens) and file restricted-scope verification now, stating
there is no server and asking in writing whether the assessment applies.
Everything lives in SQLite on the device: message and thread mirror, bodies with an
LRU, FTS5 index, per-account history cursor, and an outbox with send_at and
hold_until (send later and undo send are one mechanism). Poll history.list every
10 to 15 seconds in the foreground. No server, no push; say so in the product.
Mirror every pile as a real label under one namespace so Gmail web stays coherent:
Margin/Screened out, Margin/Feed, Margin/Paper trail, Margin/Reply later,
Margin/Set aside, Margin/Snoozed, Margin/Muted. All but Reply later also remove
INBOX, so Gmail web's inbox matches Margin's and the rest is findable by label. For
senders the user has explicitly routed, also create a from: filter so routing works
while Margin is closed; stay well under the 1,000 filter cap.
What cannot live in Gmail, and so does not roam to another device or to Gmail web until Margin ships its own sync: sticky notes, thread renames and merges, snooze wake times, scheduled send times, the allow-list and per-sender rules (labels show the outcome, not the rule), snippets. A second machine sees the labels, not the rules, and a snooze set on the laptop will not wake the desktop. Call this "single device of record" in the onboarding copy rather than discovering it in support.
Skip open tracking: it needs a server, contradicts the pixel blocking, and invites
scrutiny during review. Design against these numbers: 6,000 units per minute per
user, 20 units per metadata fetch, 100 per send, 35 MB per message, 500 sends per
day, 1,000 filters, 10,000 labels, 500 ids per page, 1,000 ids per batchModify, 100
calls per batch, history validity that can drop to hours, and no API for snooze,
schedule send, undo send, mute, rename, notes, or push without a server.