Skip to content

feat: add Gmail push via Cloud Pub/Sub with EventSource state changes - #13

Closed
lucamzanon wants to merge 15 commits into
bulwarkmail:mainfrom
lucamzanon:feat/gmail-push
Closed

lucamzanon wants to merge 15 commits into
bulwarkmail:mainfrom
lucamzanon:feat/gmail-push

Conversation

@lucamzanon

Copy link
Copy Markdown

Stacked on #12 (feat/gmail-delayed-send); only the last commit is new.

What

Behind GMAIL_PUSH_TOPIC + GMAIL_PUSH_TOKEN, the Gmail backend replaces polling with Gmail push via Cloud Pub/Sub and gives Gmail accounts a JMAP EventSource:

  • Watch renewal: users.watch is issued for every connected account at startup and re-issued every 24 h (Google expires watches after 7 days); failures are counted in SQLite and retried hourly. watch/stop are the only new entries in the Gmail write allowlist.
  • Endpoint POST /gmail/push?token=… for a Pub/Sub push subscription: constant-time token check (401 otherwise), the {emailAddress, historyId} hint is persisted before the 204 acknowledgement, garbage/unknown accounts are acknowledged without logging anything, bursts are coalesced per account (1.5 s) into one run of the existing incremental engine. Duplicates and out-of-order deliveries are harmless because the engine always starts from the persisted cursor.
  • EventSource: with push configured the session advertises eventSourceUrl and GET /jmap/eventsource streams RFC 8620 StateChange events (Email/Thread/Mailbox) reusing the legacy hub's wire format, emitted only when a sync actually changed state. Accounts with open streams are also re-synced every 5 min as a safety net for delayed/lost notifications (Google documents both).
  • /healthz reports watches, expiring-soon, renewal failures, notification counts, rejected/ignored requests, sync failures and open streams. No addresses or message contents in logs or counters.

Setup (documented in the README): enable Pub/Sub, create the topic, grant roles/pubsub.publisher to gmail-api-push@system.gserviceaccount.com, create a push subscription to https://<PUBLIC_URL>/gmail/push?token=<secret>.

Without the variables, behaviour is unchanged (no watch, no endpoint, no eventSourceUrl).

Tests

  • New test/unit/gmail-push.spec.ts (4 tests): token/garbage/unknown handling and persistence before ack; burst coalescing into one history read with a StateChange delivered to open streams only when state moved and not after the stream closed; daily watch renewal with failure counting and hourly retry; session/endpoint gating on configuration.
  • Gmail suite 105/105. Whole unit suite: 246 pass, 3 pre-existing failures unrelated to Gmail (refs.spec.ts, search.spec.ts, same on upstream).

🤖 Generated with Claude Code

lucamzanon and others added 15 commits September 11, 2026 08:34
Behind GMAIL_ALIASES_ENABLED. Reads users.settings.sendAs (covered by the
existing gmail.modify grant), offers the primary address plus accepted
aliases as read-only identities with stable ids, and lets drafts, MIME
imports and submissions use any of them. The identity is re-validated
against Gmail right before drafts.send; a removed alias yields
forbiddenFrom and keeps the draft. Signatures stay client-side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Behind GMAIL_SCHEDULE_ENABLED. HOLDFOR/HOLDUNTIL submissions are stored in
SQLite instead of being sent; 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 entries that come due while
the bridge is down beyond the tolerance are suspended, never sent.
undoStatus=canceled cancels pending entries; EmailSubmission/query lists
them; interrupted sends are reconciled on restart; /healthz reports
per-status counters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Behind GMAIL_PUSH_TOPIC/GMAIL_PUSH_TOKEN. The bridge issues users.watch
for connected accounts and renews it daily; POST /gmail/push validates
the shared token in constant time, persists the hint before the 204 ack,
coalesces bursts per account and runs the existing incremental engine.
With push configured the session advertises eventSourceUrl and
/jmap/eventsource streams StateChange events after a sync changed
state; streams are re-synced every 5 min as a safety net. /healthz gains
push counters.

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

Copy link
Copy Markdown
Author

Superseded by #14, which consolidates this stack into one reviewable branch.

@lucamzanon lucamzanon closed this Sep 14, 2026
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.

1 participant