scaffold the app: tauri crate, vite front end, icons, ci

This commit is contained in:
pj committed 2026-09-03 17:01:50 +05:30
commit 088ec9c6e4
267 files changed
+18617

No files matched your search

+749
View File
@@ -0,0 +1,749 @@
# 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_grant` by 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
1. `getProfile` and record `historyId` before you list anything, so the first partial
sync covers everything that changed during the crawl.
2. `messages.list?maxResults=500&includeSpamTrash=true` (5 units per page, ids and
threadIds only, newest first) and page with `pageToken` until exhausted. 20,000
messages is 40 pages, 200 units, a few seconds. Store ids, threadIds and a
"needs hydration" flag.
3. Hydrate newest first in batches of 50 `messages.get?format=metadata` with
`metadataHeaders` limited 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 403 `userRateLimitExceeded` with truncated exponential
backoff starting at 1 second, and treat 404 as "deleted since listing".
4. 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.
5. Bodies: fetch `format=full` (or `raw`) lazily on open and prefetch the last N days
in the background. Cache decoded HTML and text in SQLite with an LRU cap.
6. 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 plus `ids[]`, "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:
remove `INBOX`. Spam: add `SPAM` (removes from inbox in Gmail's view). Category
move: remove one `CATEGORY_*`, add another.
- Trash: `messages.trash` / `threads.trash` (20) and `untrash` (5). Gmail purges trash
after 30 days. Permanent delete: `messages.delete` (10), `batchDelete` (50),
`threads.delete` (20); these need `https://mail.google.com/` (long-standing,
unverified in this pass).
- Mute: there is no mute label or method. `q=is:muted` does find muted threads, so
the state is readable but not writable. Emulate with a `Margin/Muted` label and a
client rule that strips `INBOX` from 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 with
`displayName`, `replyToAddress`, `signature` (HTML, "added to new emails only",
Gmail "will sanitize the HTML before saving it"), `isPrimary`, `isDefault`,
`treatAsAlias`, `verificationStatus` (`accepted`, `pending`). `sendAs.update/patch`
on the primary address works with `gmail.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", and `create`, `delete`, `verify` are
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`, `size` with `sizeComparison` (`smaller`, `larger`). Actions:
`addLabelIds[]`, `removeLabelIds[]`, `forward` (needs a verified forwarding address,
which you cannot create). The guide's examples use `INBOX` and `UNREAD` in
`removeLabelIds` (skip inbox, mark read), `TRASH`, `STARRED`, `IMPORTANT` in
`addLabelIds`, 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 with `batchModify`.
- 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" including `processForCalendar`;
`messages.insert` just 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.get` is 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.list` 404 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.get` 404 on a
`messagesAdded` id means it was deleted in the same window. Log and move on.
- Label IDs are not names. `Label_123` is 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).
- `labelIds` on `messages.send` should be assumed ignored; `modify` afterwards.
- `raw` is 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 `send` ceiling 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.list` pages at most 500 and returns only ids; `resultSizeEstimate` is an
estimate and can be wildly wrong. `threads.list` likewise.
- 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/v1` on `www.googleapis.com`.
- 429 and 403 `userRateLimitExceeded` / `rateLimitExceeded` both 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:x` for 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 in `Content-Disposition`, and
charset-labelled bodies (`iso-8859-1`, `windows-1252`, `gb2312`) all show up daily.
Use a real MIME parser on `raw` rather than trusting `payload` for 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, rewrite `cid:` 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-Mailer` and a `CATEGORY_PROMOTIONS`
or `CATEGORY_UPDATES` label together give a reliable "not a human" signal for the
screener's default bucket.
- Authentication results are only in raw headers. Fetch `Authentication-Results` via
`metadataHeaders` and parse `spf=`, `dkim=`, `dmarc=` from the `mx.google.com` stanza.
There is no API field for spam score or phishing verdict; `SPAM` label 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-ID` or strips `References` produces
orphan threads; a reply sent without `threadId` lands 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_grant` and 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`, `messagesTotal` and 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.