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
+33 -12
View File
@@ -11,18 +11,26 @@ import { useFilterStore } from '@/stores/filter-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
import { useTaskStore } from '@/stores/task-store';
export interface StoreSnapshot<S> {
snapshot: () => Partial<S>;
clear: () => Partial<S>;
}
// Minimal snapshot shapes - we only capture what we need
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type StoreSnapshot = Record<string, any>;
type StoreData = Record<string, any>;
interface AccountSnapshot {
email: StoreSnapshot;
contact: StoreSnapshot;
calendar: StoreSnapshot;
filter: StoreSnapshot;
identity: StoreSnapshot;
vacation: StoreSnapshot;
email: StoreData;
contact: StoreData;
calendar: StoreData;
filter: StoreData;
identity: StoreData;
vacation: StoreData;
messageListTabs: StoreData;
tasks: StoreData;
}
const cache = new Map<string, AccountSnapshot>();
@@ -35,11 +43,9 @@ export function snapshotAccount(accountId: string): void {
const filterState = useFilterStore.getState();
const identityState = useIdentityStore.getState();
const vacationState = useVacationStore.getState();
const messageListTabsState = useMessageListTabsStore.getState();
const taskState = useTaskStore.getState();
// Copy the captured collections so the snapshot is decoupled from the live
// store: a later in-place mutation (e.g. an array push/splice, or stamping
// fields onto a shared email object) must not retroactively corrupt a
// snapshot taken earlier.
cache.set(accountId, {
email: {
emails: [...emailState.emails],
@@ -73,6 +79,17 @@ export function snapshotAccount(accountId: string): void {
isEnabled: vacationState.isEnabled,
isSupported: vacationState.isSupported,
},
messageListTabs: {
registrations: { ...messageListTabsState.registrations },
tabs: [...messageListTabsState.tabs],
activeTabId: messageListTabsState.activeTabId,
},
tasks: {
tasks: [...taskState.tasks],
selectedTaskId: taskState.selectedTaskId,
filter: taskState.filter,
showCompleted: taskState.showCompleted,
},
});
}
@@ -98,6 +115,8 @@ export function restoreAccount(accountId: string): boolean {
useFilterStore.setState(snapshot.filter);
useIdentityStore.setState(snapshot.identity);
useVacationStore.setState(snapshot.vacation);
useMessageListTabsStore.setState(snapshot.messageListTabs);
useTaskStore.setState(snapshot.tasks);
return true;
}
@@ -132,6 +151,8 @@ export function clearAllStores(): void {
useVacationStore.getState().clearState();
useCalendarStore.getState().clearState();
useFilterStore.getState().clearState();
useMessageListTabsStore.getState().clearState();
useTaskStore.getState().clearTasks();
}
/** Evict cached state for one account */
+6
View File
@@ -0,0 +1,6 @@
import { configManager } from './config-manager';
import type { FeatureGates } from './types';
export function isFeatureEnabledServer(feature: keyof FeatureGates): boolean {
return configManager.getPolicy().features[feature] ?? true;
}
+9
View File
@@ -0,0 +1,9 @@
import { useAuthStore } from '@/stores/auth-store';
export function handleAuthError(error: unknown): boolean {
if (error instanceof Error && error.message.includes('401')) {
useAuthStore.getState().logout();
return true;
}
return false;
}
+106
View File
@@ -0,0 +1,106 @@
const SESSION_KEY_STORAGE_KEY = 'vncmail:session-encryption-key';
const ALGORITHM = 'AES-GCM';
let _available: boolean | null = null;
export function isEncryptionAvailable(): boolean {
if (_available !== null) return _available;
try {
if (typeof window === 'undefined') { _available = false; return false; }
if (!window.crypto || !window.crypto.subtle) { _available = false; return false; }
_available = true;
return true;
} catch {
_available = false;
return false;
}
}
function getOrCreateSessionKey(): Promise<CryptoKey | null> {
if (!isEncryptionAvailable()) return Promise.resolve(null);
try {
let raw = sessionStorage.getItem(SESSION_KEY_STORAGE_KEY);
if (!raw) {
const keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
raw = btoa(String.fromCharCode(...keyBytes));
sessionStorage.setItem(SESSION_KEY_STORAGE_KEY, raw);
}
const keyData = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
return crypto.subtle.importKey('raw', keyData, { name: ALGORITHM }, false, [
'encrypt',
'decrypt',
]);
} catch {
return Promise.resolve(null);
}
}
let _cachedKey: CryptoKey | null | undefined;
async function getKey(): Promise<CryptoKey | null> {
if (_cachedKey !== undefined) return _cachedKey;
_cachedKey = await getOrCreateSessionKey();
return _cachedKey;
}
function invalidateKey(): void {
_cachedKey = undefined;
}
export async function encryptValue(plaintext: string): Promise<string> {
if (!isEncryptionAvailable()) {
console.warn('[localStorage crypto] Web Crypto unavailable, storing in plaintext');
return plaintext;
}
const key = await getKey();
if (!key) {
console.warn('[localStorage crypto] Failed to derive key, storing in plaintext');
return plaintext;
}
try {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const ciphertext = await crypto.subtle.encrypt({ name: ALGORITHM, iv }, key, encoded);
const combined = new Uint8Array(iv.length + new Uint8Array(ciphertext).length);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
return btoa(String.fromCharCode(...combined));
} catch (err) {
console.warn('[localStorage crypto] Encryption failed:', err);
return plaintext;
}
}
export async function decryptValue(ciphertext: string): Promise<string | null> {
if (!isEncryptionAvailable()) {
return ciphertext;
}
const key = await getKey();
if (!key) {
return ciphertext;
}
try {
const combined = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0));
if (combined.length < 13) return null;
const iv = combined.slice(0, 12);
const data = combined.slice(12);
const decrypted = await crypto.subtle.decrypt({ name: ALGORITHM, iv }, key, data);
return new TextDecoder().decode(decrypted);
} catch {
return null;
}
}
export function resetSessionKey(): void {
try {
sessionStorage.removeItem(SESSION_KEY_STORAGE_KEY);
} catch {
/* noop */
}
invalidateKey();
}
+12 -3
View File
@@ -7,9 +7,6 @@
// Web/PWA deployments never get `window.vnc` at all (contextBridge only
// exists inside the Electron shell), so `isElectronShell()` is false there
// and callers should keep using the lib/web-push.ts + public/sw.js path.
// Wiring this bridge up to real mail-delivery events (JMAP WebSocket push
// vs. polling) is a separate, later decision - this module is only the
// plumbing.
export interface ShowNotificationOptions {
body?: string;
@@ -20,12 +17,24 @@ export interface ShowNotificationResult {
shown: boolean;
}
export interface WsMessageEvent {
id: string;
type: "open" | "message" | "close" | "error";
data?: string;
code?: number;
message?: string;
}
export interface VncElectronBridge {
isElectron: true;
showNotification: (
title: string,
options?: ShowNotificationOptions,
) => Promise<ShowNotificationResult>;
wsConnect: (url: string, authHeader: string) => Promise<string>;
wsSend: (id: string, data: string) => Promise<boolean>;
wsClose: (id: string) => Promise<void>;
onWsMessage: (callback: (event: WsMessageEvent) => void) => () => void;
}
declare global {
+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;
+268
View File
@@ -0,0 +1,268 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { TransportError } from '@/lib/jmap/client';
import { debug } from '@/lib/debug';
const STORAGE_KEY = 'vncmail:pending-ops';
export type OperationType =
| 'sendEmail'
| 'createEvent'
| 'updateEvent'
| 'deleteEvent'
| 'createContact'
| 'updateContact'
| 'deleteContact'
| 'createTask'
| 'updateTask'
| 'deleteTask';
export interface PendingOperation {
id: string;
type: OperationType;
accountId: string;
payload: unknown;
createdAt: string;
retryCount: number;
}
function loadOps(): PendingOperation[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw) as PendingOperation[];
} catch {
return [];
}
}
function saveOps(ops: PendingOperation[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ops));
} catch {
debug.error('offline-write-queue', 'Failed to persist pending operations');
}
}
export function enqueueOperation(
op: Omit<PendingOperation, 'id' | 'createdAt' | 'retryCount'>,
): void {
const ops = loadOps();
ops.push({
...op,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
retryCount: 0,
});
saveOps(ops);
notifyCountChanged(getPendingOperationsCount());
}
export function dequeueOperation(id: string): void {
const ops = loadOps();
saveOps(ops.filter((o) => o.id !== id));
notifyCountChanged(getPendingOperationsCount());
}
export function getPendingOperations(accountId: string): PendingOperation[] {
return loadOps().filter((o) => o.accountId === accountId);
}
export function getPendingOperationsCount(): number {
return loadOps().length;
}
export function clearAllOperations(): void {
saveOps([]);
notifyCountChanged(0);
}
export async function processQueue(
client: IJMAPClient,
accountId: string,
): Promise<{ succeeded: number; failed: number }> {
const ops = getPendingOperations(accountId);
let succeeded = 0;
let failed = 0;
for (const op of ops) {
try {
await executeOperation(client, op);
dequeueOperation(op.id);
succeeded++;
} catch {
op.retryCount += 1;
failed++;
if (op.retryCount >= 5) {
dequeueOperation(op.id);
debug.warn('offline-write-queue', 'Dropping operation after max retries', {
id: op.id,
type: op.type,
});
failed++;
}
}
}
// Persist updated retry counts for failed operations
const allOps = loadOps();
for (const failedOp of ops.filter((o) => allOps.some((a) => a.id === o.id))) {
const idx = allOps.findIndex((a) => a.id === failedOp.id);
if (idx >= 0) allOps[idx] = failedOp;
}
saveOps(allOps);
notifyCountChanged(getPendingOperationsCount());
return { succeeded, failed };
}
async function executeOperation(
client: IJMAPClient,
op: PendingOperation,
): Promise<void> {
switch (op.type) {
case 'sendEmail': {
const p = op.payload as {
to: string[];
subject: string;
body: string;
cc?: string[];
bcc?: string[];
identityId?: string;
fromEmail?: string;
draftId?: string;
fromName?: string;
htmlBody?: string;
attachments?: Array<{
blobId: string;
name: string;
type: string;
size: number;
disposition?: 'attachment' | 'inline';
cid?: string;
}>;
inReplyTo?: string[];
references?: string[];
delayedUntil?: string;
envelopeMailFrom?: string;
options?: { requestReadReceipt?: boolean };
};
await client.sendEmail(
p.to,
p.subject,
p.body,
p.cc,
p.bcc,
p.identityId,
p.fromEmail,
p.draftId,
p.fromName,
p.htmlBody,
p.attachments,
p.inReplyTo,
p.references,
p.delayedUntil,
p.envelopeMailFrom,
p.options,
);
break;
}
case 'createEvent':
await client.createCalendarEvent(op.payload as Record<string, unknown>);
break;
case 'updateEvent': {
const up = op.payload as { id: string; updates: Record<string, unknown> };
await client.updateCalendarEvent(up.id, up.updates);
break;
}
case 'deleteEvent':
await client.deleteCalendarEvent(op.payload as string);
break;
case 'createContact':
await client.createContact(op.payload as Record<string, unknown>);
break;
case 'updateContact': {
const uc = op.payload as { id: string; updates: Record<string, unknown> };
await client.updateContact(uc.id, uc.updates);
break;
}
case 'deleteContact': {
const dc = op.payload as { id: string; targetAccountId?: string };
await client.deleteContact(dc.id, dc.targetAccountId);
break;
}
case 'createTask':
await client.createCalendarTask(op.payload as Record<string, unknown>);
break;
case 'updateTask': {
const ut = op.payload as { id: string; updates: Record<string, unknown> };
await client.updateCalendarTask(ut.id, ut.updates);
break;
}
case 'deleteTask': {
const dt = op.payload as { id: string; targetAccountId?: string };
await client.deleteCalendarTask(dt.id, dt.targetAccountId);
break;
}
default:
throw new Error(`Unknown operation type: ${op.type}`);
}
}
const countListeners = new Set<(count: number) => void>();
export function onPendingCountChange(listener: (count: number) => void): () => void {
countListeners.add(listener);
return () => countListeners.delete(listener);
}
function notifyCountChanged(count: number): void {
for (const listener of countListeners) {
try {
listener(count);
} catch {
/* noop */
}
}
}
export function isNetworkError(error: unknown): boolean {
if (error instanceof TransportError) return true;
if (error instanceof TypeError) return true;
if (error instanceof Error) {
const msg = error.message.toLowerCase();
return (
msg.includes('network') ||
msg.includes('fetch') ||
msg.includes('econnrefused') ||
msg.includes('timeout') ||
msg.includes('offline') ||
msg.includes('abort')
);
}
return false;
}
let cleanupHandler: (() => void) | null = null;
export function initOfflineQueueHandler(
getClient: () => IJMAPClient | null,
getAccountId: () => string | null,
): () => void {
if (typeof window === 'undefined') return () => {};
const handleOnline = () => {
const client = getClient();
const accountId = getAccountId();
if (!client || !accountId) return;
processQueue(client, accountId).catch((err) => {
debug.error('offline-write-queue', 'Failed to process queue on reconnect', err);
});
};
window.addEventListener('online', handleOnline);
cleanupHandler = () => window.removeEventListener('online', handleOnline);
return cleanupHandler;
}
+38
View File
@@ -0,0 +1,38 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
type JmapTypeHandler = (client: IJMAPClient, accountChanges: Record<string, string>) => Promise<void>;
const handlers = new Map<string, JmapTypeHandler[]>();
export function registerPushHandler(jmapType: string, handler: JmapTypeHandler): () => void {
const list = handlers.get(jmapType) ?? [];
list.push(handler);
handlers.set(jmapType, list);
return () => {
const current = handlers.get(jmapType);
if (!current) return;
const idx = current.indexOf(handler);
if (idx >= 0) current.splice(idx, 1);
if (current.length === 0) handlers.delete(jmapType);
};
}
export async function dispatchPushEvent(
client: IJMAPClient,
changed: Record<string, Record<string, string>>,
accountId: string,
): Promise<void> {
const accountChanges = changed[accountId];
for (const [jmapType, typeHandlers] of handlers) {
if (accountChanges?.[jmapType]) {
for (const handler of typeHandlers) {
try {
await handler(client, accountChanges);
} catch (error) {
console.error(`Push handler for ${jmapType} failed:`, error);
}
}
}
}
}