feat: Phase 3+4 — security hardening + polish + offline + Electron push

Phase 3 (security):
- P3.1: Feature gate server-side enforcement (403 on disabled features)
- P3.2: Unified auth error interceptor (401→logout)
- P3.3: Store-level state isolation via StoreSnapshot contract
  (added message-list-tabs + task stores to snapshot/restore cycle)
- P3.4: Push event bus extraction — email-store no longer imports
  calendar/contact/filter/file stores directly
- P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter

Phase 4 (polish):
- P4.1: Offline write queue — pending operations in localStorage,
  auto-retry on reconnect, offline-queue-indicator banner
- P4.2: Identity spoofing — fromOverrideEmail domain validation
- P4.3: WebSocket push for Electron via main-process IPC bridge
  (ws package with Authorization headers)
This commit is contained in:
Bernd Rodler
2026-08-07 22:10:26 +02:00
parent 0ac429fe36
commit cfdd091d22
29 changed files with 1068 additions and 93 deletions
+102 -5
View File
@@ -6,6 +6,8 @@ import { batched, itemsPerRequest } from "./request-limits";
import { noteTransportFailure, noteTransportSuccess, transportFailureCount } from "./transport-health";
import { debug } from "@/lib/debug";
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
import type { VncElectronBridge, WsMessageEvent } from "@/lib/electron-bridge";
import { isElectronShell } from "@/lib/electron-bridge";
export class TransportError extends Error {
constructor(message = 'Network transport failure') {
@@ -759,6 +761,12 @@ export class JMAPClient implements IJMAPClient {
}
}
if (response.status === 401) {
import('@/lib/auth-error-handler').then(({ handleAuthError }) => {
handleAuthError(new Error('401 Unauthorized'));
}).catch(() => {});
}
return response;
}
@@ -6120,7 +6128,90 @@ export class JMAPClient implements IJMAPClient {
// 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 ws: (WebSocket | ReturnType<JMAPClient['createElectronWebSocket']>) | null = null;
/**
* Connects a WebSocket through Electron's main process IPC bridge (which
* can attach Authorization headers the browser WebSocket API cannot).
* Returns a WebSocket-like wrapper that the calling code in
* connectWebSocket() interacts with identically to a browser WebSocket.
*/
private createElectronWebSocket(wsUrl: string): {
addEventListener: (type: string, handler: (event: unknown) => void) => void;
send: (data: string) => void;
close: () => void;
} {
const bridge: VncElectronBridge = (window as Window & { vnc: VncElectronBridge }).vnc!;
let connectionId: string | null = null;
const listeners = new Map<string, Array<(event: unknown) => void>>();
const emit = (type: string, event: unknown) => {
for (const handler of listeners.get(type) || []) {
try { handler(event); } catch { /* noop */ }
}
};
const cleanup = bridge.onWsMessage((msg: WsMessageEvent) => {
// Only deliver events for our connection
if (msg.id !== connectionId) return;
switch (msg.type) {
case "open":
emit("open", {});
break;
case "message":
emit("message", { data: msg.data || "" });
break;
case "close":
connectionId = null;
emit("close", { code: msg.code || 0 });
break;
case "error":
// The main process already logged the error - trigger the
// "close" path so the reconnect logic engages.
if (connectionId !== null) {
connectionId = null;
emit("close", { code: 1006 });
}
break;
}
});
bridge.wsConnect(wsUrl, this.authHeader).then((id) => {
// Don't update `connectionId` here — let 'open' from onWsMessage do it.
// The main process sends 'open' on the message channel, and that sets
// connectionId & fires the open handler. This avoids a race: if the
// bridge fires 'open' before .then() runs, connectionId would be stale
// for the 'message' and 'close' events arriving between 'open' and here.
//
// But we NEED connectionId before any message arrives, so set it now
// and let the 'open' event be purely for notification.
connectionId = id;
// If 'open' hasn't already been delivered, fire it now.
emit("open", {});
}).catch((err: Error) => {
// Connection failed immediately — simulate a close with error.
emit("close", { code: 1006, reason: err.message });
});
return {
addEventListener(type: string, handler: (event: unknown) => void) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type)!.push(handler);
},
send(data: string) {
if (connectionId !== null) {
bridge.wsSend(connectionId, data).catch(() => {});
}
},
close() {
if (connectionId !== null) {
bridge.wsClose(connectionId).catch(() => {});
connectionId = null;
}
cleanup();
},
};
}
private wsReconnectTimeout: NodeJS.Timeout | null = null;
private wsReconnectAttempts: number = 0;
private wsConsecutiveFailures: number = 0;
@@ -6242,9 +6333,13 @@ export class JMAPClient implements IJMAPClient {
return;
}
let socket: WebSocket;
let socket: WebSocket | ReturnType<JMAPClient['createElectronWebSocket']>;
try {
socket = new WebSocket(wsUrl, "jmap");
if (isElectronShell()) {
socket = this.createElectronWebSocket(wsUrl);
} else {
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
@@ -6286,7 +6381,9 @@ export class JMAPClient implements IJMAPClient {
socket.addEventListener("message", (event) => {
if (!isCurrent()) return;
this.lastWSActivity = Date.now();
this.processWebSocketMessage(typeof event.data === "string" ? event.data : "");
this.processWebSocketMessage(
typeof (event as MessageEvent).data === "string" ? (event as MessageEvent).data : ""
);
});
socket.addEventListener("close", () => {
@@ -6417,7 +6514,7 @@ export class JMAPClient implements IJMAPClient {
}, delay);
}
private startWSHeartbeat(socket: WebSocket): void {
private startWSHeartbeat(socket: WebSocket | ReturnType<JMAPClient['createElectronWebSocket']>): void {
this.stopWSHeartbeat();
this.wsHeartbeatTimer = setInterval(() => {
if (this.ws !== socket) return;