Skip to content

feat: Gmail API backend over JMAP (OAuth, sync, compose, aliases, delayed send, push) - #14

Open
lucamzanon wants to merge 46 commits into
bulwarkmail:mainfrom
lucamzanon:feat/gmail-backend
Open

lucamzanon wants to merge 46 commits into
bulwarkmail:mainfrom
lucamzanon:feat/gmail-backend

Conversation

@lucamzanon

@lucamzanon lucamzanon commented Sep 14, 2026 •

Copy link
Copy Markdown

The Gmail work previously stacked as drafts #4–#13, consolidated into one branch and extended since. Follow-up to the RFC in discussion bulwarkmail/webmail#1007 and to the suggestion there to build on legacy-proxy rather than on a separate bridge.

What this adds

An opt-in Gmail API backend that lets any JMAP client, Bulwark in particular, use Gmail and Google Workspace mailboxes natively: no IMAP, no App Passwords, label ids stay stable and historyId drives /changes. Every feature is gated by an environment variable so existing legacy-proxy deployments are untouched when the variables are absent.

Commit Flag Adds
refactor: decouple JMAP dispatch — router/method table separated from the IMAP/SMTP transports
OAuth connection GMAIL_OAUTH_CLIENT_FILE, GMAIL_ALLOWED_EMAILS /auth/google/* consent flow, encrypted token vault, per-account bridge password
read-only backend (same) Mailbox/Email/Thread get/query, blobs, bounded cache, quota + concurrency budget, retries
management GMAIL_WRITE_ENABLED read/starred/important/archive/trash and flat user labels
composition GMAIL_COMPOSE_ENABLED uploads, native drafts, Email/import into any folder, drafts.send with a durable intent ledger
incremental sync (same) history.list driven Email/Mailbox /changes, persistent cursor, recovery across restarts
send-as aliases GMAIL_ALIASES_ENABLED Gmail "Send mail as" addresses as read-only identities, re-validated before sending
delayed send GMAIL_SCHEDULE_ENABLED RFC 4865 FUTURERELEASE queue with atomic worker, cancel/undo, suspension instead of surprises
push GMAIL_PUSH_TOPIC, GMAIL_PUSH_TOKEN Cloud Pub/Sub watch renewal + push endpoint, JMAP EventSource StateChange events
onboarding — the consent page issues the Bulwark bridge password itself, shown once
labels as tags GMAIL_LABEL_TAGS (on by default) user labels also as $label:… keywords; Keyword/get gives their names and colours
EmailPush (push) draft-ietf-jmap-emailpush subscriptions: a Web Push names the message that arrived
sign-in for clients GMAIL_OAUTH_CLIENTS OAuth 2.0 authorisation server for registered clients (PKCE): "Sign in with Google" in the client, no bridge password; bridge tokens only, Google's never leave
performance — Gmail batch endpoint for messages and label counters, encrypted cache keyed by account state, folder and tag counts from label counters instead of searches
docs — README: Google Cloud setup per deployment, incl. what Testing / In production / Internal mean for token expiry

Design notes that matter for review:

  • An uncertain drafts.send is never replayed; the ledger blocks further sends of that draft and the client is told to check Sent.
  • The scheduled queue pins the approved draft by MIME hash; an edited/deleted draft, a removed alias or an entry that comes due while the bridge is down beyond a tolerance is suspended, never sent.
  • No addresses, subjects or bodies in logs; /healthz exposes counters only.
  • Google's raw error objects never reach the dispatcher (they can carry Authorization headers).
  • Nothing is tied to a specific Google project or domain: each operator brings their own OAuth client; the README explains the publishing-status trade-offs (Testing expires refresh tokens after 7 days).

Tests

  • Gmail unit suites (test/unit/gmail*.spec.ts): 160 tests.
  • Whole unit suite: 315 pass; the 3 failures (refs.spec.ts, search.spec.ts) are pre-existing on main.
  • Everything has been running in production for a personal @gmail.com account and a Workspace account with Bulwark as the client (aliases, scheduled/undo send, push verified end to end). Setup steps are in the README.

Happy to split this differently if a smaller first slice (e.g. up to incremental sync) is easier to review. The stacked drafts #4–#13 are closed in favour of this one.

lucamzanon and others added 11 commits September 14, 2026 18:08
Split the JMAP router from the IMAP/SMTP/ManageSieve transports so an
alternative backend can serve the same method table without creating a
legacy account.
Google Web OAuth client loaded from GMAIL_OAUTH_CLIENT_FILE, an allowlist
of connectable addresses, encrypted token storage in a dedicated SQLite
file, a per-account bridge password issued after consent, and the
/auth/google/* flow with CSP and origin handling for the consent form.
Mailbox/Email/Thread get, query and blob download on top of the Gmail
REST API with label/mailbox mapping, a bounded read cache, shared
per-account quota and concurrency budget, retries on temporary errors,
tolerant handling of Bulwark's batch sizes, punctuated keyword names and
missing objects.
With GMAIL_WRITE_ENABLED and a gmail.modify grant: read/unread, starred,
important, archive, trash and flat user labels through Email/set and
Mailbox/set, serialized per account, validated before any upstream call.
With GMAIL_COMPOSE_ENABLED: uploads, Email/set create and Email/import
into native Gmail drafts, EmailSubmission/set via drafts.send with a
durable intent ledger so an uncertain outcome is never replayed, and
stable JMAP ids across Gmail's post-send id change.
history.list driven Email/changes and Mailbox/changes with a persistent
cursor, targeted cache invalidation, revision guards against stale
reads, native threadId lookup for replies, and recovery of tokens and
pending state across restarts.
Behind GMAIL_ALIASES_ENABLED: users.settings.sendAs (covered by the
existing gmail.modify grant) becomes read-only identities with stable
ids; drafts, imports and submissions may use any of them, re-validated
right before sending. Signatures stay client-side.
Behind GMAIL_SCHEDULE_ENABLED: HOLDFOR/HOLDUNTIL submissions are stored,
a 5 s worker leases due entries atomically and re-validates identity,
draft hash and ledger before drafts.send; edited/deleted drafts, removed
identities and late entries are suspended, never sent; undoStatus
canceled cancels pending entries; interrupted sends are reconciled on
restart; /healthz reports per-status counters.
Behind GMAIL_PUSH_TOPIC/GMAIL_PUSH_TOKEN: daily users.watch renewal, a
push endpoint that validates the shared token in constant time (kept out
of request logs), persists the hint before acknowledging and coalesces
bursts into the incremental engine, and a JMAP EventSource streaming
StateChange events after a sync changed state.
The consent page gains an opt-out checkbox; after a successful consent
the result page shows server, username and a freshly issued bridge
password exactly once, bound to the browser by a two-minute cookie.
Operators no longer need to run the password tool for each user.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Explain that operators bring their own OAuth client, what Internal /
Testing / In production mean for who can connect and for token expiry
(Testing expires refresh tokens after 7 days), and when Google's app
verification is actually needed. Move the incremental-sync notes into
the Gmail section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lucamzanon and others added 5 commits September 16, 2026 08:14
Adds PushSubscription/get and /set to the Gmail backend, gated on push
being configured: https-only endpoints, the PushVerification handshake,
capped expiry and per-account limits. The Pub/Sub driven sync now tells
genuine arrivals from label changes, sends and drafts, asking Gmail for
labels when a history record omits them, and fans out StateChange with
EmailDelivery to verified subscribers while open tabs keep using the
EventSource stream. Dead endpoints are dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Clients resolve the Inbox by role; the push-notification preview does
exactly that, and a rejected filter reads as an account with no Inbox,
so no notification is ever shown. Supports role, hasAnyRole, name,
parentId and isSubscribed, plus AND/OR/NOT.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An unread badge or a push preview asks for the newest unread message in
a folder; that filter fell to the enumerating path, which paged every
matching id (seconds on a large mailbox, and the preview gave up).
Gmail keeps UNREAD as a label, so the query lists by label membership
and takes its total from the folder's messagesUnread. Gmail list
parameters can now repeat, as the API expects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…port

Email/import only knew how to create drafts, so Bulwark's account-to-account
transfer failed with "New mail must be a draft" whenever the destination was
a Gmail account. Imports whose mailboxIds do not include Drafts now go through
users.messages.import: any From is accepted, $seen/$flagged/$important map to
UNREAD/STARRED/IMPORTANT, other keywords are dropped, Gmail orders the message
by its Date header and it is never marked as spam. Only writable labels
(Inbox, Spam, Trash, user labels, All mail alone to archive) are accepted;
the draft path and its identity check are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Live Gmail answers users.messages.import with the id alone, so the Email/import
record carried "t_undefined". Read the stored message with format=minimal after
the import and report Gmail's threadId, labelIds and sizeEstimate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rathlinus

Copy link
Copy Markdown
Member

Tell me when you are ready

lucamzanon added 2 commits September 20, 2026 12:34
maxObjectsInGet bounds how many ids a caller may name, not how large the
account's folder list may be. Mailbox/get with no ids - what Bulwark sends -
was rejected with requestTooLarge on any account with more than 100 labels,
so a Workspace mailbox with 124 of them arrived in the client with no folders
at all: an empty inbox that kept retrying. Mailbox/query truncated the same
list at 100.

Mailboxes are already materialised in memory, so they get their own ceiling
(10k, Gmail's own label limit); caller-supplied id batches stay bounded by
maxObjectsInGet.
…tion

Mailbox/query only ever returns ids, and the folder/unread page of Email/query
needs the counters of a single label, but both went through the full label
listing - one Gmail labels.get per label in the account. On a Workspace
mailbox with 124 labels that is ~7.5 s of Gmail calls, paid again on every
delivery because the cache key carries the account's history id.

That is what left a push notification generic: /api/push/preview asks
Mailbox/query which mailbox is the inbox, then Email/query for the newest
unread in it, and the service worker had given up before the answer came, so
it fell back to "You have new mail".

Mailbox/query now builds its records from the plain labels.list (one call, no
counters - none of the filters read one) and hashes the matching ids for its
query state, which is what a query state is meant to track. Email/query
resolves the Gmail label id straight out of the mailbox id and reads that one
label. The full listing stays where the counters are the answer: Mailbox/get.
@lucamzanon

Copy link
Copy Markdown
Author

there are still a couple of things that needs to be fixed, mainly in the queries.. Right now some queries are too heavy and the whole system lags so the UX sucks.

lucamzanon and others added 7 commits September 21, 2026 08:13
Reading fifty messages for a folder page took 12.8 s against a real
mailbox, and almost none of it was Google's doing.

Two self-imposed costs made it. Requests were priced at a flat rate this
bridge invented - messages.get at 20 quota units, threads.get at 40 -
and paced against a budget of 80 units a second, so a single message
reserved 250 ms of a serialized slot before it was even sent. Google
charges 5 for messages.get and refills 250 units a second: the page was
waiting on a limiter twelve times stricter than the one it was
protecting. Each read then travelled on its own, four at a time, through
a concurrency gate Email/get narrowed to four again.

So: charge what Google charges, pace at 200 units a second (room left
for the retries a 429 costs), and read a page through the batch
endpoint - one HTTP request for up to a hundred messages, correlated
back by Content-ID because Google answers out of order. The per-message
path is unchanged and still serves anything the batch did not cover.

A throttled batch arrives as 200 with a refusal inside every part; that
case fails fast rather than becoming fifty individual reads against the
budget that just ran out.

The same page now takes 2.3 s cold, 0.25 s from cache.
The cache held mail for thirty minutes. Open the client after lunch and
every page was read from Google again - 2.3 s for a folder that was
already known - while the 256 MB it is allowed to keep sat almost empty:
eight of them, on an account that has been running for a week.

Thirty minutes was never what made the cache correct. Every change Gmail
reports through history.list already evicts the messages it names, and a
cursor too old to replay drops the account's cache outright, so an entry
still present is an entry Gmail has said nothing about since it was
written. The clock was a second guess at a job the history cursor
already does, and a poor one: it expired entries that were right and
kept none that were useful. Messages, threads and the listings behind a
folder page now live thirty days, and the size budget - not the clock -
is what reclaims them.

Keeping mail on disk for thirty days rather than thirty minutes is worth
doing properly, so entries are sealed with the vault key that already
protects the credentials beside them. AES-GCM over a few hundred bytes
costs microseconds against the round trip it saves, and a row that
cannot be opened is dropped and fetched again. Rows written in the clear
by an earlier version are discarded on first open.

Mail that history.list names as new is fetched in one batch as the sync
notices it, rather than when the user clicks: by the time a push
notification is read, the message behind it is already here.
A Gmail label is not a folder. A message wears several at once, they are
shown on it rather than around it, and each has a colour the user chose.
The bridge only ever offered them as JMAP mailboxes, so a client read
"filed in three places" where Gmail says "labelled three ways", and the
labels disappeared from the message itself.

Every user label is now offered both ways. It stays a mailbox, which is
what a folder view and its filters need, and it also appears on each
message as the `$label:<name>` keyword clients read tags from. The id is
the label's own name folded to what an IMAP flag can hold - accents
stripped, spaces hyphenated, case dropped - so Gmail's "Clienti/Acme"
arrives as the nested tag those clients already understand.

Writing works the same way round: adding or removing a tag keyword adds
or removes the label it names, and a query for the tag becomes a query
for the label, so the folder and the tag always agree. Setting keywords
wholesale strips labels only when the set mentions tags at all - a
client marking a message read must not unfile it by omission.

GMAIL_LABEL_TAGS=false returns to mailboxes alone.
A client that wants to know which tags exist has, in plain JMAP, one way
to find out: walk the mailbox and collect the keywords it meets. That is
thousands of messages read to answer a question about a dozen labels,
and it still misses any label whose mail is older than the walk, or that
carries no mail at all.

Gmail already knows. Every label reports its own totals, and the labels
this bridge exposes as tags report their name and the colour Gmail shows
them in as well. Keyword/get hands all of it over in one call, under the
keyword-enumeration capability Bulwark clients already look for, so the
recovery flow that used to page through the mailbox now answers at once
and answers better: a tag comes back named as the user named it and
coloured as they coloured it, rather than guessed at from its id.

The states Gmail keeps as labels of its own - starred, important, draft,
and read as the complement of unread - are reported too, counted just as
exactly, and marked as what they are rather than as tags.
Gmail returns a message's snippet HTML-escaped, because it is meant for a
page; JMAP's preview is plain text, and clients write it straight into a
list row or a system notification. So an apostrophe reached the user as
"dell&#39;Agenzia" - in the mail list and, since the Gmail-style push
notifications landed, in the notification body too.

Decode it once, here, where Gmail's conventions stop. The same pass drops
the invisible padding bulk senders put after their preheader: runs of
combining grapheme joiners and zero-width spaces meant to push the quoted
body out of the inbox preview, which otherwise are most of what the
preview consists of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bare EmailDelivery state change says only that something arrived. The
client then has to work out what: in practice it asks for "the newest
unread in the Inbox", which is the right message only while mail is read
in the order it comes, and nothing at all once the Inbox has no unread
left to point at - the user gets "New mail / You have new mail".

Implement draft-ietf-jmap-emailpush, which the client already asks for:
a subscriber registers, per account, a filter and the Email properties it
wants, and every delivery that passes the filter is pushed as

  {"@type":"EmailPush","accountId":"…","emails":[{"id":"…"}],"state":"…"}

The messages come from the cache the arrival warm-up already fills, so
answering usually costs no Gmail call. Only the requested properties
travel: bodies, attachments and raw headers are refused at registration
rather than truncated at delivery, and so is any filter condition that
cannot be decided from where a message was filed and how it is flagged -
accepting one and ignoring it would push somebody's junk to their phone.

A subscription with a config for the account no longer gets the
EmailDelivery ping for the same arrival: it would only send the client
back to guessing, and have it announce a second, different message. When
the delivery does not pass the filter, nothing is sent at all - which is
what makes Gmail's spam verdict able to keep a device quiet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gmail reports a label's message and thread counts only through labels.get,
one label at a time. A full Mailbox/get needs them all, and on a Workspace
mailbox with 124 labels that was 11 round trips at 12 in flight - about
3.5 s, paid by the first client to open the account after the cache
lapsed, and by the unified inbox waiting on it.

Send them through the batch endpoint, 100 to a request: 2 round trips. A
part that fails on its own is read again singly; a batch Google throttled
is not, since one read per label would spend the same exhausted budget.

The cache key already carries the account state, which moves with every
history record, so counters are never older than the last change Gmail
reported. Keep an idle account's entry for ten minutes instead of one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…assword

Registered clients (GMAIL_OAUTH_CLIENTS) can use the bridge as an OAuth
2.0 authorization server: authorization code with PKCE S256, metadata
at /.well-known/oauth-authorization-server, token and revocation
endpoints. The user picks a Google account. One whose Gmail grant is
already stored signs in without a consent screen; any other goes
through consent once and is connected as by Connect Gmail. The client
receives bridge tokens (one-hour access token, Bearer only, and a
refresh token that lasts while it is used). Only their hashes are
stored, every client has its own, and disconnecting an account deletes
them. Google tokens never leave the bridge.
@lucamzanon

Copy link
Copy Markdown
Author

Added 4abd15b: the bridge can now sign users in to a mail client with Google, so nobody has to copy a bridge password.

  • Opt-in: clients are registered in GMAIL_OAUTH_CLIENTS (id plus redirect URIs, * = one path segment). Without it nothing changes.
  • The bridge acts as an OAuth 2.0 authorization server for those clients: authorization code with PKCE S256, metadata at /.well-known/oauth-authorization-server, plus /oauth/token and /oauth/revoke.
  • Google is asked with prompt=select_account. An account whose grant is already stored signs in without a consent screen. Any other account goes through consent once and is connected the same way as Connect Gmail.
  • The client gets bridge tokens: a gmat_ access token (one hour, Bearer only) and a gmrt_ refresh token (lasts while it is used). Only their hashes are stored. They are independent of the bridge password, and disconnecting an account deletes them. Google tokens never leave the bridge.

On the webmail side, the server entry's per-server OAuth client is all that is needed. A small companion PR there makes the button show up without enabling OAuth globally.

Clients count every tag with two Email/query calls, total and unread,
often for tags another account defines. Each one became a Gmail search,
so a client with a few dozen tags sent dozens of searches at once; with
several Gmail accounts Google throttled them, and anything else queued
behind them - a send included - waited for half a minute.

A tag is a Gmail label, so {hasKeyword: "$label:x"}, alone or with
{notKeyword: "$seen"}, is now answered as the same query on the
label's mailbox: exact counters and a listing by label id, no search. A
tag the account has no label for matches nothing and costs no call.
The label path asked Gmail for a page of 500 ids whatever the query's
limit. A tag or folder count asks for one id, and listing 500 of a large
label to return it took seconds - 40 counts took 46 s on a cold cache.
The page size now follows the position and limit asked for (up to 500).
@lucamzanon

Copy link
Copy Markdown
Author

@rathlinus ready for review now. The heavy queries I mentioned on 21 Sep are fixed: label counters come through Gmail's batch endpoint, folder and tag counts are answered from label counters instead of searches, and listings ask Gmail for only as many ids as the query returns. Since then it has also gained a small OAuth authorisation server, so a client can offer "Sign in with Google" without a bridge password. The description is up to date. Everything is behind environment variables, so an existing deployment is unchanged until they are set.

@lucamzanon

Copy link
Copy Markdown
Author

the tag part of webmail #1133 is now a Bulwark plugin instead of core, since only this backend needs it: it adopts Gmail labels as tags (name and nearest colour) and can show the inbox categories as tabs. one commit on top of this branch: lucamzanon/legacy-proxy@feat/gmail-backend...feat/webmail-plugin

want it added here, or as its own PR once this one is in?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants