From cfdd091d225911a84b0af00e02a5d739d3b737f8 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 22:10:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203+4=20=E2=80=94=20security=20ha?= =?UTF-8?q?rdening=20+=20polish=20+=20offline=20+=20Electron=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/api/calendar-agenda/route.ts | 5 + app/api/offline/reindex/route.ts | 5 + app/api/plugins/route.ts | 5 + app/api/smime/enroll/route.ts | 5 + components/email/email-composer.tsx | 35 ++- components/layout/offline-queue-indicator.tsx | 49 ++++ electron/main.ts | 65 +++++ electron/preload.ts | 30 ++ lib/account-state-manager.ts | 45 ++- lib/admin/feature-gate.ts | 6 + lib/auth-error-handler.ts | 9 + lib/auth/local-storage-crypto.ts | 106 +++++++ lib/electron-bridge.ts | 15 +- lib/jmap/client.ts | 107 ++++++- lib/offline-write-queue.ts | 268 ++++++++++++++++++ lib/push-event-bus.ts | 38 +++ package-lock.json | 22 ++ package.json | 1 + scripts/build-electron.mjs | 2 +- stores/account-store.ts | 4 +- stores/auth-store.ts | 4 +- stores/calendar-store.ts | 62 +++- stores/contact-store.ts | 47 ++- stores/email-store.ts | 66 ++--- stores/encrypted-storage.ts | 95 +++++++ stores/file-store.ts | 10 + stores/filter-store.ts | 11 + stores/message-list-tabs-store.ts | 10 + stores/task-store.ts | 34 ++- 29 files changed, 1068 insertions(+), 93 deletions(-) create mode 100644 components/layout/offline-queue-indicator.tsx create mode 100644 lib/admin/feature-gate.ts create mode 100644 lib/auth-error-handler.ts create mode 100644 lib/auth/local-storage-crypto.ts create mode 100644 lib/offline-write-queue.ts create mode 100644 lib/push-event-bus.ts create mode 100644 stores/encrypted-storage.ts diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 923f3564..907ba132 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -6,6 +6,7 @@ import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { parseISO } from 'date-fns'; import type { CalendarEvent } from '@/lib/jmap/types'; +import { isFeatureEnabledServer } from '@/lib/admin/feature-gate'; /** * POST /api/calendar-agenda @@ -100,6 +101,10 @@ function firstCalendarId(event: Partial): string | null { } export async function POST(request: NextRequest) { + if (!isFeatureEnabledServer('calendarEnabled')) { + return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }); + } + try { const creds = await getStalwartCredentials(request); if (!creds) { diff --git a/app/api/offline/reindex/route.ts b/app/api/offline/reindex/route.ts index ef500b0c..180904a8 100644 --- a/app/api/offline/reindex/route.ts +++ b/app/api/offline/reindex/route.ts @@ -22,6 +22,7 @@ import { } from '@/lib/mail-index/reindex'; import { CONTENT_TYPES, isContentType, type ContentType } from '@/lib/mail-index/store'; import { JmapIndexError } from '@/lib/mail-index/jmap'; +import { isFeatureEnabledServer } from '@/lib/admin/feature-gate'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -40,6 +41,10 @@ function parseIdMap(raw: unknown): Partial> | unde } export async function POST(request: NextRequest) { + if (!isFeatureEnabledServer('aiAssistantEnabled')) { + return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }); + } + if (!getStoreDir()) { return new NextResponse(null, { status: 404 }); } diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 3cb3a63f..c5d8a16b 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -3,6 +3,7 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; +import { isFeatureEnabledServer } from '@/lib/admin/feature-gate'; /** * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes @@ -11,6 +12,10 @@ import { logger } from '@/lib/logger'; * No admin auth required - this is how regular users receive plugins/themes. */ export async function GET() { + if (!isFeatureEnabledServer('pluginsEnabled')) { + return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }); + } + try { await configManager.ensureLoaded(); const policy = configManager.getPolicy(); diff --git a/app/api/smime/enroll/route.ts b/app/api/smime/enroll/route.ts index 01ac06b2..016ec9d9 100644 --- a/app/api/smime/enroll/route.ts +++ b/app/api/smime/enroll/route.ts @@ -15,12 +15,17 @@ import { NextResponse } from 'next/server'; import { readStalwartAuthContext } from '@/lib/stalwart/auth-context'; import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api'; import { CaError, getCaProvider } from '@/lib/smime-ca'; +import { isFeatureEnabledServer } from '@/lib/admin/feature-gate'; export const runtime = 'nodejs'; const MAX_CSR_BYTES = 8 * 1024; export async function POST(request: Request) { + if (!isFeatureEnabledServer('smimeEnabled')) { + return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }); + } + const provider = getCaProvider(); if (!provider) { return NextResponse.json( diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 20400084..d765b80a 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; @@ -569,6 +569,34 @@ export function EmailComposer({ const [fromOverrideEnabled, setFromOverrideEnabled] = useState(initialData?.fromOverrideEnabled ?? false); const [fromOverrideEmail, setFromOverrideEmail] = useState(initialData?.fromOverrideEmail ?? ''); const [fromOverrideName, setFromOverrideName] = useState(initialData?.fromOverrideName ?? ''); + const [fromOverrideWarning, setFromOverrideWarning] = useState(''); + + // Validate that from override domain matches at least one of the user's identities + const ownIdentityDomains = useMemo(() => new Set( + identities.map(i => i.email).filter(Boolean).map(email => { + const atPos = email.indexOf('@'); + return atPos >= 0 ? email.slice(atPos + 1).toLowerCase() : ''; + }).filter(d => d.length > 0), + ), [identities]); + + useEffect(() => { + if (!fromOverrideEnabled || !fromOverrideEmail.trim()) { + setFromOverrideWarning(''); + return; + } + const email = fromOverrideEmail.trim(); + const atPos = email.indexOf('@'); + if (atPos < 0) { + setFromOverrideWarning('Invalid email address'); + return; + } + const domain = email.slice(atPos + 1).toLowerCase(); + if (!ownIdentityDomains.has(domain)) { + setFromOverrideWarning(`This email's domain (${domain}) does not match any of your verified identities`); + } else { + setFromOverrideWarning(''); + } + }, [fromOverrideEnabled, fromOverrideEmail, ownIdentityDomains]); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false); @@ -2358,6 +2386,11 @@ export function EmailComposer({ > {fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')} + {fromOverrideWarning && ( + + {fromOverrideWarning} + + )} diff --git a/components/layout/offline-queue-indicator.tsx b/components/layout/offline-queue-indicator.tsx new file mode 100644 index 00000000..fb0d7bf2 --- /dev/null +++ b/components/layout/offline-queue-indicator.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; +import { + getPendingOperationsCount, + onPendingCountChange, + processQueue, +} from '@/lib/offline-write-queue'; + +export function OfflineQueueIndicator() { + const [count, setCount] = useState(0); + const [processing, setProcessing] = useState(false); + const client = useAuthStore((s) => s.client); + const activeAccountId = useAccountStore((s) => s.activeAccountId); + + useEffect(() => { + setCount(getPendingOperationsCount()); + return onPendingCountChange(setCount); + }, []); + + const handleRetry = useCallback(async () => { + if (!client || !activeAccountId) return; + setProcessing(true); + try { + await processQueue(client, activeAccountId); + } finally { + setProcessing(false); + } + }, [client, activeAccountId]); + + if (count === 0) return null; + + return ( +
+ + {count} pending {count === 1 ? 'operation' : 'operations'} (offline) + + +
+ ); +} diff --git a/electron/main.ts b/electron/main.ts index 91fda357..b40e16e7 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -15,6 +15,7 @@ import { get as httpGet } from "node:http"; import path from "node:path"; import fs from "node:fs"; import type { Duplex } from "node:stream"; +import { WebSocket } from "ws"; import { attachKeyService, checkEncryptionAvailable } from "./key-service"; let serverProcess: ChildProcess | null = null; @@ -463,6 +464,70 @@ ipcMain.handle( }, ); +// --- WebSocket bridge for renderer ---------------------------------------- +// The browser WebSocket constructor cannot attach Authorization headers, so +// JMAP-over-WebSocket (RFC 8887) push paths that require auth at the upgrade +// handshake are unreachable from the renderer. This IPC bridge opens the +// WebSocket from the main process (where we control headers) and forwards +// messages to the renderer as 'vnc:ws-message' events. + +const wsConnections = new Map(); + +ipcMain.handle( + "vnc:ws-connect", + (event, { url, authHeader }: { url: string; authHeader: string }) => { + const id = randomBytes(8).toString("hex"); + const ws = new WebSocket(url, { + headers: { Authorization: authHeader }, + }); + + ws.on("open", () => { + event.sender.send("vnc:ws-message", { id, type: "open" }); + }); + + ws.on("message", (data: Buffer) => { + event.sender.send("vnc:ws-message", { + id, + type: "message", + data: data.toString(), + }); + }); + + ws.on("close", (code: number) => { + wsConnections.delete(id); + event.sender.send("vnc:ws-message", { id, type: "close", code }); + }); + + ws.on("error", (err: Error) => { + event.sender.send("vnc:ws-message", { + id, + type: "error", + message: err.message, + }); + }); + + wsConnections.set(id, ws); + return id; + }, +); + +ipcMain.handle( + "vnc:ws-send", + (_event, { id, data }: { id: string; data: string }) => { + const ws = wsConnections.get(id); + if (!ws || ws.readyState !== WebSocket.OPEN) return false; + ws.send(data); + return true; + }, +); + +ipcMain.handle("vnc:ws-close", (_event, { id }: { id: string }) => { + const ws = wsConnections.get(id); + if (!ws) return; + ws.close(); + wsConnections.delete(id); +}); + // --- Auto-update ------------------------------------------------------- // GitHub Releases as the update feed (electron-builder.config.js's // `publish` block) - the skill's recommendation over standing up a new diff --git a/electron/preload.ts b/electron/preload.ts index 867bcd0a..f9a7cfda 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -13,6 +13,14 @@ export interface ShowNotificationResult { shown: boolean; } +export interface WsMessageEvent { + id: string; + type: "open" | "message" | "close" | "error"; + data?: string; + code?: number; + message?: string; +} + contextBridge.exposeInMainWorld("vnc", { isElectron: true, // Routes to Electron's own Notification API in main.ts (ipcMain.handle @@ -24,4 +32,26 @@ contextBridge.exposeInMainWorld("vnc", { options?: ShowNotificationOptions, ): Promise => ipcRenderer.invoke("vnc:show-notification", title, options), + + // WebSocket bridge for JMAP-over-WebSocket (RFC 8887). The browser + // WebSocket constructor cannot attach Authorization headers, so + // connections go through the main process which controls headers. + wsConnect: ( + url: string, + authHeader: string, + ): Promise => + ipcRenderer.invoke("vnc:ws-connect", { url, authHeader }), + + wsSend: (id: string, data: string): Promise => + ipcRenderer.invoke("vnc:ws-send", { id, data }), + + wsClose: (id: string): Promise => + ipcRenderer.invoke("vnc:ws-close", { id }), + + onWsMessage: (callback: (event: WsMessageEvent) => void): () => void => { + const handler = (_event: Electron.IpcRendererEvent, data: WsMessageEvent) => + callback(data); + ipcRenderer.on("vnc:ws-message", handler); + return () => { ipcRenderer.removeListener("vnc:ws-message", handler); }; + }, }); diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index 036cf6d5..b73b18c8 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -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 { + snapshot: () => Partial; + clear: () => Partial; +} -// Minimal snapshot shapes - we only capture what we need // eslint-disable-next-line @typescript-eslint/no-explicit-any -type StoreSnapshot = Record; +type StoreData = Record; 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(); @@ -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 */ diff --git a/lib/admin/feature-gate.ts b/lib/admin/feature-gate.ts new file mode 100644 index 00000000..28ef9dd9 --- /dev/null +++ b/lib/admin/feature-gate.ts @@ -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; +} diff --git a/lib/auth-error-handler.ts b/lib/auth-error-handler.ts new file mode 100644 index 00000000..50e3ec6f --- /dev/null +++ b/lib/auth-error-handler.ts @@ -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; +} diff --git a/lib/auth/local-storage-crypto.ts b/lib/auth/local-storage-crypto.ts new file mode 100644 index 00000000..281654f2 --- /dev/null +++ b/lib/auth/local-storage-crypto.ts @@ -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 { + 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 { + if (_cachedKey !== undefined) return _cachedKey; + _cachedKey = await getOrCreateSessionKey(); + return _cachedKey; +} + +function invalidateKey(): void { + _cachedKey = undefined; +} + +export async function encryptValue(plaintext: string): Promise { + 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 { + 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(); +} diff --git a/lib/electron-bridge.ts b/lib/electron-bridge.ts index fb214235..346c3a88 100644 --- a/lib/electron-bridge.ts +++ b/lib/electron-bridge.ts @@ -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; + wsConnect: (url: string, authHeader: string) => Promise; + wsSend: (id: string, data: string) => Promise; + wsClose: (id: string) => Promise; + onWsMessage: (callback: (event: WsMessageEvent) => void) => () => void; } declare global { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 163b5840..aa52e619 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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) | 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 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; 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): void { this.stopWSHeartbeat(); this.wsHeartbeatTimer = setInterval(() => { if (this.ws !== socket) return; diff --git a/lib/offline-write-queue.ts b/lib/offline-write-queue.ts new file mode 100644 index 00000000..b1018ec6 --- /dev/null +++ b/lib/offline-write-queue.ts @@ -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, +): 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 { + 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); + break; + case 'updateEvent': { + const up = op.payload as { id: string; updates: Record }; + 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); + break; + case 'updateContact': { + const uc = op.payload as { id: string; updates: Record }; + 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); + break; + case 'updateTask': { + const ut = op.payload as { id: string; updates: Record }; + 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; +} diff --git a/lib/push-event-bus.ts b/lib/push-event-bus.ts new file mode 100644 index 00000000..f321d51c --- /dev/null +++ b/lib/push-event-bus.ts @@ -0,0 +1,38 @@ +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +type JmapTypeHandler = (client: IJMAPClient, accountChanges: Record) => Promise; + +const handlers = new Map(); + +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>, + accountId: string, +): Promise { + 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); + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 6c7789e1..ae873f2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "webcrypto-liner": "^1.4.3", + "ws": "^8.21.3", "zustand": "^5.0.12" }, "devDependencies": { @@ -12858,6 +12859,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 93a25f71..2705ac17 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "webcrypto-liner": "^1.4.3", + "ws": "^8.21.3", "zustand": "^5.0.12" }, "optionalDependencies": { diff --git a/scripts/build-electron.mjs b/scripts/build-electron.mjs index 18c1d857..f02bae0e 100644 --- a/scripts/build-electron.mjs +++ b/scripts/build-electron.mjs @@ -19,7 +19,7 @@ const shared = { // stays external so electron-builder ships it from node_modules as a // normal production dependency instead of us re-bundling its native-ish // internals (see electron-builder.config.js's file collection). - external: ["electron", "electron-updater"], + external: ["electron", "electron-updater", "ws"], logLevel: "info", }; diff --git a/stores/account-store.ts b/stores/account-store.ts index 49835fe4..04414458 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { encryptedStorage } from '@/stores/encrypted-storage'; import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils'; export interface AccountEntry { @@ -218,6 +219,7 @@ export const useAccountStore = create()( }), { name: 'account-registry', + storage: createJSONStorage(() => encryptedStorage), partialize: (state) => ({ accounts: state.accounts, activeAccountId: state.activeAccountId, diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 6e11de84..435a382a 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { encryptedStorage } from '@/stores/encrypted-storage'; import { JMAPClient, RateLimitError } from '@/lib/jmap/client'; import { withOfflineFallback } from '@/lib/offline-fallback-client'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; @@ -2010,6 +2011,7 @@ export const useAuthStore = create()( }), { name: 'auth-storage', + storage: createJSONStorage(() => encryptedStorage), partialize: (state) => { // Don't persist unauthenticated state - prevents resurrecting stale sessions if (!state.isAuthenticated) return {}; diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index a4be3f98..6529a6f0 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -12,6 +12,8 @@ import { generateUUID } from '@/lib/utils'; import { apiFetch } from '@/lib/browser-navigation'; import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar'; import { getClientByLocalAccountId } from './client-registry'; +import { enqueueOperation, isNetworkError } from '@/lib/offline-write-queue'; +import { useAccountStore } from '@/stores/account-store'; /** * When the Pro shell aggregates calendars/events from every connected @@ -409,13 +411,13 @@ export const useCalendarStore = create()( createEvent: async (client, event, sendSchedulingMessages) => { set({ error: null }); + let targetAccountId: string | undefined = event.accountId; + const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event }); try { // Resolve shared calendar context from calendarIds. Also pin the // local account from the calendar so we route through that // server's client when in multi-account Pro mode. - let targetAccountId = event.accountId; let localAccountId = event.localAccountId; - const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event }); if (event.calendarIds) { const remapped: Record = {}; for (const calId of Object.keys(event.calendarIds)) { @@ -491,6 +493,12 @@ export const useCalendarStore = create()( return mappedCreated; } catch (error) { debug.error('Failed to create event:', error); + if (isNetworkError(error)) { + const accountId = targetAccountId || useAccountStore.getState().activeAccountId; + if (accountId) { + enqueueOperation({ type: 'createEvent', accountId, payload: cleanEvent }); + } + } set({ error: 'Failed to create event' }); return null; } @@ -498,11 +506,12 @@ export const useCalendarStore = create()( updateEvent: async (client, id, updates, sendSchedulingMessages) => { set({ error: null }); + const storeEvent = get().events.find(e => e.id === id); + const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId); + const targetAccountId = storeEvent?.accountId; + const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates }); try { // Resolve shared event IDs and client-side expanded occurrence IDs - const storeEvent = get().events.find(e => e.id === id); - const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId); - const targetAccountId = storeEvent?.accountId; client = resolveAccountClient(client, storeEvent?.localAccountId); debug.log('calendar', 'Calendar updateEvent', { storeId: id, @@ -513,7 +522,6 @@ export const useCalendarStore = create()( updateKeys: Object.keys(updates), }); // Remap namespaced calendarIds back to original IDs - const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates }); if (cleanUpdates.calendarIds) { const remapped: Record = {}; for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) { @@ -556,6 +564,12 @@ export const useCalendarStore = create()( // iMIP send here produced duplicate emails. } catch (error) { debug.error('Failed to update event:', error); + if (isNetworkError(error)) { + const accountId = targetAccountId || useAccountStore.getState().activeAccountId; + if (accountId) { + enqueueOperation({ type: 'updateEvent', accountId, payload: { id: realId, updates: cleanUpdates } }); + } + } set({ error: 'Failed to update event' }); throw error; } @@ -788,11 +802,11 @@ export const useCalendarStore = create()( deleteEvent: async (client, id, sendSchedulingMessages) => { set({ error: null }); + const storeEvent = get().events.find(e => e.id === id); + const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId); + const targetAccountId = storeEvent?.accountId; try { // Resolve shared event IDs and client-side expanded occurrence IDs - const storeEvent = get().events.find(e => e.id === id); - const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId); - const targetAccountId = storeEvent?.accountId; client = resolveAccountClient(client, storeEvent?.localAccountId); // Cancellation emails (iTIP CANCEL) are sent by the server via the // `sendSchedulingMessages` argument on the destroy below - a manual @@ -811,6 +825,12 @@ export const useCalendarStore = create()( })); } catch (error) { debug.error('Failed to delete event:', error); + if (isNetworkError(error)) { + const accountId = targetAccountId || useAccountStore.getState().activeAccountId; + if (accountId) { + enqueueOperation({ type: 'deleteEvent', accountId, payload: realId }); + } + } set({ error: 'Failed to delete event' }); throw error; } @@ -1302,3 +1322,27 @@ export const useCalendarStore = create()( } ) ); + +import { registerPushHandler } from '@/lib/push-event-bus'; + +registerPushHandler('Calendar', async (client) => { + const store = useCalendarStore.getState(); + if (store.supportsCalendar) { + store.fetchCalendars(client); + } +}); + +registerPushHandler('CalendarEvent', async (client) => { + const store = useCalendarStore.getState(); + if (store.supportsCalendar) { + const { dateRange, selectedCalendarIds } = store; + if (dateRange && selectedCalendarIds.length > 0) { + store.fetchEvents(client, dateRange.start, dateRange.end); + } + const { useTaskStore } = await import('./task-store'); + const taskStore = useTaskStore.getState(); + if (taskStore.tasks.length > 0 || store.viewMode === 'tasks') { + taskStore.fetchTasks(client); + } + } +}); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index df2ab54c..05f27e18 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -5,6 +5,8 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { generateUUID } from '@/lib/utils'; import { debug } from '@/lib/debug'; import { getClientByLocalAccountId } from './client-registry'; +import { enqueueOperation, isNetworkError } from '@/lib/offline-write-queue'; +import { useAccountStore } from '@/stores/account-store'; /** One connected JMAP account for contact multi-account aggregation. */ export interface ContactAccountClient { @@ -375,12 +377,12 @@ export const useContactStore = create()( createContact: async (client, contact) => { set({ isLoading: true, error: null }); + let accountId: string | undefined = contact.isShared ? contact.accountId : undefined; + let cleanedContact = contact; try { // Determine target account from the selected address book. Also // pin the local account so we route through the right server's // client in multi-account Pro mode. - let accountId = contact.isShared ? contact.accountId : undefined; - let cleanedContact = contact; let localAccountId = contact.localAccountId; // De-namespace addressBookIds if they reference a shared address book @@ -424,6 +426,12 @@ export const useContactStore = create()( })); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to create contact'; + if (isNetworkError(error)) { + const queueAccountId = accountId || useAccountStore.getState().activeAccountId; + if (queueAccountId) { + enqueueOperation({ type: 'createContact', accountId: queueAccountId, payload: cleanedContact }); + } + } set({ error: msg, isLoading: false }); throw error; } @@ -431,14 +439,14 @@ export const useContactStore = create()( updateContact: async (client, id, updates) => { set({ error: null }); + const contact = get().contacts.find(c => c.id === id); + const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId); + const accountId = contact?.isShared ? contact.accountId : undefined; + let cleanedUpdates = updates; try { - const contact = get().contacts.find(c => c.id === id); - const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId); - const accountId = contact?.isShared ? contact.accountId : undefined; client = resolveAccountClient(client, contact?.localAccountId); // De-namespace addressBookIds for shared contacts before sending to JMAP server - let cleanedUpdates = updates; if (contact?.isShared && contact?.accountId && updates.addressBookIds) { const prefix = `${contact.accountId}:`; const deNamespaced = Object.fromEntries( @@ -458,6 +466,12 @@ export const useContactStore = create()( })); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to update contact'; + if (isNetworkError(error)) { + const queueAccountId = accountId || useAccountStore.getState().activeAccountId; + if (queueAccountId) { + enqueueOperation({ type: 'updateContact', accountId: queueAccountId, payload: { id: originalId, updates: cleanedUpdates } }); + } + } set({ error: msg }); throw error; } @@ -465,10 +479,10 @@ export const useContactStore = create()( deleteContact: async (client, id) => { set({ error: null }); + const contact = get().contacts.find(c => c.id === id); + const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId); + const accountId = contact?.isShared ? contact.accountId : undefined; try { - const contact = get().contacts.find(c => c.id === id); - const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId); - const accountId = contact?.isShared ? contact.accountId : undefined; client = resolveAccountClient(client, contact?.localAccountId); await client.deleteContact(originalId, accountId); set((state) => { @@ -481,6 +495,12 @@ export const useContactStore = create()( }); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to delete contact'; + if (isNetworkError(error)) { + const queueAccountId = accountId || useAccountStore.getState().activeAccountId; + if (queueAccountId) { + enqueueOperation({ type: 'deleteContact', accountId: queueAccountId, payload: { id: originalId, targetAccountId: accountId } }); + } + } set({ error: msg }); throw error; } @@ -1143,4 +1163,13 @@ export const useContactStore = create()( ) ); +import { registerPushHandler } from '@/lib/push-event-bus'; + +registerPushHandler('ContactCard', async (client) => { + const store = useContactStore.getState(); + store.fetchContacts(client).catch((err) => { + console.error('Failed to refresh contacts on push:', err); + }); +}); + export type { ContactName }; diff --git a/stores/email-store.ts b/stores/email-store.ts index d19eb877..bf5ac05c 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -3,7 +3,6 @@ import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnified import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useSettingsStore } from "@/stores/settings-store"; -import { useCalendarStore } from "@/stores/calendar-store"; import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import type { ExternalSearchResult } from "@/lib/plugin-types"; @@ -11,6 +10,7 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, adv import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useMessageListTabsStore } from "@/stores/message-list-tabs-store"; +import { enqueueOperation, isNetworkError } from "@/lib/offline-write-queue"; type ScheduledSubmissionMetadata = { submissionId: string; @@ -1367,6 +1367,16 @@ export const useEmailStore = create((set, get) => ({ }); return result; } catch (error) { + if (isNetworkError(error)) { + const accountId = useAccountStore.getState().activeAccountId; + if (accountId) { + enqueueOperation({ + type: 'sendEmail', + accountId, + payload: { to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options }, + }); + } + } set({ error: error instanceof Error ? error.message : "Failed to send email", isLoading: false @@ -2911,53 +2921,15 @@ export const useEmailStore = create((set, get) => ({ await get().fetchMailboxes(client); } - // Handle Calendar/CalendarEvent state changes - refresh calendar data - if (accountChanges?.Calendar || accountChanges?.CalendarEvent) { - const calendarStore = useCalendarStore.getState(); - if (calendarStore.supportsCalendar) { - calendarStore.fetchCalendars(client); - const { dateRange, selectedCalendarIds } = calendarStore; - if (dateRange && selectedCalendarIds.length > 0) { - calendarStore.fetchEvents(client, dateRange.start, dateRange.end); - } - // Refresh tasks when calendar events change (e.g. task created via CalDAV) - const { useTaskStore } = await import('./task-store'); - const taskStore = useTaskStore.getState(); - if (taskStore.tasks.length > 0 || calendarStore.viewMode === 'tasks') { - taskStore.fetchTasks(client); - } - } - } - - // Handle SieveScript state changes - refresh filter rules - if (accountChanges?.SieveScript) { - const { useFilterStore } = await import('./filter-store'); - const filterStore = useFilterStore.getState(); - if (filterStore.isSupported) { - filterStore.fetchFilters(client).catch((err) => { - console.error('Failed to refresh filters:', err); - }); - } - } - - // Handle ContactCard state changes - refresh contacts - if (accountChanges?.ContactCard) { - const { useContactStore } = await import('./contact-store'); - const contactStore = useContactStore.getState(); - contactStore.fetchContacts(client).catch((err) => { - console.error('Failed to refresh contacts on push:', err); + // Delegate Calendar/CalendarEvent, SieveScript, ContactCard, FileNode + // push handling to the push event bus where each feature store + // registers itself. Decouples email-store from the 5+ other stores + // it previously imported directly for push handling. + import('@/lib/push-event-bus').then(({ dispatchPushEvent }) => { + dispatchPushEvent(client, change.changed, accountId).catch((err) => { + console.error('Push event bus dispatch failed:', err); }); - } - - // Handle FileNode state changes - refresh current directory - if (accountChanges?.FileNode) { - const { useFileStore } = await import('./file-store'); - const fileStore = useFileStore.getState(); - const currentParentId = fileStore.currentParentId; - fileStore.navigate(currentParentId).catch((err) => { - console.error('Failed to refresh files on push:', err); - }); - } + }).catch(() => {}); // Local search index last, with the refreshed ids (see above). scheduleIndexUpdate(); diff --git a/stores/encrypted-storage.ts b/stores/encrypted-storage.ts new file mode 100644 index 00000000..097559c1 --- /dev/null +++ b/stores/encrypted-storage.ts @@ -0,0 +1,95 @@ +import { encryptValue, decryptValue, isEncryptionAvailable } from '@/lib/auth/local-storage-crypto'; + +const ENCRYPTED_PREFIX = 'ENC:'; + +function isEncrypted(value: string): boolean { + return value.startsWith(ENCRYPTED_PREFIX); +} + +function stripPrefix(value: string): string { + return value.slice(ENCRYPTED_PREFIX.length); +} + +// Cache of recently decrypted values. The Zustand persist middleware calls +// getItem frequently during rehydration, and we want to avoid re-decrypting +// the same ciphertext on every read. Keyed by storage key. +const decryptedCache = new Map(); + +function cacheKey(name: string): string { + return `vncmail:decrypted:${name}`; +} + +function getCachedDecrypted(name: string): string | null | undefined { + return decryptedCache.get(cacheKey(name)); +} + +function setCachedDecrypted(name: string, value: string | null): void { + decryptedCache.set(cacheKey(name), value); +} + +function invalidateDecryptedCache(name: string): void { + decryptedCache.delete(cacheKey(name)); +} + +export function createEncryptedStorage(): { + getItem: (name: string) => Promise; + setItem: (name: string, value: string) => Promise; + removeItem: (name: string) => Promise; +} { + return { + getItem: async (name: string): Promise => { + try { + const raw = localStorage.getItem(name); + if (raw === null) return null; + + if (!isEncrypted(raw)) { + if (isEncryptionAvailable()) { + // Legacy plaintext value found — return as-is, but re-encrypt on + // the next write (setItem below always encrypts when available). + return raw; + } + return raw; + } + + const cached = getCachedDecrypted(name); + if (cached !== undefined) return cached; + + const ciphertext = stripPrefix(raw); + const decrypted = await decryptValue(ciphertext); + setCachedDecrypted(name, decrypted); + return decrypted; + } catch { + return null; + } + }, + + setItem: async (name: string, value: string): Promise => { + try { + if (isEncryptionAvailable()) { + const ciphertext = await encryptValue(value); + localStorage.setItem(name, `${ENCRYPTED_PREFIX}${ciphertext}`); + } else { + localStorage.setItem(name, value); + } + invalidateDecryptedCache(name); + } catch { + try { + localStorage.setItem(name, value); + } catch { + /* noop */ + } + } + }, + + removeItem: async (name: string): Promise => { + try { + localStorage.removeItem(name); + } catch { + /* noop */ + } + invalidateDecryptedCache(name); + }, + }; +} + +export const encryptedStorage = createEncryptedStorage(); diff --git a/stores/file-store.ts b/stores/file-store.ts index ddd92314..43b7174b 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -1048,3 +1048,13 @@ export const useFileStore = create((set, get) => ({ } }, })); + +import { registerPushHandler } from '@/lib/push-event-bus'; + +registerPushHandler('FileNode', async (_client) => { + const store = useFileStore.getState(); + const currentParentId = store.currentParentId; + store.navigate(currentParentId).catch((err) => { + console.error('Failed to refresh files on push:', err); + }); +}); diff --git a/stores/filter-store.ts b/stores/filter-store.ts index 0964aefc..9008c7f9 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -252,3 +252,14 @@ export const useFilterStore = create()((set, get) => ({ selectedAccountId: null, }), })); + +import { registerPushHandler } from '@/lib/push-event-bus'; + +registerPushHandler('SieveScript', async (client) => { + const store = useFilterStore.getState(); + if (store.isSupported) { + store.fetchFilters(client).catch((err) => { + console.error('Failed to refresh filters on push:', err); + }); + } +}); diff --git a/stores/message-list-tabs-store.ts b/stores/message-list-tabs-store.ts index 7b0e7415..6dcaf71d 100644 --- a/stores/message-list-tabs-store.ts +++ b/stores/message-list-tabs-store.ts @@ -153,6 +153,7 @@ interface MessageListTabsStore { registerTabs: (pluginId: string, config: MessageListTabsConfig) => void; clearTabs: (pluginId: string) => void; + clearState: () => void; setActiveTab: (tabId: string, mailboxId: string | null) => void; /** * JMAP filter fragment for the active tab (to AND into the mailbox query), @@ -336,4 +337,13 @@ export const useMessageListTabsStore = create()((set, get) void messageListTabHooks.onEmailCategorize.emit(ctx); return true; }, + + clearState: () => set({ + registrations: {}, + tabs: [], + mailboxRoles: [], + activeTabId: null, + tabCounts: {}, + isCountsLoading: false, + }), })); diff --git a/stores/task-store.ts b/stores/task-store.ts index 7a3e0cdf..58c1771f 100644 --- a/stores/task-store.ts +++ b/stores/task-store.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import type { CalendarTask } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { debug } from '@/lib/debug'; +import { enqueueOperation, isNetworkError } from '@/lib/offline-write-queue'; +import { useAccountStore } from '@/stores/account-store'; export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue'; @@ -58,10 +60,22 @@ export const useTaskStore = create((set, get) => ({ createTask: async (client, task) => { debug.log('tasks', 'TaskStore/createTask', task); - const created = await client.createCalendarTask(task); - debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title }); - set({ tasks: [...get().tasks, created] }); - return created; + try { + const created = await client.createCalendarTask(task); + debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title }); + set({ tasks: [...get().tasks, created] }); + return created; + } catch (error) { + debug.error('TaskStore/createTask failed', error); + if (isNetworkError(error)) { + const accountId = useAccountStore.getState().activeAccountId; + if (accountId) { + enqueueOperation({ type: 'createTask', accountId, payload: task }); + } + } + set({ error: 'Failed to create task' }); + throw error; + } }, updateTask: async (client, id, updates) => { @@ -72,6 +86,12 @@ export const useTaskStore = create((set, get) => ({ }); } catch (error) { debug.error('TaskStore/updateTask failed', error); + if (isNetworkError(error)) { + const accountId = useAccountStore.getState().activeAccountId; + if (accountId) { + enqueueOperation({ type: 'updateTask', accountId, payload: { id, updates } }); + } + } set({ error: 'Failed to update task' }); } }, @@ -85,6 +105,12 @@ export const useTaskStore = create((set, get) => ({ }); } catch (error) { debug.error('TaskStore/deleteTask failed', error); + if (isNetworkError(error)) { + const accountId = useAccountStore.getState().activeAccountId; + if (accountId) { + enqueueOperation({ type: 'deleteTask', accountId, payload: { id } }); + } + } set({ error: 'Failed to delete task' }); } },