diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 5f03ea1d..00bfd246 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -32,6 +32,7 @@ import { usePromptDialog } from "@/hooks/use-prompt-dialog"; import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation"; import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; +import { isElectronShell, showElectronNotification } from "@/lib/electron-bridge"; import { cn } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; @@ -1186,13 +1187,34 @@ export default function Home() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEmail?.id, isScheduledView]); - // Handle new email notifications - play sound + // Handle new email notifications - play sound, and (in the Electron shell) + // fire a native OS notification. This effect is the transport-agnostic + // "genuinely new unread mail arrived" signal - stores/email-store.ts's + // refreshCurrentMailbox() already filters out sends/moves/drafts and only + // sets newEmailNotification for a real new top-of-inbox message, and it + // fires identically whether the underlying JMAP StateChange arrived over + // the WebSocket push connection (lib/jmap/client.ts's connectWebSocket), + // SSE, or the polling fallback - no need to duplicate this per transport. useEffect(() => { if (newEmailNotification) { const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState(); if (emailNotificationsEnabled && emailNotificationSound) { playNotificationSound(notificationSoundChoice); } + if (emailNotificationsEnabled && isElectronShell()) { + // Same fallback text public/sw.js's push handler already uses for + // its (also un-translated) system notifications - a native OS + // notification body isn't run through next-intl either way, so + // matching that existing precedent instead of introducing new + // translation keys for a rarely-hit fallback. + const sender = newEmailNotification.from?.[0]; + const senderName = sender?.name || sender?.email || 'New mail'; + const body = newEmailNotification.subject || newEmailNotification.preview || '(no subject)'; + void showElectronNotification(senderName, { + body, + tag: `bulwark-mail:${newEmailNotification.id}`, + }); + } debug.log('email', 'New email received:', newEmailNotification.subject); clearNewEmailNotification(); } diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index e7b42fc5..95fab642 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -75,6 +75,7 @@ export class DemoJMAPClient implements IJMAPClient { getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; } hasDelayedSend(): boolean { return true; } getEventSourceUrl(): string | null { return null; } + getWebSocketUrl(): string | null { return null; } supportsEmailSubmission(): boolean { return true; } supportsQuota(): boolean { return true; } supportsVacationResponse(): boolean { return true; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 2693f3d8..20750d24 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -35,6 +35,7 @@ export interface IJMAPClient { getMaxDelayedSend(accountId?: string): number; hasDelayedSend(accountId?: string): boolean; getEventSourceUrl(): string | null; + getWebSocketUrl(): string | null; supportsEmailSubmission(): boolean; supportsQuota(): boolean; supportsVacationResponse(): boolean; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 63e368fa..79a8b3f3 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -953,6 +953,36 @@ export class JMAPClient implements IJMAPClient { if (session.eventSourceUrl) { session.eventSourceUrl = this.rewriteSessionUrl(session.eventSourceUrl); } + const wsCapability = session.capabilities?.["urn:ietf:params:jmap:websocket"] as + | { url?: string } + | undefined; + if (wsCapability?.url) { + wsCapability.url = this.rewriteWebSocketUrl(wsCapability.url); + } + } + + /** + * Same reasoning as rewriteSessionUrl (a reverse proxy may advertise its + * own internal hostname), but scheme-aware: unlike apiUrl/eventSourceUrl, + * this URL is never touched by fetch() - it goes straight into `new + * WebSocket(...)`, and a ws/wss URL can never share an origin string with + * an http/https serverUrl even when the host is identical, so reusing + * rewriteSessionUrl's plain origin-equality check would rewrite EVERY + * websocket URL onto an http(s) scheme and break the constructor outright. + */ + private rewriteWebSocketUrl(url: string): string { + try { + const parsed = new URL(url); + const server = new URL(this.serverUrl); + const expectedScheme = server.protocol === "https:" ? "wss:" : "ws:"; + if (parsed.host === server.host && parsed.protocol === expectedScheme) { + return url; + } + const pathAndRest = url.slice(url.indexOf("/", url.indexOf("//") + 2)); + return `${expectedScheme}//${server.host}${pathAndRest}`; + } catch { + return url; + } } private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise { @@ -3761,6 +3791,22 @@ export class JMAPClient implements IJMAPClient { return this.session.eventSourceUrl || coreCapability?.eventSourceUrl || null; } + /** + * RFC 8887 (JMAP over WebSocket) push endpoint, advertised under the + * `urn:ietf:params:jmap:websocket` capability (not a root session field + * like eventSourceUrl - it's nested the same way every other JMAP + * extension capability is). Rewritten to the client's own server host in + * rewriteSessionUrls() at connect time, same reasoning as apiUrl/ + * downloadUrl/eventSourceUrl. Returns null for servers that don't + * advertise it - callers fall back to SSE/polling. + */ + getWebSocketUrl(): string | null { + const wsCapability = this.capabilities["urn:ietf:params:jmap:websocket"] as + | { url?: string; supportsPush?: boolean } + | undefined; + return wsCapability?.url || null; + } + getAccountId(): string { return this.accountId; } @@ -5982,6 +6028,43 @@ export class JMAPClient implements IJMAPClient { private visibilityHandler: (() => void) | null = null; private onlineHandler: (() => void) | null = null; + // JMAP-over-WebSocket (RFC 8887) push - preferred over SSE when the server + // advertises it (getWebSocketUrl()), since it's the transport the desktop + // shell's main process eventually wants for background/no-window + // notifications (see electron/preload.ts's showNotification bridge). + // Falls back to the existing SSE/polling chain below when unsupported OR + // when the handshake itself keeps failing (see wsPermanentlyDisabled). + // + // KNOWN LIMITATION, confirmed empirically against the sandbox server this + // was built against (stalwart.sandbox.vnc.de): its /jmap/ws endpoint + // requires the same HTTP Basic/Bearer Authorization header as every other + // JMAP endpoint on the WebSocket UPGRADE request itself (curling it with + // no Authorization header returns a plain 401 before any WS frame is + // possible). The browser WebSocket constructor has no way to attach + // custom headers to that handshake (a WHATWG spec restriction, not an + // Electron/browser quirk - credentials in the URL are actively rejected + // too), so from this renderer-side client there is no way to satisfy that + // auth requirement. Against a server with this exact auth model, every + // connection attempt below will fail at the handshake and the circuit + // breaker (wsPermanentlyDisabled) will fall back to SSE after a few quick + // retries - which is not a bug in this code, it is what actually happens + // on the wire. It's still implemented for real (not stubbed) because (a) + // it's fully spec-correct and will light up automatically against any + // server whose WS endpoint doesn't have this requirement - e.g. one + // sitting behind a proxy that authenticates via cookies instead - with no + // further changes, and (b) the alternative (opening it from Electron's + // main process via a header-capable client like the `ws` package) would + // mean piping raw credentials from the renderer to the main process over + // IPC, which is a materially bigger security-sensitive change than what + // was scoped here. + private ws: WebSocket | null = null; + private wsReconnectTimeout: NodeJS.Timeout | null = null; + private wsReconnectAttempts: number = 0; + private wsConsecutiveFailures: number = 0; + private wsPermanentlyDisabled: boolean = false; + private wsHeartbeatTimer: NodeJS.Timeout | null = null; + private lastWSActivity: number = 0; + private static readonly STATE_TYPE_MAP: Record = { 'Mailbox/get': 'Mailbox', 'Email/get': 'Email', @@ -5998,20 +6081,262 @@ export class JMAPClient implements IJMAPClient { private static readonly SSE_RECONNECT_DELAY = 3_000; private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval + // Exponential backoff with full jitter (0..cap), doubling from a 1s base + // and capping at 30s - unlike SSE's fixed 3s retry, a long-lived WebSocket + // genuinely needs backoff: it can be closed by a server-side idle timeout, + // a proxy, or a laptop sleep/wake cycle repeatedly in a row, and hammering + // a reconnect every 3s in that situation is exactly the kind of thing that + // gets a client rate-limited (see isRateLimited()/setRateLimited() above). + private static readonly WS_RECONNECT_BASE_DELAY = 1_000; + private static readonly WS_RECONNECT_MAX_DELAY = 30_000; + // App-level heartbeat: a WebSocket can sit in "open" readyState for a long + // time after the underlying network path is actually gone (sleep, network + // switch, a NAT/proxy that silently drops idle connections) - TCP alone + // won't always surface that promptly. Send a lightweight JMAP request + // every 30s and force-reconnect if nothing (heartbeat response OR a real + // push) has arrived within 3x that window, mirroring the SSE ping monitor + // above. + private static readonly WS_HEARTBEAT_INTERVAL = 30_000; + private static readonly WS_ACTIVITY_TIMEOUT = 90_000; + // Give up on WS for this client instance after this many CONSECUTIVE + // attempts that never reach "open" (a connection that opened fine and + // later dropped does not count - see connectWebSocket's openedSuccessfully + // tracking). Bounds the cost of the auth limitation described above to a + // handful of quick handshake attempts (worst case a bit over 30s of + // jittered backoff) instead of retrying a request that can never succeed, + // forever, every ~30s, for the lifetime of the session. + private static readonly WS_MAX_CONSECUTIVE_FAILURES = 5; + + /** getWebSocketUrl(), gated by the circuit breaker above. */ + private effectiveWebSocketUrl(): string | null { + return this.wsPermanentlyDisabled ? null : this.getWebSocketUrl(); + } + setupPushNotifications(): boolean { - const eventSourceUrl = this.getEventSourceUrl(); - if (eventSourceUrl) { - this.connectSSE(eventSourceUrl); - // SSE covers the primary account only; keep shared accounts fresh too. + const wsUrl = this.effectiveWebSocketUrl(); + if (wsUrl) { + this.wsReconnectAttempts = 0; + this.connectWebSocket(wsUrl); + // Not confirmed either way whether this server's WebSocket push fans + // out to shared/secondary accounts or, like Stalwart's SSE, covers the + // primary account only - keep the same secondary poll running under + // WS that SSE already needed, rather than assume broader coverage and + // risk shared-account counters going stale. this.startSecondaryAccountPoll(); } else { - // The fallback poll already covers every session account. - this.startPollingFallback(); + const eventSourceUrl = this.getEventSourceUrl(); + if (eventSourceUrl) { + this.connectSSE(eventSourceUrl); + // SSE covers the primary account only; keep shared accounts fresh too. + this.startSecondaryAccountPoll(); + } else { + // The fallback poll already covers every session account. + this.startPollingFallback(); + } } this.setupBrowserEventListeners(); return true; } + /** + * Opens the RFC 8887 JMAP-over-WebSocket connection and subscribes to + * push for every data type (`WebSocketPushEnable` with dataTypes: null). + * Reconnect on close/error is handled by scheduleWSReconnect() below with + * exponential backoff - this method only ever represents a single + * connection attempt. + */ + private connectWebSocket(wsUrl: string): void { + if (this.isRateLimited()) { + this.scheduleWSReconnect(); + return; + } + + let socket: WebSocket; + try { + socket = new WebSocket(wsUrl, "jmap"); + } catch { + // New URL()-level failures (malformed URL) - retry later in case a + // session refresh fixes it; getWebSocketUrl() re-reads capabilities + // fresh on every attempt. + this.scheduleWSReconnect(); + return; + } + + this.ws = socket; + const isCurrent = () => this.ws === socket; + // Tracks whether THIS specific attempt ever reached "open" - a socket + // that opened fine and dropped later (real network blip on an + // established connection) must not count toward the circuit breaker the + // same way a handshake that never completes does (see + // wsPermanentlyDisabled's declaration above for why the latter needs + // one at all). + let openedSuccessfully = false; + + socket.addEventListener("open", () => { + if (!isCurrent()) return; + openedSuccessfully = true; + // A real connection succeeded - both counters reset: the backoff + // ladder no longer applies to whatever eventually causes the NEXT + // disconnect, and the "give up on WS entirely" counter only tracks + // CONSECUTIVE handshake failures. + this.wsReconnectAttempts = 0; + this.wsConsecutiveFailures = 0; + this.lastWSActivity = Date.now(); + this.startWSHeartbeat(socket); + try { + socket.send(JSON.stringify({ "@type": "WebSocketPushEnable", dataTypes: null })); + } catch { + // send() can throw if the socket already closed between "open" + // firing and this line running - the "close" handler below will + // schedule a reconnect regardless. + } + }); + + socket.addEventListener("message", (event) => { + if (!isCurrent()) return; + this.lastWSActivity = Date.now(); + this.processWebSocketMessage(typeof event.data === "string" ? event.data : ""); + }); + + socket.addEventListener("close", () => { + if (!isCurrent()) return; + this.stopWSHeartbeat(); + this.ws = null; + if (this.intentionallyDisconnected) return; + + if (!openedSuccessfully) { + this.wsConsecutiveFailures += 1; + if (this.wsConsecutiveFailures >= JMAPClient.WS_MAX_CONSECUTIVE_FAILURES) { + // The handshake itself is what's failing, repeatedly - most + // commonly (confirmed against this client's own reference + // server) because the WS endpoint requires an Authorization + // header the browser WebSocket API cannot attach. Retrying that + // forever would just hammer the server every ~30s with a request + // that can never succeed from here. Give up on WS for the rest of + // this client instance's life and stay on SSE/polling, which + // don't have this limitation. + this.wsPermanentlyDisabled = true; + console.warn( + '[JMAP] WebSocket push failed to establish after repeated attempts; falling back to SSE/polling for this session.', + ); + this.fallbackFromWebSocket(); + return; + } + } + + this.scheduleWSReconnect(); + }); + + // WebSocket always fires "close" right after "error" - the reconnect + // logic lives entirely in the "close" handler above so there is exactly + // one path that schedules a retry, not two racing each other. + } + + /** Whatever push transport SSE would have used, now that WS has given up. */ + private fallbackFromWebSocket(): void { + const eventSourceUrl = this.getEventSourceUrl(); + if (eventSourceUrl) { + this.connectSSE(eventSourceUrl); + this.startSecondaryAccountPoll(); + } else { + this.startPollingFallback(); + } + } + + /** + * Parses one WebSocket text frame. Per RFC 8887 the server can send + * Response, StateChange, or PushState frames; only StateChange is + * consumed today (method calls aren't yet routed over this socket - + * request()/authenticatedFetch() still uses plain HTTP), so anything else + * is silently ignored rather than treated as an error. + */ + private processWebSocketMessage(raw: string): void { + if (!raw) return; + let message: { "@type"?: string; changed?: StateChange["changed"] } | null = null; + try { + message = JSON.parse(raw); + } catch { + return; // malformed frame - ignore, matches processSSEEvent's handling + } + if (message?.["@type"] === "StateChange" && message.changed) { + this.stateChangeCallback?.({ "@type": "StateChange", changed: message.changed }); + } + } + + private scheduleWSReconnect(): void { + if (this.intentionallyDisconnected) return; + if (this.wsReconnectTimeout) return; // already scheduled - don't stack retries + + const wsUrl = this.effectiveWebSocketUrl(); + if (!wsUrl) { + // Either the server capability disappeared (e.g. a session refresh + // dropped WebSocket support) or the circuit breaker already tripped - + // fall back to whatever push transport is still available instead of + // retrying a URL that's gone or a handshake that won't succeed. + this.fallbackFromWebSocket(); + return; + } + + const attempt = this.wsReconnectAttempts; + this.wsReconnectAttempts += 1; + const exponential = JMAPClient.WS_RECONNECT_BASE_DELAY * Math.pow(2, attempt); + const cap = Math.min(exponential, JMAPClient.WS_RECONNECT_MAX_DELAY); + // Full jitter (uniform 0..cap) rather than a fixed exponential delay - + // spreads reconnect attempts out after a shared network blip (proxy + // restart, wifi handoff affecting every open tab/window at once) + // instead of having them all retry in lockstep. + const delay = Math.random() * cap; + + this.wsReconnectTimeout = setTimeout(() => { + this.wsReconnectTimeout = null; + if (this.isRateLimited()) { + this.scheduleWSReconnect(); + return; + } + this.connectWebSocket(wsUrl); + }, delay); + } + + private startWSHeartbeat(socket: WebSocket): void { + this.stopWSHeartbeat(); + this.wsHeartbeatTimer = setInterval(() => { + if (this.ws !== socket) return; + if (Date.now() - this.lastWSActivity > JMAPClient.WS_ACTIVITY_TIMEOUT) { + // Silently dead connection (sleep/network switch/idle proxy) - the + // socket can still report readyState OPEN long after the underlying + // path is gone. Force-close; the "close" handler schedules the + // reconnect via the normal backoff path. + this.stopWSHeartbeat(); + try { + socket.close(); + } catch { + // Already closing/closed - the "close" handler (if it hasn't + // already run) will still fire and take care of reconnecting. + } + return; + } + try { + socket.send(JSON.stringify({ + "@type": "Request", + requestId: `ws-heartbeat-${Date.now()}`, + using: ["urn:ietf:params:jmap:core"], + methodCalls: [["Core/echo", {}, "0"]], + })); + } catch { + // send() failing means the socket is already dead - the activity + // timeout above will catch it on the next tick if "close" doesn't + // fire first. + } + }, JMAPClient.WS_HEARTBEAT_INTERVAL); + } + + private stopWSHeartbeat(): void { + if (this.wsHeartbeatTimer) { + clearInterval(this.wsHeartbeatTimer); + this.wsHeartbeatTimer = null; + } + } + /** * Slow poll of the session's shared/secondary accounts, run in parallel with * SSE (which never reports them). Skipped when there are no shared accounts, @@ -6310,6 +6635,27 @@ export class JMAPClient implements IJMAPClient { this.eventSource = null; } this.stopSSEPingMonitor(); + if (this.wsReconnectTimeout) { + clearTimeout(this.wsReconnectTimeout); + this.wsReconnectTimeout = null; + } + this.stopWSHeartbeat(); + if (this.ws) { + // Null out this.ws BEFORE close() so the "close" event handler's + // isCurrent() check (this.ws === socket) sees a mismatch once the + // event fires and skips scheduling a reconnect - this is an + // intentional teardown, not a dropped connection. + const socket = this.ws; + this.ws = null; + try { + socket.close(); + } catch { + // Already closing/closed. + } + } + this.wsReconnectAttempts = 0; + this.wsConsecutiveFailures = 0; + this.wsPermanentlyDisabled = false; this.cleanupBrowserEventListeners(); this.stateChangeCallback = null; this.pollingStates = {}; @@ -6350,7 +6696,22 @@ export class JMAPClient implements IJMAPClient { if (typeof window !== 'undefined') { this.onlineHandler = () => { - // Network reconnected - reconnect SSE or force a poll + // Network reconnected - reconnect WS/SSE or force a poll. Don't + // make the user wait through whatever backoff delay was already in + // flight from repeated failures while offline - the network is + // confirmed back, so retry immediately. + const wsUrl = this.effectiveWebSocketUrl(); + if (wsUrl) { + if (!this.ws) { + if (this.wsReconnectTimeout) { + clearTimeout(this.wsReconnectTimeout); + this.wsReconnectTimeout = null; + } + this.wsReconnectAttempts = 0; + this.connectWebSocket(wsUrl); + } + return; + } const eventSourceUrl = this.getEventSourceUrl(); if (eventSourceUrl && !this.sseAbortController) { this.connectSSE(eventSourceUrl);