Skip to content

Realtime push (IMAP IDLE → SSE) + delta-sync latency fixes#8

Open
Aravinda-HWK wants to merge 2 commits into
LSFLK:mainfrom
Aravinda-HWK:feat/realtime-sse-push
Open

Realtime push (IMAP IDLE → SSE) + delta-sync latency fixes#8
Aravinda-HWK wants to merge 2 commits into
LSFLK:mainfrom
Aravinda-HWK:feat/realtime-sse-push

Conversation

@Aravinda-HWK

Copy link
Copy Markdown
Collaborator

Implements Phase 4 (Realtime) of the proposal: new mail appears in the client within ~1–2s, no reload.

How it works

Gateway holds an IMAP IDLE connection per watched mailbox → on activity it pushes a tiny changed event over SSE → the client runs a delta sync. The push carries only the mailbox name; the delta is pulled over the existing REST API, keeping the event lightweight.

Server

  • imap.Client.Watch — runs IMAP IDLE on a dedicated connection (never the shared request connection), forwarding mailbox/message/expunge updates via a callback. go-imap handles the IDLE capability check, periodic restart, and polling fallback.
  • realtime.Hub — per-session fan-out to SSE subscribers with ref-counted watchers (N tabs on one folder share one IMAP connection; torn down at zero subscribers) and capped-backoff reconnect. Unit-tested with the race detector.
  • GET /api/v1/events — SSE stream. Clears the per-connection write deadline so it isn't killed by the server WriteTimeout; 25s heartbeat that also keeps the session warm.
  • AuthRequireSession also accepts the JWT as an access_token query param, because the browser EventSource API can't set an Authorization header. (Access logs record only the path, not the query.)

Client

  • One EventSource watching the inbox; on each push it runs a debounced delta sync for the affected folder.
  • A green "Live" indicator in the toolbar shows the stream is connected.

Latency & correctness fixes (found during end-to-end testing on Gmail)

  • Read-only/read-write SELECT bug: selectMailbox reused a cached read-only SELECT for a write, causing a STORE on READ-ONLY folder 502 retry-storm on mark-as-read. Now tracks access mode and re-SELECTs when needed.
  • Cold-connection reconnect (~1.5s): keep the session IMAP connection warm via a keepalive NOOP on each sweep, and skip the per-request liveness NOOP within a freshness window — moving the reconnect cost off the user-facing sync path.
  • One fewer round trip: delta sync fetches the newest messages by sequence number in a single FETCH instead of SEARCH-then-FETCH.

Measured: warm-path delta sync on Gmail dropped from ~3.5s → ~1.5s.

Testing

  • go build, go vet, go test ./... (incl. race-tested realtime hub), and frontend tsc --noEmit all pass.
  • Verified end-to-end against a live Gmail account: IDLE confirmed active (not polling), new mail pushes through, mark-as-read works without the 502 storm.

Notes

  • Watches the INBOX only (one IDLE connection per session) to bound resources; the mailbox query param is CSV-capable for future extension.
  • No new dependencies — SSE is stdlib net/http + browser EventSource; IDLE uses the existing emersion/go-imap.
  • Going below ~1.5s is structural to Gmail's per-round-trip latency; the proposal's server-side warm cache (Redis/Postgres) is the next lever.

Implements Phase 3 of the Email 2.0 proposal: refresh now fetches only
what changed instead of re-listing the whole folder.

Because go-imap v1 exposes no CONDSTORE/MODSEQ, the delta is computed from
UIDs + flags rather than a MODSEQ token:

Server
- New GET /api/v1/mailboxes/{mailbox}/changes endpoint returning a
  MailboxDelta {uidvalidity, total, resync, added, flags, removed}.
- imap.MailboxChanges: UID SEARCH (since+1):* for new envelopes, a
  flags-only UID FETCH over the client's known UIDs to derive flag
  changes + removals; UIDVALIDITY mismatch signals a full resync.
- ListMessages now also returns uidvalidity so the client has a baseline.
- Factored shared fetchEnvelopes/fetchFlags helpers.

Client
- Dexie v2 syncMeta table persists uidvalidity per folder.
- mailboxes.changes() endpoint + MailboxDelta/FlagUpdate types.
- DataContext reconciles added/removed/flag changes idempotently (keyed
  on UID) and exposes refreshFolder(role); a ThreadList refresh button
  wires it into Inbox/Sent/Trash.

Performance
- refreshFolder syncs only the viewed folder, so four folder syncs no
  longer serialize on the session's single IMAP connection.
- IMAPFor drops a redundant per-request NOOP probe (one RTT) since every
  operation already self-heals via ensureLive.

Tests: parseUIDList unit tests; server build + go test and frontend
typecheck pass.
Phase 4 of the proposal: new mail appears without a manual refresh.

Realtime pipeline:
- imap.Client.Watch: run IMAP IDLE on a dedicated connection, forwarding
  mailbox/message/expunge updates via a callback (go-imap handles the
  capability check, periodic restart, and polling fallback).
- realtime.Hub: per-session fan-out to SSE subscribers with ref-counted
  IDLE watchers (N tabs on one folder share one connection) and capped
  backoff reconnect.
- GET /api/v1/events SSE handler: clears the write deadline so the stream
  isn't killed by WriteTimeout; 25s heartbeat that also keeps the session warm.
- RequireSession also accepts the JWT as an access_token query param, since
  the browser EventSource API cannot set an Authorization header.
- Client opens one EventSource watching the inbox and runs a delta sync on
  each push; a green "Live" indicator shows the stream is connected.

Latency / correctness fixes surfaced during end-to-end testing on Gmail:
- selectMailbox now tracks read-only vs read-write mode and re-SELECTs when a
  write follows a read-only SELECT, fixing a "STORE on READ-ONLY folder" 502
  storm on mark-as-read.
- Keep the session's IMAP connection warm (keepalive NOOP on each sweep) and
  skip the per-request liveness NOOP within a freshness window, moving the
  ~1.5s reconnect cost off the user-facing sync path.
- Delta sync fetches the newest messages by sequence number in one round trip
  instead of SEARCH-then-FETCH, removing a round trip.

Measured warm-path delta sync on Gmail dropped from ~3.5s to ~1.5s.
@Aravinda-HWK Aravinda-HWK requested a review from maneeshaxyz June 22, 2026 07:58
@Aravinda-HWK Aravinda-HWK self-assigned this Jun 22, 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