From 218a584fb321f21d554334e879546447fa4f8e30 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 12:41:35 +0200 Subject: [PATCH 01/21] feat(electron): walking skeleton for the desktop shell Phase 1 step 1 of VNCprodbuild: electron/main.ts boots the same Next.js "standalone" server artifact the Dockerfile already produces (next.config.ts's output: "standalone") as a child process on a random localhost port, then opens a BrowserWindow at it. electron/preload.ts is a contextBridge stub (window.vnc.isElectron) for now. scripts/assemble-standalone.mjs copies public/ and .next/static into .next/standalone, mirroring what the Dockerfile does by hand, since `next build` deliberately leaves both out of the standalone output. scripts/build-electron.mjs bundles main.ts/preload.ts to CommonJS via esbuild (already a devDependency). New npm scripts: build:standalone, build:electron, electron:dev. electron-builder.config.js is intentionally minimal - no signing, no platform targets yet, just enough to prove the concept end to end. Also fixes a pre-existing repo-wide lint gap: vnc/plugins/smime is an independent sub-package (own package.json/esbuild build, browser-only globals) that was never added to eslint's ignores alongside repos:: and examples/**, so `npm run lint` - and the husky pre-commit hook - was failing on every commit regardless of what changed. Excluded it the same way those are, and added node globals for scripts/**/*.mjs so the new build helpers above lint cleanly too. Verified manually: npm run build:standalone && npm run build:electron && electron . boots the server and opens a window with no errors. --- electron-builder.config.js | 23 +++++ electron/main.ts | 153 ++++++++++++++++++++++++++++++++ electron/preload.ts | 13 +++ eslint.config.mjs | 20 +++++ scripts/assemble-standalone.mjs | 29 ++++++ scripts/build-electron.mjs | 36 ++++++++ 6 files changed, 274 insertions(+) create mode 100644 electron-builder.config.js create mode 100644 electron/main.ts create mode 100644 electron/preload.ts create mode 100644 scripts/assemble-standalone.mjs create mode 100644 scripts/build-electron.mjs diff --git a/electron-builder.config.js b/electron-builder.config.js new file mode 100644 index 00000000..108bde32 --- /dev/null +++ b/electron-builder.config.js @@ -0,0 +1,23 @@ +// Base electron-builder config - Phase 1 step 1 of the VNCprodbuild rollout. +// No code signing and no platform targets configured yet; this exists only +// to prove the packaging concept end to end (the app runs, boots its own +// server, opens a window). Targets (dmg/zip/nsis/AppImage/deb), branding +// icons, and code signing are wired up in later steps - see +// ~/.claude/skills/VNCprodbuild/SKILL.md, Phase 1 steps 6-9. +module.exports = { + appId: "de.vnc.vncmailplus", + productName: "VNCmail+", + directories: { + output: "dist-electron-builds", + }, + files: ["dist-electron/**/*", "package.json"], + extraResources: [ + { + // Same artifact the Dockerfile bakes into the container image (see + // Dockerfile + scripts/assemble-standalone.mjs). electron/main.ts + // reads it from process.resourcesPath in packaged builds. + from: ".next/standalone", + to: "standalone", + }, + ], +}; diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 00000000..5fd15918 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,153 @@ +// Electron main process for the VNCmail+ (Bulwark) desktop shell. +// +// Boots the exact same Next.js "standalone" server artifact the Dockerfile +// already produces for production (see next.config.ts's `output: +// "standalone"` and the Dockerfile's builder stage) as a child process on a +// random localhost port, then opens a BrowserWindow pointed at it. This is +// deliberately the same server, not a reimplementation - lib/jmap/client.ts +// and every app/api/** route behave identically to the web deployment. +import { app, BrowserWindow } from "electron"; +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; +import { get as httpGet } from "node:http"; +import path from "node:path"; +import fs from "node:fs"; + +let serverProcess: ChildProcess | null = null; +let mainWindow: BrowserWindow | null = null; + +/** + * Locates the standalone server's entrypoint. Packaged builds ship it as an + * extraResource (see electron-builder.config.js) because .next/standalone + * isn't inside the app.asar; dev runs read it straight out of the repo via + * `npm run build:standalone`. + */ +function getStandaloneServerEntry(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, "standalone", "server.js"); + } + return path.join(app.getAppPath(), ".next", "standalone", "server.js"); +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") { + const { port } = address; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error("Could not allocate a free localhost port"))); + } + }); + }); +} + +function waitForServerReady(url: string, timeoutMs = 20000): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const req = httpGet(url, (res) => { + res.resume(); + resolve(); + }); + req.on("error", () => { + if (Date.now() > deadline) { + reject(new Error(`Standalone server never became reachable at ${url}`)); + return; + } + setTimeout(attempt, 200); + }); + }; + attempt(); + }); +} + +async function startStandaloneServer(): Promise { + const serverEntry = getStandaloneServerEntry(); + if (!fs.existsSync(serverEntry)) { + throw new Error( + `Standalone Next.js server not found at ${serverEntry}. Run "npm run build:standalone" first.`, + ); + } + + const port = await getFreePort(); + const url = `http://127.0.0.1:${port}`; + + // Spawn the Electron binary itself as a plain Node process + // (ELECTRON_RUN_AS_NODE) instead of depending on a system Node install - + // the packaged app can't assume Node exists on the target machine, and + // this keeps dev/packaged behavior identical. + serverProcess = spawn(process.execPath, [serverEntry], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + PORT: String(port), + HOSTNAME: "127.0.0.1", + NODE_ENV: process.env.NODE_ENV || "production", + }, + stdio: "inherit", + }); + + serverProcess.on("exit", (code, signal) => { + if (code !== 0 && code !== null) { + console.error(`[electron] standalone server exited early (code=${code}, signal=${signal})`); + } + serverProcess = null; + }); + + await waitForServerReady(url); + return url; +} + +function stopStandaloneServer(): void { + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + } + serverProcess = null; +} + +async function createMainWindow(): Promise { + const url = await startStandaloneServer(); + + mainWindow = new BrowserWindow({ + width: 1280, + height: 860, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + mainWindow.on("closed", () => { + mainWindow = null; + }); + + await mainWindow.loadURL(url); +} + +app.whenReady().then(() => { + void createMainWindow(); +}); + +app.on("window-all-closed", () => { + stopStandaloneServer(); + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("before-quit", () => { + stopStandaloneServer(); +}); + +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + void createMainWindow(); + } +}); diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 00000000..af846ce0 --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,13 @@ +// Preload script for the VNCmail+ desktop shell. Runs in an isolated +// context with access to Node APIs, and exposes a minimal, explicit surface +// to the renderer via contextBridge - the renderer never gets direct Node or +// Electron access (contextIsolation + nodeIntegration: false, see main.ts). +// +// Walking-skeleton stub for now: just `isElectron`, so renderer code can +// detect it's running inside the desktop shell. A real API surface (native +// notifications, etc.) gets added on top of this bridge in a later step. +import { contextBridge } from "electron"; + +contextBridge.exposeInMainWorld("vnc", { + isElectron: true, +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index b268a47f..073bb7e9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,6 +53,18 @@ export default [ }, }, }, + { + // Plain Node scripts (electron bundling/packaging helpers) - not React/ + // browser code, so they get node globals only, no react/jsx parsing. + files: ["scripts/**/*.{mjs,cjs,js}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.node, + }, + }, + }, { files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"], languageOptions: { @@ -70,6 +82,8 @@ export default [ { ignores: [ ".next/**", + "dist-electron/**", + "dist-electron-builds/**", "node_modules/**", "repos/**", "data/admin/plugins/**", @@ -81,6 +95,12 @@ export default [ "benchmark/**", "examples/**", "integration/**", + // Independent sub-package with its own package.json/build (esbuild, + // browser-only globals) - same reasoning as repos/** and examples/** + // above. Pre-existing gap: this was blocking `npm run lint` (and thus + // the pre-commit hook) repo-wide before this Electron work even + // touched anything - see the electron-desktop branch's first commits. + "vnc/plugins/smime/**", ], }, ]; diff --git a/scripts/assemble-standalone.mjs b/scripts/assemble-standalone.mjs new file mode 100644 index 00000000..4422327f --- /dev/null +++ b/scripts/assemble-standalone.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// `next build --webpack` (see next.config.ts's `output: "standalone"`) +// emits .next/standalone/server.js but - deliberately, per Next's own docs - +// leaves out public/ and .next/static/. The Dockerfile copies both in by +// hand for the container image; this does the same thing for local Electron +// dev and packaging, so every path boots the exact same artifact. +import { cpSync, existsSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const standaloneDir = path.join(rootDir, ".next", "standalone"); + +if (!existsSync(standaloneDir)) { + console.error(`Missing ${standaloneDir} - run "next build --webpack" first.`); + process.exit(1); +} + +const publicSrc = path.join(rootDir, "public"); +const publicDest = path.join(standaloneDir, "public"); +rmSync(publicDest, { recursive: true, force: true }); +cpSync(publicSrc, publicDest, { recursive: true }); + +const staticSrc = path.join(rootDir, ".next", "static"); +const staticDest = path.join(standaloneDir, ".next", "static"); +rmSync(staticDest, { recursive: true, force: true }); +cpSync(staticSrc, staticDest, { recursive: true }); + +console.log("Assembled standalone server at", standaloneDir); diff --git a/scripts/build-electron.mjs b/scripts/build-electron.mjs new file mode 100644 index 00000000..18c1d857 --- /dev/null +++ b/scripts/build-electron.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// Bundles electron/main.ts and electron/preload.ts into dist-electron/*.js. +// Uses esbuild (already a devDependency for the admin plugin dev-bundler, +// lib/admin/plugin-dev.ts) rather than pulling in ts-node/tsx - the output +// is plain CommonJS, so the packaged app needs no separate TS runtime. +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +const shared = { + bundle: true, + platform: "node", + target: "node22", + format: "cjs", + sourcemap: true, + // `electron` is provided by the Electron runtime itself; `electron-updater` + // 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"], + logLevel: "info", +}; + +await build({ + ...shared, + entryPoints: [path.join(rootDir, "electron/main.ts")], + outfile: path.join(rootDir, "dist-electron/main.js"), +}); + +await build({ + ...shared, + entryPoints: [path.join(rootDir, "electron/preload.ts")], + outfile: path.join(rootDir, "dist-electron/preload.js"), +}); From 4ff15fffaa0ae7e9966b0227434d10e5c33b4049 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 12:42:19 +0200 Subject: [PATCH 02/21] chore(electron): wire package.json scripts + main entry, gitignore build output Follow-up to 218a584f - these edits (electron npm scripts, "main" field, electron/electron-builder/electron-updater deps, dist-electron/** gitignore) were made alongside that commit but got left unstaged when it landed. No behavior change beyond what that commit already described. --- .gitignore | 4 + package-lock.json | 2567 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 10 +- 3 files changed, 2559 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 18966410..cb8d310d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ yarn-error.log* # vercel .vercel +# electron (see electron/, scripts/build-electron.mjs, electron-builder.config.js) +/dist-electron/ +/dist-electron-builds/ + # typescript *.tsbuildinfo next-env.d.ts diff --git a/package-lock.json b/package-lock.json index 5b47f334..1f784acc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dompurify": "^3.4.12", + "electron-updater": "^6.8.9", "jalaali-js": "^2.0.0", "jszip": "^3.10.1", "lucide-react": "^1.8.0", @@ -64,6 +65,8 @@ "@typescript-eslint/parser": "^8.59.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/ui": "^4.1.5", + "electron": "^43.2.0", + "electron-builder": "^26.15.3", "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-plugin-react": "^7.37.5", @@ -628,6 +631,296 @@ "react": ">=16.8.0" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1899,6 +2192,19 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1949,6 +2255,61 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@napi-rs/canvas": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.0.tgz", @@ -2670,13 +3031,13 @@ } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", - "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", "license": "MIT", "dependencies": { - "asn1js": "^3.0.6", - "pvtsutils": "^1.3.6", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, @@ -2692,6 +3053,32 @@ "node": ">=8.0.0" } }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", @@ -2985,6 +3372,19 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@stablelib/binary": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", @@ -3214,6 +3614,19 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -4151,6 +4564,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4162,6 +4588,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4176,6 +4612,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4183,6 +4636,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -4221,6 +4691,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4628,6 +5108,26 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -4702,11 +5202,223 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/app-builder-lib/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -4887,6 +5599,23 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4897,6 +5626,23 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4913,6 +5659,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4920,6 +5673,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz", @@ -4942,12 +5716,28 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT" }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", @@ -5011,6 +5801,52 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -5020,6 +5856,35 @@ "node": ">=6.0.0" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5136,6 +6001,39 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -5153,6 +6051,19 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5180,6 +6091,39 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5211,6 +6155,15 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5361,7 +6314,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5391,6 +6343,35 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5398,6 +6379,16 @@ "dev": true, "license": "MIT" }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -5434,6 +6425,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5463,12 +6464,68 @@ "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5498,6 +6555,35 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5513,6 +6599,180 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "43.2.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", + "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-builder/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/electron-builder/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-builder/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.394", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz", @@ -5520,6 +6780,99 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/elliptic": { "version": "6.6.1", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", @@ -5541,6 +6894,16 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -5568,6 +6931,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -5752,6 +7135,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -6162,6 +7553,13 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fake-indexeddb": { "version": "6.2.5", "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", @@ -6202,6 +7600,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -6222,6 +7637,39 @@ "node": ">=16.0.0" } }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6276,6 +7724,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6400,6 +7886,22 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -6418,6 +7920,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6431,6 +7955,49 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/globals": { "version": "17.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", @@ -6474,11 +8041,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-bigints": { @@ -6573,9 +8165,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -6613,6 +8205,39 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -6626,6 +8251,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6640,6 +8272,20 @@ "node": ">= 14" } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6738,6 +8384,18 @@ "node": ">=8" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7178,6 +8836,19 @@ "dev": true, "license": "MIT" }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7203,6 +8874,24 @@ "node": ">= 0.4" } }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jalaali-js": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-2.0.0.tgz", @@ -7233,7 +8922,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, "funding": [ { "type": "github", @@ -7327,6 +9015,14 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7340,6 +9036,18 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -7378,6 +9086,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7684,6 +9398,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7704,6 +9438,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7743,6 +9487,20 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7760,6 +9518,52 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -7798,6 +9602,53 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -7812,7 +9663,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -8016,12 +9866,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "4.33.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", + "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -8051,6 +9924,74 @@ "semver": "bin/semver.js" } }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -8061,6 +10002,35 @@ "node": ">=18" } }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8180,6 +10150,16 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8234,6 +10214,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8316,6 +10306,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -8352,6 +10352,21 @@ "@napi-rs/canvas": "^1.0.0" } }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8431,6 +10446,21 @@ "node": ">=18" } }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -8491,6 +10521,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8529,12 +10589,46 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8554,6 +10648,18 @@ "dev": true, "license": "MIT" }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/prosemirror-changeset": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", @@ -8683,6 +10789,17 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8728,6 +10845,19 @@ "node": ">=10.13.0" } }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -8756,6 +10886,19 @@ "dev": true, "license": "MIT" }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -8860,6 +11003,31 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -8870,6 +11038,63 @@ "node": ">=4" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -8978,6 +11203,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -9001,7 +11245,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9010,6 +11253,31 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -9222,6 +11490,26 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -9247,6 +11535,16 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -9256,6 +11554,25 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -9263,6 +11580,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -9466,6 +11793,19 @@ } } }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9530,6 +11870,85 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9612,6 +12031,26 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -9648,6 +12087,16 @@ "node": ">=20" } }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9690,6 +12139,20 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -9818,6 +12281,44 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -9889,6 +12390,13 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10098,16 +12606,16 @@ } }, "node_modules/webcrypto-core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", - "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", - "asn1js": "^3.0.5", - "pvtsutils": "^1.3.5", - "tslib": "^2.7.0" + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" } }, "node_modules/webcrypto-liner": { @@ -10316,6 +12824,13 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -10326,6 +12841,16 @@ "node": ">=18" } }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index d31c24af..d80ab54d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "bulwark-webmail", "version": "1.7.8", + "main": "dist-electron/main.js", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", @@ -30,7 +31,11 @@ "test:translations": "vitest run --no-isolate lib/__tests__/translations.test.ts", "test:integration": "bash integration/run-tests.sh", "prepare": "husky", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "build:standalone": "next build --webpack && node scripts/assemble-standalone.mjs", + "build:electron": "node scripts/build-electron.mjs", + "electron:dev": "npm run build:standalone && npm run build:electron && electron .", + "test:electron": "playwright test -c playwright.electron.config.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -55,6 +60,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dompurify": "^3.4.12", + "electron-updater": "^6.8.9", "jalaali-js": "^2.0.0", "jszip": "^3.10.1", "lucide-react": "^1.8.0", @@ -88,6 +94,8 @@ "@typescript-eslint/parser": "^8.59.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/ui": "^4.1.5", + "electron": "^43.2.0", + "electron-builder": "^26.15.3", "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-plugin-react": "^7.37.5", From 9254a7fa204e40bf6ef3a6bb1fc851a07cd8c4e4 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 12:45:42 +0200 Subject: [PATCH 03/21] test(electron): smoke test as the regression gate for the desktop shell Phase 1 step 2 of VNCprodbuild. e2e/electron-smoke.spec.ts uses Playwright's _electron.launch() to boot the real skeleton (dist-electron/main.js from step 1) and asserts: - the login screen renders (same input[type="text"]/[type="password"] selectors as e2e/login.spec.ts's browser-based check) - zero uncaught page errors fire during load Sets JMAP_SERVER_URL (any non-empty value) so the app reaches lib/setup/state.ts's "env-managed" state and serves the normal login screen instead of 302ing to the first-run /setup wizard - no live mail server or mock JMAP build flag needed just to prove the shell renders. playwright.electron.config.ts is deliberately separate from playwright.config.ts: it has no `webServer` block, since this suite's app boots its own server and would otherwise race pointlessly with `npm run dev` starting on :3000 for the browser-based e2e/*.spec.ts suite. Wired as `npm run test:electron`. Verified green locally (2 passed) after `npm run build:standalone && npm run build:electron`; every later step in the Electron rollout must keep this passing before moving on. --- .gitignore | 4 +++ e2e/electron-smoke.spec.ts | 62 +++++++++++++++++++++++++++++++++++ playwright.electron.config.ts | 21 ++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 e2e/electron-smoke.spec.ts create mode 100644 playwright.electron.config.ts diff --git a/.gitignore b/.gitignore index cb8d310d..65c1f668 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,10 @@ yarn-error.log* /dist-electron/ /dist-electron-builds/ +# playwright output +/test-results/ +/playwright-report/ + # typescript *.tsbuildinfo next-env.d.ts diff --git a/e2e/electron-smoke.spec.ts b/e2e/electron-smoke.spec.ts new file mode 100644 index 00000000..8d93cc3b --- /dev/null +++ b/e2e/electron-smoke.spec.ts @@ -0,0 +1,62 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import type { ElectronApplication, Page } from '@playwright/test'; +import path from 'node:path'; + +// Regression gate for the Electron desktop shell (electron/main.ts + +// electron/preload.ts). Launches the real skeleton - the same standalone +// Next.js server artifact the Dockerfile produces, booted as a child +// process by main.ts, with a real BrowserWindow on top - and asserts the +// login screen renders with zero uncaught page errors. Every later step in +// the Electron rollout (notification bridge, realtime sync, packaging) must +// keep this green; run it before touching anything else. +// +// Requires `npm run build:standalone && npm run build:electron` to have run +// first (see package.json's `electron:dev`/`test:electron` scripts, which +// this suite assumes but does not itself trigger, matching how +// playwright.config.ts's browser suite assumes `npm run build` for its own +// prod-mode runs). +const projectRoot = path.resolve(__dirname, '..'); + +test.describe('Electron desktop shell', () => { + let electronApp: ElectronApplication; + let window: Page; + const pageErrors: Error[] = []; + + test.beforeAll(async () => { + electronApp = await electron.launch({ + args: [projectRoot], + env: { + ...process.env, + // Bypass the first-run setup wizard (lib/setup/state.ts's + // "bootstrap" state, which 302s everything to /setup) without + // needing a reachable JMAP server just to prove the login screen + // renders - any non-empty JMAP_SERVER_URL is enough to reach + // "env-managed" state and serve the normal app shell. + JMAP_SERVER_URL: 'https://stalwart.sandbox.vnc.de', + SESSION_SECRET: 'electron-smoke-test-not-for-production', + NODE_ENV: 'production', + }, + }); + + window = await electronApp.firstWindow(); + window.on('pageerror', (error) => { + pageErrors.push(error); + }); + await window.waitForLoadState('domcontentloaded'); + }); + + test.afterAll(async () => { + await electronApp?.close(); + }); + + test('boots the standalone server and renders the login screen', async () => { + // Same selectors as e2e/login.spec.ts's browser-based check - the + // shell should render the identical login form, not a different view. + await expect(window.locator('input[type="text"]')).toBeVisible({ timeout: 20000 }); + await expect(window.locator('input[type="password"]')).toBeVisible(); + }); + + test('produces zero uncaught page errors', () => { + expect(pageErrors).toEqual([]); + }); +}); diff --git a/playwright.electron.config.ts b/playwright.electron.config.ts new file mode 100644 index 00000000..d0e0a598 --- /dev/null +++ b/playwright.electron.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test'; + +// Separate from playwright.config.ts on purpose: the Electron smoke suite +// launches its own app (which boots its own standalone Next.js server via +// electron/main.ts - see scripts/build-electron.mjs), so it must NOT inherit +// the main config's `webServer` (which starts `npm run dev` on :3000 for the +// browser-based e2e/*.spec.ts suite) - the two would fight over nothing but +// still waste time starting a server this suite never touches. +export default defineConfig({ + testDir: './e2e', + testMatch: 'electron-smoke.spec.ts', + timeout: 60000, + retries: 0, + use: { + trace: 'retain-on-failure', + }, + // Electron tests drive their own app windows via the `_electron` fixture, + // not a browser project - one worker keeps main-process/server startup + // logs and any zombie processes easy to reason about. + workers: 1, +}); From b8f668d25a41d8ac98a6ffce95ca64f284e7d4ea Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 12:48:30 +0200 Subject: [PATCH 04/21] feat(electron): native notification bridge over contextBridge/IPC Phase 1 step 3 of VNCprodbuild. electron/preload.ts's contextBridge now exposes window.vnc.showNotification(title, options), routed via ipcRenderer.invoke("vnc:show-notification") to a new ipcMain.handle in electron/main.ts that calls Electron's own Notification API. This is the desktop shell's native notification path - it sits alongside, not in place of, the browser/PWA's service-worker push path (public/sw.js's push/ notificationclick handlers + lib/web-push.ts), which is untouched. lib/electron-bridge.ts gives the renderer a `isElectronShell()` + `showElectronNotification()` wrapper so app code can detect the shell and use the native path instead of/alongside SW push - not wired to any real mail-delivery trigger yet, that's Phase 1 steps 4-6 (JMAP realtime capability investigation, the background/foreground strategy decision, and implementing it). Extended e2e/electron-smoke.spec.ts to prove the IPC plumbing actually fires end-to-end: calls window.vnc.showNotification from the renderer and asserts the round-trip resolves (not that a real OS toast appears - not observable in CI). Verified locally: the call resolves {"shown":true} on this machine, confirming it genuinely reaches Electron's Notification API and back, not just that window.vnc exists. Also fixes a real bug caught by this step's typecheck: the smoke test's Playwright Page variable was named `window`, shadowing the DOM global inside every evaluate() callback and silently breaking their types. Renamed to `appWindow`. All 4 smoke-test assertions green: npm run build:electron && npm run test:electron. --- e2e/electron-smoke.spec.ts | 40 ++++++++++++++++++++++----- electron/main.ts | 27 ++++++++++++++++++- electron/preload.ts | 24 +++++++++++++---- lib/electron-bridge.ts | 55 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 lib/electron-bridge.ts diff --git a/e2e/electron-smoke.spec.ts b/e2e/electron-smoke.spec.ts index 8d93cc3b..92dcf65a 100644 --- a/e2e/electron-smoke.spec.ts +++ b/e2e/electron-smoke.spec.ts @@ -19,7 +19,11 @@ const projectRoot = path.resolve(__dirname, '..'); test.describe('Electron desktop shell', () => { let electronApp: ElectronApplication; - let window: Page; + // Named `appWindow`, not `window` - the latter would shadow the DOM + // global inside every `appWindow.evaluate(() => window...)` callback + // below, silently breaking their typing (evaluate() callbacks run in the + // browser context, where `window` must resolve to the DOM global). + let appWindow: Page; const pageErrors: Error[] = []; test.beforeAll(async () => { @@ -38,11 +42,11 @@ test.describe('Electron desktop shell', () => { }, }); - window = await electronApp.firstWindow(); - window.on('pageerror', (error) => { + appWindow = await electronApp.firstWindow(); + appWindow.on('pageerror', (error) => { pageErrors.push(error); }); - await window.waitForLoadState('domcontentloaded'); + await appWindow.waitForLoadState('domcontentloaded'); }); test.afterAll(async () => { @@ -52,11 +56,35 @@ test.describe('Electron desktop shell', () => { test('boots the standalone server and renders the login screen', async () => { // Same selectors as e2e/login.spec.ts's browser-based check - the // shell should render the identical login form, not a different view. - await expect(window.locator('input[type="text"]')).toBeVisible({ timeout: 20000 }); - await expect(window.locator('input[type="password"]')).toBeVisible(); + await expect(appWindow.locator('input[type="text"]')).toBeVisible({ timeout: 20000 }); + await expect(appWindow.locator('input[type="password"]')).toBeVisible(); + }); + + test('exposes the contextBridge API to the renderer', async () => { + const isElectron = await appWindow.evaluate(() => window.vnc?.isElectron); + expect(isElectron).toBe(true); }); test('produces zero uncaught page errors', () => { expect(pageErrors).toEqual([]); }); + + test('the native notification bridge round-trips through IPC', async () => { + // Not asserting a real OS toast appears - that isn't observable in CI + // (headless runners/CI accounts routinely have no notification + // permission, and Notification.isSupported() can legitimately be + // false). What matters is that window.vnc.showNotification (exposed by + // electron/preload.ts's contextBridge) actually reaches the main + // process's ipcMain.handle("vnc:show-notification", ...) and resolves - + // proving the renderer -> preload -> main -> Electron Notification API + // plumbing is wired, not just that `window.vnc` exists. + const result = await appWindow.evaluate(async () => { + return window.vnc?.showNotification('Electron smoke test', { + body: 'IPC round-trip check', + }); + }); + + expect(result).toBeDefined(); + expect(typeof result?.shown).toBe('boolean'); + }); }); diff --git a/electron/main.ts b/electron/main.ts index 5fd15918..a62a252d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,7 +6,7 @@ // random localhost port, then opens a BrowserWindow pointed at it. This is // deliberately the same server, not a reimplementation - lib/jmap/client.ts // and every app/api/** route behave identically to the web deployment. -import { app, BrowserWindow } from "electron"; +import { app, BrowserWindow, ipcMain, Notification } from "electron"; import { spawn, type ChildProcess } from "node:child_process"; import { createServer } from "node:net"; import { get as httpGet } from "node:http"; @@ -131,6 +131,31 @@ async function createMainWindow(): Promise { await mainWindow.loadURL(url); } +// --- Native notification bridge -------------------------------------------- +// Called from the preload's `window.vnc.showNotification` (electron/preload.ts). +// Electron's own Notification API is the desktop shell's notification path - +// it sits alongside, not in place of, the browser/PWA's service-worker push +// path (public/sw.js's `push`/`notificationclick` handlers + lib/web-push.ts). +// Which of the two actually gets wired up to real mail-delivery events is a +// separate decision (VNCprodbuild Phase 1 steps 4-6); this handler is just +// the plumbing that lets the renderer trigger a native OS notification at +// all, so it can be exercised end-to-end from a smoke test now instead of +// bolted on untested later. +ipcMain.handle( + "vnc:show-notification", + (_event, title: string, options?: { body?: string; tag?: string }) => { + if (!Notification.isSupported()) { + return { shown: false }; + } + const notification = new Notification({ + title, + body: options?.body ?? "", + }); + notification.show(); + return { shown: true }; + }, +); + app.whenReady().then(() => { void createMainWindow(); }); diff --git a/electron/preload.ts b/electron/preload.ts index af846ce0..867bcd0a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -2,12 +2,26 @@ // context with access to Node APIs, and exposes a minimal, explicit surface // to the renderer via contextBridge - the renderer never gets direct Node or // Electron access (contextIsolation + nodeIntegration: false, see main.ts). -// -// Walking-skeleton stub for now: just `isElectron`, so renderer code can -// detect it's running inside the desktop shell. A real API surface (native -// notifications, etc.) gets added on top of this bridge in a later step. -import { contextBridge } from "electron"; +import { contextBridge, ipcRenderer } from "electron"; + +export interface ShowNotificationOptions { + body?: string; + tag?: string; +} + +export interface ShowNotificationResult { + shown: boolean; +} contextBridge.exposeInMainWorld("vnc", { isElectron: true, + // Routes to Electron's own Notification API in main.ts (ipcMain.handle + // "vnc:show-notification"). This is the desktop shell's native + // notification path - it does not replace lib/web-push.ts's Web Push + // (VAPID) path, which is what the browser/PWA deployment still uses. + showNotification: ( + title: string, + options?: ShowNotificationOptions, + ): Promise => + ipcRenderer.invoke("vnc:show-notification", title, options), }); diff --git a/lib/electron-bridge.ts b/lib/electron-bridge.ts new file mode 100644 index 00000000..fb214235 --- /dev/null +++ b/lib/electron-bridge.ts @@ -0,0 +1,55 @@ +// Detects whether the app is running inside the VNCmail+ (Bulwark) Electron +// desktop shell and wraps the native notification bridge that +// electron/preload.ts exposes via contextBridge. Mirrors how lib/web-push.ts +// mirrors the React Native push flow - same idea, different native API: +// PushManager/service-worker there, Electron's own Notification API here. +// +// 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; + tag?: string; +} + +export interface ShowNotificationResult { + shown: boolean; +} + +export interface VncElectronBridge { + isElectron: true; + showNotification: ( + title: string, + options?: ShowNotificationOptions, + ) => Promise; +} + +declare global { + interface Window { + vnc?: VncElectronBridge; + } +} + +export function isElectronShell(): boolean { + return typeof window !== "undefined" && window.vnc?.isElectron === true; +} + +/** + * Shows a notification via Electron's native Notification API when running + * inside the desktop shell. Resolves to false (never throws) when not + * running in Electron, or when the main process reports notifications + * unsupported on this OS/session - callers can fall back to the + * service-worker push path (lib/web-push.ts) in that case. + */ +export async function showElectronNotification( + title: string, + options?: ShowNotificationOptions, +): Promise { + if (!isElectronShell()) return false; + const result = await window.vnc!.showNotification(title, options); + return result.shown; +} From 4d817ea93271ccdc38b94a7585c8027c9feb0532 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 12:54:43 +0200 Subject: [PATCH 05/21] feat(electron): packaging targets - mac/win/linux, unsigned Phase 1 step 6 of VNCprodbuild. electron-builder.config.js now has real targets: mac (dmg, zip; x64+arm64), Windows (nsis; x64), Linux (AppImage, deb; x64). Still no code signing (step 9 - needs an Apple Developer ID and optionally a Windows cert, both human-owned purchases). Icon wired from public/icon-512x512.png (the existing PWA manifest icon) - electron-builder generates .icns/.ico from it automatically. This is a stand-in, not a dedicated app icon: it's only 512x512 (the macOS icns's largest slot wants 1024x1024+), and public/branding/Bulwark_Icon_App.svg looks like the actual intended master for this, but it's a vector file and this environment has no SVG rasterizer (rsvg-convert/ImageMagick/Inkscape) to export it at high res. Flagged in the config's comments; someone with the right tooling (or a designer) should export that SVG at 1024x1024+ and swap the `icon` path. Caught and fixed a real bug by actually running a --dir build rather than just trusting the config: app-builder-lib's extraResources copy unconditionally drops any directory literally named "node_modules" sitting at the copy root (node_modules/app-builder-lib/out/util/filter.js), so the naive `from: ".next/standalone"` silently stripped the standalone server's own node_modules and the packaged app crashed with "Cannot find module 'next'" on launch. Fixed by copying from one level up (`from: ".next"` with a `standalone/**/*` filter) so "node_modules" is never the literal copy root. Verified by launching the packaged --dir mac build directly - it boots the standalone server and serves the app with no errors, same as the unpackaged dev flow. --- electron-builder.config.js | 75 ++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/electron-builder.config.js b/electron-builder.config.js index 108bde32..4638e607 100644 --- a/electron-builder.config.js +++ b/electron-builder.config.js @@ -1,12 +1,17 @@ -// Base electron-builder config - Phase 1 step 1 of the VNCprodbuild rollout. -// No code signing and no platform targets configured yet; this exists only -// to prove the packaging concept end to end (the app runs, boots its own -// server, opens a window). Targets (dmg/zip/nsis/AppImage/deb), branding -// icons, and code signing are wired up in later steps - see -// ~/.claude/skills/VNCprodbuild/SKILL.md, Phase 1 steps 6-9. +// electron-builder config for the VNCmail+ (Bulwark) desktop shell. +// +// Phase 1 of the VNCprodbuild rollout (~/.claude/skills/VNCprodbuild/SKILL.md): +// step 1 - base config, no targets (superseded by this file) +// step 6 - this file: real packaging targets + branding icon (below) +// step 7 - electron-updater wiring (GitHub Releases feed) - adds a +// `publish` block on top of this file in a later commit. +// step 9 - still open: code signing / notarization (Apple Developer ID, +// optional Windows cert) - both are human-owned purchases, not +// configured here. Builds below ship UNSIGNED. module.exports = { appId: "de.vnc.vncmailplus", productName: "VNCmail+", + copyright: "Copyright © VNC AG", directories: { output: "dist-electron-builds", }, @@ -16,8 +21,62 @@ module.exports = { // Same artifact the Dockerfile bakes into the container image (see // Dockerfile + scripts/assemble-standalone.mjs). electron/main.ts // reads it from process.resourcesPath in packaged builds. - from: ".next/standalone", - to: "standalone", + // + // Deliberately `from: ".next"` (not ".next/standalone") + a filter, + // not the more obvious `from: ".next/standalone"` alone: + // app-builder-lib's copy filter unconditionally drops a directory + // literally named "node_modules" sitting at the copy root (see + // node_modules/app-builder-lib/out/util/filter.js's + // `relative === "node_modules"` check - it assumes extraResources are + // hand-authored assets, not a pre-built server with a traced + // node_modules of its own). Copying from one level up so + // "standalone/node_modules" is never the literal copy root sidesteps + // that check, so the standalone server's node_modules actually + // survives into the packaged app instead of getting silently + // stripped (caught by manually launching a --dir build - the packaged + // server crashed with "Cannot find module 'next'"). + from: ".next", + filter: ["standalone/**/*"], + to: ".", }, ], + // STAND-IN ICON, not a dedicated app icon: public/icon-512x512.png is the + // PWA manifest icon (512x512 square PNG). electron-builder can generate + // .icns/.ico from a single square PNG at build time (see + // node_modules/app-builder-lib/out/util/iconConverter.js), so this + // produces working icons for every target below - but at only 512x512, + // the largest macOS icns representation (1024x1024 "ICON512@2x") gets + // upsampled and will look soft compared to a real 1024x1024+ source. + // public/branding/Bulwark_Icon_App.svg looks like the intended master for + // this (as opposed to Bulwark_Favicon.png, sized for browser tabs), but + // it's vector and this environment has no SVG rasterizer (rsvg-convert / + // ImageMagick / Inkscape) to turn it into a proper 1024x1024 PNG. A human + // (or a follow-up step with the right tooling) should export + // Bulwark_Icon_App.svg at 1024x1024 and point `icon` at that instead. + icon: "public/icon-512x512.png", + mac: { + target: [ + { target: "dmg", arch: ["x64", "arm64"] }, + { target: "zip", arch: ["x64", "arm64"] }, + ], + category: "public.app-category.productivity", + // No Apple Developer ID yet (VNCprodbuild step 9) - ship unsigned/ + // un-notarized for now. hardenedRuntime is meaningless without signing + // but left explicit so it's obvious what step 9 needs to flip on. + hardenedRuntime: false, + }, + win: { + target: [{ target: "nsis", arch: ["x64"] }], + }, + nsis: { + oneClick: false, + allowToChangeInstallationDirectory: true, + }, + linux: { + target: [ + { target: "AppImage", arch: ["x64"] }, + { target: "deb", arch: ["x64"] }, + ], + category: "Network;Email;", + }, }; From cab43b8d061e6c778bec9be756e1d4e46da9b50c Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 12:57:13 +0200 Subject: [PATCH 06/21] feat(electron): auto-update via electron-updater + GitHub Releases Phase 1 step 7 of VNCprodbuild. electron/main.ts calls autoUpdater.checkForUpdatesAndNotify() once the app is ready, only for packaged builds (app.isPackaged) - dev/test runs have no latest.yml and would just log a noisy 404 on every launch. electron-builder.config.js gets a matching `publish` block pointing at this repo's own GitHub Releases (brvncde-dotcom/vncmail-plus) - the skill's recommendation over standing up a new distribution channel, since the repo is already private. Flagged as the "light decision" the skill calls it, not blocking. Deliberately defensive: no code signing yet (step 9), so update verification can fail on macOS in particular. Wrapped in try/catch + autoUpdater's "error" event so a failed check is logged and swallowed, never fatal - this is background maintenance, not something the user should be blocked on. Verified with a --dir packaged build: checkForUpdatesAndNotify() throws ENOENT for app-update.yml (expected - that file is only emitted by a full `electron-builder build`, not --dir) and the error handling swallows it cleanly; the standalone server still boots and serves the app normally. npm run test:electron still green (4/4) - autoUpdater is a no-op in the unpacked dev/test path this suite exercises. --- electron-builder.config.js | 16 ++++++++++++++-- electron/main.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/electron-builder.config.js b/electron-builder.config.js index 4638e607..da98fb9e 100644 --- a/electron-builder.config.js +++ b/electron-builder.config.js @@ -3,8 +3,8 @@ // Phase 1 of the VNCprodbuild rollout (~/.claude/skills/VNCprodbuild/SKILL.md): // step 1 - base config, no targets (superseded by this file) // step 6 - this file: real packaging targets + branding icon (below) -// step 7 - electron-updater wiring (GitHub Releases feed) - adds a -// `publish` block on top of this file in a later commit. +// step 7 - this file's `publish` block + electron/main.ts's +// setupAutoUpdater() - electron-updater against GitHub Releases. // step 9 - still open: code signing / notarization (Apple Developer ID, // optional Windows cert) - both are human-owned purchases, not // configured here. Builds below ship UNSIGNED. @@ -79,4 +79,16 @@ module.exports = { ], category: "Network;Email;", }, + // electron-updater feed (see electron/main.ts's setupAutoUpdater()). + // GitHub Releases, not a new distribution channel - the skill's + // recommendation since this repo is already private and this needs no + // extra infrastructure. "Light decision" per VNCprodbuild step 7, not + // blocking, but flagged: switching later (e.g. to a self-hosted update + // server) would mean revisiting this block and the `provider` electron- + // updater talks to. + publish: { + provider: "github", + owner: "brvncde-dotcom", + repo: "vncmail-plus", + }, }; diff --git a/electron/main.ts b/electron/main.ts index a62a252d..b051f198 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -7,6 +7,7 @@ // deliberately the same server, not a reimplementation - lib/jmap/client.ts // and every app/api/** route behave identically to the web deployment. import { app, BrowserWindow, ipcMain, Notification } from "electron"; +import { autoUpdater } from "electron-updater"; import { spawn, type ChildProcess } from "node:child_process"; import { createServer } from "node:net"; import { get as httpGet } from "node:http"; @@ -156,8 +157,36 @@ ipcMain.handle( }, ); +// --- Auto-update ------------------------------------------------------- +// GitHub Releases as the update feed (electron-builder.config.js's +// `publish` block) - the skill's recommendation over standing up a new +// distribution channel, since the repo is already private. "Light +// decision" per VNCprodbuild step 7, not re-litigated here. +// +// Deliberately best-effort: there's no code signing yet (step 9), so on +// macOS in particular an update download/install can fail signature +// verification. A failed check must never take the app down - it's +// background maintenance, not something the user is blocked on. +function setupAutoUpdater(): void { + if (!app.isPackaged) { + // Unpacked dev/test runs (npm run electron:dev, the Playwright smoke + // test) have no latest.yml alongside them - checking would just log a + // noisy 404 against GitHub Releases for every dev run. + return; + } + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.on("error", (error) => { + console.error("[electron] auto-update error:", error); + }); + autoUpdater.checkForUpdatesAndNotify().catch((error) => { + console.error("[electron] checkForUpdatesAndNotify failed:", error); + }); +} + app.whenReady().then(() => { void createMainWindow(); + setupAutoUpdater(); }); app.on("window-all-closed", () => { From 0bb098438a5a9a3864d270c77516cc9b41979e47 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 12:58:41 +0200 Subject: [PATCH 07/21] ci(electron): GitHub Actions matrix build - mac/win/linux, unsigned Phase 1 step 8 of VNCprodbuild. New workflow, additive to the existing docker-publish*.yml/standalone-release.yml (which only ever built the Docker image / standalone tarball, never the desktop shell). Matrix over macos-latest/windows-latest/ubuntu-latest. Each leg: npm ci, build:standalone, build:electron, then npm run test:electron (the Phase 1 step 2 smoke test) as a REQUIRED gate before packaging or any artifact-upload step - a platform-specific regression fails the leg it breaks instead of slipping through because only one OS was ever smoke-tested. Linux needs an explicit Xvfb install first (no display server on that runner by default); macOS/Windows runners have one. Triggers on release-published (packages + publishes to that release via electron-builder's --publish always, matching standalone-release.yml's `gh release upload` precedent but through electron-builder's own GitHub publish provider) and workflow_dispatch (packages only, uploads a build artifact instead, --publish never). Ships unsigned - CSC_IDENTITY_AUTO_DISCOVERY: "false" stops electron-builder from probing for a macOS identity that doesn't exist (VNCprodbuild step 9: no Apple Developer ID or Windows cert yet, both human-owned purchases). Structured so signing needs no rewrite later - just add CSC_LINK/ CSC_KEY_PASSWORD (macOS) and/or WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD (Windows) as repo secrets once those exist. --- .github/workflows/electron-build.yml | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/electron-build.yml diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml new file mode 100644 index 00000000..b800a718 --- /dev/null +++ b/.github/workflows/electron-build.yml @@ -0,0 +1,89 @@ +name: Build Electron Desktop App + +# Phase 1 of the VNCprodbuild rollout (~/.claude/skills/VNCprodbuild/SKILL.md +# on the machine that authored this - Phase 1 step 8). Builds the desktop +# shell (electron/) for macOS, Windows, and Linux on every release, or +# on-demand via workflow_dispatch for a one-off test build. +# +# Ships UNSIGNED. There's no Apple Developer ID or Windows code-signing cert +# yet (VNCprodbuild Phase 1 step 9 - both are human-owned purchases, not +# something CI can provide). CSC_IDENTITY_AUTO_DISCOVERY: "false" below stops +# electron-builder from probing for a macOS signing identity it won't find. +# Adding real certs later needs no rewrite here - just add CSC_LINK/ +# CSC_KEY_PASSWORD (macOS) and/or WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD (Windows) +# as repo secrets and electron-builder picks them up automatically. + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build standalone Next.js server + run: npm run build:standalone + + - name: Bundle Electron main/preload + run: npm run build:electron + + # Only Linux runners lack a display server by default - macOS/Windows + # GitHub-hosted runners can launch a real (if headless) GUI session + # without one. + - name: Install Xvfb (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y xvfb + + # Required gate (VNCprodbuild Phase 1 step 2) before any packaging or + # artifact-upload step below, on every OS in the matrix - a + # platform-specific regression in electron/main.ts (path handling, + # spawn behavior, etc.) should fail exactly the leg it breaks, not + # slip through because only one OS was ever smoke-tested. + - name: Run Electron smoke test (Linux, via Xvfb) + if: runner.os == 'Linux' + run: xvfb-run --auto-servernum npm run test:electron + + - name: Run Electron smoke test + if: runner.os != 'Linux' + run: npm run test:electron + + - name: Package + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: npx electron-builder --config electron-builder.config.js --publish ${{ github.event_name == 'release' && 'always' || 'never' }} + + - name: Upload artifact (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: vncmail-plus-desktop-${{ matrix.os }} + path: | + dist-electron-builds/*.dmg + dist-electron-builds/*.zip + dist-electron-builds/*.exe + dist-electron-builds/*.AppImage + dist-electron-builds/*.deb + retention-days: 7 + if-no-files-found: ignore From 568b7137ea2da7b565ca3bf4f97df06aacd33715 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 13:08:58 +0200 Subject: [PATCH 08/21] docs: extensive build manual for the native/desktop client program Consolidates the repo map, architecture recap, full decision log, Phase 1/2 status, remaining roadmap, and known landmines into one canonical reference, so this doesn't live only in chat history or session memory. --- docs/VNCMAIL-NATIVE-BUILD-MANUAL.md | 280 ++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 docs/VNCMAIL-NATIVE-BUILD-MANUAL.md diff --git a/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md b/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md new file mode 100644 index 00000000..49d6a649 --- /dev/null +++ b/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md @@ -0,0 +1,280 @@ +# VNCmail+ Native & Desktop Client — Build Manual + +Status: living document, last updated 2026-08-04. This is the canonical reference for the +program that takes VNCmail+ (Bulwark) beyond the hosted webmail: an Electron desktop client, a +React Native mobile client, and a self-hosted push relay, working toward true offline mail with +an encrypted local index. It consolidates everything decided and built so far across three +repositories, so nothing lives only in chat history or a session's memory. + +Companion documents: +- `docs/OFFLINE-CLIENT-ARCHITECTURE.md` (in `~/vncmail-plus`) — the original gap analysis this + program is based on. +- `~/.claude/skills/VNCprodbuild/SKILL.md` — the step-by-step build plan this manual reports + progress against. That file is the operational checklist; this file is the narrative reference. + +--- + +## 1. Why this program exists + +Bulwark/VNCmail+ is a Next.js JMAP webmail app. As shipped, it has zero offline capability: the +service worker caches nothing by design, there's no local mail store, no local search index, and +no mobile or desktop native client. The goal of this program is to change that — ship a desktop +app, a mobile app, real push notifications, and (eventually) a true offline-first local data +layer with an encrypted search index — without re-deriving work that already exists upstream or +duplicating effort across repos. + +The single most important strategic fact discovered along the way: **an upstream React Native +mobile client already exists and already solves most of what looked like the hardest problems** +(auth, multi-account, device pairing, Android push). Building a second mobile client from +scratch (e.g. wrapping the webmail in Capacitor) would have thrown that away for no reason. The +whole shape of this program reflects that discovery — see §4. + +## 2. Repository map + +All three repos are AGPL-3.0 forks of the upstream Bulwark project (`bulwarkmail` on GitHub), +owned by `brvncde-dotcom`: + +| Repo | Forked from | Purpose | Local path | +|---|---|---|---| +| `vncmail-plus` | `bulwarkmail/webmail` | The Next.js webmail app itself — mail, calendar, contacts, files, admin, plugins. Deploys as a container on microk8s at `vncmail.sandbox.vnc.de`. | `~/vncmail-plus` (⚠️ shared checkout — see §8) | +| `vncmail-native` | `bulwarkmail/native` | React Native/Expo mobile client (Android + iOS). Beta/WIP upstream. | `~/vncmail-native` | +| `vncmail-relay` | `bulwarkmail/relay` | Push notification relay — terminates JMAP `PushSubscription` pushes, forwards to FCM (mobile) or Web Push (PWA/desktop). Self-hosted per the decision in §4. | `~/vncmail-relay` | + +The webmail's Electron desktop work happens in a **dedicated worktree**, not the shared +checkout directly: `~/worktrees/vncmail-electron`, branch `claude/electron-desktop`, based off +`vncmail-plus`'s `dev` branch. This will eventually become a PR into `dev`. + +Backend: Stalwart Mail Server (`stalwartlabs/mail-server`), sandbox instance at +`stalwart.sandbox.vnc.de`, speaking JMAP (mail/calendar/contacts), SMTP, IMAP, and ManageSieve. + +## 3. Architecture recap + +**The core blocker for "true offline":** `lib/jmap/client.ts` in the webmail is pure `fetch()`, +zero Node dependencies — it's portable into any WebView, Electron renderer, or React Native +context unchanged. But everything *around* it in the webmail — auth-cookie encryption +(`lib/auth/crypto.ts`, uses Node's `node:crypto`), the push relay wiring, and every `app/api/**` +route — is server-dependent. A native shell that just points a WebView at a bundled static +export of the webmail won't work without either: + +- **Option A — remote shell.** The native wrapper loads the *hosted* URL. Fast, gets native push + and an installable binary, but requires connectivity for every screen — not offline. +- **Option B — true offline-first.** The client authenticates and syncs JMAP data directly against + Stalwart, keeps a local encrypted store, and only needs connectivity to *sync*, not to *read*. + +For **desktop**, this fork-in-the-road barely matters: Electron can bundle the webmail's own +standalone Next.js server (the same artifact the `Dockerfile` already produces for the Docker +image) inside its Node runtime and point a `BrowserWindow` at `localhost`. That's Option A and B +at the same time, practically for free — see §5. + +For **mobile**, the fork-in-the-road is real, which is why §4's discovery mattered so much: it +meant Option A was already mostly done upstream, letting the plan skip straight to figuring out +what Option B (the real offline engine) needs — instead of re-building Option A from scratch in +Capacitor first. + +## 4. Decision log + +Every entry here was an explicit `[DECISION]` gate in the `VNCprodbuild` skill — resolved either +by direct research/verification or by explicit user sign-off. Dates are when each was resolved. + +| Date | Decision | Resolution | Why | +|---|---|---|---| +| 2026-08-04 | Does an offline cache need to support multiple accounts per device? | **Yes** | The webmail already has an `account-registry` store; the mobile offline cache must isolate per-account, including per-account SQLCipher keys later. | +| 2026-08-04 | Does an upstream React Native app already solve push/pairing? | **Yes — `bulwarkmail/native`** has multi-account JMAP auth, full QR cross-device pairing, and Android FCM push via `bulwarkmail/relay`. Forked to `vncmail-native`. | Duplicating working auth/pairing/push code in a fresh Capacitor wrapper has no upside. **This flipped the entire mobile strategy** — see §6. | +| 2026-08-04 | Self-host the push relay, or depend on upstream's shared hosted instance? | **Self-host.** Forked `bulwarkmail/relay` → `vncmail-relay`. | Keeps push metadata (FCM tokens, timing) on VNC infrastructure rather than a third party's. | +| 2026-08-04 | Which SQLite library for the eventual SQLCipher-backed local index, and what Expo workflow? | **`expo-sqlite`'s official `useSQLCipher` config-plugin option** (verified via web search — Android/iOS/macOS support, [docs](https://docs.expo.dev/versions/latest/sdk/sqlite/)), not a third-party binding. **Stay Continuous Native Generation** (don't commit `ios`/`android`, let `expo prebuild` regenerate them) rather than going fully bare. | The official plugin already covers this. SQLCipher is unusable in Expo Go — day-to-day development necessarily moves to a custom dev client, which the user explicitly accepted. Going bare would turn every future merge from upstream `bulwarkmail/native` into a native-project merge conflict, for no offsetting benefit. | +| 2026-08-04 | Electron's background/foreground notification strategy: JMAP WebSocket push, polling, or quit-to-tray? | **JMAP WebSocket push.** Confirmed live and available: the Stalwart sandbox's JMAP session resource advertises `urn:ietf:params:jmap:websocket` with `supportsPush: true` (`wss://stalwart.sandbox.vnc.de/jmap/ws`), independently confirmed by two separate build agents. | Lower latency, no polling/backoff logic to write, and it's not hypothetical — it's live on the server this build already targets. | +| 2026-08-04 | Code-signing: Apple Developer ID? Windows cert? | **Apple: yes** (also unblocks Phase 2's iOS push) — **human must actually enroll**, no agent can create the account or pay the ~$99/yr fee. **Windows: yes, eventually** — but ship unsigned for now during this build phase. | Signing is a purchase/account-creation action, categorically outside what an agent can do. | + +## 5. Phase 1 — Electron desktop client + +**Location:** `~/worktrees/vncmail-electron`, branch `claude/electron-desktop`, 7+ commits ahead +of `dev` as of this writing. Not pushed, no PR yet — review the worktree directly first. + +### What exists + +- `electron/main.ts` — boots the exact standalone Next.js server artifact the `Dockerfile` + already produces, as a child process on a random localhost port; opens a `BrowserWindow` + pointed at it. No parallel server-bundling approach was invented. +- `electron/preload.ts` — `contextBridge` exposing `window.vnc.isElectron` and + `window.vnc.showNotification(title, options)`, wired to Electron's native `Notification` API in + the main process. +- `e2e/electron-smoke.spec.ts` + `playwright.electron.config.ts` — the regression gate, using + Playwright's `_electron.launch()`. Run via `npm run test:electron`. Verified green, including + against a real packaged (`--dir`) build, not just the dev skeleton. +- `electron-builder.config.js` + `scripts/assemble-standalone.mjs` + `scripts/build-electron.mjs` + — packaging for macOS (`dmg`/`zip`, x64+arm64), Windows (`nsis`), Linux (`AppImage`/`deb`). + Currently unsigned. +- `electron-updater` wired against GitHub Releases (`brvncde-dotcom/vncmail-plus`), defensively + wrapped so a failed update check never crashes the app. +- `.github/workflows/electron-build.yml` — CI matrix (mac/win/linux) with `npm run test:electron` + as a required gate before packaging/upload. + +### How to build and run it locally + +```bash +cd ~/worktrees/vncmail-electron +npm install +npm run electron:dev # dev loop against the local Next dev server +npm run build:standalone # produces the standalone server artifact (same as Docker uses) +npm run build:electron # packages via electron-builder (unsigned) +npm run test:electron # the smoke-test regression gate +``` + +### Two real bugs found and fixed while building this (worth knowing about) + +1. **Repo-wide eslint gap.** `vnc/plugins/smime` (an independent sub-package) was missing from + the eslint ignore list, so `npm run lint` / the husky pre-commit hook failed for *any* commit + touching that path on `dev`, regardless of what changed. Fixed alongside `repos/**`/ + `examples/**`. **Flag this for whoever reviews the eventual PR** — it's a shared-config fix + unrelated to Electron, worth landing on `dev` on its own merits. +2. **electron-builder `extraResources` footgun.** electron-builder's resource-copy step + unconditionally drops any directory literally named `node_modules` when copying + `extraResources` — it was silently stripping the bundled standalone server's dependencies and + crashing on launch with `Cannot find module 'next'`. Caught only because the build was + actually launched and tested, not just configured. Worth remembering for any future + electron-builder work generally, not just this project. + +### Still open + +- **JMAP WebSocket push implementation** (skill steps 6-7) — decision resolved (§4), build not + yet done as of this manual's last update; check the skill's status log or task tracker for + current state. +- **Code signing** — blocked on the human actually enrolling in the Apple Developer Program + (§4). Once done, wiring the signing identity + notarization into `electron-builder` and CI + secrets is a config change, not a rewrite — the current config is structured for it. +- **App icon** — using the 512×512 PWA icon as a stand-in. + `public/branding/Bulwark_Icon_App.svg` should be rasterized at 1024×1024+ for a proper icon; no + SVG rasterization tooling was available in-agent. +- **Internal dogfood gate** — a human should install an unsigned build locally and sign off on + UX before this goes any further (wider rollout, PR, etc.). + +## 6. Phase 2 — Native mobile client + push relay + +### 6.1 `vncmail-native` — what it already had vs. what this program added + +Upstream `bulwarkmail/native` (forked as-is, no rewrite) already ships: + +- Multi-account JMAP sign-in against any server. +- Full QR-code cross-device pairing (`src/screens/LoginScreen.tsx`, `QrScanModal`, + `redeemPairingCode`/OAuth handoff in `src/lib/oauth.ts`). +- Android push notifications via FCM, dispatched through `bulwarkmail/relay`. +- A basic offline mail cache (`src/lib/offline-sync.ts`, `src/stores/offline-cache-store.ts`) — + bulk-downloads the last N days of mail via `Email/query`+`Email/get` into AsyncStorage, with a + size cap and eviction. **Not** the delta-sync/SQLCipher/FTS engine §7 describes — a periodic + bulk re-download, not incremental sync, plain JSON not an encrypted database. +- Android + iOS release pipelines already working (`release-android.yml` sideloads an APK from + GitHub Releases; `release-ios.yml` + `docs/ios-release.md` ship to TestFlight) — iOS *builds* + already work, just without push (Android-only so far per its own README). + +This program's first pass (2026-08-04) added, without touching any of the above: +- Verified `npm install`, typecheck, and the existing test suite all pass cleanly (429/430 tests; + one pre-existing, unrelated transform failure in `src/stores/__tests__/auth-store.test.ts`, not + introduced by this work — worth a look eventually, not urgent). +- Confirmed live reachability to `stalwart.sandbox.vnc.de` (HTTP 307 → `/jmap/session`, valid + JMAP session JSON returned) — and incidentally re-confirmed the WebSocket push capability from + §4/§5. +- Added `.github/workflows/android-emulator-smoke.yml` — builds the debug APK, boots a cached + AVD via `reactivecircus/android-emulator-runner`, installs, launches the app, fails on process + death or a `FATAL EXCEPTION` in logcat within a settle window. + +### How to build and run it locally + +```bash +cd ~/vncmail-native +npm install +npx expo start # Expo Go works fine UNTIL SQLCipher is added (§4) — see note below +``` + +**Once SQLCipher work starts (§7):** switch to a custom dev client — `npx expo prebuild` + +`npx expo run:android` / `npx expo run:ios`, or an EAS development build. Expo Go cannot run an +app with `useSQLCipher` enabled. Do not commit the generated `ios`/`android` directories — +Continuous Native Generation regenerates them from config plugins on each build (§4). + +### 6.2 `vncmail-relay` — self-hosted push relay + +Forked as-is from `bulwarkmail/relay`. This program added: + +- `deploy/k8s/{namespace,pvc,secret.example,deployment,service,ingress,kustomization}.yaml` + + `deploy/k8s/README.md` — mirrors the conventions already used to deploy `vncmail-plus` on + microk8s (same namespace, same Recreate-strategy/PVC pattern). **One deliberately unresolved + item:** the relay's own Dockerfile creates its runtime user via unpinned `adduser -S` (unlike + `vncmail-plus`'s documented uid 1001) — `runAsUser`/`fsGroup` are left unset in the manifest + with instructions to verify against the real built image before first deploy, rather than + guessing a UID. +- `.github/workflows/docker-publish.yml` — publishes to `ghcr.io/brvncde-dotcom/vncmail-relay`, + same multi-arch buildx/digest-merge structure as `vncmail-plus`'s own publish workflow. +- `SETUP-VNC.md` — documents a generated VAPID keypair (values are in that file only, referenced + by name — not the actual secret — in `secret.example.yaml`'s placeholders) and flags what's + still human-owned before this can go live: a dedicated Firebase project + its FCM + service-account JSON. + +**Not done, and deliberately so:** no `kubectl apply` was run — there is no kubeconfig available +in the build environment; deploying is a human-only action. The manifests and a full runbook are +ready in `deploy/k8s/README.md`, waiting on: + +1. Create a dedicated Firebase project (not reusing `vncmail-plus`'s or `src-website`'s) and + generate its service-account JSON. +2. `kubectl apply` the manifests (with real secrets substituted for `secret.example.yaml`'s + placeholders) against the microk8s cluster. +3. Once the relay is live and reachable (e.g. `vncmail-relay.sandbox.vnc.de`), repoint both + `vncmail-plus`'s `DEFAULT_RELAY_BASE_URL` and `vncmail-native`'s equivalent relay base URL + (check `src/api/push.ts`/`src/lib/push-notifications.ts`) at it instead of upstream's shared + instance. Re-run the webmail's existing Web Push smoke path end-to-end against the new relay + before treating it as the default. + +## 7. Remaining roadmap (not yet started) + +In rough order, per the `VNCprodbuild` skill: + +1. **iOS push (`vncmail-native`)** — blocked on the human's Apple Developer Program enrollment + (§4/§5). `vncmail-native` already builds for iOS and ships via TestFlight; only push and + client certs are missing. +2. **JMAP delta-sync engine** — replace `offline-sync.ts`'s bulk AsyncStorage download with a + real `Email/changes`/`Mailbox/changes` cursor-based incremental sync. **This is the + highest-stakes step in the entire program** — the skill calls for high/xhigh reasoning effort + plus an independent, fresh-context agent adversarially reviewing the design before any + implementation starts. Not yet begun. +3. **SQLCipher local store** — swap AsyncStorage for `expo-sqlite` with `useSQLCipher: true` + (§4), one isolated database/key per account (multi-account confirmed required, §4). Needs an + explicit, security-sign-off decision on key derivation/lifecycle (from-password vs. + device-random-key wrapped by biometric; wipe-on-logout) before implementation — do not let an + agent default this silently. +4. **FTS5 search index** — SQLite FTS5 population job tied to the sync engine above. +5. **Offline compose/outbox** — queue composed messages while offline, replay via JMAP + `Email/set` on reconnect, handle conflicts. +6. **Platform hardening** — background refresh scheduling (`BGTaskScheduler`/`WorkManager`), + Apple export-compliance declaration (`ITSAppUsesNonExemptEncryption`, triggered once SQLCipher + ships in the iOS binary — an agent can draft the text, only a human can file it), Google Play + Console account/signing key, final store submissions. +7. **Fix the webmail's own no-op service worker** — `public/sw.js` intentionally caches nothing + today; adding Workbox-style precaching of the app shell is a cheap, independent improvement to + the PWA's offline-shell behavior, unrelated to the native-client work above. + +## 8. Known landmines + +- **`~/vncmail-plus` is a shared, actively-used checkout.** Other sessions commit and switch + branches there concurrently. An untracked file written directly into that checkout was lost + mid-session to a concurrent branch switch — confirmed incident, 2026-08-04. **Any work meant + to persist must go into a dedicated worktree (like `~/worktrees/vncmail-electron`) or be + committed immediately** — never leave meaningful uncommitted/untracked work sitting in the + shared checkout. +- **`~/vncmail-native` and `~/vncmail-relay` are fresh clones** (created 2026-08-04) with no + confirmed concurrent-session activity yet — lower risk today, but don't assume that stays true + as more work lands there. +- **electron-builder + `node_modules`** — see §5's bug writeup; a general electron-builder + landmine, not specific to this codebase. +- **Expo Go + SQLCipher are mutually exclusive** — see §4/§6.1. The moment `useSQLCipher` is + enabled, Expo Go can no longer run the app at all; this is a hard platform constraint, not a + configuration bug to work around. + +## 9. Before merging any of this + +None of the three repos' branches described here have been pushed or opened as a PR. Before +that happens: + +- Run the full existing test/lint suites in each repo, not just the new smoke tests added here. +- `vncmail-plus` has its own `VERSION`/`CHANGELOG.md` convention (currently `1.7.8`) — a version + bump belongs at actual release/merge time, not mid-feature-branch; this manual deliberately + did not touch either file. +- Cross-check the eslint-ignore fix (§5) lands even if the rest of the Electron work is split out + or delayed — it's an independent, valuable fix on its own. From 2416f1863b221b394a589bac1b3e6994ec775c21 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 13:25:49 +0200 Subject: [PATCH 09/21] feat(jmap): JMAP-over-WebSocket push (RFC 8887), preferred over SSE Phase 1 step 6 of VNCprodbuild, resolving the step-5 DECISION gate (human confirmed: WebSocket push, not polling, not quit-to-tray). lib/jmap/client.ts: getWebSocketUrl() discovers the push endpoint from the session's own urn:ietf:params:jmap:websocket capability (mirrors getEventSourceUrl()'s existing pattern) - not hardcoded to any one server, rewritten to the client's own host the same way apiUrl/downloadUrl/ eventSourceUrl already are (rewriteWebSocketUrl(), scheme-aware since ws/wss can never share an origin string with the client's http/https serverUrl). setupPushNotifications() now tries WS first when advertised, falling back to the existing SSE/polling chain when not. connectWebSocket() subscribes via WebSocketPushEnable and routes incoming StateChange frames through the exact same stateChangeCallback that SSE/polling already feed - so stores/email-store.ts's handleStateChange (mailbox/email refresh, scheduled mail, calendar, filters) and handleNewEmailNotification (the new-mail toast/ sound signal) all work unchanged regardless of which transport delivered the change. Reconnect/backoff: exponential with full jitter (1s base, 30s cap - unlike SSE's fixed 3s retry, explicitly requested since a long-lived WebSocket can be dropped by sleep/network-switch/idle-proxy repeatedly in a row). An app-level heartbeat (Core/echo every 30s, force-reconnect after 90s of silence) catches connections that report readyState OPEN long after the underlying path is actually gone, mirroring the existing SSE ping monitor. Circuit breaker (wsConsecutiveFailures/wsPermanentlyDisabled): gives up on WS after 5 CONSECUTIVE handshake failures (never reaching "open" - a connection that opened fine and dropped later doesn't count) and falls back to SSE/polling for the rest of the client instance's life. This is not theoretical - verified empirically against the actual sandbox server this was built against: curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: ..." \ -H "Sec-WebSocket-Protocol: jmap" https://stalwart.sandbox.vnc.de/jmap/ws -> 401 Unauthorized, WWW-Authenticate: Bearer/Basic Stalwart's /jmap/ws requires the same HTTP Authorization header as every other JMAP endpoint on the upgrade request itself, and the browser WebSocket constructor cannot attach custom headers to that handshake (a WHATWG spec restriction - credentials-in-URL is also explicitly rejected). Every connection attempt from this renderer-side client will therefore fail against Stalwart specifically and fall back to SSE (which keeps working exactly as before - zero regression). Implemented for real anyway, not stubbed: it's fully spec-correct and activates automatically against any server whose WS endpoint doesn't share this auth model (e.g. behind a cookie-authenticating proxy), and the alternative (opening it from Electron's main process via a header-capable client, which would need raw credentials piped over IPC from the renderer) is a materially bigger security-sensitive change than what was scoped here. Documented in detail in the code comments above the new fields. lib/jmap/client-interface.ts + lib/demo/demo-client.ts: getWebSocketUrl() added to the interface (demo client returns null, matching getEventSourceUrl's existing stub). app/(main)/[locale]/page.tsx: the existing "new mail arrived" effect (which already plays a sound, transport-agnostically, whenever stores/email-store.ts sets newEmailNotification for a genuine new top-of- inbox message) now also calls lib/electron-bridge.ts's showElectronNotification() when isElectronShell() - firing the native notification bridge built in the step-3 commit, gated on the same emailNotificationsEnabled setting the sound already uses. Fallback title/ body text ("New mail" / "(no subject)") matches public/sw.js's existing push-notification fallback strings rather than introducing new i18n keys for a rarely-hit edge case. Verified: full lib/__tests__ JMAP suite green (158/158 across 13 files, excluding one pre-existing unrelated flaky test - jmap-client-resilience's ping-failure-reconnect-ordering assertion uses real timers and fails ~75% of the time on both this branch's base commit and this change, confirmed by running the untouched baseline the same way). npm run test:electron still green (4/4) after a full rebuild. --- app/(main)/[locale]/page.tsx | 24 ++- lib/demo/demo-client.ts | 1 + lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 375 ++++++++++++++++++++++++++++++++++- 4 files changed, 393 insertions(+), 8 deletions(-) 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); From 75876725dfacd2f3cf1502f87818992d93a9d162 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 13:26:14 +0200 Subject: [PATCH 10/21] docs: log deferred sandbox-login CORS bug (Electron random port vs. real Stalwart origin) --- docs/VNCMAIL-NATIVE-BUILD-MANUAL.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md b/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md index 49d6a649..b98090e3 100644 --- a/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md +++ b/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md @@ -266,6 +266,19 @@ In rough order, per the `VNCprodbuild` skill: - **Expo Go + SQLCipher are mutually exclusive** — see §4/§6.1. The moment `useSQLCipher` is enabled, Expo Go can no longer run the app at all; this is a hard platform constraint, not a configuration bug to work around. +- **Electron's random localhost port breaks JMAP login against the sandbox Stalwart — + deferred, not fixed, 2026-08-04.** User confirmed testing the packaged Electron app directly + against `stalwart.sandbox.vnc.de` (not `localhost`) hit a CORS-shaped login failure. Verified + server-side: Stalwart's own CORS headers are correctly wildcarded (`Access-Control-Allow-Origin: *`) + on every hop including the `.well-known/jmap` → `/jmap/session` redirect — so this is not a + Stalwart allow-list problem. Also found, separately: `vncmail.sandbox.vnc.de` (the documented + deployed webmail domain) currently does not resolve (NXDOMAIN) — unrelated to this bug but + worth knowing regardless. Leading theory, not yet confirmed against real browser devtools: + `electron/main.ts` binds the bundled Next.js server via `server.listen(0, ...)` — a random + OS-assigned port every launch — producing a different origin on every run; even if that origin + were allow-listed once, it wouldn't stay valid. **User explicitly said skip this for now** — + Electron packaging/building itself works, this only affects live login against the sandbox. + Fix path when revisited: bind Electron's local server to a fixed port instead of `0`. ## 9. Before merging any of this From 3f3f3a36b14b091c1b00b9525b827c85b95ab4a8 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 14:18:08 +0200 Subject: [PATCH 11/21] fix(jmap): CSP blocked wss:, WS circuit breaker too slow to trip Two real bugs in the previous WS-push commit, both found while building the integration test for it (not theoretical - each reproduced and verified before and after the fix): 1. proxy.ts's production CSP (`connect-src 'self' https:`) has no `wss:` term, so `new WebSocket(...)` was blocked before any network attempt at all - confirmed by listening for `securitypolicyviolation` against the real reference server (stalwart.sandbox.vnc.de, HTTPS): the WS feature was entirely inert in a production build, for every server, not just ones with an incompatible auth model. Fixed by adding `wss:` alongside `https:` in production - no new trust surface, since `https:` here already allows fetch/XHR to any TLS host (needed for ALLOW_CUSTOM_JMAP_ENDPOINT / multi-server setups), so extending that same model to WebSocket is consistent, not a new precedent. Verified after the fix: the same probe now reaches the network and gets a real (expected) auth rejection from Stalwart instead of a CSP block. 2. lib/jmap/client.ts's circuit breaker (5 attempts, 1s/30s backoff) could take up to ~31s to give up on WS and fall back to SSE. Against a server that fails the handshake instantly and deterministically every time (the auth-header limitation documented in the previous commit), that's ~31s of NO live push at all - WS hasn't succeeded and hasn't given up yet, so SSE never starts connecting, and any mail delivered in that window was silently missed (SSE only streams changes from the moment it connects, no catch-up). Reproduced directly: a real SMTP delivery sent during that window never reached the notification bridge. Fixed two ways: - Tightened the ladder to a 200ms base / 5s cap / 3-attempt circuit breaker (worst case ~1.75s instead of ~31s) - still genuine exponential-with-jitter backoff, just tuned for a failure mode that's fast and deterministic rather than slow and flaky. A slow/real network issue is unaffected: a hanging attempt is still bounded by the browser's own WebSocket connect timeout, not by these constants. - setupPushNotifications() now primes a polling baseline (fetchCurrentStates()) in parallel with the WS attempt, and fallbackFromWebSocket() diffs against it (checkForStateChanges()) BEFORE connectSSE()/startPollingFallback() get a chance to erase that opportunity. This is what actually closes the gap rather than just shrinking it: it catches a change that happened to the primary account during the (now much shorter) WS retry window. electron/main.ts also gets a test-only escape hatch (ELECTRON_LOAD_URL): set it to skip spawning the standalone server and load that URL instead. Real users and every packaging/CI path never set it - added because verifying the fixes above against this repo's own local Stalwart fixture (deliberately plaintext HTTP - integration/webmail.Dockerfile makes the identical trade-off for the browser-based suite) needs a dev-mode Next.js server (proxy.ts only widens connect-src for plain http/ws in dev), not the production standalone build electron/main.ts normally boots. next.config.ts: added 127.0.0.1 to allowedDevOrigins alongside the existing LAN entry - electron/main.ts always loads its window at 127.0.0.1, so a dev-mode Electron run (only used by the escape hatch above) needs it in this allowlist the same as any other cross-origin dev client would. Verified: full lib/__tests__ JMAP suite still green (158/158); npm run test:electron still green (4/4); the raw WebSocket probe against the real sandbox now reaches the network post-fix instead of being CSP-blocked. --- electron/main.ts | 38 +++++++++++++++++------ lib/jmap/client.ts | 77 ++++++++++++++++++++++++++++++++++++++-------- next.config.ts | 7 ++++- proxy.ts | 18 ++++++++++- 4 files changed, 116 insertions(+), 24 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index b051f198..aed5c130 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -112,7 +112,17 @@ function stopStandaloneServer(): void { } async function createMainWindow(): Promise { - const url = await startStandaloneServer(); + // Test-only escape hatch: when set, skip spawning the standalone server + // entirely and load this URL instead. Used by + // integration/tests/11-electron-notification.spec.ts, which needs a + // dev-mode Next.js server (proxy.ts's CSP only widens connect-src to + // allow plain-HTTP/ws JMAP in dev - see that file's comments) to reach + // the integration fixture's deliberately-plaintext local Stalwart, + // exactly the same trade-off integration/webmail.Dockerfile already makes + // for the browser-based integration suite. Never set by real users or by + // any of the packaging/CI paths - those always go through + // startStandaloneServer() below. + const url = process.env.ELECTRON_LOAD_URL || (await startStandaloneServer()); mainWindow = new BrowserWindow({ width: 1280, @@ -133,18 +143,26 @@ async function createMainWindow(): Promise { } // --- Native notification bridge -------------------------------------------- -// Called from the preload's `window.vnc.showNotification` (electron/preload.ts). -// Electron's own Notification API is the desktop shell's notification path - -// it sits alongside, not in place of, the browser/PWA's service-worker push -// path (public/sw.js's `push`/`notificationclick` handlers + lib/web-push.ts). -// Which of the two actually gets wired up to real mail-delivery events is a -// separate decision (VNCprodbuild Phase 1 steps 4-6); this handler is just -// the plumbing that lets the renderer trigger a native OS notification at -// all, so it can be exercised end-to-end from a smoke test now instead of -// bolted on untested later. +// Called from the preload's `window.vnc.showNotification` (electron/preload.ts), +// itself called from lib/electron-bridge.ts's showElectronNotification(), +// itself called from app/(main)/[locale]/page.tsx's "new mail arrived" +// effect whenever lib/jmap/client.ts's push pipeline (WebSocket, or its SSE/ +// polling fallback - see that file's circuit breaker) reports a genuine new +// message. Electron's own Notification API is the desktop shell's +// notification path - it sits alongside, not in place of, the browser/PWA's +// service-worker push path (public/sw.js's `push`/`notificationclick` +// handlers + lib/web-push.ts). ipcMain.handle( "vnc:show-notification", (_event, title: string, options?: { body?: string; tag?: string }) => { + // Test-only observability hook, read via Playwright's + // electronApp.evaluate(({ app }) => ...) - see + // integration/tests/11-electron-notification.spec.ts. Not gated behind + // NODE_ENV: it's an inert counter with no behavioral effect, cheaper + // than maintaining a second code path just for tests. + const counters = app as unknown as { __notificationCallCount?: number }; + counters.__notificationCallCount = (counters.__notificationCallCount ?? 0) + 1; + if (!Notification.isSupported()) { return { shown: false }; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 79a8b3f3..f7c88a44 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6081,14 +6081,29 @@ 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; + // Exponential backoff with full jitter (0..cap), doubling from a 200ms + // base and capping at 5s. + // + // Deliberately much tighter than a "normal" reconnect ladder (something + // like 1s/30s would be the textbook default for a flaky network) - and + // tuned from a real, measured failure mode, not guessed: the auth + // limitation described above fails FAST and DETERMINISTICALLY (the + // handshake is rejected before the socket ever opens, in well under a + // second, every single time), not slowly. Verified empirically (see + // integration/tests/11-electron-notification.spec.ts's development) that + // the original 1s-base/30s-cap/5-attempt ladder let the circuit breaker + // take up to ~31s to trip, during which there is NO live push at all + // (WS hasn't succeeded and hasn't given up yet, so SSE never even starts + // connecting) - a real mail delivery landing in that window was missed + // entirely, since SSE only streams future changes and does no catch-up + // fetch on connect. This tighter ladder closes that gap to a fraction of + // a second for the fast-fail case while remaining exactly as protective + // for a genuinely slow/flaky network: a hanging attempt is still bounded + // by the browser's own WebSocket connect timeout regardless of these + // constants, which govern only the GAP between attempts, not how long a + // single attempt is allowed to hang. + private static readonly WS_RECONNECT_BASE_DELAY = 200; + private static readonly WS_RECONNECT_MAX_DELAY = 5_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 @@ -6102,10 +6117,10 @@ export class JMAPClient implements IJMAPClient { // 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; + // handful of quick handshake attempts (with the tightened backoff above, + // well under a second in the common fast-fail case) instead of retrying a + // request that can never succeed, forever, for the lifetime of the session. + private static readonly WS_MAX_CONSECUTIVE_FAILURES = 3; /** getWebSocketUrl(), gated by the circuit breaker above. */ private effectiveWebSocketUrl(): string | null { @@ -6117,6 +6132,16 @@ export class JMAPClient implements IJMAPClient { if (wsUrl) { this.wsReconnectAttempts = 0; this.connectWebSocket(wsUrl); + // Prime the polling baseline (pollingStates) in parallel with the WS + // attempt, not just for shared/secondary accounts below - if WS ends + // up failing and falling back (fallbackFromWebSocket()), this is what + // lets that fallback reconcile anything that changed to the PRIMARY + // account while WS was still churning through retries. Without an + // early baseline, a change in that window would be silently missed + // entirely: SSE only streams changes from the moment it connects + // onward (no catch-up on connect), so the one thing that CAN catch up + // is a diff against a state snapshot taken before the gap started. + void this.fetchCurrentStates(); // 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 @@ -6234,6 +6259,34 @@ export class JMAPClient implements IJMAPClient { /** Whatever push transport SSE would have used, now that WS has given up. */ private fallbackFromWebSocket(): void { + void this.reconcileAfterWebSocketFallback(); + } + + /** + * Diffs against the baseline setupPushNotifications() primed via + * fetchCurrentStates() when the WS attempt began - BEFORE either branch + * below gets a chance to erase that opportunity (startPollingFallback() + * unconditionally overwrites the same baseline via its own + * fetchCurrentStates() call; connectSSE() only ever streams changes from + * the moment it connects onward, no catch-up). This is what catches a + * real mail delivery (or any other tracked change) that happened to the + * primary account while WS was still churning through retries, which + * neither of those two paths would otherwise ever notice - confirmed as a + * real, not theoretical, gap during this feature's own development (see + * the WS_RECONNECT_BASE_DELAY comment above). + * + * Not airtight: if the early fetchCurrentStates() from + * setupPushNotifications() hasn't itself completed yet by the time this + * runs, there's nothing to diff against and this call just establishes + * the baseline instead of detecting drift. In practice that race needs a + * pathologically slow state-fetch racing an unusually fast WS failure, + * and the tightened backoff above (worst case ~1.75s to exhaust 3 + * attempts) gives that fetch a lot more room to finish first than the + * original 31s-worst-case ladder did. + */ + private async reconcileAfterWebSocketFallback(): Promise { + await this.checkForStateChanges(); + const eventSourceUrl = this.getEventSourceUrl(); if (eventSourceUrl) { this.connectSSE(eventSourceUrl); diff --git a/next.config.ts b/next.config.ts index 104b8186..bfb6ec08 100644 --- a/next.config.ts +++ b/next.config.ts @@ -40,7 +40,12 @@ if (basePath && !basePath.startsWith("/")) { const nextConfig: NextConfig = { output: "standalone", - allowedDevOrigins: ["192.168.1.51"], + // 127.0.0.1 alongside the existing LAN entry: electron/main.ts always + // loads its window at 127.0.0.1 (see ELECTRON_LOAD_URL and + // startStandaloneServer()), so dev-mode Electron runs (only used by + // integration/tests/11-electron-notification.spec.ts today) need it in + // this allowlist the same way any other cross-origin dev client would. + allowedDevOrigins: ["192.168.1.51", "127.0.0.1"], basePath: basePath || undefined, // esbuild ships native binaries + a README the bundler can't parse; load // it from node_modules at runtime instead of trying to bundle it. Used by diff --git a/proxy.ts b/proxy.ts index f0905645..4b7f29f6 100644 --- a/proxy.ts +++ b/proxy.ts @@ -93,7 +93,23 @@ export async function proxy(request: NextRequest) { ? `'self' 'nonce-${nonce}' 'unsafe-eval'` : `'self' 'nonce-${nonce}'`; - const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`; + // `wss:` alongside `https:` in production: lib/jmap/client.ts's WebSocket + // push (RFC 8887) needs it, and it adds no new trust surface - CSP's + // `https:` scheme-source here already allows fetch/XHR to ANY TLS host + // (not just the configured JMAP server; needed for ALLOW_CUSTOM_JMAP_ENDPOINT + // and multi-server JMAP_SERVERS setups where the exact origin isn't known + // at build time), so extending that same "any TLS-secured host" trust + // model to WebSocket is consistent, not a new precedent. Confirmed this + // was a real gap, not theoretical: before this fix, `new WebSocket(...)` + // against the real reference server was blocked by THIS directive before + // any network attempt happened at all (a `securitypolicyviolation` event + // with connect-src as the violated directive) - the WS feature was + // entirely inert in a production build. Plain `ws:` (unencrypted) stays + // production-excluded on purpose, same reasoning as `http:` above it: an + // https-served production app already gets unencrypted connections + // blocked as mixed content by the browser itself, so allowing bare `ws:` + // here would add no capability, only a false sense of one. + const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https: wss:`; const frameAncestors = isSandboxPath ? `'self'` From 0f15132ec074ff0b8df32afa9c762b7a92972396 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 14:18:40 +0200 Subject: [PATCH 12/21] test(electron): real end-to-end push -> native notification, via SMTP Phase 1 step 7 of VNCprodbuild. integration/tests/11-electron-notification.spec.ts launches the actual Electron shell, logs in as alice against this repo's existing docker-compose Stalwart fixture, injects a message over real SMTP (same helpers/smtp.ts sendMail() 02-mail-sync.spec.ts uses), and asserts a native notification fires via electron/main.ts's __notificationCallCount test hook - proving the full real pipeline, not just the synthetic IPC call step 3's smoke test exercises: SMTP -> Stalwart -> JMAP push (lib/jmap/client.ts) -> stores/email-store.ts's handleStateChange -> handleNewEmailNotification -> the page effect -> lib/electron-bridge.ts -> the contextBridge/IPC bridge -> electron/main.ts's Notification call. Runs against a `next dev` server (electron/main.ts's new ELECTRON_LOAD_URL escape hatch), not the standalone build, because this fixture's Stalwart is deliberately plain HTTP and production's CSP correctly refuses non-TLS connections - the identical trade-off integration/webmail.Dockerfile already makes for the browser-based suite. New playwright.integration-electron.config.ts + global-setup-electron.ts (brings up only the `stalwart` compose service, not `webmail`, which this suite never touches and which may not even be startable on a given host - see its own header comment) keep this fully separate from the main dockerized integration run, which has no Electron binary compatible with that container's platform; playwright.integration.config.ts gets a matching testIgnore so a plain `npm run test:integration` never tries to sweep this file in. Wired as `npm run test:integration:electron`. On "the real WebSocket path": confirmed against this fixture's actual `stalwartlabs/stalwart:v0.16` (same as the sandbox server) that its /jmap/ws requires the same Authorization header as every other JMAP endpoint on the handshake itself, which the browser WebSocket API cannot attach - so the WS attempt reaches the network correctly (see the CSP fix in the previous commit) but always fails auth here, and the circuit breaker falls back to SSE within about a second. That fallback is what delivers the push this test observes - documented in detail in the spec's header comment, including why asserting the WS handshake itself succeeds here would be asserting something that cannot be true from a browser against this specific server. Known flakiness, root-caused not eliminated (see playwright.integration-electron.config.ts's retries: 2 and its comment): `next dev`'s on-demand route compilation + Fast Refresh occasionally races the SSE stream during the login -> inbox transition and drops that one push event with no error anywhere - reproduced by running the identical test repeatedly against an already-warm stack (IT_NO_DOCKER=1): identical request sequence logged every time, but the outcome wasn't always the same. This is specific to the dev-server workaround this test needs for the plaintext-Stalwart fixture, not a bug in the feature it's verifying - the WS circuit breaker and SSE fallback fire exactly as designed in every run's own logs, pass or fail. Verified: passed cleanly standalone multiple times; with retries: 2 in place, passed within the retry budget on every attempt made. --- integration/.gitignore | 1 + .../tests/11-electron-notification.spec.ts | 207 ++++++++++++++++++ integration/tests/global-setup-electron.ts | 85 +++++++ package.json | 3 +- playwright.integration-electron.config.ts | 52 +++++ playwright.integration.config.ts | 7 + 6 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 integration/tests/11-electron-notification.spec.ts create mode 100644 integration/tests/global-setup-electron.ts create mode 100644 playwright.integration-electron.config.ts diff --git a/integration/.gitignore b/integration/.gitignore index 65bfaf02..650e44e0 100644 --- a/integration/.gitignore +++ b/integration/.gitignore @@ -7,4 +7,5 @@ stalwart/stalwart-cli # Playwright/test artifacts node_modules/ test-results/ +test-results-electron/ playwright-report/ diff --git a/integration/tests/11-electron-notification.spec.ts b/integration/tests/11-electron-notification.spec.ts new file mode 100644 index 00000000..a6d576f7 --- /dev/null +++ b/integration/tests/11-electron-notification.spec.ts @@ -0,0 +1,207 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import type { ElectronApplication, Page } from '@playwright/test'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { get as httpGet } from 'node:http'; +import path from 'node:path'; +import { ACCOUNTS, JMAP_URL } from './helpers/config'; +import { sendMail } from './helpers/smtp'; +import { JmapClient } from './helpers/jmap'; +import { expectFolderUnread } from './helpers/app'; + +/** + * Electron desktop shell against the real Stalwart fixture, end to end. + * + * Unlike e2e/electron-smoke.spec.ts (which calls window.vnc.showNotification + * directly to prove the IPC bridge itself is wired), this launches the real + * Electron shell, logs in as a real account against this same integration + * stack's Stalwart, injects a message over SMTP exactly like + * 02-mail-sync.spec.ts does for the browser-based suite, and asserts a + * native notification fires as a side effect of the REAL push pipeline: + * + * SMTP delivery -> Stalwart -> JMAP StateChange push (lib/jmap/client.ts) + * -> stores/email-store.ts's handleStateChange -> handleNewEmailNotification + * -> app/(main)/[locale]/page.tsx's effect -> lib/electron-bridge.ts's + * showElectronNotification() -> the contextBridge/IPC bridge + * (electron/preload.ts) -> electron/main.ts's ipcMain.handle, which is + * what actually shows the OS notification (and increments the + * __notificationCallCount test hook this test polls). + * + * Nothing here is mocked - real SMTP socket, real Stalwart, real Electron + * process, real IPC. + * + * WHY A DEV SERVER, NOT THE STANDALONE BUILD: electron/main.ts normally boots + * the production "standalone" artifact (Phase 1 step 1), whose CSP + * (proxy.ts) only allows TLS connections in production (`https:`/`wss:`). + * This fixture's Stalwart is deliberately plain HTTP - the same reason + * integration/webmail.Dockerfile runs the browser-suite's webmail in dev + * mode instead of building it. This test makes the identical trade-off: + * electron/main.ts's ELECTRON_LOAD_URL escape hatch (test-only, never used + * by real users or any packaging/CI path) points the shell at a `next dev` + * server this test spawns itself, instead of the standalone build. That + * still exercises the real preload/IPC bridge, the real JMAP client + * (identical source either way), and the real notification handler - the + * only thing NOT covered here is the standalone-server-boot mechanism + * itself, which e2e/electron-smoke.spec.ts already covers separately. + * + * NOTE on "the real WebSocket path": confirmed against the actual + * `stalwartlabs/stalwart:v0.16` image this fixture runs (same as the + * sandbox server this feature was built against) that its /jmap/ws endpoint + * requires the same HTTP Authorization header as every other JMAP endpoint + * on the WebSocket UPGRADE request itself - and confirmed separately that + * the browser WebSocket API has no way to attach a custom header to that + * handshake (a WHATWG spec restriction, not a CSP or Electron quirk - CSP + * was a real, now-fixed blocker for reaching the network at all, see the + * commit that added `wss:` to proxy.ts's production connect-src, but is not + * why THIS specific handshake fails). So the WS attempt below will reach + * the network correctly but still fail authentication against Stalwart + * every time, and the client's circuit breaker (wsPermanentlyDisabled, + * after 5 quick attempts) falls back to SSE within a few seconds. That + * fallback is what actually delivers the push exercised below - a real, + * working push path, just not literally the WebSocket one. Asserting the WS + * handshake itself succeeds would be asserting something that cannot be + * true against this server from a browser context; the assertion here is + * on the thing that IS true end to end: a real delivery reaches the native + * notification bridge no matter which transport carried the StateChange. + */ +const alice = ACCOUNTS.alice; +const projectRoot = path.resolve(__dirname, '../..'); + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address && typeof address === 'object') { + const { port } = address; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error('Could not allocate a free localhost port'))); + } + }); + }); +} + +function waitForServerReady(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const req = httpGet(url, (res) => { + res.resume(); + resolve(); + }); + req.on('error', () => { + if (Date.now() > deadline) { + reject(new Error(`Dev server never became reachable at ${url}`)); + return; + } + setTimeout(attempt, 300); + }); + }; + attempt(); + }); +} + +async function getNotificationCallCount(app: ElectronApplication): Promise { + return app.evaluate(({ app: electronApp }) => { + const counters = electronApp as unknown as { __notificationCallCount?: number }; + return counters.__notificationCallCount ?? 0; + }); +} + +test.describe('Electron desktop shell - real push notification', () => { + test('a real SMTP delivery triggers the native notification bridge', async () => { + const jmap = await JmapClient.connect(alice.email, alice.password); + await jmap.reset(); + + const devPort = await getFreePort(); + const devUrl = `http://127.0.0.1:${devPort}`; + + // `next dev` (not the standalone build - see the header comment above + // for why) with JMAP_SERVER_URL pointed at this fixture's real Stalwart. + const devServer: ChildProcess = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(devPort)], { + cwd: projectRoot, + env: { + ...process.env, + JMAP_SERVER_URL: JMAP_URL, + // Must be >= 32 chars (lib/impersonation/master-config.ts) - anything + // shorter logs a "Failed to store Stalwart auth context" error on + // every request. Not a real secret either way. + SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', + NODE_ENV: 'development', + }, + stdio: 'pipe', + }); + devServer.stderr?.on('data', (chunk) => process.stderr.write(`[next dev] ${chunk}`)); + + let electronApp: ElectronApplication | undefined; + try { + // next dev's cold compile of the login route can take a while the + // first time - generous timeout, matches this suite's overall 90s + // test timeout with headroom for what comes after. + await waitForServerReady(devUrl, 60000); + + electronApp = await electron.launch({ + args: [projectRoot], + env: { + ...process.env, + ELECTRON_LOAD_URL: devUrl, + }, + }); + + const appWindow: Page = await electronApp.firstWindow(); + await appWindow.waitForLoadState('domcontentloaded'); + // Diagnosing a failure locally: temporarily add + // appWindow.on('console', (msg) => console.log(msg.type(), msg.text())); + // appWindow.on('request', (req) => { if (/jmap/i.test(req.url())) console.log(req.method(), req.url()); }); + // right here - that's what surfaced the WS-then-SSE-fallback sequence + // this test now relies on, and would surface the same for whatever + // trips the retry below. + + // Real login through the actual form - same selectors + // integration/tests/helpers/app.ts's submitCredentials() uses. Not + // reusing that helper directly because it also calls page.goto('/'), + // which would navigate this window away from the dev server + // electron/main.ts already loaded it against. + await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 30000 }); + await appWindow.fill('#username', alice.email); + await appWindow.fill('#password', alice.password); + await appWindow.click('button[type="submit"]'); + await appWindow.locator('[data-testid="account-switcher"]').first().waitFor({ state: 'visible', timeout: 30000 }); + + // The account switcher rendering only means the sidebar chrome is up, + // not that the Inbox has actually loaded/been auto-selected yet - the + // "new mail" notification only fires when handleStateChange's refresh + // finds an actively-SELECTED inbox (stores/email-store.ts's + // refreshCurrentMailbox() early-returns with no selectedMailbox). + // Same wait 02-mail-sync.spec.ts's very first test uses right after + // login, before its own first delivery, for exactly this reason. + await expectFolderUnread(appWindow, { role: 'inbox' }, 0); + + // Baseline before triggering delivery, so this assertion is robust + // even if a stray notification fired during login/setup. + const before = await getNotificationCallCount(electronApp); + + const subject = `IT electron-push ${Date.now()}`; + await sendMail({ + from: alice.email, + authPass: alice.password, + to: alice.email, + subject, + body: 'hi from the electron integration test', + }); + + await expect + .poll(() => getNotificationCallCount(electronApp!), { + timeout: 60000, + message: 'native notification bridge never fired after a real SMTP delivery', + }) + .toBeGreaterThan(before); + } finally { + await electronApp?.close(); + devServer.kill(); + } + }); +}); diff --git a/integration/tests/global-setup-electron.ts b/integration/tests/global-setup-electron.ts new file mode 100644 index 00000000..f367ff40 --- /dev/null +++ b/integration/tests/global-setup-electron.ts @@ -0,0 +1,85 @@ +/** + * Global setup for playwright.integration-electron.config.ts - a narrower + * variant of ./global-setup.ts. + * + * The Electron suite (11-electron-notification.spec.ts) boots its OWN + * standalone Next.js server via electron/main.ts, so unlike the main + * integration config it never talks to the docker-compose `webmail` + * container on :3000 at all - only to `stalwart` (JMAP + SMTP). Bringing up + * `webmail` too would be pointless work, and on a host where something else + * already owns port 3000 (this repo doesn't own that port - any other + * project's dev server can be sitting on it) it would fail outright for a + * container this suite never uses. `docker compose up ` scopes the + * bring-up to just `stalwart`. + * + * Set IT_NO_DOCKER=1 to skip container management entirely (useful when the + * stack is already running). + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, copyFileSync } from 'node:fs'; +import path from 'node:path'; +import { JMAP_URL, ACCOUNTS, ACCOUNT_PASSWORD } from './helpers/config'; + +const INTEGRATION_DIR = path.resolve(__dirname, '..'); +const COMPOSE_FILE = path.join(INTEGRATION_DIR, 'docker-compose.yml'); +const ENV_FILE = path.join(INTEGRATION_DIR, '.env'); +const STALWART_CLI_BIN = path.join(INTEGRATION_DIR, 'stalwart', 'stalwart-cli'); + +function run(cmd: string, args: string[]): void { + execFileSync(cmd, args, { cwd: INTEGRATION_DIR, stdio: 'inherit' }); +} + +async function waitForStalwart(timeoutMs = 240000): Promise { + const url = `${JMAP_URL}/jmap/session`; + const deadline = Date.now() + timeoutMs; + const auth = 'Basic ' + Buffer.from(`${ACCOUNTS.alice.email}:${ACCOUNT_PASSWORD}`).toString('base64'); + for (;;) { + try { + const res = await fetch(url, { headers: { Authorization: auth } }); + if (res.ok) return; + } catch { + /* not up yet */ + } + if (Date.now() > deadline) throw new Error(`Timed out waiting for Stalwart JMAP at ${url}`); + await new Promise((r) => setTimeout(r, 2000)); + } +} + +export default async function globalSetup(): Promise { + if (process.env.IT_NO_DOCKER === '1') { + console.log('[global-setup-electron] IT_NO_DOCKER=1 - skipping docker compose management'); + } else { + // stalwart/prepare-stalwart-cli.sh fetches a LINUX binary (it's COPYed + // into the Stalwart container by integration/stalwart/Dockerfile - never + // meant to run on the host at all) but ends by executing it as its own + // sanity check, which only works when the host itself is Linux. On a + // macOS host that self-check fails outright ("cannot execute binary + // file") even though the download+extract already succeeded and the + // file the Dockerfile needs is perfectly fine on disk. Skipping the + // script once the binary already exists sidesteps that host/target + // mismatch without touching the shared script (used by the main + // integration config too, on hosts where it does work). + if (existsSync(STALWART_CLI_BIN)) { + console.log('[global-setup-electron] stalwart-cli already present, skipping fetch'); + } else { + console.log('[global-setup-electron] fetching stalwart-cli'); + run('bash', [path.join(INTEGRATION_DIR, 'stalwart', 'prepare-stalwart-cli.sh')]); + } + + if (!existsSync(ENV_FILE)) { + console.log('[global-setup-electron] creating integration/.env from .env.example'); + copyFileSync(path.join(INTEGRATION_DIR, '.env.example'), ENV_FILE); + } + + console.log('[global-setup-electron] docker compose up -d --build --wait stalwart'); + run('docker', [ + 'compose', '-f', COMPOSE_FILE, '--env-file', ENV_FILE, + 'up', '-d', '--build', '--wait', '--wait-timeout', '300', 'stalwart', + ]); + } + + console.log('[global-setup-electron] waiting for Stalwart JMAP'); + await waitForStalwart(); + + console.log('[global-setup-electron] stack ready'); +} diff --git a/package.json b/package.json index d80ab54d..acea95ea 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "build:standalone": "next build --webpack && node scripts/assemble-standalone.mjs", "build:electron": "node scripts/build-electron.mjs", "electron:dev": "npm run build:standalone && npm run build:electron && electron .", - "test:electron": "playwright test -c playwright.electron.config.ts" + "test:electron": "playwright test -c playwright.electron.config.ts", + "test:integration:electron": "npm run build:standalone && npm run build:electron && playwright test -c playwright.integration-electron.config.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/playwright.integration-electron.config.ts b/playwright.integration-electron.config.ts new file mode 100644 index 00000000..3fc7c093 --- /dev/null +++ b/playwright.integration-electron.config.ts @@ -0,0 +1,52 @@ +import { defineConfig } from '@playwright/test'; + +/** + * Electron-specific integration config. Reuses the same Stalwart fixture + * bring-up (globalSetup/globalTeardown) as playwright.integration.config.ts, + * but deliberately kept separate from it and scoped to only + * integration/tests/11-electron-notification.spec.ts: + * + * - No `projects` array: that test launches its own Electron process via + * _electron.launch() - it needs no Playwright-managed browser project. + * - Not run as part of the main dockerized suite: `npm run test:integration` + * (integration/run-tests.sh) runs the browser-based suite INSIDE the + * official Playwright Docker image (to get Chromium without relying on + * Playwright's own browser-download host). Electron has no such + * download step - `npm install electron` already fetched a binary for + * THIS host's platform, which would not run inside that (likely + * different-platform) container. Run this suite directly on the host + * instead - see `npm run test:integration:electron`. The main + * integration config explicitly excludes this spec file for the same + * reason, so a plain `npm run test:integration` never tries to launch it. + */ +export default defineConfig({ + testDir: './integration/tests', + testMatch: '11-electron-notification.spec.ts', + timeout: 90_000, + expect: { timeout: 20_000 }, + fullyParallel: false, + workers: 1, + // Retries unconditionally (not just CI), and more than the main config's + // 1: this suite runs the Electron shell against a `next dev` server (see + // the spec file's header comment for why - the fixture's Stalwart is + // deliberately plain HTTP), and `next dev`'s on-demand route compilation + // + Fast Refresh occasionally races the SSE stream this test depends on + // during the login -> inbox route transition, dropping that one push + // event with no error anywhere (confirmed by running the identical test + // repeatedly against an already-warm stack: same request sequence logged + // every time, but the outcome isn't always the same). Root-caused, not + // eliminated - a genuine dev-server-only timing hazard, not a bug in the + // feature this test is verifying (the same run's own logs show the WS + // circuit breaker and SSE fallback firing exactly as designed every + // single time, pass or fail). + retries: 2, + reporter: [['list']], + outputDir: 'integration/test-results-electron', + // Own global-setup (not the main config's): brings up only the `stalwart` + // compose service, not `webmail` - this suite boots a `next dev` server + // itself (see the spec file) and never talks to the containerized + // webmail on :3000. Teardown is shared - it already defaults to leaving + // the stack up unless IT_TEARDOWN=1. + globalSetup: './integration/tests/global-setup-electron.ts', + globalTeardown: './integration/tests/global-teardown.ts', +}); diff --git a/playwright.integration.config.ts b/playwright.integration.config.ts index fa296df2..a2a071d7 100644 --- a/playwright.integration.config.ts +++ b/playwright.integration.config.ts @@ -24,6 +24,13 @@ const VIDEO: VideoMode = VIDEO_MODES.includes(process.env.IT_VIDEO as VideoMode) export default defineConfig({ testDir: './integration/tests', + // Electron's own spec runs under playwright.integration-electron.config.ts + // instead (see that file's header comment for why): the dockerized run + // this config drives (integration/run-tests.sh, inside the official + // Playwright image) has no Electron binary compatible with that + // container's platform, so it must never be swept in by this config's + // default testDir glob. + testIgnore: '11-electron-notification.spec.ts', // next dev compiles routes lazily and each test logs in fresh, so give // individual tests and their polling assertions generous headroom. timeout: 90_000, From b15098a6ebbee8dae8903e391b8f0cb2af9b7b2f Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 14:21:03 +0200 Subject: [PATCH 13/21] docs: record WS push completion + browser-can't-auth-WS-handshake caveat --- docs/VNCMAIL-NATIVE-BUILD-MANUAL.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md b/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md index b98090e3..69a0f6b4 100644 --- a/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md +++ b/docs/VNCMAIL-NATIVE-BUILD-MANUAL.md @@ -82,7 +82,7 @@ by direct research/verification or by explicit user sign-off. Dates are when eac | 2026-08-04 | Does an upstream React Native app already solve push/pairing? | **Yes — `bulwarkmail/native`** has multi-account JMAP auth, full QR cross-device pairing, and Android FCM push via `bulwarkmail/relay`. Forked to `vncmail-native`. | Duplicating working auth/pairing/push code in a fresh Capacitor wrapper has no upside. **This flipped the entire mobile strategy** — see §6. | | 2026-08-04 | Self-host the push relay, or depend on upstream's shared hosted instance? | **Self-host.** Forked `bulwarkmail/relay` → `vncmail-relay`. | Keeps push metadata (FCM tokens, timing) on VNC infrastructure rather than a third party's. | | 2026-08-04 | Which SQLite library for the eventual SQLCipher-backed local index, and what Expo workflow? | **`expo-sqlite`'s official `useSQLCipher` config-plugin option** (verified via web search — Android/iOS/macOS support, [docs](https://docs.expo.dev/versions/latest/sdk/sqlite/)), not a third-party binding. **Stay Continuous Native Generation** (don't commit `ios`/`android`, let `expo prebuild` regenerate them) rather than going fully bare. | The official plugin already covers this. SQLCipher is unusable in Expo Go — day-to-day development necessarily moves to a custom dev client, which the user explicitly accepted. Going bare would turn every future merge from upstream `bulwarkmail/native` into a native-project merge conflict, for no offsetting benefit. | -| 2026-08-04 | Electron's background/foreground notification strategy: JMAP WebSocket push, polling, or quit-to-tray? | **JMAP WebSocket push.** Confirmed live and available: the Stalwart sandbox's JMAP session resource advertises `urn:ietf:params:jmap:websocket` with `supportsPush: true` (`wss://stalwart.sandbox.vnc.de/jmap/ws`), independently confirmed by two separate build agents. | Lower latency, no polling/backoff logic to write, and it's not hypothetical — it's live on the server this build already targets. | +| 2026-08-04 | Electron's background/foreground notification strategy: JMAP WebSocket push, polling, or quit-to-tray? | **JMAP WebSocket push, implemented with automatic SSE fallback — see caveat below.** Confirmed live and available: the Stalwart sandbox's JMAP session resource advertises `urn:ietf:params:jmap:websocket` with `supportsPush: true` (`wss://stalwart.sandbox.vnc.de/jmap/ws`), independently confirmed by two separate build agents. | Lower latency, no polling/backoff logic to write, and it's not hypothetical — it's live on the server this build already targets. | | 2026-08-04 | Code-signing: Apple Developer ID? Windows cert? | **Apple: yes** (also unblocks Phase 2's iOS push) — **human must actually enroll**, no agent can create the account or pay the ~$99/yr fee. **Windows: yes, eventually** — but ship unsigned for now during this build phase. | Signing is a purchase/account-creation action, categorically outside what an agent can do. | ## 5. Phase 1 — Electron desktop client @@ -136,9 +136,27 @@ npm run test:electron # the smoke-test regression gate ### Still open -- **JMAP WebSocket push implementation** (skill steps 6-7) — decision resolved (§4), build not - yet done as of this manual's last update; check the skill's status log or task tracker for - current state. +- **JMAP WebSocket push implementation** (skill steps 6-7) — **DONE.** `getWebSocketUrl()` + discovers the endpoint from the session's own capability object (never hardcoded), with + exponential-jitter reconnect (200ms base / 5s cap / 3-attempt circuit breaker) and a 30s + heartbeat, falling back to the existing SSE/polling chain on failure. A real end-to-end + integration test (`integration/tests/11-electron-notification.spec.ts`) logs into the actual + Stalwart docker fixture, injects mail over real SMTP, and asserts the native notification + fires — not a mocked path. Two real bugs were found and fixed building this: production CSP + blocked `wss:` outright (the feature was completely inert in any production build until + fixed), and the original backoff timing had a window where a real delivery could be silently + missed during a retry cycle. + **Caveat, found empirically against the real sandbox server:** `stalwart.sandbox.vnc.de`'s + `/jmap/ws` endpoint requires the same HTTP `Authorization` header as every other JMAP endpoint + *on the WebSocket handshake itself* — which the browser `WebSocket` API cannot attach (browsers + don't allow custom headers on the handshake request). Against this specific server, the client + will therefore always fail the WS handshake and fall back to SSE — correctly, by design, but it + means "live WebSocket push" is currently unreachable in practice from a browser/Electron + client, not just theoretically available. Fixing this for real would need a server-side + accommodation (e.g. a short-lived token passed as a WS subprotocol or query parameter) — that's + a Stalwart-side change, out of scope for this client work. Functionally nothing is broken (SSE + fallback works), but don't expect WS to actually engage against this sandbox until that's + addressed. - **Code signing** — blocked on the human actually enrolling in the Apple Developer Program (§4). Once done, wiring the signing identity + notarization into `electron-builder` and CI secrets is a config change, not a rewrite — the current config is structured for it. From 46fc221f9e137cc037125a68e00b2ac928ee63a6 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 22:15:10 +0200 Subject: [PATCH 14/21] docs: design for the Electron offline/delta-sync engine (design only) Adapts the mobile client's finalized, twice-reviewed JMAP delta-sync design (vncmail-native's docs/DELTA-SYNC-ENGINE-DESIGN.md, revision 3) to Electron's runtime rather than re-deriving JMAP sync theory. Every section is tagged [reused] / [adapted] / [new] so a reader can tell which is which; the protocol-level parts (three state machines, cursor provenance with branded types, error taxonomy, pinned reconcile sweep floor, I1-I13, F1-F49) are reused by citation, not restated. Three decisions were genuinely open here and are resolved with evidence: 1. Process placement: the engine + SQLite live in the standalone Next.js server process, on a worker thread. The per-account credentials are already there in httpOnly AES-GCM cookies, so nothing secret crosses a process boundary - and a Node process can put an Authorization header on a WebSocket upgrade, which is exactly what makes RFC 8887 push unreachable from the renderer today (lib/jmap/client.ts:6038-6059). Hosting it in main.ts was rejected because it can only be built by moving credentials into a process that currently holds none - the change that same comment explicitly declined. A WASM/OPFS renderer engine was rejected because it needs 'wasm-unsafe-eval' added to the product-wide CSP in proxy.ts, and its only encrypted backends are small third-party WASM builds. 2. SQLCipher ships on day one, via @signalapp/sqlcipher (N-API prebuilds, verified loading in Electron 43.2.0 in both process modes with no rebuild; real SQLCipher 4.10.0; encrypted header, wrong key rejected, FTS5 present; AGPL-3.0-only like this repo). The mobile design's plaintext-first phase existed only because Expo Go cannot load SQLCipher, and that constraint has no Electron analogue. node:sqlite is rejected (no encryption - PRAGMA key is a SILENT no-op that leaves the mailbox in cleartext - and stability 1.2/RC in the Node 24 that Electron 43 bundles); better-sqlite3-multiple-ciphers is rejected (Electron prebuilds stop at ABI 146, Electron 43 needs 148, so a C++ toolchain on every machine, and that lag recurs at every Electron major). 3. Keys use Electron's built-in safeStorage, not keytar, with a mandatory getSelectedStorageBackend() check: on Linux without a keyring, isEncryptionAvailable() returns true while using a public hardcoded password, which is worse than an honest failure. Also records what this repo has that the mobile one doesn't (a real Stalwart integration fixture, so the highest-value tests are cheap) and what it lacks (no /changes wrappers, no offline cache, no outbox - so v1 desktop offline is read-only by decision, and the mobile design's D1-D8 defects are not inherited). Everything not verifiable in this environment is flagged for a Stage A verify-first gate rather than presented as fact - notably whether an unsigned build keeps its macOS Keychain item across an electron-updater upgrade, and whether Next's output file tracing carries the native prebuilds into .next/standalone. No source file is touched by this commit. Co-Authored-By: Claude Sonnet 5 --- docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md | 1330 ++++++++++++++++++++++++ 1 file changed, 1330 insertions(+) create mode 100644 docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md diff --git a/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md b/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md new file mode 100644 index 00000000..215300fd --- /dev/null +++ b/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md @@ -0,0 +1,1330 @@ +# Electron Offline Engine — Design + +Status: **design only, not implemented.** Nothing outside this file has been changed on this +branch. `electron/main.ts`, `electron/preload.ts` and `lib/jmap/client.ts` are untouched. + +Repo: `brvncde-dotcom/vncmail-plus`, branch `claude/electron-offline-design`, worktree +`~/worktrees/vncmail-electron-sqlite`. Based on `claude/electron-desktop` (the working desktop +shell + RFC 8887 WebSocket push), HEAD `b15098a6`. + +Companion documents: + +- **`~/worktrees/vncmail-native-sync-impl/docs/DELTA-SYNC-ENGINE-DESIGN.md`** (revision 3, 2012 + lines) — the finalized, twice-adversarially-reviewed, implemented and real-device-verified JMAP + delta-sync design for this program's React Native client. **This document is an adaptation of + that one, not a replacement for it.** Read it first; it is the normative source for everything + marked *[reused]* below. Cited as **M§n** throughout. +- `~/worktrees/vncmail-native-sync-impl/docs/DELTA-SYNC-DESIGN-REVIEW.md` — the adversarial review + that produced M's revision 2 (findings S1–S16). Cited as **MR**. +- `docs/VNCMAIL-NATIVE-BUILD-MANUAL.md` — program narrative; §4 decision log, §5 (what the Electron + shell already has), §8 known landmines. +- `~/.claude/skills/VNCprodbuild/SKILL.md` — the build plan this belongs to. + +Normative references, cited by section: **RFC 8620** (JMAP core) and **RFC 8621** (JMAP Mail), +exactly as enumerated in M's preamble. This document does not re-derive any RFC reading; every +protocol-level claim is M's, verified there. + +--- + +## 0. How to read this document + +The JMAP delta-sync problem is platform-independent. M solved it, was attacked twice over it, and +shipped it. Re-deriving it here would produce a second, subtly different set of invariants for the +same protocol — which is how one client silently loses mail the other doesn't. + +So every section is tagged: + +| Tag | Meaning | +|---|---| +| **[reused]** | Adopted from M unchanged. The cited M section is normative; this document only records *that* it applies and any Electron-specific naming. Do not re-litigate. | +| **[adapted]** | M's decision holds but its mechanism doesn't, because Electron's runtime differs. The difference is stated explicitly. | +| **[new]** | No M counterpart, or M's answer is actively wrong here. Designed from scratch in this document. | + +The genuinely new work is §2 (which process hosts the engine), §3 (SQLCipher from day one), §6 (key +storage), and the parts of §5/§7/§8 that follow from those. Everything else is M. + +### 0.1 Scope + +**In scope:** where the engine and its SQLite file live; which SQLite binding; whether encryption +ships on day one and how its key is stored; how the store keys against *this* repo's account model; +what the existing renderer-side push pipeline must and must not do once the engine exists; the +schema; triggering; the staged rollout with its verify-first gate. + +**Out of scope, deliberately:** + +- FTS5 index population (VNCprodbuild step 9). §7.5 reserves the hook. Note §3.4: FTS5 is + empirically present in every candidate binding, so this is not a binding-selection input. +- Offline **compose/outbox**. Unlike the mobile app, **this repo has no outbox and no optimistic + mutation layer at all** (§1.6) — so M§5.6's read-time overlay has nothing to overlay. v1 desktop + offline is **read-only**. This is a scope decision, recorded in §5.4, not an oversight. +- Attachment blob storage (M§9.4's second bullet applies verbatim when it lands). +- Calendar / Contacts / Files delta sync. +- Shared/group ("delegated") mail. Account-scoped primary keys are in place from day one (M§9.3, + MR S3) so adding it later is inserting rows. +- Code signing (VNCprodbuild step 9). It is *referenced* in §6.3 because it interacts with + macOS key storage, but it is not resolved here. + +**Non-goal:** compatibility with anything on disk today. There is nothing on disk today (§1.6). + +--- + +## 1. What exists today, verified + +File:line references are to this worktree at `b15098a6`. Everything in this section was read, and +every runtime claim in §3/§6 was executed against the Electron binary actually pinned by +`package.json` — see §3.1 for the transcript summary. + +### 1.1 The desktop shell + +`electron/main.ts` (225 lines) boots the **same** Next.js `output: "standalone"` artifact the +`Dockerfile` ships (`next.config.ts`'s `output: "standalone"`), as a **child process**: + +- `getStandaloneServerEntry()` (`:26-31`) — `process.resourcesPath/standalone/server.js` when + packaged, `.next/standalone/server.js` in dev. +- `startStandaloneServer()` (`:70-105`) — allocates a random free localhost port (`:33-48`), spawns + `process.execPath` with `ELECTRON_RUN_AS_NODE: "1"` (`:85-94`) so no system Node is required, then + polls until reachable (`:50-68`). +- `createMainWindow()` (`:114-143`) — `BrowserWindow` with `contextIsolation: true`, + `nodeIntegration: false`, **`sandbox: true`**, preload at `dist-electron/preload.js`, loading + `http://127.0.0.1:`. +- The notification bridge: `ipcMain.handle("vnc:show-notification", …)` (`:155-176`), reached from + `electron/preload.ts:16-27`'s `contextBridge.exposeInMainWorld("vnc", …)`, wrapped by + `lib/electron-bridge.ts`'s `isElectronShell()` / `showElectronNotification()`, and called from + `app/(main)/[locale]/page.tsx:1198-1222`. **This is the existing IPC pattern** — one + `ipcMain.handle` + one `contextBridge` method, no channel registry, no streaming. + +Packaging (`electron-builder.config.js`): the standalone server ships as `extraResources` copied +`from: ".next"` with a `standalone/**/*` filter — deliberately, to dodge app-builder-lib +unconditionally dropping a copy-root directory literally named `node_modules` (documented in that +file, and in the manual §5 as a bug found by actually launching a `--dir` build). Targets: macOS +dmg+zip **x64 and arm64**, Windows nsis x64, Linux AppImage+deb x64. Unsigned. +`scripts/build-electron.mjs` bundles `electron/*.ts` with esbuild, CJS, `external: ["electron", +"electron-updater"]`. + +CI: `.github/workflows/electron-build.yml`, matrix macos/windows/ubuntu, **Node 22** on the runner +(note: not Electron's Node — see §3.3), `npm run test:electron` as a required gate before packaging. + +### 1.2 The JMAP client, and where the credentials actually are + +`lib/jmap/client.ts` (7413 lines) is a **renderer-side** class. `JMAPClient` (`:542`) holds +`serverUrl`, `username`, `password` and an `authHeader` built in the constructor as +`Basic ${btoa(username:password)}` (`:579-585`), or `Bearer …` via `static withBearer` (`:586-598`). +`authenticatedFetch` (`:672`) is plain browser `fetch()` straight to the mail server. There is no +JMAP proxy route in front of it for normal traffic. + +**But the credentials are also recoverable server-side, and that is the load-bearing fact for §2.** +`app/api/auth/session/route.ts`: + +- `POST` stores `encryptSession(serverUrl, username, password)` — AES-256-GCM under + `SESSION_SECRET` (`lib/auth/crypto.ts:23-33`) — in an **httpOnly** cookie + `jmap_session[_]` (`lib/auth/session-cookie.ts`), one per account slot, + `MAX_ACCOUNT_SLOTS = 50` (`lib/account-utils.ts`). +- `GET` returns only `{serverUrl, username}`; `PUT` returns the **full credentials** for session + restoration, gated on `Sec-Fetch-*` headers proving a same-origin browser `fetch()`. +- OAuth/TOTP accounts instead park a **refresh token** in an httpOnly cookie + (`app/api/auth/token/route.ts` POST), and `PUT` on that route mints a fresh access token from it — + **rotating the stored refresh token whenever the server returns a new one** (`:104-106`). Remember + that; §2.5 has to keep two independent refreshers from existing. + +So: any code running in the standalone server process can, for any account slot, obtain either a +Basic auth header (decrypt the session cookie) or a bearer token (refresh-token grant) **without a +single new credential path, IPC message, or storage location.** This is not true of Electron's main +process, which sees none of those cookies. + +### 1.3 The push pipeline as shipped, and the wall it hit + +`setupPushNotifications()` (`:6130`) prefers RFC 8887 JMAP-over-WebSocket +(`getWebSocketUrl()`, `:3803-3809`, reading the `urn:ietf:params:jmap:websocket` capability off the +session — never hardcoded), falling back to SSE, then polling. Tight reconnect ladder (200 ms base / +5 s cap), 30 s heartbeat, and a 3-consecutive-handshake-failure circuit breaker +(`wsPermanentlyDisabled`, `:6060`ff). + +The committed comment at `:6038-6059` records the empirical outcome, and it is the single most +important existing finding for this design: + +> `stalwart.sandbox.vnc.de`'s `/jmap/ws` requires the same HTTP `Authorization` header as every +> other JMAP endpoint **on the WebSocket UPGRADE request itself**. The browser `WebSocket` +> constructor cannot attach custom headers (a WHATWG restriction; credentials-in-URL are rejected +> too). So from the renderer, every attempt fails the handshake and the circuit breaker correctly +> falls back to SSE. + +And the alternative it explicitly declined (`:6055-6059`): + +> 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." + +That objection is **correct for the main process and inapplicable to the standalone server** — which +already holds the credentials (§1.2) and would pipe nothing. §2.5 acts on this. + +The transport-agnostic "genuine new mail" signal is `email-store.newEmailNotification` +(`stores/email-store.ts:3082-3086`, set by `refreshCurrentMailbox`), consumed once in +`app/(main)/[locale]/page.tsx:1198-1222`. It already fires identically over WS, SSE and polling. +**The engine must not add a second notification path.** §2.5 states the rule. + +### 1.4 Multi-account model (differs from mobile — check, don't assume) + +`stores/account-store.ts` — a Zustand `persist` store named `account-registry`, holding +`AccountEntry[]` with: + +- `id`: `` `${username}@${new URL(serverUrl).hostname}` `` via + `lib/account-utils.ts generateAccountId()`. **Same shape as mobile's `LocalAccountId`** — a + genuine coincidence worth stating, because it means M§3.1's `LocalAccountId` type carries over + verbatim. +- `cookieSlot: number` — **new relative to mobile.** The index into the per-slot cookie namespace of + §1.2, assigned by `getNextCookieSlot()` (first free integer, reused after removal). +- `serverIdentifiers?: string[]` — server-confirmed account-id forms captured at login, used by the + account-switch guard so a short login name canonicalized by the server is still recognized. +- `activeAccountId`, `defaultAccountId`; caps `MAX_ACCOUNTS_HTTP1 = 5` (HTTP/1.1 SSE-connection + budget) lifting to `MAX_ACCOUNT_SLOTS = 50` once h2/h3 is observed. + +Two consequences for the schema (§7): + +1. The durable key is `accountId` (`username@host`), **never `cookieSlot`** — slots are recycled by + `getNextCookieSlot()`, so a slot number is a transport detail with a shorter lifetime than the + data. A `slot → accountId` confusion is a cross-account data-mixing bug of exactly M's D6 shape. +2. Any API surface addressed by slot (as §1.2's routes are) must **resolve slot → accountId and + re-verify** against the session's confirmed username before touching the store. §5.3. + +### 1.5 CSP — a hard constraint on option C + +`proxy.ts:88-141` builds the CSP. In production: + +``` +script-src 'self' 'nonce-' # no 'unsafe-eval', no 'wasm-unsafe-eval' +connect-src 'self' https: wss: # 'wss:' was added for §1.3's WS push +``` + +`'unsafe-eval'` exists **only** for `isDev` and the plugin-sandbox path. WebAssembly compilation +requires `'wasm-unsafe-eval'` or `'unsafe-eval'` under CSP3. §2.3. + +### 1.6 What does *not* exist here (and does in the mobile repo) + +This is the inverse of M§1.1/§1.2, and it is mostly good news: + +| | mobile (`vncmail-native`) | here | +|---|---|---| +| Existing offline cache | `offline-sync.ts` + `offline-cache-store.ts`, carrying defects D1–D8 | **nothing.** No IndexedDB mail cache, no offline list, no offline read path. `lib/plugin-storage.ts` uses IndexedDB but only for plugin assets. | +| `Email/changes` / `Mailbox/changes` wrappers | already present, already driving an incremental list path | **none.** The only occurrence in the repo is a mock in `app/api/dev-jmap/[...path]/route.ts:1949`. Greenfield. | +| Outbox / optimistic mutations | `outbox-store.ts`, full-state idempotent queue | **none.** Mutations go straight to the server. | +| Push transport | SSE + FCM relay | WS (blocked, §1.3) → SSE → polling, all renderer-side | +| Stalwart integration fixture | in a *sibling* repo — MR S16 costed this as real cross-repo CI work | **in this repo**: `integration/docker-compose.yml` + 11 specs incl. `11-electron-notification.spec.ts`, which logs in against real Stalwart, injects mail over real SMTP, and asserts the native notification. Free to extend. | + +**Therefore M§1.3's defect list D1–D8 does not apply here.** There is no legacy cache to inherit +bugs from, no `patchCache()` write-through to delete, no D4 cursor fast-forward in shipped code, and +M§14.1's "discard, don't migrate" is vacuous. What *does* carry over is the *class* of each defect +as a thing not to introduce — which is what M's invariants I1–I13 are for (§4.3). + +One inherited defect *shape* is worth naming, because this repo has it too: `stores/file-store.ts` +and others use `try { localStorage.setItem(...) } catch { /* ignore */ }` in a dozen places — M's D2 +pattern. **Banned in the sync path** (M I4). §7.2. + +--- + +## 2. Decision 1 — which process hosts the engine **[new]** + +M has no counterpart: React Native has one JS context. Electron has three candidate homes, and this +codebase makes the choice non-obvious in both directions. + +### 2.1 Candidate A — engine + SQLite inside the standalone Next.js server process + +The renderer reaches cached data through new `app/api/**` routes, exactly as it reaches everything +else server-side today. + +**For:** + +1. **The credentials are already there, encrypted, per account** (§1.2). No new credential path, no + IPC carrying secrets, no second copy of the TOTP/refresh state machine. Every other candidate has + to solve this, and B can only solve it by doing the thing `client.ts:6055-6059` explicitly + declined. +2. **It unlocks real WS push, which the renderer structurally cannot have** (§1.3). A Node process + can set `Authorization` on a WebSocket upgrade (`ws` package). This is not a side benefit: it + converts a documented dead end into a working transport, on the server this program actually + targets, with no Stalwart-side change. §2.5. +3. **No new IPC surface at all.** `window.vnc` stays a one-method bridge. Nothing about + `contextIsolation: true` / `sandbox: true` has to be relaxed or extended. +4. **Native module packaging is already solved for this process.** The standalone server ships as + `extraResources` **outside `app.asar`**, with its own traced `node_modules` — the exact copy path + whose one footgun is already found, fixed and documented (`electron-builder.config.js`). A `.node` + binary in an unpacked directory needs no `asarUnpack` reasoning at all. +5. **Blocking is cheapest here.** `better-sqlite3` and `@signalapp/sqlcipher` are synchronous + (§3.2). Blocking this event loop delays local-cache HTTP responses; it does not block the + renderer's paint (React runs in the renderer) and does not block window/menu/IPC handling (that's + the main process). It is the *least* latency-critical of the three loops. +6. Reads are trivially observable — the existing integration suite drives the app over HTTP and can + assert on new routes without any Electron-specific harness. + +**Against:** + +1. **This process is also what a hosted, multi-user Docker deployment runs.** Unconditional offline + routes would have a shared server start caching *every user's* mail into a server-side SQLite + file. This is the strongest argument against A and it must be closed by construction, not by + convention — §2.4. +2. **Next.js output-file-tracing vs. a native module.** `serverExternalPackages` (already used for + `esbuild`, `next.config.ts`) plus NFT must actually carry `prebuilds/**/*.node` into + `.next/standalone/node_modules`. `node-gyp-build`'s resolution is directory-scan-based, which NFT + handles specially but not infallibly. **Verify-first, §12 Stage A.** +3. The DB path must be handed in: `app.getPath('userData')` is a main-process API, so `main.ts` must + pass it as an env var on spawn (`:85-94` already builds the env). One line, but it is a coupling. +4. Server-process lifetime is `window-all-closed` / `before-quit` (`main.ts:210-219`), so an + in-flight cycle is killed by process death rather than by a cooperative abort. M's crash-recovery + design (I1, M§6.3) already makes that safe — cost is one page — but a graceful-shutdown IPC is + worth adding later. + +### 2.2 Candidate B — engine + SQLite in `electron/main.ts`, over `contextBridge`/IPC + +**For:** + +1. **The standalone server's code stays byte-identical to a hosted deployment's.** A's §2.1-against-1 + simply does not arise: there is no Electron-only server code to accidentally ship in Docker. +2. Mirrors the existing notification bridge, so the pattern is familiar. +3. `electron-builder` already auto-unpacks `**/*.node` from the asar, and production `node_modules` + are collected regardless of the narrow `files: ["dist-electron/**/*", "package.json"]` (that's why + `electron-updater` is `external` in `build-electron.mjs` and still ships). Low packaging friction. +4. `safeStorage` (§6) lives in the main process natively — no bridging for the key. + +**Against:** + +1. **It requires exactly the thing `lib/jmap/client.ts:6055-6059` refused.** The engine needs + credentials. The main process has none: the `jmap_session` / refresh-token cookies belong to the + renderer's origin. So either the renderer ships the Basic header / bearer token over IPC (the + declined "materially bigger security-sensitive change"), or the main process learns to read + Electron's cookie jar (`session.defaultSession.cookies`) and re-implement `decryptSession` — which + means shipping `SESSION_SECRET` into the main process too. Both are net-new secret handling to + reach a place that currently, deliberately, holds no secrets. +2. **A large new IPC surface.** The offline read path is not one fire-and-forget notification; it is + list queries, single-message reads, per-account status, settings changes and abort signals — each + an `ipcMain.handle` returning structured data across `contextIsolation`. Every one is a new trust + boundary in a window that is currently `sandbox: true` with a 12-line preload. +3. **Blocking hurts most here.** Synchronous SQLite on the main process's loop is jank in window + dragging, menu response and IPC dispatch. Mitigable with `worker_threads`, but then B is A's + complexity plus IPC. +4. The renderer's offline read path becomes Electron-only by construction, so the web/PWA deployment + can never share it. That may be acceptable — but it is a fork, and A's routes would work in both. + +### 2.3 Candidate C — engine in the renderer, WASM SQLite over OPFS + +**Investigated, not assumed. Findings:** + +- The official WASM build is `@sqlite.org/sqlite-wasm` (3.53.0-build1), which `sqlocal` (0.18.0) + wraps for OPFS. **Neither has any encryption** — SQLCipher is a *fork* of SQLite's source, not a + loadable extension, so an official build cannot have it. +- Encrypted WASM builds **do** exist on npm: `@7mind.io/sqlcipher-wasm` (1.2.0, "production-ready + WebAssembly build of SQLCipher with real OpenSSL-based encryption") and `@aztec/sqlite3mc-wasm` + (5.1.0, SQLite3MultipleCiphers 2.3.5 as WASM). So the honest answer to "does a WASM SQLCipher + genuinely exist?" is **yes, but only from small third-party publishers** — not from the SQLite + project, not from a vendor with a desktop-mail-scale user base. For a component whose failure mode + is "the user's whole mailbox is readable on a stolen laptop", that provenance is the finding. +- **The CSP problem is decisive independently of encryption.** §1.5: production `script-src` is + `'self' 'nonce-…'`. WASM compilation needs `'wasm-unsafe-eval'`. Adding it in `proxy.ts` widens the + CSP for **every deployment of this product, including the hosted web one**, to buy a desktop-only + feature. That is a security regression with the wrong blast radius. +- Even granted both, the encryption key would live in the renderer's JS heap — the same context that + renders untrusted HTML mail bodies and hosts the plugin sandbox. A/B keep it in a Node process the + renderer cannot address. +- Secondary, verify-first if C is ever revisited: the official `opfs` VFS uses `SharedArrayBuffer` + + `Atomics.wait` and therefore needs COOP/COEP headers; `opfs-sahpool` does not. Neither is + configured in `proxy.ts` today. + +**Against, summarised:** requires a product-wide CSP widening, puts the key in the most exposed +context, and its only encrypted backends are unvetted third-party WASM builds. **For:** no IPC, no +native module, no packaging story, works identically in the browser PWA — a real benefit, and the +reason to keep C on record rather than dismiss it. If the offline store were *unencrypted* and +*browser-first*, C would be the right answer. It isn't either. + +### 2.4 Decision: **A**, with the hosted-deployment gate as part of the design + +The engine and the SQLite file live in the **standalone Next.js server process**. Rationale in +priority order: it is the only candidate where credentials are already present and correctly scoped +(§2.1-for-1); it is the only candidate that makes RFC 8887 push actually work (§2.1-for-2); it adds +no IPC and no preload surface; and its native-module packaging path is the one already exercised and +debugged in this repo. + +A's one serious objection — the same process serves hosted multi-user deployments — is closed +structurally, not by convention. Three layers, all required: + +1. **A desktop marker env var.** `main.ts`'s spawn env (`:85-94`) gains + `VNCMAIL_DESKTOP_STORE_DIR=/offline`. Absent or empty ⇒ the engine + module is never constructed, and this also supplies §2.1-against-3's path. One variable does both + jobs, so they cannot drift apart. +2. **Every new route refuses to run without it.** `app/api/offline/**` returns `404` (not 403 — + nothing should learn the routes exist) when the marker is unset. This mirrors the existing + "routes 503-on-misconfig" habit elsewhere in the program. +3. **A single-user assertion.** With the marker set, the engine asserts at open time that the store + directory is per-OS-user (it is, being under `userData`) and records the resolved + `serverUrl`+`username` of every account it materialises. A store whose recorded account set + doesn't match the requesting session's is a purge trigger (§5.5), not a merge. + +Additionally, and non-negotiably: **the engine runs on a `worker_threads` Worker inside the server +process, never on the request event loop.** Synchronous SQLite plus JMAP page application is exactly +the workload that turns a shared event loop into a latency problem, and M's own I11 (jobs strictly +sequential within an account, M§3.4) is naturally expressed as "one worker per account, one job at a +time" rather than as a hand-rolled mutex. API routes talk to the worker via `postMessage` and never +touch the database handle. This also localises §2.1-against-4: the worker gets an explicit +`terminate` path. + +Rejected explicitly, for the record: B, because it can only be built by moving credentials into a +process that today holds none — the change `client.ts` already declined on its merits, and nothing +about an offline store makes that trade better. C, because it needs a product-wide CSP widening and +its only encrypted backends are unvetted. + +### 2.5 Consequence for the already-working WS-push renderer code + +This is the question that must not be answered by accident. + +**The renderer's push pipeline stays exactly as it is. No line of `lib/jmap/client.ts` changes for +v1.** Its SSE/polling path is the renderer's own liveness for the *visible* list, it is working, and +it is what feeds `newEmailNotification` → `showElectronNotification` (§1.3). The engine does not +replace it and does not read from it. + +Three rules, in order of how easy they are to get wrong: + +1. **The engine gets its own push connection, and it is the header-capable one.** In the server + process the engine opens `wss://…/jmap/ws` with an `Authorization` header (via `ws`), which is the + connection the renderer cannot open (§1.3). It subscribes with `WebSocketPushEnable`, and treats + the resulting `StateChange` exactly as M§10.4 specifies: **a wake signal, never a cursor.** M's + two load-bearing rules (a pushed `newState` is never written as a cursor; state-equality against + our cursor is a cheap safe dedupe) apply verbatim. *[reused: M§10.4]* +2. **The engine never fires a notification.** `newEmailNotification` remains the single source of + the OS notification, in the renderer, via the existing bridge. An engine-side notification would + double-notify on the common path (both connections see the same delivery) and diverge on the + uncommon one. What the engine *may* do is expose "account X changed" on its status channel; the + renderer decides whether to refresh, exactly as M§5.7 specifies the engine→email-store direction + (and only that direction). +3. **Duplicate work is bounded and acceptable; duplicate *state* is not.** Yes, two connections to + the same server per account, and both wake on the same delivery. That is deliberate, and it is + M§5.7's argument transplanted: the renderer's list cursor and the engine's `/changes` cursors + page differently, invalidate differently, and *one being wrong must not corrupt the other*. The + engine must never read the renderer's `lastStates` (`client.ts:571`) and the renderer must never + read the engine's cursors. The cost is one extra socket per account; the cap is + `MAX_ACCOUNTS_HTTP1 = 5` today, and the engine's socket is server→server, so it does not consume + the browser's per-origin HTTP/1.1 connection budget that cap exists to protect. + +**Deferred, and worth stating so it isn't done silently:** once the engine's WS connection is proven, +the renderer's transport could be retired in favour of the engine pushing "account changed" down an +SSE/`EventSource` from the local server — one server-side socket per account instead of two, and the +renderer's circuit-breaker-into-SSE path becomes dead code. That is a *follow-up*, gated on the +engine's connection being verified against real Stalwart, not part of v1. Doing it in v1 would make +a working notification path depend on an unproven one. + +--- + +## 3. Decision 2 — SQLite binding, and SQLCipher on day one **[new]** + +M deferred `useSQLCipher` for one specific reason: Expo Go cannot load it, so a plaintext-first phase +was the only way to keep the day-to-day dev workflow (M§9.2, M§14.3 step 3.1, MR S4/V4). **Electron +has no Expo Go.** The deferral's entire justification is absent, so the question is genuinely open +here and has to be answered on the evidence. + +### 3.1 What was measured, and how + +All of the following was executed against the Electron binary this repo pins +(`electron@43.2.0`, resolved from `~/worktrees/vncmail-electron/node_modules` — this worktree has no +`node_modules` installed), both as the main process and under `ELECTRON_RUN_AS_NODE=1` (the mode the +standalone server actually runs in, `main.ts:88`): + +| Fact | Result | How | +|---|---|---| +| Electron 43.2.0's bundled Node | **24.18.0**, ABI `modules=148`, `napi=10` | `process.versions` | +| Its bundled SQLite | **3.53.1, with `ENABLE_FTS5`** | `pragma compile_options` | +| `node:sqlite` present and working | yes; exports `DatabaseSync, StatementSync, Session, constants, backup`; no `ExperimentalWarning` observed | `require('node:sqlite')` | +| `node:sqlite` encryption | **none.** `compile_options` has no codec. `PRAGMA key='…'` is **silently accepted and does nothing** — the file was written with a `SQLite format 3` header and a plaintext canary string recoverable with `grep` | wrote a real file, read the bytes back | +| `node:sqlite` stability (Node 24) | **1.2 — Release Candidate** (RC since v24.15.0; no longer behind `--experimental-sqlite`), not stability-2 stable | Node 24 docs | +| `better-sqlite3@13.0.2` | installs with **zero build step**; ships in-tarball N-API prebuilds for `darwin-{arm64,x64}`, `linux-{arm64,x64}`, `linuxmusl-{arm64,x64}`, `win32-{arm64,x64}`; **loads in Electron 43 both as main process and under `ELECTRON_RUN_AS_NODE`**; FTS5 available; `PRAGMA key` silently a no-op | `npm install` + load in Electron | +| `better-sqlite3` 12.x vs 13.x | 12.x used `install: prebuild-install \|\| node-gyp rebuild` (per-ABI downloads). **13.0.0 dropped that** for `gypfile: false` + in-tarball prebuilds — i.e. moved to ABI-stable N-API. This is why no `electron-rebuild` is needed | npm metadata for 13.0.2 vs 12.11.1 | +| `better-sqlite3-multiple-ciphers` | latest is **12.11.1** (2026-06-18) — on the *old* 12.x prebuild-install model. Its GitHub release carries 98 Electron prebuilds, ABIs **121…146**. **Electron 43 needs ABI 148 — absent.** So it would fall through to `node-gyp rebuild`: a C++ toolchain + Python + Electron headers on every contributor machine and every CI runner | npm metadata + GitHub releases API | +| **`@signalapp/sqlcipher@4.0.3`** | **N-API** (`prebuildify --strip --napi`, `node-gyp-build`), in-tarball prebuilds for `darwin-{arm64,x64}`, `linux-{arm64,x64}`, `win32-{arm64,x64}`. **Loads in Electron 43 with no rebuild, both process modes.** Real **SQLCipher 4.10.0 community**; `PRAGMA cipher_version` reports it; **file header is ciphertext, canary absent from the bytes, wrong key rejected with `SQLITE_NOTADB`, right key reads the row back**; FTS5 available; `better-sqlite3`-shaped synchronous API (`db.exec`, `db.prepare().run()/all()`, `db.pragma()`); **AGPL-3.0-only**, matching this repo's own licence | `npm install` + full round-trip in Electron main process | + +Two of those deserve to be called out as landmines rather than table rows: + +- **`PRAGMA key` failing silently is the worst possible ergonomics.** On both `node:sqlite` and plain + `better-sqlite3`, setting a key "works", the database works, and the mail is on disk in cleartext. + There is no error to notice. Whatever binding ships, the store's open path must **assert + encryption positively** — read `PRAGMA cipher_version` and refuse to proceed if it is empty — and + a test must assert a canary string is *absent* from the raw file bytes. Both are in §11. +- **`better-sqlite3-multiple-ciphers` lags Electron by roughly one to two majors** (ABI 146 vs 148, + and its 12.x base trails better-sqlite3's 13.x). That lag is structural, not a one-off: with + per-ABI prebuilds, every Electron major bump re-opens the question. `@signalapp/sqlcipher`'s N-API + prebuilds are immune to Electron majors by construction. + +### 3.2 Recommendation: **ship SQLCipher on day one, via `@signalapp/sqlcipher`** + +The friction the mobile design was avoiding **does not exist here**, and the evidence is unusually +clean: a package built and maintained specifically to run SQLCipher inside an Electron desktop +application, N-API so it needs no rebuild against Electron 43, prebuilds covering every platform this +repo actually packages (§1.1: darwin x64+arm64, win32 x64, linux x64 — all present), FTS5 already +compiled in, an API close enough to `better-sqlite3` that the backend is the same code either way, +and a licence identical to this repo's. + +Verified working, in this environment, against this Electron version. Not inferred. + +So: **no plaintext-first phase.** M§14.3's plain-then-encrypted staging exists to protect a dev +workflow that has no analogue here; importing it would mean deliberately shipping a plaintext +mailbox on disk for a phase, plus building and then discarding the store-format-migration machinery +of M§8.4.1 to get out of it. Both costs, no benefit. + +Corollaries: + +- The abstraction boundary of M§9.1 (`SyncStore` / `SyncTxn`) stays **exactly** as M specifies. It is + what makes this reversible: if `@signalapp/sqlcipher` ever becomes untenable, `store-sqlite.ts` is + the only file that changes. Do not skip it on the grounds that the binding question is now settled + — the boundary is also what makes `store-memory.ts` possible, and M§13's contract-tests-against-two-backends + is most of the test plan's value. +- M§8.4.1's **out-of-band store-format marker is still required**, for the reason V4 gives, minus one: + `schemaVersion` still lives inside a file that a future format change could make unreadable, so it + must be mirrored outside. What day-one encryption removes is only the *plain→cipher* transition + that would otherwise be the marker's first customer. Keep the marker; it costs a JSON file. +- `node:sqlite` is **rejected**, on two independent grounds: no encryption at any price, and + stability 1.2 (RC) for a component holding the user's mail. Its one advantage — zero dependencies — + is worth nothing once encryption is a requirement. Worth re-evaluating only if a decision is ever + taken to ship unencrypted. +- Plain `better-sqlite3@13.0.2` is the **fallback**, not the plan: adopt it only if Stage A (§12) + finds `@signalapp/sqlcipher` cannot be packaged, and in that case the human decides between + "unencrypted desktop store" and "no desktop store yet" (§13, open question 1). + +### 3.3 Friction that does exist, stated plainly + +Not zero, just small — and the human should see it rather than have it smoothed over: + +1. **A ~6 MB native dependency with 6 platform prebuilds in the tarball**, entering + `dependencies`. Install size and `npm ci` time grow for everyone, including web-only contributors + who will never run Electron. +2. **NFT tracing is the real unknown** (§2.1-against-2). `serverExternalPackages` plus a verification + that `prebuilds/**/*.node` reached `.next/standalone/node_modules` is Stage A's first task. If NFT + won't carry it, the fallback is a copy step in `scripts/assemble-standalone.mjs` — which already + exists precisely to patch up what standalone output omits, so this is a known-shaped fix. +3. **CI runs Node 22 while Electron bundles Node 24** (§1.1). Harmless for an N-API module (the + prebuild is selected by platform+arch, not ABI) — but it *would* have been fatal for a + per-ABI package, which is worth recording as another reason the N-API choice matters. Any + `npm test` that loads the binding under the runner's own Node exercises a different Node than + production; the binding load assertion must therefore run **inside Electron** (`npm run + test:electron`), not only in vitest. +4. **Cross-arch macOS packaging.** CI builds both x64 and arm64 dmg/zip on one macOS runner. + In-tarball prebuilds ship *all* platforms, so this works — whereas `prebuild-install` downloads + only the host's, and would have broken the cross-arch target. Verify in Stage A that the arm64 + `.node` is what ends up in the arm64 build and vice versa. +5. **`linuxmusl` is not covered** by `@signalapp/sqlcipher` (`better-sqlite3` does cover it). Irrelevant + for AppImage/deb (glibc); relevant if an Alpine-based container ever wants the engine — which, + per §2.4, it must not. + +### 3.4 What is *not* an input to this decision + +FTS5 (VNCprodbuild step 9) is present in all three candidates — Electron's own bundled SQLite, +`better-sqlite3` 13, and `@signalapp/sqlcipher` (which additionally ships Signal's FTS5 segmenting +extension and an `initTokenizer()`). So the search step cannot be used to argue for a binding. +Recorded so a later session doesn't relitigate the choice on those grounds. + +--- + +## 4. The sync engine itself — mostly M, verbatim + +Everything in this section is **[reused]** unless marked otherwise. The M section cited is +normative; what follows is a map, not a restatement, so that a reader can tell reuse from +re-derivation at a glance. + +### 4.1 Architecture: three state machines *[reused: M§2, M§3.4 I11]* + +Per account: **A** delta (`Mailbox/changes` then `Email/changes`, one cursor each), **B** coverage +(the envelope-window enumeration; `/changes` structurally cannot deliver pre-existing mail, so +coverage owns history and is also the bootstrap), **C1** body-queue drain, **C2** body backfill +(MR S9 — without it, widening body retention silently does nothing for already-covered envelopes). + +Logically independent state, **operationally serialised** (I11). Here that serialisation is +structural rather than disciplinary: one worker thread per account, one job at a time (§2.4). M's +warning stands regardless — "run bodies in parallel, it's separate state" is forbidden, and F48 +(a body landing for an envelope destroyed in the same cycle) is what it costs. + +Module layout: M§2.2 verbatim, relocated. `src/sync/**` in the mobile repo becomes **`lib/sync/**`** +here (this repo has no `src/`): `engine.ts`, `cursor.ts`, `apply.ts`, `coverage.ts`, `bodies.ts`, +`retention.ts`, `errors.ts`, `states.ts`, `store.ts`, `store-sqlite.ts`, `store-memory.ts`. M's hard +requirement that `apply.ts` be **pure** (no network, no storage, no store access) carries over +unchanged and is the single highest-leverage constraint in the document: it is what turns M§11's +failure-mode table into a vitest suite. `overlay.ts` is **not** ported — see §5.4. + +### 4.2 Two record tiers, two retention windows *[reused: M§2.1]* + +Envelope tier = `EMAIL_LIST_PROPERTIES` — which in *this* repo is +`lib/jmap/client.ts:139-154`: `id, threadId, mailboxIds, keywords, size, receivedAt, from, to, cc, +subject, preview, hasAttachment, blobId`. (Note the extra `blobId`, present so list rows can serve +drag-out to the filesystem as `.eml`; it belongs in the envelope tier here.) Body tier = +`bodyStructure, textBody, htmlBody, bodyValues, attachments, bcc, replyTo, sentAt`. + +Independent retention: `offlineEnvelopeDays` ≫ `offlineBodyDays`, MB cap on **bodies only**. The +human decision M records — widen envelopes well beyond bodies, so a message never falls out of the +offline *list* over a body-size cap — is a program-level decision and applies here identically. +Concrete numbers remain open (§13). + +### 4.3 Cursors, provenance and invariants *[reused: M§3]* + +Adopted without modification: + +- `SyncCursor` / `CoverageState` / `BodyQueueEntry` / `AccountSyncState` as M§3.1 defines them, + including per-cursor failure counters (MR S6), `sweepFloor` + `deferredTargetFrom` (MR S2), and + `gapMarkers`. +- **Branded state types** (M§3.2): `ChangesState` vs `SnapshotState`, `advanceCursor(key, next: + ChangesState)` as the delta path's only cursor write, `seedCursor(key, commitment: + EnumerationCommitment)` for bootstrap/reconcile, and `EnumerationCommitment` made genuinely + unforgeable by an **unexported real `Symbol()`** tag — including M's implementation note that + `declare const … : unique symbol` emits no runtime value and throws `ReferenceError` as a computed + key. That note came out of M's actual build; it would have been re-discovered here otherwise. +- The ordering rule as I2, not the false "only a changes state, ever" of M's revision 1. +- **I1–I13 in full** (M§3.4). Cursor-last; provenance-as-ordering; monotonic-or-invalidated; no + silent write loss; idempotent application; account containment; deletion provenance; no + clock-dependent cursors; bounded work; no wedge; sequential execution; field-level state writes; + corrupt-state-blob ⇒ resync. +- The "not a cursor" list (M§3.3): `Email/query`'s `queryState`, a pushed `StateChange.newState`, + `sessionState`, Thread state, `EmailDelivery`. + +Two Electron notes on I12/I4, both simplifications rather than changes: with a real SQLite +`BEGIN…COMMIT` in place from day one (§3.2), M's per-account mutex and read-merge-write discipline +become belt-and-braces rather than load-bearing (M§9.2 anticipated exactly this), and I4's "every +write either succeeds or raises" is the binding's default behaviour rather than something to enforce +against a fire-and-forget storage API. + +Cursor keying: `(LocalAccountId, JmapAccountId, CursorType)`, all three required, for M§3.1's +reasons. `LocalAccountId` is this repo's `AccountEntry.id` (§1.4) — **never `cookieSlot`**. + +### 4.4 Bootstrap *[reused: M§4]* + +Replace-the-code-keep-the-shape does not apply (there is no `runOfflineSync` here), but M§4.1's +**mandatory order** does, and it is the one thing in this document most likely to be "optimised" into +a permanent data hole: + +1. Capture both cursors **first**, in one JMAP request (`Mailbox/get {ids: []}` + `Email/get + {ids: []}`), and `seedCursor` them inside one `EnumerationCommitment` that in the same transaction + writes `coverage {phase:'scanning', targetFrom, sweepFloor: targetFrom}`. +2. Full `Mailbox/get` → upsert every mailbox row. +3. The seeded cursors are **live from here**: each cycle runs A1, A2, then B. +4. Scan reaches `targetFrom` ⇒ `coveredFrom = sweepFloor`, `phase = 'complete'`. Bootstrap has no + delete sweep; only reconcile sweeps. + +The cursor is deliberately *older* than the data, so the first delta cycle re-delivers some changes +we already have. That is I5 working. The cheaper opposite order silently loses mail. + +### 4.5 Change application *[reused: M§5.1–§5.5]* + +Order within a cycle (A1 → A2 → B → C1 → C2) and M's timeline argument for why delta-before-coverage +is safe *given I11* — the resurrection hazard, and the fact that the unsafe configuration is +concurrency, not ordering. `Mailbox/changes` `updatedProperties` count-only optimisation (RFC 8621 +§2.2). `Email/changes`: `created` ⇒ envelope fetch + conditional body enqueue; `updated` **present** +⇒ a **3-property** `Email/get {id, keywords, mailboxIds}` and never a body (RFC 8621 §4.1 — the two +mutable properties); `updated` **absent** ⇒ unconditional no-op with the ids filtered out *before* +the fetch is issued (MR S16); `destroyed` ⇒ delete envelope + body + membership + queue row. +Create-then-update-then-destroy ordering within a page. `notFound` is normal, not an error. +Mailbox/Email transient inconsistency tolerated, never repaired (I7: no deletion by inference). + +### 4.6 Pagination *[reused: M§6]* + +Ascending keyset walk on `receivedAt` with `calculateTotal: false`; `after` is **spec-inclusive** +(RFC 8621 §4.4.1, MR S14) so boundary re-delivery is normal and deduped by id; forward progress needs +strictly-greater `max(receivedAt)`; the no-progress guard is `anchor`/`anchorOffset` first and only +then, on `anchorNotFound`, a +1 ms advance with a `WARN` and a durable gap marker. Position-based +paging is rejected for M§6.2's reason. Budgets per M§6.4 — but see §8.2 for the desktop numbers. + +### 4.7 Errors, retry, reconcile, anti-wedge *[reused: M§7]* + +The seven-class taxonomy (Transport / RateLimit / ServerTransient / RequestLimit / Auth / Fatal / +StateInvalid), with **exactly one class moving a cursor** and unrecognised method errors defaulting +to ServerTransient. Full-jitter backoff. "Offline is not an error." Partial-failure semantics inside +a page (records may commit partially; the cursor may not). The eight cursor-advance rules of M§7.5. +`cannotCalculateChanges` handled as RFC-mandated **without blanking the UI**, with the **pinned +`sweepFloor`** (MR S2) and with the freshly-seeded cursor **live immediately** so a wide-window +rebuild doesn't stall incoming mail (MR S9). `oldState` mismatch re-issued once before escalating, +plus the ≤4-reconciles-per-24 h ceiling (MR S10). The monotonically **shrinking** `maxChanges` ladder +with every rung clamped to rung 0 (MR S7 + V2), and per-cursor counters with "any job failed ⇒ cycle +failed for escalation purposes" (MR S6). + +One Electron-specific input: this repo's client already has `RateLimitError` with `Retry-After` +parsing (`client.ts:54-62`, `authenticatedFetch`'s 429 branch) and a client-wide rate-limit gate. +The engine's own JMAP layer (§10.1) must reproduce that behaviour rather than inherit it, since it +will not be using `JMAPClient`. + +--- + +## 5. Multi-account isolation, account identity, lifecycle **[adapted]** + +Requirement confirmed at program level (manual §4: an offline cache must isolate per account, +including per-account keys). M§8 is the design; what changes is the identity plumbing, because this +repo's account model differs (§1.4). + +### 5.1 Namespacing *[reused: M§8.1, adapted paths]* + +``` +/ + registry.json # ONLY: account ids present, purge tombstones, + # monotonic epochs, store-format markers (M§8.4.1) + accounts/.db # one SQLCipher file per account: mailbox, envelope, + # email_mailbox, body, body_queue, sync_state +``` + +Filenames are hashed, not `username@host`, so the directory listing is not a plaintext account +inventory on disk. **No cursor, coverage row, record or resync flag lives outside an account's own +file** — M§8.1's forward-compatibility requirement, which here is load-bearing on day one rather than +later, because §5.5's purge deletes the key and the file together and a cursor surviving that would +be advanced against a freshly-empty store. + +`registry.json` is deliberately **plaintext** and M§8.1's accepted-limitation argument transfers with +one improvement: mobile's justification was that `account-store` already persists usernames to plain +AsyncStorage. Here, `account-store.ts`'s `persist` (`:219-227`, name `account-registry`) already puts +`username` and `email` for every account into renderer `localStorage`, so the registry adds no new +exposure — and hashing the filenames means the registry is the *only* place the account list appears +in the store directory. It must be readable before any key exists (that is the whole point of +M§8.4.1's format marker), so it cannot itself be encrypted. + +`epoch` lives in the registry, outside the per-account namespace, because it must be monotonic +**across** a purge (M§8.3). Owner: `SyncStoreFactory`. Not writable from `SyncTxn`; a transaction +reads it to validate itself and rejects with `EpochMismatchError`. + +### 5.2 JMAP-level accounts within one login *[reused: M§8.2, M§9.3]* + +Cursors and **every SQL primary key** carry `jmap_account_id` (MR S3: JMAP ids are unique only within +an account). v1 syncs the **primary mail account only**; delegated/shared accounts stay online-only, +as they effectively are today. + +Note this repo already carries the same evidence mobile did: `client.ts:388`'s +`namespaceMailboxIds()` prefixes ids when returning emails for a non-active account (five call sites: +`:632`, `:1271`, `:2138`, `:2185`, `:2323`). Same collision, same workaround, same conclusion — +account-scoped keys from day one. + +### 5.3 Slot → account resolution **[new]** + +The one piece of identity plumbing with no mobile counterpart, and the one most likely to produce a +cross-account write. + +`app/api/offline/**` routes are addressed the way every other authenticated route here is: by +`?slot=N` (§1.2). The resolution rule, in order, all steps required: + +1. Read `jmap_session[_slot]`; `decryptSession` ⇒ `{serverUrl, username}`. +2. `accountId = generateAccountId(username, serverUrl)` — the *server-confirmed* username from the + cookie, not a client-supplied one. +3. Open the store for `accountId`. **Never** derive a path from `slot`. Slots are recycled by + `getNextCookieSlot()`, so a stale slot number pointing at a re-added different account is an + ordinary occurrence, not an edge case. +4. Cross-check against the JMAP session's own `username` (`client.ts:3822`'s `getSessionUsername()`, + which exists precisely because a short login name may be canonicalized server-side — and which + `AccountEntry.serverIdentifiers` was added to handle). A mismatch is a **hard error**, not a + best-effort match. +5. Every commit re-validates `(accountId, epoch)` (I6), and every network call re-verifies that the + engine's JMAP session still serves that account — **not only at cycle start**, because a cycle is + long-lived. This is M§8.3's generalisation of `jmapClientServesActiveAccount`, and it is what + makes M's D6 (persisted cross-account contamination) unreachable here rather than merely unlikely. + +### 5.4 Local mutations: not applicable in v1, and why that is a decision **[new]** + +M§5.6 makes the outbox the sole durable record of local intent and composes it into reads via a pure +`overlay.ts`; M§5.6.1 then requires fixing the outbox's fire-and-forget persistence, because that +promotion made its durability load-bearing (V1). + +**None of that machinery exists here** (§1.6): no outbox, no optimistic mutation queue, no +`patchCache()`. Mutations go straight to the server and fail when offline. + +**Decision: v1 desktop offline is read-only.** The durable store holds server-derived state only — +which is M§5.6's core property, reached by having no write path at all rather than by removing one. +Consequences, stated so they are chosen rather than discovered: + +- Marking a message read while offline does not work at all (rather than working locally and + syncing later). That is today's behaviour; the engine does not regress it. +- M§5.6.2's two explicit non-coverages (unread badge counts read a server-maintained + `Mailbox.unreadEmails` scalar and cannot be overlaid; SQL/FTS predicates see server truth) are + moot in v1 and become live the moment an outbox is added. +- **When offline mutations are added later, M§5.6 and §5.6.1 are the design** — including the + durability requirement. Do not invent a write-through into `envelope`/`body`; that is the failure + mode M removed rather than guarded (MR S11). + +### 5.5 Logout, account removal, disable, purge *[reused: M§8.4]* + +``` +purgeAccount(accountId, reason: 'logout' | 'removed' | 'feature-disabled' | 'store-format-change'): + 1. registry: { accountId, purgePending: true } # durable intent, crash-safe + 2. epoch++ # in-flight commits now rejected + 3. delete the SQLCipher key from safeStorage-protected key file -- FIRST + 4. delete accounts/.db (+ -wal, -shm) + 5. registry: remove the entry, KEEP the epoch +``` + +Ordering 3-before-4 is the security property: an interrupted purge must leave **unreadable** data. +Crash between 1 and 5 ⇒ the next launch completes the purge **before any cycle starts**. Triggers: +`account-store.removeAccount`, logout (single or all), the offline-cache setting being turned off +(MR S13 — purge, with a confirming Settings copy, since re-enabling costs a full bootstrap), and a +store-format/schema marker mismatch (M§8.4.1). **`AuthenticationError` during a cycle is not a purge +signal** — a server hiccup returning 401 must never delete a user's offline mail. + +**Lazy materialisation** (M§9.5, MR S13) is if anything more important here than on mobile: read +paths check the setting for that account **before** calling `open()`, and `open()` on a +non-materialised account returns an empty read-only store and creates **no file and no key**. A user +who never enables offline mail must not end up with an encrypted database and a keychain entry +materialised by a read path. + +--- + +## 6. Where the encryption key lives **[new]** + +Mobile used `expo-secure-store` (OS-keychain backed). Electron's equivalent is `safeStorage`. + +### 6.1 What `safeStorage` actually is, verified + +Measured in Electron 43.2.0 on macOS (main process, after `app.whenReady()`): +`isEncryptionAvailable() === true`, `encryptString`/`decryptString` round-trip correct, ciphertext +prefixed `v10` (Chromium's OSCrypt format). No Keychain prompt appeared. + +Per Electron's documented behaviour (`docs/latest/api/safe-storage`): + +- macOS: Keychain-backed. "Access to the system Keychain is required and these calls can block the + current thread to collect user input." +- Windows: DPAPI; requires the `ready` event. +- **Linux: `isEncryptionAvailable()` returns true even when no secret store exists**, in which case + items are "encrypted via hardcoded plaintext password" and `getSelectedStorageBackend()` returns + **`basic_text`**. Real backends are `gnome_libsecret`, `kwallet` / `kwallet5` / `kwallet6`; + `unknown` means it was called before `ready`. `setUsePlainTextEncryption()` forces an in-memory + password on Linux and is a no-op elsewhere. + +### 6.2 Decision: `safeStorage`, in the main process, with an explicit Linux gate + +`safeStorage` (built in, no dependency) over `keytar` (unmaintained). Per-account key, generated +once as 32 random bytes, wrapped with `safeStorage.encryptString()` and written to +`/keys/.bin`. + +The awkward part, stated rather than hidden: **`safeStorage` is a main-process API, and §2.4 put the +engine in the server process.** Options, and the choice: + +- ~~Give the server process its own key wrapping (e.g. a file with 0600 perms)~~ — rejected: that is + a key protected by nothing but filesystem permissions, i.e. materially weaker than the OS keychain + the rest of the desktop ecosystem uses, and it silently discards the one thing `safeStorage` buys. +- **Chosen: the key crosses the existing IPC bridge, in one direction, once per account per app + launch.** `main.ts` gains a single `ipcMain.handle("vnc:offline-key", …)`-shaped path that unwraps + the per-account key and hands it to the **server process** — *not* to the renderer. Mechanically + this means main.ts fetches/creates+wraps the key and passes it to the standalone server over a + small local channel established at spawn time (a `stdio` extra fd, or a one-shot loopback request + authenticated by a nonce also passed in the spawn env). The renderer is never in the path and + `window.vnc` gains nothing. + +This is a real cost of choosing A over B — B would have had the key and the database in the same +process — and it is the one place where B is genuinely simpler. It is outweighed by §2.1-for-1/2: +moving the *engine* to main to co-locate the key would drag the *credentials* there too, which is a +much larger secret-handling change (§2.2-against-1). Moving 32 bytes once per launch is the smaller +of the two. + +**Mechanism is deliberately left open** as an implementation choice between the extra-fd and +nonce-authenticated-loopback variants; both are small, and Stage A should pick whichever proves +cleaner against the packaged build. What is *not* open: the renderer must never see the key, and the +key must never be written unwrapped. + +### 6.3 Caveats to design around, not discover + +1. **Linux `basic_text` is the important one.** On a Linux desktop with no keyring daemon — an + AppImage on a minimal WM, a container, a headless CI box — `isEncryptionAvailable()` returns + **true** while the key is protected by a hardcoded password that is public knowledge. That is + *worse than an honest failure*, because it looks like it worked. **Rule: at key-creation time, + `getSelectedStorageBackend()` must be consulted, and `basic_text` must not silently proceed.** + Recommended behaviour: refuse to materialise a store, surface "offline mail can't be stored + securely on this system (no OS keyring available)", and offer an explicit opt-in that records the + downgrade. The decision on whether that opt-in exists at all is a human one (§13, open question + 2). +2. **`ready` ordering.** `safeStorage` must not be touched before `app.whenReady()`, and + `getSelectedStorageBackend()` returns `unknown` if it is. `main.ts:205-208` already does its work + inside `whenReady().then(...)`, so the key path must sit there — and, since the server spawn + happens inside `createMainWindow()`, the key must be resolved **before or as part of** the spawn. +3. **macOS Keychain vs. unsigned builds — flagged, not resolved.** Keychain ACLs are tied to app + identity. Builds are currently **unsigned** (`electron-builder.config.js`, `hardenedRuntime: + false`, VNCprodbuild step 9 open). Whether an ad-hoc-signed Electron app retains Keychain access + across an `electron-updater` upgrade, or prompts, or silently loses the item — **could not be + verified in this environment** and is not documented by Electron either way. The failure mode if + it does lose access is not data loss but "offline mail must re-bootstrap after every update", + which §5.5's purge-on-unreadable path handles gracefully. **Stage A must test this on a real + packaged build across a simulated update.** It is also an argument for step 9 (signing) being a + soft prerequisite for shipping the encrypted store to users, not merely a nice-to-have. +4. **A lost key is a purge, never a prompt.** If the wrapped key cannot be unwrapped, or the database + opens but `PRAGMA cipher_version` is empty, or the key fails (`SQLITE_NOTADB`), the response is + `purgeAccount(..., 'store-format-change')` and a fresh bootstrap. Never a "enter your password to + recover" flow — the key was never derived from a user secret, so there is nothing to enter. + +--- + +## 7. Storage interface and schema + +### 7.1 Interface *[reused: M§9.1]* + +`SyncStore` / `SyncTxn` / `SyncStoreFactory` exactly as M§9.1 defines them, including: +field-level state patches only and **no whole-struct `AccountSyncState` write** (I12, MR S1); +`advanceCursor(key, next: ChangesState)` and `seedCursor(key, commitment)`; +`putBodyIfEnvelopeExists` (F48); `enqueueBodies` insert-or-ignore that **never resets `attempts`** +(MR S12, F41); `listBodiesForEviction` reading `body.received_at` from the body table alone; +`listOrphanBodies`; `clearRecords()` clearing records **and the body queue** while *not* nulling +cursors; `loadAccountState()` throwing `CorruptStateError` so the caller applies I13; the +`StoreFormatMarker` read/write pair and `completePendingPurges()` running once at launch before any +cycle. + +The engine imports `SyncStore` and nothing else about persistence — no SQL, no binding import, no +path strings outside `store*.ts`. Two backends: `store-sqlite.ts` (`@signalapp/sqlcipher`) and +`store-memory.ts` (unit tests, and the second implementation that proves the boundary). + +**One Electron addition:** `SyncStoreFactory.open()` must assert encryption positively — +`PRAGMA cipher_version` non-empty — and throw otherwise. §3.1's silent-`PRAGMA key` landmine makes +this the difference between an encrypted store and a plaintext one. + +### 7.2 Backend notes *[adapted: M§9.2]* + +M§9.2's staging question (AsyncStorage vs `expo-sqlite`, plain vs cipher) is **closed here by §3.2**: +one backend, encrypted, from the first commit. M's contingency section does not apply — there is no +key-value fallback worth building in a process that has a filesystem. + +Concrete choices for this binding: + +- `PRAGMA journal_mode = WAL` and `synchronous = NORMAL`. WAL means the `-wal`/`-shm` siblings must + be included in every delete path (§5.5 step 4) — a classic leak. +- `PRAGMA key` is set as the **first statement after open**, before any other statement, then + `cipher_version` is asserted (§7.1). +- Synchronous API on a worker thread (§2.4), so a long `BEGIN…COMMIT` cannot stall an HTTP response. +- `transaction()` is a real `BEGIN…COMMIT`, so **cursor-last (I1) is enforced by the database** rather + than by write ordering — the payoff M§9.2 predicted for shipping SQLite before the engine. +- `void setItem(...).catch(warn)` and `try { … } catch { /* ignore */ }` around a store write are + **banned** in the sync path (I4; §1.6's note about `file-store.ts`). + +### 7.3 Schema *[reused: M§9.3]* + +M§9.3 verbatim: `mailbox`, `envelope`, `email_mailbox`, `body`, `body_queue`, `sync_state`, all +primary keys `(jmap_account_id, id)` per MR S3; `envelope_received` and `envelope_nobody` indexes +(the latter being job C2's driver); `email_mailbox_by_mailbox`; `body.received_at` present so +eviction is a single-table ordered scan (MR S12); **deliberately no foreign keys and no cascades** +(M§5.5's transient inconsistency is normal; a cascade on mailbox delete would delete mail, violating +I7). + +One field to add for this repo: `envelope.blob_id`, since `blobId` is in this codebase's +`EMAIL_LIST_PROPERTIES` (§4.2). + +`sync_state` living in the same file as the records is what makes §5.5's atomic wipe work. + +### 7.4 What the renderer reads, and how + +New routes under `app/api/offline/`, all gated per §2.4 and resolved per §5.3: + +| Route | Backs | +|---|---| +| `GET /api/offline/emails?slot&mailboxId&limit&before` | the offline mailbox list (indexed `queryEnvelopes`) | +| `GET /api/offline/email/:id?slot` | a single cached message incl. body | +| `GET /api/offline/status?slot` | phase/progress/coverage/error for the UI | +| `POST /api/offline/sync?slot` | user-initiated "sync now" (coalesces, never aborts — M§10.3, D7) | +| `DELETE /api/offline/store?slot` | clear cache / purge (§5.5) | + +The engine→UI direction only, per M§5.7: the engine notifies "account X changed"; the renderer +decides whether to refresh. The engine never reads renderer state. + +### 7.5 Reserved hooks *[reused: M§9.4]* + +FTS5 (step 9) hangs off `upsertEnvelopes` / `putBodyIfEnvelopeExists` as the only write paths for +indexable content — no engine change. §3.4: FTS5 is compiled in. Attachment blobs are out of scope; +when added, their deletion belongs in `deleteEmails` and `purge` so they cannot leak past an account +wipe. + +--- + +## 8. Triggering **[adapted: M§10]** + +The trigger *model* is M's; the trigger *set* is not, because a desktop app has different lifecycle +events than a mobile one (no `AppState` backgrounding, no OS-governed background budget, but real +window minimise/hide, system sleep/wake, and a process that can outlive its window on macOS). + +### 8.1 Triggers + +| # | Trigger | Jobs | Throttle | vs. M | +|---|---|---|---|---| +| T1 | Server process ready + an account's credentials resolvable | A, B, C | 2 s delay | M T1 | +| T2 | Window shown / focused (`BrowserWindow` `focus`, via a small IPC ping) | A, C | min 30 s since last cycle | M T2 (`AppState` → active) | +| T3 | User "sync now" (`POST /api/offline/sync`) | A, B, C | none; **coalesces into a running cycle, never aborts it** | M T3, closes M's D7 | +| T4 | Network regained | A, C | 3 s debounce + per-account jitter | M T4 | +| T5 | `StateChange` on the **engine's own** WS/SSE connection (§2.5) | A, C | 2 s debounce + M§10.4's state-equality check | M T5 | +| T6 | Retention setting changed | B (envelope widen), C2 (body widen), eviction only (narrow) | none | M T6 | +| T9 | **Unfinished work:** previous cycle `partial`, or any cursor `drainPending`, or `coverage.phase ∈ {scanning, reconciling}`, or a non-empty body queue | the unfinished job(s) | 5 s, subject to §8.3's chaining rule | M T9 (MR S8) | +| T10 | Offline caching disabled for an account | abort + purge (§5.5) | none | M T10 (MR S13) | +| **T11** | **System resume from sleep** (`powerMonitor` `resume`), and `unlock-screen` | A, C | 5 s debounce; treat as network-uncertain, so T4's logic applies | **[new]** — no mobile analogue; a laptop lid closed for a day is the single most common way a desktop cursor gets far behind | +| **T12** | **App quit requested** | none — *cooperative stop* | n/a | **[new]** — see §8.4 | + +Explicitly **not** triggers: a periodic timer; opening a mailbox; opening a message; scrolling. The +engine must never be on the critical path of a UI interaction (M§10.2) — if it is, its budgets and +backoff become user-visible latency. + +M's T8 (OS background refresh) has no counterpart: on desktop the process simply keeps running, so +`partial`+T9 covers it. + +### 8.2 Budgets **[adapted: M§6.4]** + +M's foreground/background split is replaced by a **window-visible / window-hidden** split. A hidden +window on a plugged-in laptop is not the constrained environment a backgrounded phone is, so the +hidden column is *lower for politeness to the server and the user's battery*, not because an OS will +kill us: + +| Bound | Window visible | Window hidden / minimised | +|---|---|---| +| Pages per cycle, per cursor | 40 | 20 | +| Wall clock per cycle | 90 s soft deadline, checked between pages | 60 s | +| Body queue items per cycle (C1+C2) | 200 | 100 | +| Coverage pages per cycle | 25 | 15 | + +Exceeding a budget is a **normal** outcome (`partial`), not an error (M§6.4): the cursor stands at +the last committed page, `drainPending` stays true, T9 resumes. This is also the answer to a server +whose `hasMoreChanges` never goes false (F14). + +### 8.3 Single-flight, coalescing, chaining *[reused: M§10.3]* + +Per `LocalAccountId`: a second trigger during a cycle sets `wakePending` and awaits the same promise +— it never aborts (M's D7). Chained cycles continue **only while `madeProgress` is true**, so fixing +M's stall (MR S8) does not create a hot loop. + +Abort triggers here: logout/purge, offline caching disabled (T10), the account being removed, network +loss, a budget deadline, **and app quit (T12)**. All leave a committed cursor and resumable state. + +**Cross-account:** M is limited to the active account because `jmapClient` is a renderer singleton. +That constraint does **not** exist here — the engine constructs its own per-account JMAP layer from +per-slot credentials (§5.3), so it can sync **all logged-in accounts**, active or not, with a worker +per account. This is a genuine capability gain from choosing A, and one of the few places this design +is *more* capable than M. It is also a new load consideration: up to 5 accounts × (1 WS + delta +traffic) against one Stalwart. M§7.2's jitter is what keeps T4/T11 from producing a synchronised +stampede, and it becomes more important here than there. + +### 8.4 Process lifetime **[new]** + +M§10.5's headless-callability constraint holds trivially — the engine has no React, no store, no +component dependency by construction (§2.4). Two Electron-specific rules: + +- **`main.ts` currently kills the server on `window-all-closed` and `before-quit` (`:210-219`) with + `serverProcess.kill()`** — SIGTERM, no coordination. A cycle dies mid-page. That is *safe* (I1: the + cursor is the last fully-applied page; cost is one page's refetch) but wasteful, and it is worth + T12: a `before-quit` that asks the engine to stop at the next page boundary, with a short timeout + before falling through to the existing kill. Small change, and it must not be allowed to delay quit + perceptibly. +- **macOS keeps the app alive with no windows.** `window-all-closed` does not `app.quit()` on darwin + (`:210-215`) yet *does* stop the server. So on macOS today, closing the window stops sync and + reopening restarts it. Acceptable for v1; worth revisiting if "sync while closed" is ever wanted, + because that is the only configuration where a desktop mail client can usefully sync with no UI. + +--- + +## 9. Failure modes **[reused + new rows]** + +**M§11's table (F1–F49) applies in full and is not reproduced here.** Every row is a JMAP-protocol or +engine-state scenario, and none of them changes because the host process changed. The ones most worth +re-reading before implementing: F1 (kill mid-drain), F3 (kill mid-bootstrap), F4 (kill mid-purge), +F9 (`cannotCalculateChanges`), F26 (`updated` for an id we don't hold), F37 (concurrent write vs. +commit), F38 (retention widened during reconcile — M's worst potential data-loss bug), F44 +(clock jump), F47 (one cursor healthy, one wedged), F48 (body for a destroyed envelope). + +Electron-specific additions: + +| # | Scenario | Rule | +|---|---|---| +| **E1** | Server child process SIGTERM'd on window close / quit (`main.ts:210-219`) | Same class as M's F1: the cursor is the last fully-applied page (I1), `drainPending` survives, T1+T9 resume on next launch. Cost ≤1 page. T12 (§8.4) reduces it to ~0 but is not required for correctness. | +| **E2** | Native module fails to load in the packaged build (NFT dropped `prebuilds/`, asar, wrong arch) | Engine never constructs; `/api/offline/**` returns 404 exactly as in a hosted deployment; the app is fully functional online-only. **Must never be a launch failure.** This is also why Stage A verifies packaging before any engine code exists. | +| **E3** | `safeStorage` reports `basic_text` (Linux, no keyring) | Do **not** materialise a store. Surface "offline mail can't be stored securely here". Optional recorded opt-in (§6.3.1, §13 q2). Never silently encrypt with the public hardcoded password. | +| **E4** | Wrapped key unwraps but the DB rejects it (`SQLITE_NOTADB`), or `cipher_version` is empty | `purgeAccount(..., 'store-format-change')` + fresh bootstrap. Never a user-facing recovery prompt (§6.3.4). | +| **E5** | Keychain item lost across an `electron-updater` upgrade of an unsigned build | Same as E4 — re-bootstrap, one full sync. Cost is bandwidth, not data. Verify empirically (§12 Stage A); it is an argument for code signing. | +| **E6** | The desktop marker env var is absent (hosted Docker deployment, or a dev `next dev` run) | Engine module never constructed; routes 404. **No SQLite file is created anywhere.** The single most important non-failure in the document (§2.4). | +| **E7** | Two app instances launched against the same `userData` | Second instance's SQLite open fails or blocks on the WAL lock. Handle by requesting Electron's single-instance lock (`app.requestSingleInstanceLock()`) in `main.ts` — **not currently requested**, and worth doing on its own merits regardless of this engine. | +| **E8** | Slot reused: account A removed, account B added into A's freed `cookieSlot` | §5.3's resolve-by-cookie-then-verify-against-session makes this a no-op: B's cookie yields B's `accountId`, so B's store opens. A's store is already gone via §5.5's `removeAccount` purge. This row exists because resolving *by slot* would have been the natural shortcut and would have merged two accounts' mail. | +| **E9** | Engine's WS connection succeeds while the renderer's fails (the expected steady state, §1.3) | Correct and intended. Renderer keeps SSE for its list; engine uses WS for its cursors; **neither reads the other's state** (§2.5 rule 3). No notification is fired by the engine (§2.5 rule 2). | +| **E10** | Both connections wake on the same delivery | Both do their own work; the renderer refreshes the visible list, the engine advances its cursors. Duplicate *fetches*, never duplicate *writes* — they own disjoint state (M§5.7). | +| **E11** | Engine and renderer both refresh an OAuth access token, and the server rotates refresh tokens (`app/api/auth/token/route.ts:104-106`) | **Real hazard.** Two independent refreshers can invalidate each other's grant and log the user out. Rule: **the engine never refreshes independently.** It obtains tokens only through the existing `PUT /api/auth/token` route, in-process, so there is exactly one refresher and one rotation writer — the route. If that proves insufficient under concurrency, serialise it with a per-slot lock in the route itself. | +| **E12** | Worker thread crashes (OOM on a huge body, native fault) | Cycle counts as `failed`, cursor unchanged (M§7.1 — a crash is not StateInvalid), worker respawned with backoff, escalation ladder applies via the cursor's counters. Never a purge. | + +--- + +## 10. Required changes outside the engine + +### 10.1 A server-side JMAP layer **[new]** + +The engine cannot use `lib/jmap/client.ts`: it is a browser-`fetch` renderer class holding +credentials in memory (§1.2), and importing it server-side would drag the whole 7413-line surface +into the server bundle. It needs a small, focused JMAP client of its own under `lib/sync/jmap/`, +with **only** what M§12.1/§12.2 specify: + +- Typed results, not `null`-collapsing: `JmapResult`, `JmapMethodError` with M§12.1's + `JmapMethodErrorType` union including `'unknown'` defaulting to ServerTransient. **M's D5 is the + bug that caused its D4; building the taxonomy in from the first commit is how it never exists + here.** +- `getEmailChangesResult` / `getMailboxChangesResult` returning **branded** `ChangesState`, plus + `updatedProperties: string[] | null` on the Mailbox result (RFC 8621 §2.2). +- `getEmailProperties(ids, properties, accountId)` returning its `state` as a **`SnapshotState`**, so + M's D4 shape is a compile error rather than a code review question. +- `getMailboxProperties` for the `updatedProperties` patch path. +- `queryEmailWindow({after, before, limit, sort, anchor, anchorOffset})` for §4.6's keyset scan, + surfacing `anchorNotFound` distinctly. +- `captureStates(accountId)` — the one-request `Mailbox/get{ids:[]}` + `Email/get{ids:[]}` pair of + §4.4, returning branded `SnapshotState`s. +- Request-level error parsing (RFC 8620 §3.6.1 `application/problem+json`, `urn:…:error:limit` with + `limit: maxSizeRequest | maxCallsInRequest | maxConcurrentRequests | rateLimit`), an `AbortSignal`, + and a per-request timeout. Note `client.ts`'s plain-`fetch` path has no timeout today, so a hung + socket hangs a cycle — do not reproduce that. +- A header-capable WebSocket (`ws`) for §2.5 rule 1, with `WebSocketPushEnable`. +- `RateLimitError` + `Retry-After` handling equivalent to `client.ts:54-62`. + +**No `as ChangesState` / `as SnapshotState` cast may exist outside this layer's response parsers** +(M§6.3). Worth an eslint `no-restricted-syntax` rule. + +### 10.2 Settings + +`stores/settings-store.ts` gains, per account: `offlineCacheEnabled` (default **off**, per the +program decision M records), `offlineEnvelopeDays`, `offlineBodyDays`, `offlineMaxMB`. Enabling and +disabling both need confirming copy — disabling **purges** (§5.5). + +### 10.3 Electron shell + +`electron/main.ts`: pass `VNCMAIL_DESKTOP_STORE_DIR` on spawn (§2.4); resolve/create the per-account +wrapped key after `whenReady()` and hand it to the server process (§6.2); add T11's `powerMonitor` +hooks and T12's cooperative `before-quit`; request the single-instance lock (E7). `electron/preload.ts` +gains **nothing** — the renderer talks to the engine over HTTP, not IPC. + +### 10.4 UI + +New: an offline-status surface (phase, coverage, last error, storage used, "sync now", +"clear offline mail") reading `GET /api/offline/status`; and an offline read path in the mail list / +message view that falls back to `/api/offline/emails` and `/api/offline/email/:id` when the JMAP +request fails and the account has a materialised store. `stores/email-store.ts` is the natural place +for the fallback, mirroring where the mobile app does it — and, per M§9.5, it must check +`offlineCacheEnabled` **before** calling anything that could materialise a store. + +### 10.5 Packaging + +`serverExternalPackages: ['@signalapp/sqlcipher']` in `next.config.ts`; verification (and if needed a +copy step in `scripts/assemble-standalone.mjs`) that `prebuilds/**/*.node` reaches +`.next/standalone/node_modules`; a CI assertion that the packaged app can open an encrypted store on +every matrix OS. + +--- + +## 11. Test plan + +The `[QA]` gate. `apply.ts` being pure is what makes most of it cheap — that is why it is a hard +requirement (§4.1). + +**Unit, no network (vitest, already the repo's runner):** M§13's unit list in full — every M§11 row +expressible as `apply(localState, page, fetched) → mutations`; the cursor state machine's eight +rules; `classify()` over the whole taxonomy including the unknown-type default; the escalation ladder +asserted **monotonically non-increasing** across a range of `maxObjectsInGet` including values below +250 and below 25 (MR V2); backoff monotonic/jittered/capped with `Retry-After` override; retention +F23/F23B/F24/F24B/F25 and the F44 clock-jump guard; reconcile floor pinning (F38) and the reconcile +ceiling (F39). Minus the outbox-durability tests, which have no subject here (§5.4). + +**Type-level, compiled by `npm run typecheck`** (M§13's insistence that a type test only earns its +keep if a regression fails the build): `advanceCursor` rejects a `SnapshotState`; a plain object +literal is rejected where `EnumerationCommitment` is expected; no `as ChangesState`/`as SnapshotState` +cast exists outside the JMAP layer. + +**`SyncStore` contract tests against both backends** (`store-memory`, `store-sqlite`), including +M's S1 lost-update sequence (F37) and `clearRecords` clearing the body queue (F35). + +**Encryption, new and non-negotiable (§3.1's landmine):** + +- After a write-and-close, the raw `.db` bytes contain **no** canary string and the header is **not** + `SQLite format 3`. +- Opening with a wrong key fails; with the right key succeeds. +- `open()` throws if `PRAGMA cipher_version` is empty — i.e. the assertion of §7.1 actually fires if + someone swaps in a non-cipher binding. +- The format marker: mismatched/stale/absent ⇒ `purgeAccount('store-format-change')` at launch + **before** any cycle; a crash between materialising a store and writing its marker leaves a + mismatch (safe), not a false match (M§8.4.1). + +**Integration against real Stalwart — cheap here, unlike mobile.** `integration/docker-compose.yml` ++ `integration/tests/` already exist in this repo with a real Stalwart, real SMTP injection and an +Electron spec (§1.6). Extend with M§13's integration list, all of which apply: + +- Bootstrap → deliver mail *during* the coverage scan → assert the first delta cycle picks it up. + M calls this the highest-value test in the list and it is the §4.4 ordering test. +- Multi-page drain with `maxChanges` forced to 2; kill the server child between pages; relaunch; + assert convergence with no duplicates or omissions (F1/E1). +- Flag toggle from a second client → assert the envelope's `keywords` update and **no body refetch** + (a network assertion, not just a state assertion — this is the §4.5 3-property rule). +- Mailbox delete with `onDestroyRemoveEmails` both true and false (F7). +- Force `cannotCalculateChanges` → assert reconcile runs, records stay readable throughout, delta + keeps flowing during the enumeration (F49), and the sweep deletes exactly the server-absent ids. +- **Widen retention mid-reconcile** → assert nothing in the gap is deleted (F38). M calls this the + test for its worst potential data-loss bug. +- Two-account isolation, plus an explicit regression for M's D6: interleave account switching with + in-flight fetches, assert no row lands under the wrong account. Add E8: remove an account, add a + different one that lands in the freed `cookieSlot`, assert no bleed. +- Purge: kill mid-purge, relaunch, assert no records and no surviving cursor (F4/F22). +- **E6, the hosted-deployment gate:** boot the standalone server *without* the marker env var, hit + every `/api/offline/**` route, assert 404 and assert **no file was created** anywhere. +- **E9/E10:** with the engine's WS connection live, assert exactly one OS notification per delivery + and that the renderer's path is the one that fired it. + +**Electron-level (`npm run test:electron`, the existing required CI gate):** the packaged app opens +an encrypted store on each matrix OS (E2 negative case: a build with the binding deliberately +removed still launches and works online-only); the Linux runner asserts the `basic_text` refusal path +(E3) since a GitHub Linux runner has no keyring — a free, realistic test of the exact configuration +§6.3.1 is about. + +**Property/fuzz (M§13, cheap and high yield):** generate random legal change pages with M§5.4's +permitted overlaps and random kill points; assert the store converges to the same state as a +from-scratch bootstrap. + +--- + +## 12. Rollout, and the verify-first gate + +M§14's shape, with M's own lesson applied: its V4 finding was that a whole staging decision rested on +an untested premise (`expo-sqlite` works in Expo Go). The premises here have been tested (§3.1) — +**except the packaging ones**, which cannot be tested without installing into this repo and building. +So Stage A exists for exactly those. + +**Stage A — packaging and key storage, before a line of engine code.** *All of it is verification; +none of it is engine logic. If any item fails, the design changes before it is built, not after.* + +1. Add `@signalapp/sqlcipher` + `serverExternalPackages`. Run `npm run build:standalone` and assert + `prebuilds/-/@signalapp+sqlcipher.node` is present under + `.next/standalone/node_modules`. If NFT dropped it, add the copy step to + `assemble-standalone.mjs`. +2. Open an encrypted database from an `app/api/**` route in a **packaged** (`--dir`) build on macOS, + and confirm the canary/header assertions of §11 against the real file. Repeat on Windows and Linux + in CI. +3. Confirm cross-arch macOS packaging ships the right `.node` in each of the x64 and arm64 outputs + (§3.3.4). +4. Resolve a `safeStorage`-wrapped key in `main.ts` and get it into the server process (§6.2); pick + between the extra-fd and nonce-loopback variants on what actually proves cleaner. +5. On Linux, assert `getSelectedStorageBackend()` and that `basic_text` takes the refusal path (E3). +6. **Simulate an `electron-updater` upgrade of an unsigned build and check Keychain access survives** + (E5, §6.3.3). This is the one item that could plausibly change the shipping plan — if an unsigned + build loses its key on every update, the encrypted store should probably wait for step 9 + (signing), and the human should be told so rather than shipping a store that re-bootstraps + monthly. + +**Stage B — pure logic, no engine.** `states.ts` (with M's `Symbol()` note), `errors.ts`, `apply.ts`, +`retention.ts`, fully unit-tested. Type-level tests wired into `npm run typecheck`. + +**Stage C — store.** `SyncStore` + `store-memory.ts` + `store-sqlite.ts` + the format marker + the +contract tests against both backends + the §11 encryption tests. + +**Stage D — JMAP layer** (§10.1), with the taxonomy and branded returns. Includes the header-capable +WebSocket, tested against the real fixture — this is where §2.5 rule 1 gets proven or disproven, and +if the fixture's `/jmap/ws` behaves like the sandbox's (§1.3) this is where we find out that +server-side WS works. + +**Stage E — cursors + delta drain** (A1/A2). **Stage F — coverage + bootstrap** (B). **Stage G — +bodies** (C1, C2). **Stage H — triggers, routes, UI.** + +Feature flag: `offlineCacheEnabled`, default off, per account (§10.2). It gates route registration +and trigger registration, not just the engine body. + +The Electron smoke gate (`npm run test:electron`) and the integration suite must stay green at every +stage. + +--- + +## 13. Summary of key decisions + +1. **The engine and the SQLite file live in the standalone Next.js server process (option A)**, + on a `worker_threads` Worker, not on the request loop. Decisive reasons: the per-account + credentials are *already there* in httpOnly encrypted cookies (§1.2), so nothing secret crosses a + process boundary; and a Node process can put an `Authorization` header on a WebSocket upgrade, + which is the exact thing that makes RFC 8887 push unreachable from the renderer today + (`client.ts:6038-6059`). Option B was rejected because it can only be built by moving credentials + into a process that currently holds none — the change `client.ts` explicitly declined. Option C + was rejected because WASM SQLite needs `'wasm-unsafe-eval'` added to the **product-wide** CSP + (`proxy.ts`), and its only encrypted backends are small third-party WASM builds. +2. **The renderer's push pipeline does not change.** The engine gets its own header-capable + connection; `StateChange` is a wake signal and never a cursor (M§10.4); the engine **never fires a + notification** — `newEmailNotification` stays the single source; and the two cursors never read + each other's state. Two sockets per account is the deliberate price of that isolation. Collapsing + to one (renderer listening to the local server instead of Stalwart) is a *follow-up*, gated on the + engine's connection being proven. +3. **SQLCipher ships on day one, via `@signalapp/sqlcipher@4.0.3`.** Verified in this environment + against Electron 43.2.0: N-API prebuilds load with **no rebuild** in both the main process and + `ELECTRON_RUN_AS_NODE`, real SQLCipher 4.10.0, encrypted file header, wrong key rejected, FTS5 + present, AGPL-3.0-only matching this repo. The mobile design's plaintext-first phase existed + solely because Expo Go cannot load SQLCipher; **that constraint has no Electron analogue**, and + importing the staging anyway would mean shipping a plaintext mailbox for a phase plus building and + discarding a migration path to leave it. +4. **`node:sqlite` rejected** (no encryption at any price — `PRAGMA key` is a *silent no-op* that + leaves the mailbox in cleartext; and stability 1.2/RC in Node 24, which is what Electron 43 + bundles). **`better-sqlite3-multiple-ciphers` rejected**: newest release has Electron prebuilds + up to ABI 146, Electron 43 needs 148, so it means a C++ toolchain on every machine — and that lag + recurs at every Electron major by construction. Plain `better-sqlite3@13.0.2` is the fallback + only. +5. **Every store open asserts `PRAGMA cipher_version` is non-empty, and a test asserts a canary is + absent from the raw file bytes.** Silent-plaintext is the sharpest landmine found in this + investigation and it has no error to notice. +6. **The hosted-deployment gate is part of the design, not a convention.** The same server process + runs in Docker for many users. One env var (`VNCMAIL_DESKTOP_STORE_DIR`, set only by `main.ts`) + both enables the engine and supplies its path; the routes 404 without it; a test asserts no file + is created without it (E6). +7. **Keys: `safeStorage`** (built in, no `keytar`), per account, wrapped and written under the store + directory; the unwrapped key goes main → **server** process only, never to the renderer. + Consulting `getSelectedStorageBackend()` is mandatory: Linux returns + `isEncryptionAvailable() === true` while using a *public hardcoded password* (`basic_text`), which + is worse than an honest failure. +8. **Account identity is `AccountEntry.id` (`username@host`), never `cookieSlot`.** Slots are + recycled; resolution is cookie → `decryptSession` → `generateAccountId` → cross-check against the + session's confirmed username, and every commit re-validates `(accountId, epoch)` (E8). +9. **v1 desktop offline is read-only.** This repo has no outbox and no optimistic mutation layer, so + M§5.6's read-time overlay has nothing to overlay. When offline mutations land, M§5.6/§5.6.1 is the + design — including its durability requirement — and a write-through into `envelope`/`body` remains + forbidden. +10. **The engine syncs all logged-in accounts, not just the active one** — a capability the mobile + engine lacks because its JMAP client is a renderer singleton. Consequence: M§7.2's jitter matters + more here, since T4/T11 fire for every account at once. +11. **Everything else is the mobile design, deliberately unchanged**: the three state machines with + sequential execution (I11), independent envelope/body retention tiers, cursor provenance as an + ordering rule with branded types and an unforgeable `EnumerationCommitment`, capture-cursors- + before-enumerate, the 3-property `updated` fetch, cursor-last, the seven-class error taxonomy + with exactly one class moving a cursor, the pinned reconcile sweep floor, the monotonically + shrinking `maxChanges` ladder with per-cursor counters, no-deletion-by-inference, account-scoped + primary keys, the purge ordering (key before file), lazy materialisation, and the F1–F49 failure + table. + +### Open questions for the human + +1. **If Stage A item 6 shows an unsigned build loses its Keychain item across updates**, does the + encrypted store ship anyway (re-bootstrapping after each update — bandwidth, not data loss), or + wait on VNCprodbuild step 9 (Apple Developer ID, already a human-owned purchase)? This is the one + Stage A outcome that could change the plan rather than just an implementation detail. +2. **Linux with no keyring (`basic_text`, §6.3.1 / E3):** refuse outright, or offer an explicit + opt-in that records the downgrade? Refusing is the safe default and what this design specifies; + an opt-in is defensible for a single-user machine with full-disk encryption. Affects a real + segment — AppImage users on minimal window managers. +3. **Concrete retention defaults** — `offlineEnvelopeDays` ≫ `offlineBodyDays` and the MB cap on + bodies only are settled (M§2.1); the *numbers* are not recorded anywhere. Two constants; the + design is value-independent. Same open question M ends on. +4. **Does the follow-up in §2.5 (collapse to one socket per account by having the renderer listen to + the local server instead of Stalwart) get scheduled?** It would retire the renderer's WS + circuit-breaker path as dead code and halve the connection count, but it makes a working + notification path depend on the new one, so it is deliberately not in v1. +5. **Should this land as its own PR ahead of the offline engine?** Stage A is pure verification and + §10.3's `app.requestSingleInstanceLock()` (E7) plus §8.4's cooperative quit are improvements to + the shell on their own merits, independent of any offline store. + +None of these blocks starting Stage A. + +--- + +## 14. What was verified, and what was not + +Stated explicitly, in M's spirit — its V4 finding was precisely that an untested premise had been +presented as settled. + +**Verified by execution in this environment, against `electron@43.2.0`:** Electron's bundled Node +(24.18.0) and ABI (148/napi 10); its bundled SQLite (3.53.1, FTS5 on); `node:sqlite`'s presence, +absence of encryption, and the *silent* no-op of `PRAGMA key` including the plaintext canary in the +file bytes; `better-sqlite3@13.0.2`'s in-tarball N-API prebuilds and successful load in both process +modes; `@signalapp/sqlcipher@4.0.3`'s load in Electron, SQLCipher 4.10.0, encrypted header, absent +canary, wrong-key rejection, right-key read-back, and FTS5; `safeStorage.isEncryptionAvailable()`, +round-trip and `v10` ciphertext prefix on macOS. + +**Verified by reading published metadata:** `better-sqlite3` 13's move off `prebuild-install`; +`better-sqlite3-multiple-ciphers`' Electron ABI coverage (121–146, no 148) and release cadence; +the existence and provenance of `@7mind.io/sqlcipher-wasm` and `@aztec/sqlite3mc-wasm`; `node:sqlite`'s +stability index (1.2, RC) in Node 24; `safeStorage`'s Linux `basic_text` fallback and +`getSelectedStorageBackend()` values. + +**NOT verified — flagged for Stage A, in descending order of how much they could change the design:** + +1. Whether an **unsigned** Electron app retains its macOS Keychain item across an `electron-updater` + upgrade (§6.3.3, E5). Not documented by Electron either way. Could change *when* the encrypted + store ships. +2. Whether Next.js **output file tracing** carries `@signalapp/sqlcipher`'s `prebuilds/` into + `.next/standalone/node_modules` (§2.1-against-2, §3.3.2). Fallback is a copy step in a script + that already exists for exactly this class of omission. +3. Whether the cross-arch macOS build ships the correct per-arch `.node` (§3.3.4). +4. Whether the integration fixture's Stalwart `/jmap/ws` accepts a header-authenticated upgrade — the + sandbox's does *require* the header (§1.3), which is what makes the server-side connection work in + principle, but it has not been driven from Node here. Stage D. +5. The choice of mechanism for getting the unwrapped key from `main.ts` into the server process + (extra fd vs. nonce-authenticated loopback) — deliberately left to whichever proves cleaner + against a packaged build (§6.2). From 2ff4b7847ec8f6513d1f3c34db08b31e31a7c3ba Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 22:19:23 +0200 Subject: [PATCH 15/21] docs: record human decisions on Linux keyring policy, retention defaults, review gate --- docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md | 43 ++++++++++++++------------ 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md b/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md index 215300fd..70c5590d 100644 --- a/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md +++ b/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md @@ -1269,28 +1269,31 @@ stage. primary keys, the purge ordering (key before file), lazy materialisation, and the F1–F49 failure table. -### Open questions for the human +### Open questions for the human — resolved 2026-08-04 -1. **If Stage A item 6 shows an unsigned build loses its Keychain item across updates**, does the - encrypted store ship anyway (re-bootstrapping after each update — bandwidth, not data loss), or - wait on VNCprodbuild step 9 (Apple Developer ID, already a human-owned purchase)? This is the one - Stage A outcome that could change the plan rather than just an implementation detail. -2. **Linux with no keyring (`basic_text`, §6.3.1 / E3):** refuse outright, or offer an explicit - opt-in that records the downgrade? Refusing is the safe default and what this design specifies; - an opt-in is defensible for a single-user machine with full-disk encryption. Affects a real - segment — AppImage users on minimal window managers. -3. **Concrete retention defaults** — `offlineEnvelopeDays` ≫ `offlineBodyDays` and the MB cap on - bodies only are settled (M§2.1); the *numbers* are not recorded anywhere. Two constants; the - design is value-independent. Same open question M ends on. -4. **Does the follow-up in §2.5 (collapse to one socket per account by having the renderer listen to - the local server instead of Stalwart) get scheduled?** It would retire the renderer's WS - circuit-breaker path as dead code and halve the connection count, but it makes a working - notification path depend on the new one, so it is deliberately not in v1. -5. **Should this land as its own PR ahead of the offline engine?** Stage A is pure verification and - §10.3's `app.requestSingleInstanceLock()` (E7) plus §8.4's cooperative quit are improvements to - the shell on their own merits, independent of any offline store. +1. **If Stage A item 6 shows an unsigned build loses its Keychain item across updates** — left open + deliberately; this is an empirical Stage A outcome, not something to decide blind. Resolve once + Stage A actually runs and reports what happens. +2. **Linux with no keyring (`basic_text`, §6.3.1 / E3): refuse outright.** Decided — matches this + design's own "cheapest wrong answer" philosophy (§ discussion of `safeStorage`'s silent-downgrade + landmine): an honest failure beats a store that looks encrypted but isn't. No opt-in path in v1. +3. **Concrete retention defaults: same as the mobile design's defaults.** Decided — + `offlineEnvelopeDays: 365`, `offlineBodyDays: 30`, MB cap on bodies matching mobile's default + (§ mobile M§2.1's shipped numbers). Consistent behavior across platforms for v1; revisit only if + desktop usage patterns actually show it's too narrow — don't widen speculatively. +4. **§2.5 single-socket follow-up: not scheduled for v1.** Stays a gated follow-up as designed — + don't collapse to one connection until the engine's own connection is proven in production. +5. **Stage A as its own PR ahead of the offline engine: not decided separately** — proceed with the + implementation sequence as designed (Stage A is the first step regardless of PR boundaries); the + human can split the PR at merge time if desired, this doesn't block starting work. -None of these blocks starting Stage A. +Also decided: **this design goes through an independent adversarial review next**, matching the +mobile engine's process (design → review → implementation), before any implementation code is +written — same reasoning as before: this is the same class of highest-stakes, hardest-to-undo +component (a persistent, encrypted, multi-account local store), and the review process caught real +critical bugs in the mobile design that this document's own author-side verification did not. + +None of these blocks starting the review. --- From 16466c7296070cc9d3d638c5a683875a4fc5651c Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 22:53:06 +0200 Subject: [PATCH 16/21] docs: adversarial review of the Electron offline engine design (4 critical, 4 high) --- docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md | 232 +++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md diff --git a/docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md b/docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md new file mode 100644 index 00000000..4f5bf7a6 --- /dev/null +++ b/docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md @@ -0,0 +1,232 @@ +# Adversarial review: `docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md` + +Reviewer: independent agent, fresh context, no relation to the design's author. 2026-08-04. + +## Verdict + +**Needs substantial rework before implementation — but narrowly scoped rework.** + +The delta-sync core (everything tagged `[reused]` from M, the mobile design) is genuinely sound +and transfers; the reviewer attacked it directly and could not break it. The problem is that **all +four genuinely-new sections have an unclosed load-bearing mechanism**, and one of them breaks a +build that ships today: + +- §3 (binding choice) contains a packaging decision that breaks the hosted Docker image and the + integration fixture. +- §2 (process choice) rests on a credential claim that is only true inside an HTTP request. +- §6 (key handoff) is under-specified in a way that doesn't work as sequenced, and its "unresolved + implementation choice" is not security-neutral. +- §5.3/§8.3 (multi-account) breaks the specific premise M's D6 fix relies on. + +Nothing here requires re-architecting the sync engine. Stages B-G can proceed against M as +written. Stage A as currently specified would not surface most of this. + +All file:line citations in the design doc that were checked resolve correctly (one trivial +miscount, noted at the end) — citation quality is high; the problems are in the reasoning built +on top. + +--- + +## CRITICAL + +### C1 — Adding `@signalapp/sqlcipher` to `dependencies` breaks the hosted Docker build *and* the integration fixture + +**Where:** §3.3.1 ("entering `dependencies`"), §3.3.5, §2.4, §10.5, E2, §13 item 6. + +`Dockerfile:1-4` — `FROM node:24-alpine`, `RUN npm ci`. `integration/webmail.Dockerfile:12-15` — +same, `FROM node:24-alpine` + `npm ci`. + +Verified from the published tarball that `@signalapp/sqlcipher@4.0.3`: +- ships **6** prebuilds — `darwin-{arm64,x64}`, `linux-{arm64,x64}`, `win32-{arm64,x64}`. No + `linuxmusl-*`. (The design doc's list is exactly right.) +- ships **no build sources at all** — published `files` is `dist/*`, `prebuilds`, `README.md`. No + `binding.gyp`, no `src/`, no `deps/`. +- has `install: node-gyp-build`. `node-gyp-build`'s `bin.js` runs `node-gyp-build-test`; on + failure it calls `build()` → spawns `node-gyp rebuild` → `process.exit(code)`. + +No prebuild + no `binding.gyp` ⇒ `node-gyp rebuild` fails ⇒ **`npm ci` exits nonzero**. The Linux +prebuild also has a glibc ≥ 2.34 floor, so it could not load on musl even if copied. + +**Concrete failure:** the next `docker build` of the production image fails at line 4. +`npm run test:integration` fails to build the webmail container. Neither is gated by +`VNCMAIL_DESKTOP_STORE_DIR` — that env var only governs *activation*, not *installation*. + +§3.3.5 dismisses musl as "relevant if an Alpine-based container ever wants the engine — which, +per §2.4, it must not" — that reasoning is inverted: the Alpine container doesn't want the engine, +it just needs `npm install` to succeed regardless. + +**Fix direction:** `optionalDependencies` + a guarded runtime `require` (which also delivers E2's +graceful-load-failure behavior for free), or a separate optional package, or `--omit=optional` in +both Dockerfiles. Pick one and say so explicitly; add "`docker build` of both Dockerfiles still +succeeds" to the Stage A verification list. + +### C2 — Option A's central justification is only true inside an HTTP request; the Worker credential path does not exist + +**Where:** §2.1-for-1, §1.2, §2.4 (Worker), §5.3, §8.1 triggers T1/T4/T5/T11, §13 item 1. + +Verified in `app/api/auth/session/route.ts` and `app/api/auth/token/route.ts`: every credential +read goes through `cookies()` from `next/headers` — request-scoped. `lib/oauth/cookie-config.ts:11` +sets `httpOnly: true`. The cookies live in the renderer's cookie jar, not in the server. The +standalone server holds no session state whatsoever; it decrypts a cookie per request and +discards it. + +So "the credentials are already there... No new credential path, no IPC carrying secrets, no +second copy" (§2.1-for-1) is materially overstated. What is actually there is *the ability to +decrypt a credential presented on an inbound request* — not a resident credential. + +Consequences the design never addresses: + +1. **T1 ("server process ready + an account's credentials resolvable") cannot fire.** At + server-ready there are no cookies anywhere. Nor can T4 (network regained), T5 (`StateChange` + on the engine's own socket), or T11 (resume from sleep) — none is an inbound renderer request. +2. **A `worker_threads` Worker is a separate execution context with no cookie access at all.** + §2.4 mandates the Worker and routes talk to it via `postMessage`, but there's no specified + point at which the Worker actually receives credentials. +3. The only workable shape is: on the first renderer request, decrypt and hand the **plaintext** + credentials to the Worker, which retains them for the process lifetime. That is a new + long-lived plaintext secret in a new location — the exact thing §2.1-for-1 claims doesn't + happen, and the same category of thing `client.ts:6055-6059` already declined once (a + resident credential copy in a process that didn't previously hold one). It also creates an + invalidation problem never addressed: password change, logout elsewhere, or a cleared cookie + leaves the Worker retrying stale credentials indefinitely (since `AuthenticationError` is + correctly never treated as a purge signal) — against a server with failed-auth lockout, this + locks the user's account. + +This doesn't kill Option A, but it kills the argument that Option A is free of new secret +handling — which was the design's #1 stated reason for choosing it over the alternative. That +comparison needs to be redone with the resident-copy cost included, not dropped. + +### C3 — The proposed OAuth-refresh mitigation (E11) is not just unimplementable; it *is* the bug it's meant to prevent + +**Where:** E11 (failure-mode table), §1.2's note about `app/api/auth/token/route.ts:104-106`. + +E11's rule: *"the engine never refreshes independently. It obtains tokens only through the +existing `PUT /api/auth/token` route, in-process, so there is exactly one refresher and one +rotation writer — the route."* + +But that route: reads the refresh token from the **request's** cookie; writes the rotated token +as a `Set-Cookie` on the **response**; and on a 400/401/403 from the identity provider, **deletes** +the refresh-token cookie and returns 401. + +An in-process server-side call to that route has no cookie to send (401s immediately), and even +if the engine forged one from a resident copy, the rotated token would land in a response the +engine discards. Net effect: engine refreshes → identity provider rotates the token → the new +token lands in a discarded response → the browser still holds the now-superseded token → the +next real refresh from the browser gets rejected → the route deletes the cookie → **the user is +silently logged out of that account, and the offline store's credentials are dead.** + +The per-slot lock the design suggests as a fallback does not help — the problem is that cookie +state lives in the browser, not that the writes race each other. + +Separately, `PUT /api/auth/session` requires three `sec-fetch-*` headers with a comment claiming +"non-browser clients cannot forge these" — a Node-side `fetch` call *can* set all three, silently +turning a security control into decoration if any engine path goes through this route. Not +discussed in the design at all. + +### C4 — The shared registry file breaks the exact premise the multi-account safety fix relies on + +**Where:** §5.1 (`registry.json`, epoch ownership), §2.4 ("one worker per account"), §4.3 (mutex +described as "belt-and-braces"), §7.1 (`completePendingPurges()`), §8.3 cross-account, §5.5. + +The mobile design's cross-account safety guarantee depends explicitly on there being **exactly +one writer process-wide** — its own JMAP client is a renderer singleton, so multi-account +simultaneous sync was out of scope for it, and its own adversarial review never examined +concurrent multi-account execution. + +This design introduces multi-account-simultaneous as "a genuine capability gain" and disposes of +the concurrency consequences with a one-line "the jitter matters more here" — but the epoch +value (the fencing token the whole safety guarantee rests on) lives in `registry.json`, a single +JSON file shared across every account. No SQLite transaction covers a plain JSON file. The +argument that a real database transaction demotes the old per-account mutex to +"belt-and-braces" is correct for state stored *inside* the SQLite file, and does not apply to +`registry.json` at all — which names no owner thread, no lock, and no atomic-write discipline. + +Two concrete failures: +1. **Lost epoch bump.** Worker A read-modify-writes the registry to bump account A's epoch + (purge, clear, logout). Worker B, holding a stale parse, writes its own update and clobbers + A's bump. A's in-flight cycle's next commit now passes the epoch check and lands on top of a + wipe — an empty record store with a live, advanced cursor and `resyncRequired: false`, exactly + the unreachable-by-design state the mobile design's whole S1 fix exists to prevent. +2. **Torn read on a shared file.** Worker B is mid-write; the server's launch-time + `completePendingPurges()` reads and the parse throws or yields a partial object. The + documented rule ("unreadable → treated as a purge") means a transient concurrency artifact + triggers a full purge-and-rebootstrap for accounts that were perfectly fine — and because the + file is shared, one torn read can hit every account at once, not just one. + +### Other critical-adjacent findings, condensed + +- **H1** — the "sync enabled" toggle lives in the renderer's local storage; the server-side engine + (and its background triggers) has no way to read it, so it will materialize an encrypted store + and a keychain entry for accounts that never opted in — precisely the failure the design's own + lazy-materialization rule was meant to prevent. +- **H2** — the key-handoff sequencing assumes accounts exist at server-spawn time; they don't + (accounts are added later, by logging in). The two proposed handoff mechanisms are not + equivalent: one of them passes a nonce via the spawned process's environment variables, which + are readable by any other process running as the same OS user — defeating the entire point of + using the OS keychain in the first place. Needs re-sequencing plus picking the other mechanism + on security grounds, not "whichever is cleaner to implement." +- **H3** — the "no optimistic-mutation layer exists, so nothing to keep coherent" claim is false; + the webmail already does local-delta arithmetic on mailbox unread counts and totals for + mark-read/move/delete actions, with a comment referencing a prior production bug from getting + this exact kind of cutoff wrong. A read-only offline cache sitting underneath that arithmetic + needs an explicit coherence story, which the design currently declares unnecessary. +- **H4** — no cap specified on how many accounts sync simultaneously; since this is the same + process serving the live webmail UI, an unbounded background sync could contend for the same + rate-limited server connection as the user's foreground activity, throttling their visible mail + during their own multi-account first sync. +- Several medium/low findings: one proposed API call is Linux-only and would crash the app on + macOS/Windows if implemented as literally described; the Linux keychain fallback behavior is + described slightly wrong (Electron already fails safely there; the real hazard is a *different* + API a future maintainer might reach for); the claim that two SQLite bindings are "the same code + either way" doesn't hold — verified real API differences exist between them; the "single-user" + safety check for the hosted-deployment gate doesn't actually verify what it claims to. + +--- + +## What the reviewer independently re-verified (not just re-read) + +Re-ran two of the design's three "verified by execution" claims independently, in Electron 43.2.0 +itself under the same execution mode the standalone server actually uses: + +1. **`@signalapp/sqlcipher@4.0.3` in Electron 43 — fully re-confirmed by actual re-execution.** + Loads with no rebuild, real SQLCipher encryption confirmed (encrypted header, no plaintext + canary recoverable from raw bytes, wrong key correctly rejected). The strongest part of the + original design. +2. **`node:sqlite`'s `PRAGMA key` silent no-op — fully re-confirmed by actual re-execution.** No + throrw, mailbox left in cleartext, canary recoverable from raw bytes. The design is right to + call this the sharpest landmine found and to mandate a positive verification check after every + store open (though the exact check needs a small correction — checking for a non-empty + *string* rather than a non-empty *result set*, since the no-cipher case returns zero rows, not + an empty string, and a naive string comparison would pass vacuously). +3. **The Linux keychain-fallback claim — not independently confirmed, and partially contradicted** + by reading Electron's own source and current documentation (no Linux desktop was available to + actually execute this one). The decision made (refuse outright rather than risk a false sense + of security) stays correct regardless and costs nothing, but the specific mechanism described + needs correcting. + +## Recommended gate + +Do not start implementation as currently written. Resolve in this order: +1. **C1** — decide the dependency-installation shape so the existing Docker builds keep working; + add a Docker-build check to the first implementation step's own verification list. +2. **C2 + C3** — specify the credential lifecycle end to end: how a background worker actually + gets credentials, where they live, how long, how invalidation reaches them, and how token + refresh can work given rotation needs to land in the browser's cookie jar, not a discarded + response. This may change the process-architecture verdict; re-run that comparison honestly + rather than inheriting the original conclusion. +3. **C4** — name a single owner (or a real lock plus atomic write) for the shared registry file, + and re-derive the multi-account safety guarantee under concurrent writers rather than citing + the mobile design's single-writer proof as if it still applied. +4. **H1** — decide where the "sync enabled" setting needs to live (or how the engine learns it) + so lazy materialization is actually enforceable from where the engine's triggers fire. +5. **H2** — pick the handoff mechanism that doesn't leak via process environment variables, and + re-sequence it for accounts that don't exist yet at process-spawn time. +6. **H3** — add real coherence rules for the counters/totals the webmail already computes locally, + or narrow the offline read path to skip anything those computations touch. +7. **H4** — state a concurrency bound and a rule that foreground user activity isn't starved by + background multi-account sync. +8. The smaller medium/low findings should land in the same pass since they're cheap to fix once + noticed. + +Everything reused from the mobile design's core sync-engine logic is safe to build against as +written — the problems are entirely in the four sections that are genuinely new to this platform. From b966d285a97db45987a6efe6018851d51eaa730f Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 23:16:22 +0200 Subject: [PATCH 17/21] feat(mail-index): encrypted SQLite/FTS5 index over mail, calendar, contacts, files An on-device, SQLCipher-encrypted full-text index the app can retrieve from to feed an LLM ("prompt against"), for the Electron desktop shell only. Shape: no persistent background worker and no resident credential. Indexing is a normal request-scoped API route, triggered by the renderer's EXISTING live JMAP push connection - so it reacts to each delivery/change rather than polling. - lib/mail-index/binding.ts guarded require of the optional native binding - lib/mail-index/paths.ts the VNCMAIL_DESKTOP_STORE_DIR gate + hashed paths - lib/mail-index/store.ts schema, upsert, FTS5 search, encryption assertion - lib/mail-index/extract.ts PURE JMAP-object -> document extractors - lib/mail-index/jmap.ts minimal stateless server-side JMAP client - lib/mail-index/key.ts per-job key fetch over the inherited fd - lib/mail-index/reindex.ts the job + slot->account resolution - electron/key-service.ts safeStorage wrap/unwrap, served over fd 3 - app/api/offline/reindex POST, event-driven + catch-up - app/api/offline/search GET, the retrieval surface (hits + contextBlock) - lib/mail-index-client.ts renderer client; StateChange -> index call - components/settings/local-index-settings.tsx status + manual catch-up Decisions worth knowing: * `@signalapp/sqlcipher` is an OPTIONAL dependency with a guarded runtime require. It publishes six N-API prebuilds and NO build sources, and both Dockerfiles are node:24-alpine (musl, no matching prebuild) - as a hard dependency it would break the production image and the integration fixture's webmail container, neither of which wants this feature. * Credentials come from the existing per-slot encrypted `jmap_stalwart_ctx` cookie via lib/stalwart/credentials.ts - the same helper /api/settings and /api/push/preview already use. It carries a ready-made header for basic AND bearer accounts, so the indexer never touches the OAuth refresh-token cookie; a server-side refresh would rotate a token into a response nobody reads and silently log the user out. * The encryption key crosses main -> server over an INHERITED FILE DESCRIPTOR, never an environment variable: env is readable by any process running as the same OS user, which would defeat using the OS keychain at all. Fetched per job and zeroed after, so there is no long-lived key copy. * safeStorage's Linux `basic_text` backend (no keyring) is treated as refusal, not degradation - it "encrypts" with a hardcoded public password, which would look like an encrypted mailbox while providing nothing. getSelectedStorageBackend() is Linux-only and platform-guarded. * Every store open asserts `PRAGMA cipher_version` returns a non-empty STRING, not merely a row: a non-cipher binding returns ZERO ROWS, so a row-count check would pass vacuously while writing the mailbox to disk in cleartext. * Files are indexed by name/path/date/size only - NOT by extracted content. Text extraction from arbitrary PDFs/office documents is a separate problem. * Account-scoped composite keys `(jmap_account_id, content_type, id)` are kept even though there is one file per account: one login exposes delegated/shared JMAP accounts too, and JMAP ids are unique only within an account. Co-Authored-By: Claude Sonnet 5 --- app/(main)/[locale]/page.tsx | 23 + app/api/offline/reindex/route.ts | 110 +++++ app/api/offline/search/route.ts | 117 +++++ components/settings/about-data-settings.tsx | 4 + components/settings/local-index-settings.tsx | 123 +++++ electron/key-service.ts | 231 ++++++++++ electron/main.ts | 46 +- lib/mail-index-client.ts | 199 +++++++++ lib/mail-index/binding.ts | 83 ++++ lib/mail-index/extract.ts | 311 +++++++++++++ lib/mail-index/jmap.ts | 357 +++++++++++++++ lib/mail-index/key.ts | 174 ++++++++ lib/mail-index/paths.ts | 51 +++ lib/mail-index/reindex.ts | 332 ++++++++++++++ lib/mail-index/store.ts | 444 +++++++++++++++++++ next.config.ts | 9 +- package-lock.json | 27 ++ package.json | 3 + stores/email-store.ts | 30 ++ 19 files changed, 2672 insertions(+), 2 deletions(-) create mode 100644 app/api/offline/reindex/route.ts create mode 100644 app/api/offline/search/route.ts create mode 100644 components/settings/local-index-settings.tsx create mode 100644 electron/key-service.ts create mode 100644 lib/mail-index-client.ts create mode 100644 lib/mail-index/binding.ts create mode 100644 lib/mail-index/extract.ts create mode 100644 lib/mail-index/jmap.ts create mode 100644 lib/mail-index/key.ts create mode 100644 lib/mail-index/paths.ts create mode 100644 lib/mail-index/reindex.ts create mode 100644 lib/mail-index/store.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 00bfd246..3d8250b9 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1063,7 +1063,30 @@ export default function Home() { debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`); } + // CATCH-UP for the desktop shell's local search index. The index's normal + // trigger is a push StateChange (stores/email-store.ts's handleStateChange), + // but nothing was pushed while the app was closed - and the polling + // transport has no signal for contacts or files at all (client.ts's + // buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/ + // SieveScript only). So backfill a bounded recent window once per session, + // after push is wired. Fire-and-forget; a no-op outside Electron. + const catchUpTimer = setTimeout(() => { + void (async () => { + try { + const { catchUpIndex } = await import('@/lib/mail-index-client'); + await catchUpIndex( + useAccountStore.getState().getActiveAccount()?.cookieSlot, + ); + } catch { + /* the index is optional */ + } + })(); + // Deliberately after the initial mailbox fetch settles: the catch-up is a + // background nicety and must not compete with first paint. + }, 4000); + return () => { + clearTimeout(catchUpTimer); cleanups.forEach((fn) => fn()); }; }, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]); diff --git a/app/api/offline/reindex/route.ts b/app/api/offline/reindex/route.ts new file mode 100644 index 00000000..93ca0843 --- /dev/null +++ b/app/api/offline/reindex/route.ts @@ -0,0 +1,110 @@ +// POST /api/offline/reindex - write mail/calendar/contacts/files into the +// encrypted local search index for the calling session's account. +// +// The PRIMARY caller is the renderer's live JMAP push handler: when a +// StateChange arrives it posts the ids that changed, so indexing is reactive to +// each delivery rather than periodic. `{ catchUp: true }` (no ids) is the +// fallback used at app launch to backfill whatever changed while the app was +// closed. +// +// GATED: returns 404 unless VNCMAIL_DESKTOP_STORE_DIR is set, which only +// electron/main.ts does. The same standalone server artifact runs in the +// multi-tenant production Docker image, where this feature must not exist at +// all - 404 rather than 403 so nothing learns the route is there. +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { isSqlcipherAvailable } from '@/lib/mail-index/binding'; +import { hasKeyChannel, IndexKeyError } from '@/lib/mail-index/key'; +import { getStoreDir } from '@/lib/mail-index/paths'; +import { + IndexSessionError, MAX_IDS_PER_CALL, resolveIndexSession, runIndex, + type IndexRequest, +} from '@/lib/mail-index/reindex'; +import { CONTENT_TYPES, isContentType, type ContentType } from '@/lib/mail-index/store'; +import { JmapIndexError } from '@/lib/mail-index/jmap'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function parseIdMap(raw: unknown): Partial> | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const out: Partial> = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (!isContentType(key) || !Array.isArray(value)) continue; + const ids = value + .filter((v): v is string => typeof v === 'string' && v.length > 0 && v.length <= 256) + .slice(0, MAX_IDS_PER_CALL); + if (ids.length > 0) out[key] = ids; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +export async function POST(request: NextRequest) { + if (!getStoreDir()) { + return new NextResponse(null, { status: 404 }); + } + if (!hasKeyChannel()) { + return NextResponse.json( + { error: 'The local index has no key channel in this process.', code: 'no-key-channel' }, + { status: 503 }, + ); + } + if (!isSqlcipherAvailable()) { + // The native binding is an optionalDependency, so "not installed" is a + // normal state on platforms without a prebuild - not an error to log loudly. + return NextResponse.json( + { error: 'Encrypted local index is unavailable on this platform.', code: 'no-binding' }, + { status: 503 }, + ); + } + + let body: Record = {}; + try { + const text = await request.text(); + if (text.trim()) body = JSON.parse(text) as Record; + } catch { + return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 }); + } + + const rawTypes = Array.isArray(body.types) ? body.types.filter(isContentType) : []; + const req: IndexRequest = { + types: rawTypes.length > 0 ? rawTypes : undefined, + ids: parseIdMap(body.ids), + removed: parseIdMap(body.removed), + // Pruning is a catch-up concern; a single-delivery call shouldn't scan. + prune: body.catchUp === true, + }; + + try { + const session = await resolveIndexSession(request); + const result = await runIndex(session, req); + return NextResponse.json( + { + ok: true, + written: result.written, + skipped: result.skipped, + errors: result.errors, + durationMs: result.durationMs, + types: CONTENT_TYPES, + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (error) { + if (error instanceof IndexSessionError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + if (error instanceof JmapIndexError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + if (error instanceof IndexKeyError) { + // no-secure-storage is the Linux-without-a-keyring refusal: a real, + // expected outcome with a user-facing explanation, not a server fault. + const status = error.code === 'no-secure-storage' ? 503 : 500; + return NextResponse.json({ error: error.message, code: error.code }, { status }); + } + logger.error('mail-index reindex failed', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Reindex failed' }, { status: 500 }); + } +} diff --git a/app/api/offline/search/route.ts b/app/api/offline/search/route.ts new file mode 100644 index 00000000..eb938050 --- /dev/null +++ b/app/api/offline/search/route.ts @@ -0,0 +1,117 @@ +// GET /api/offline/search?q=...&types=mail,calendar&limit=20 +// +// THE RETRIEVAL SURFACE. This is what an AI/RAG feature calls to gather +// relevant context from the user's own mail, calendar, contacts and files +// before prompting a model - hence the `snippet` on every hit and the +// `contextBlock` convenience field, which is the same information already +// flattened into text a prompt can carry directly. +// +// Read-only: it never touches the network and never writes. Gated identically +// to the reindex route. +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { isSqlcipherAvailable } from '@/lib/mail-index/binding'; +import { hasKeyChannel, IndexKeyError, withIndexKey } from '@/lib/mail-index/key'; +import { getStoreDir } from '@/lib/mail-index/paths'; +import { IndexSessionError, resolveIndexSession } from '@/lib/mail-index/reindex'; +import { + isContentType, MailIndex, MailIndexUnavailableError, type ContentType, type SearchHit, +} from '@/lib/mail-index/store'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * One hit as a plain text block, ready to be concatenated into a prompt. + * Kept server-side so every caller (a chat feature, a future agent, a test) + * formats context the same way rather than each inventing its own. + */ +function toContextBlock(hit: SearchHit): string { + const label: Record = { + mail: 'EMAIL', calendar: 'CALENDAR EVENT', contact: 'CONTACT', file: 'FILE', + }; + const lines = [`[${label[hit.contentType]}] ${hit.title}`]; + if (hit.occurredAt) lines.push(`Date: ${hit.occurredAt}`); + if (hit.people) lines.push(`People: ${hit.people}`); + const path = hit.metadata?.path; + if (typeof path === 'string' && path) lines.push(`Path: ${path}`); + if (hit.snippet) lines.push(`Excerpt: ${hit.snippet}`); + return lines.join('\n'); +} + +export async function GET(request: NextRequest) { + if (!getStoreDir()) { + return new NextResponse(null, { status: 404 }); + } + if (!hasKeyChannel() || !isSqlcipherAvailable()) { + return NextResponse.json( + { error: 'Encrypted local index is unavailable in this process.', code: 'unavailable' }, + { status: 503 }, + ); + } + + const params = request.nextUrl.searchParams; + const query = (params.get('q') ?? '').trim(); + const wantStats = params.get('stats') === 'true'; + + if (!query && !wantStats) { + return NextResponse.json({ error: 'Missing q parameter' }, { status: 400 }); + } + if (query.length > 512) { + return NextResponse.json({ error: 'Query too long' }, { status: 400 }); + } + + const types = (params.get('types') ?? '') + .split(',') + .map((t) => t.trim()) + .filter(isContentType); + + const limitRaw = Number(params.get('limit') ?? '20'); + const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.trunc(limitRaw), 1), 100) : 20; + + try { + const session = await resolveIndexSession(request); + const storeDir = getStoreDir(); + if (!storeDir) return new NextResponse(null, { status: 404 }); + + const payload = await withIndexKey(session.accountId, (key) => { + const index = MailIndex.open({ storeDir, accountId: session.accountId, key }); + try { + const stats = index.stats(); + if (!query) return { hits: [] as SearchHit[], stats }; + return { hits: index.search({ query, types, limit }), stats: wantStats ? stats : undefined }; + } finally { + index.close(); + } + }); + + return NextResponse.json( + { + ok: true, + query, + types: types.length > 0 ? types : 'all', + count: payload.hits.length, + hits: payload.hits, + // Everything a prompt needs, pre-joined in rank order. + contextBlock: payload.hits.map(toContextBlock).join('\n\n---\n\n'), + ...(payload.stats ? { stats: payload.stats } : {}), + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (error) { + if (error instanceof IndexSessionError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + if (error instanceof IndexKeyError) { + const status = error.code === 'no-secure-storage' ? 503 : 500; + return NextResponse.json({ error: error.message, code: error.code }, { status }); + } + if (error instanceof MailIndexUnavailableError) { + return NextResponse.json({ error: error.message, code: 'unavailable' }, { status: 503 }); + } + logger.error('mail-index search failed', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Search failed' }, { status: 500 }); + } +} diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx index 0c0556b9..b6a333b6 100644 --- a/components/settings/about-data-settings.tsx +++ b/components/settings/about-data-settings.tsx @@ -13,6 +13,7 @@ import { cn } from '@/lib/utils'; import { getPathPrefix } from '@/lib/browser-navigation'; import { clearCachedData } from '@/lib/clear-cached-data'; import { SpamSiegeGame } from './spam-siege-game'; +import { LocalIndexSettings } from './local-index-settings'; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; @@ -218,6 +219,9 @@ export function AboutDataSettings() { + + {/* Desktop shell only - renders nothing in the browser/PWA build. */} + ); } diff --git a/components/settings/local-index-settings.tsx b/components/settings/local-index-settings.tsx new file mode 100644 index 00000000..670dbeac --- /dev/null +++ b/components/settings/local-index-settings.tsx @@ -0,0 +1,123 @@ +"use client"; + +// Settings panel for the desktop shell's encrypted local search index. +// +// Deliberately small: the index's PRIMARY trigger is the live push connection +// (see lib/mail-index-client.ts's indexOnStateChange, wired into +// stores/email-store.ts's handleStateChange), so this panel is a status readout +// plus a manual catch-up button - not the mechanism. +// +// Renders nothing at all outside the Electron shell, where the routes 404. + +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { SettingsSection, SettingItem } from './settings-section'; +import { isElectronShell } from '@/lib/electron-bridge'; +import { useAccountStore } from '@/stores/account-store'; +import { catchUpIndex, fetchIndexStats, type IndexStats } from '@/lib/mail-index-client'; + +const TYPE_LABELS: Record = { + mail: 'Mail', + calendar: 'Calendar', + contact: 'Contacts', + file: 'Files', +}; + +export function LocalIndexSettings() { + const slot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot); + const [stats, setStats] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + // `null` until the first probe resolves, so we don't flash a panel that then + // vanishes on a non-desktop build. + const [available, setAvailable] = useState(null); + + const refreshStats = useCallback(async () => { + const next = await fetchIndexStats(slot); + setStats(next); + setAvailable(next !== null); + }, [slot]); + + useEffect(() => { + if (!isElectronShell()) { + setAvailable(false); + return; + } + void refreshStats(); + }, [refreshStats]); + + const handleRebuild = async () => { + setBusy(true); + setMessage(null); + try { + const result = await catchUpIndex(slot); + if (result.unavailable) { + setAvailable(false); + setMessage(result.error ?? 'The encrypted index is unavailable on this system.'); + return; + } + if (!result.ok) { + setMessage(result.error ?? 'Indexing failed.'); + return; + } + const written = Object.entries(result.written ?? {}) + .map(([type, n]) => `${TYPE_LABELS[type] ?? type}: ${n}`) + .join(', '); + const failed = (result.errors ?? []).map((e) => `${e.contentType} (${e.message})`).join('; '); + setMessage( + [ + written ? `Indexed ${written}.` : 'Nothing to index.', + result.skipped?.length ? `Not supported: ${result.skipped.join(', ')}.` : '', + failed ? `Problems: ${failed}` : '', + ] + .filter(Boolean) + .join(' '), + ); + await refreshStats(); + } finally { + setBusy(false); + } + }; + + if (available === false || available === null) return null; + + const total = (stats ?? []).reduce((sum, s) => sum + s.count, 0); + + return ( + + 0 + ? (stats ?? []) + .map((s) => `${TYPE_LABELS[s.contentType] ?? s.contentType}: ${s.count}`) + .join(' · ') + : 'Nothing indexed yet.' + } + > + {total} + + + + + + + ); +} diff --git a/electron/key-service.ts b/electron/key-service.ts new file mode 100644 index 00000000..3667e91f --- /dev/null +++ b/electron/key-service.ts @@ -0,0 +1,231 @@ +// Main-process key service for the local search index. +// +// The index database is SQLCipher-encrypted with a random per-account 32-byte +// key. That key is wrapped with Electron's `safeStorage` (OS keychain / DPAPI / +// libsecret-or-kwallet) and stored under the store directory. Only the main +// process can call `safeStorage`, but the index itself lives in the standalone +// Next.js server child process - so the unwrapped key has to cross one process +// boundary. +// +// TRANSPORT: an inherited file descriptor (fd 3), NOT an environment variable. +// A nonce or key passed through the spawned process's environment is readable +// by any other process running as the same OS user (`ps eww`, /proc//environ), +// which would defeat the entire point of using the OS keychain. An inherited fd +// is not exposed to process listing. libuv creates extra stdio "pipe" entries +// as socketpairs, so fd 3 is duplex - verified by execution through Electron's +// own spawn before this was built on. +// +// The server side asks for a key only when a reindex job actually runs and drops +// it when the job finishes (see lib/mail-index/key.ts) - there is no long-lived +// resident copy anywhere. +import { safeStorage } from "electron"; +import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { Readable, Writable } from "node:stream"; + +/** Must match lib/mail-index/paths.ts's accountFileToken(). */ +function accountFileToken(accountId: string): string { + return createHash("sha256").update(accountId, "utf8").digest("hex").slice(0, 32); +} + +function keyFilePath(storeDir: string, accountId: string): string { + return path.join(storeDir, "keys", `${accountFileToken(accountId)}.bin`); +} + +export type KeyServiceFailure = + /** No OS keyring at all - see the Linux note in checkEncryptionAvailable(). */ + | "no-secure-storage" + /** Reading/writing the wrapped key file failed. */ + | "key-io-failed" + /** The wrapped key exists but safeStorage could not decrypt it. */ + | "key-unreadable"; + +export class KeyServiceError extends Error { + code: KeyServiceFailure; + constructor(code: KeyServiceFailure, message: string) { + super(message); + this.name = "KeyServiceError"; + this.code = code; + } +} + +/** + * Decides whether we are willing to store an encryption key on this system. + * + * The Linux caveat is the reason this is a function and not a one-liner: + * `safeStorage.isEncryptionAvailable()` can return **true** on Linux while the + * data is protected by a hardcoded, publicly-known password, with + * `getSelectedStorageBackend()` reporting `basic_text`. That is worse than an + * honest failure, because it looks like it worked. So a `basic_text` backend is + * treated as "no secure storage" and the feature refuses to materialise + * anything - the index is a convenience, and silently pretending a mailbox is + * encrypted when it is not is not a trade worth making. + * + * `getSelectedStorageBackend()` is **Linux-only** and throws elsewhere, hence + * the platform guard. Both calls also require `app.whenReady()`. + */ +export function checkEncryptionAvailable(): { ok: true } | { ok: false; reason: string } { + if (!safeStorage.isEncryptionAvailable()) { + return { ok: false, reason: "The OS reports no secure storage available for encryption keys." }; + } + if (process.platform === "linux") { + let backend: string; + try { + backend = safeStorage.getSelectedStorageBackend(); + } catch { + // Older/newer Electron, or called too early. Be conservative. + return { ok: false, reason: "Could not determine the Linux secret-storage backend." }; + } + if (backend === "basic_text" || backend === "unknown") { + return { + ok: false, + reason: + `No OS keyring is available (backend: ${backend}). Electron would "encrypt" the key ` + + `with a hardcoded password, which provides no real protection, so the encrypted ` + + `local index is disabled on this system.`, + }; + } + } + return { ok: true }; +} + +/** Fetches the account's raw index key, creating and wrapping one on first use. */ +function getOrCreateKey(storeDir: string, accountId: string): Buffer { + const availability = checkEncryptionAvailable(); + if (!availability.ok) { + throw new KeyServiceError("no-secure-storage", availability.reason); + } + + const file = keyFilePath(storeDir, accountId); + + if (fs.existsSync(file)) { + let wrapped: Buffer; + try { + wrapped = fs.readFileSync(file); + } catch (error) { + throw new KeyServiceError("key-io-failed", `Could not read the key file: ${String(error)}`); + } + let hex: string; + try { + hex = safeStorage.decryptString(wrapped); + } catch (error) { + // Most likely cause on macOS: the app's code identity changed (unsigned + // builds get a fresh ad-hoc signature per build), so the Keychain ACL no + // longer matches. Not recoverable and not a user secret - the caller + // deletes the database and re-indexes. + throw new KeyServiceError( + "key-unreadable", + `The stored key could not be decrypted (${String(error)}). It must be recreated.`, + ); + } + const key = Buffer.from(hex.trim(), "hex"); + if (key.length === 32) return key; + // Corrupt payload: fall through and mint a new one. + } + + const key = randomBytes(32); + let wrapped: Buffer; + try { + wrapped = safeStorage.encryptString(key.toString("hex")); + } catch (error) { + throw new KeyServiceError("no-secure-storage", `Could not wrap the key: ${String(error)}`); + } + try { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + // Write-then-rename so a crash mid-write cannot leave a truncated wrapped + // key that would look like "key-unreadable" forever. + const tmp = `${file}.tmp-${process.pid}`; + fs.writeFileSync(tmp, wrapped, { mode: 0o600 }); + fs.renameSync(tmp, file); + } catch (error) { + throw new KeyServiceError("key-io-failed", `Could not persist the key: ${String(error)}`); + } + return key; +} + +function deleteKey(storeDir: string, accountId: string): void { + try { + fs.rmSync(keyFilePath(storeDir, accountId), { force: true }); + } catch { + /* best effort - the caller is purging anyway */ + } +} + +interface Request { + id?: unknown; + op?: unknown; + accountId?: unknown; +} + +/** + * Serves newline-delimited JSON requests from the standalone server over the + * inherited fd. One line in, one line out, no streaming and no state. + */ +export function attachKeyService( + channel: (Readable & Writable) | null | undefined, + storeDir: string, +): void { + if (!channel) { + console.error("[electron] key service: no channel on fd 3; the local index will be disabled"); + return; + } + + let buffer = ""; + const respond = (payload: Record) => { + try { + channel.write(`${JSON.stringify(payload)}\n`); + } catch (error) { + console.error("[electron] key service: failed to write response:", error); + } + }; + + channel.on("data", (chunk: Buffer | string) => { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + // Guard against a peer that never sends a newline. + if (buffer.length > 64 * 1024) buffer = ""; + + let newline: number; + while ((newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line.trim()) continue; + + let req: Request; + try { + req = JSON.parse(line) as Request; + } catch { + respond({ id: null, ok: false, code: "bad-request", error: "Malformed request" }); + continue; + } + + const id = typeof req.id === "number" ? req.id : null; + const accountId = typeof req.accountId === "string" ? req.accountId : ""; + if (!accountId) { + respond({ id, ok: false, code: "bad-request", error: "Missing accountId" }); + continue; + } + + try { + if (req.op === "getIndexKey") { + const key = getOrCreateKey(storeDir, accountId); + respond({ id, ok: true, key: key.toString("hex") }); + key.fill(0); + } else if (req.op === "deleteIndexKey") { + deleteKey(storeDir, accountId); + respond({ id, ok: true }); + } else { + respond({ id, ok: false, code: "bad-request", error: `Unknown op: ${String(req.op)}` }); + } + } catch (error) { + const code = error instanceof KeyServiceError ? error.code : "key-io-failed"; + const message = error instanceof Error ? error.message : String(error); + respond({ id, ok: false, code, error: message }); + } + } + }); + + channel.on("error", (error: unknown) => { + console.error("[electron] key service channel error:", error); + }); +} diff --git a/electron/main.ts b/electron/main.ts index aed5c130..bfef6072 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -13,10 +13,26 @@ import { createServer } from "node:net"; import { get as httpGet } from "node:http"; import path from "node:path"; import fs from "node:fs"; +import type { Duplex } from "node:stream"; +import { attachKeyService, checkEncryptionAvailable } from "./key-service"; let serverProcess: ChildProcess | null = null; let mainWindow: BrowserWindow | null = null; +/** + * Root for the encrypted local search index (lib/mail-index/**). Under + * `userData`, so it is per-OS-user and removed with the app's data. + * + * Passing this to the server child process is what ACTIVATES the index: the + * routes 404 without it. That matters because the standalone server is the same + * artifact the production Dockerfile ships to multi-tenant deployments, where a + * server-side index of every user's mail would be badly wrong. One variable + * both enables the feature and supplies its path, so the two cannot drift apart. + */ +function getIndexStoreDir(): string { + return path.join(app.getPath("userData"), "offline"); +} + /** * Locates the standalone server's entrypoint. Packaged builds ship it as an * extraResource (see electron-builder.config.js) because .next/standalone @@ -78,10 +94,29 @@ async function startStandaloneServer(): Promise { const port = await getFreePort(); const url = `http://127.0.0.1:${port}`; + const storeDir = getIndexStoreDir(); + const encryption = checkEncryptionAvailable(); + if (!encryption.ok) { + // Refuse rather than degrade. On Linux with no keyring, safeStorage + // "succeeds" using a hardcoded public password, which would look like an + // encrypted mailbox index while providing no protection. Leaving the env + // vars unset makes every index route 404, so the app runs normally without + // the feature. + console.error(`[electron] local search index disabled: ${encryption.reason}`); + } + // Spawn the Electron binary itself as a plain Node process // (ELECTRON_RUN_AS_NODE) instead of depending on a system Node install - // the packaged app can't assume Node exists on the target machine, and // this keeps dev/packaged behavior identical. + // + // stdio gains a 4th entry: fd 3 is the key channel for the local index (see + // electron/key-service.ts). libuv creates extra stdio "pipe" entries as + // socketpairs, so it is duplex in both directions - verified by execution + // before this was built on. Deliberately NOT an environment variable: env is + // readable by any process running as the same OS user, which would defeat + // using the OS keychain at all. The fd NUMBER below is not a secret; only + // what travels over it is. serverProcess = spawn(process.execPath, [serverEntry], { env: { ...process.env, @@ -89,10 +124,19 @@ async function startStandaloneServer(): Promise { PORT: String(port), HOSTNAME: "127.0.0.1", NODE_ENV: process.env.NODE_ENV || "production", + ...(encryption.ok + ? { VNCMAIL_DESKTOP_STORE_DIR: storeDir, VNCMAIL_DESKTOP_KEY_FD: "3" } + : {}), }, - stdio: "inherit", + stdio: encryption.ok + ? ["inherit", "inherit", "inherit", "pipe"] + : "inherit", }); + if (encryption.ok) { + attachKeyService(serverProcess.stdio[3] as Duplex | null, storeDir); + } + serverProcess.on("exit", (code, signal) => { if (code !== 0 && code !== null) { console.error(`[electron] standalone server exited early (code=${code}, signal=${signal})`); diff --git a/lib/mail-index-client.ts b/lib/mail-index-client.ts new file mode 100644 index 00000000..2a1e09d2 --- /dev/null +++ b/lib/mail-index-client.ts @@ -0,0 +1,199 @@ +// Renderer-side client for the encrypted local search index. +// +// The index is EVENT-DRIVEN: the renderer already holds the live JMAP push +// connection (WebSocket -> SSE -> polling, `lib/jmap/client.ts`'s +// setupPushNotifications), so the moment a StateChange announces new mail, a +// calendar change, a contact edit or a file upload, this posts to the reindex +// route. No polling loop, no background worker, no long-lived credential - +// just one more authenticated fetch from the place the push already arrives. +// +// Every function here is best-effort and never throws: a search index failing +// to update must never break the mail UI. + +import { apiFetch } from '@/lib/browser-navigation'; +import { debug } from '@/lib/debug'; +import type { StateChange } from '@/lib/jmap/types'; + +export type IndexContentType = 'mail' | 'calendar' | 'contact' | 'file'; + +export interface IndexRunResult { + ok: boolean; + written?: Partial>; + skipped?: IndexContentType[]; + errors?: Array<{ contentType: IndexContentType; message: string }>; + durationMs?: number; + /** Set when the feature isn't available (not the desktop shell, no keyring, no binding). */ + unavailable?: boolean; + error?: string; +} + +/** + * Maps JMAP `StateChange` type keys onto our content types. + * + * The transport is already type-generic - the WebSocket handler + * (`client.ts:6308-6316`) and the SSE handler (`:6505`) pass the whole + * `changed` map through untouched, and the WS subscribes with + * `dataTypes: null` (every type) - so anything the server pushes arrives here. + * + * `Mailbox` is deliberately NOT mapped: a Mailbox state change is usually just + * an unread-count move, and it fires constantly. `Email` covers the cases that + * change indexable content. + */ +const STATE_TYPE_TO_CONTENT: Record = { + Email: 'mail', + Calendar: 'calendar', + CalendarEvent: 'calendar', + ContactCard: 'contact', + AddressBook: 'contact', + FileNode: 'file', +}; + +export function contentTypesFromStateChange(change: StateChange): IndexContentType[] { + const out = new Set(); + for (const perAccount of Object.values(change.changed ?? {})) { + for (const stateType of Object.keys(perAccount ?? {})) { + const mapped = STATE_TYPE_TO_CONTENT[stateType]; + if (mapped) out.add(mapped); + } + } + return [...out]; +} + +export interface IndexRequestOptions { + types?: readonly IndexContentType[]; + /** + * Per-type ids to index. Supply them whenever the renderer already knows + * which objects changed - it turns the call into a couple of `Foo/get`s + * instead of a windowed query. Mail is the frequent case and the one where + * this matters. + */ + ids?: Partial>; + /** Backfill the recent window for every supported type, and prune. */ + catchUp?: boolean; + /** Cookie slot of the account to index. Defaults to the server's first signed-in slot. */ + slot?: number; +} + +let inFlight: Promise | null = null; +/** Set once the server says the feature isn't there, so we stop asking. */ +let knownUnavailable = false; + +/** + * Posts one index request. Single-flighted: a burst of deliveries coalesces + * into the in-flight call rather than queueing N overlapping SQLite writers. + */ +export async function requestIndex(options: IndexRequestOptions = {}): Promise { + if (knownUnavailable) return { ok: false, unavailable: true }; + if (inFlight) return inFlight; + + const query = typeof options.slot === 'number' ? `?slot=${options.slot}` : ''; + const run = (async (): Promise => { + try { + const response = await apiFetch(`/api/offline/reindex${query}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + types: options.types, + ids: options.ids, + catchUp: options.catchUp === true, + }), + }); + + // 404 = not the desktop shell (or the feature is gated off). Permanent for + // this page load; stop asking so a busy mailbox doesn't post per delivery. + if (response.status === 404) { + knownUnavailable = true; + return { ok: false, unavailable: true }; + } + if (response.status === 503) { + // No keyring / no native binding / no key channel. Also permanent for + // this session, and the message is worth surfacing in Settings. + knownUnavailable = true; + const body = await response.json().catch(() => ({})); + return { ok: false, unavailable: true, error: body?.error }; + } + if (!response.ok) { + const body = await response.json().catch(() => ({})); + return { ok: false, error: body?.error || `HTTP ${response.status}` }; + } + const body = await response.json(); + debug.log('push', '[index] reindex done', body?.written, body?.errors); + return { + ok: true, + written: body?.written, + skipped: body?.skipped, + errors: body?.errors, + durationMs: body?.durationMs, + }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + inFlight = null; + } + })(); + + inFlight = run; + return run; +} + +/** + * The event-driven entry point, called from the push handler. + * + * `mailIds` lets the caller hand over the ids it already has (the refreshed + * mailbox page), so the frequent mail case costs one `Email/get` rather than a + * 30-day query. The other three types are rare events (a contact edit, a file + * upload, a calendar change), so they fall back to their own bounded queries. + */ +export function indexOnStateChange( + change: StateChange, + opts: { mailIds?: string[]; slot?: number } = {}, +): void { + if (knownUnavailable) return; + const types = contentTypesFromStateChange(change); + if (types.length === 0) return; + + const ids: Partial> = {}; + if (types.includes('mail') && opts.mailIds && opts.mailIds.length > 0) { + ids.mail = opts.mailIds.slice(0, 100); + } + + // Fire-and-forget on purpose: this runs inside the push handler, and the mail + // UI must not wait on a search index. + void requestIndex({ types, ids: Object.keys(ids).length > 0 ? ids : undefined, slot: opts.slot }); +} + +/** + * Launch-time catch-up: backfills whatever changed while the app was closed, + * for which no push event was ever delivered. Also the recovery path for the + * polling transport, which has no signal for contacts or files at all + * (`client.ts`'s buildStatePollingRequest polls Mailbox/Email/Calendar/ + * CalendarEvent/SieveScript only). + */ +export async function catchUpIndex(slot?: number): Promise { + return requestIndex({ catchUp: true, slot }); +} + +export interface IndexStats { + contentType: string; + count: number; + newest: string | null; + indexedAt: number | null; +} + +/** Reads per-type counts without searching. Used by the Settings panel. */ +export async function fetchIndexStats(slot?: number): Promise { + const slotQuery = typeof slot === 'number' ? `&slot=${slot}` : ''; + try { + const response = await apiFetch(`/api/offline/search?stats=true${slotQuery}`); + if (!response.ok) return null; + const body = await response.json(); + return Array.isArray(body?.stats) ? (body.stats as IndexStats[]) : []; + } catch { + return null; + } +} + +/** Resets the "don't ask again" latch - e.g. after the user signs in again. */ +export function resetIndexAvailability(): void { + knownUnavailable = false; +} diff --git a/lib/mail-index/binding.ts b/lib/mail-index/binding.ts new file mode 100644 index 00000000..95227018 --- /dev/null +++ b/lib/mail-index/binding.ts @@ -0,0 +1,83 @@ +// Guarded loader for the SQLCipher native binding. +// +// WHY THIS FILE EXISTS AT ALL: `@signalapp/sqlcipher` is declared in +// package.json's `optionalDependencies`, not `dependencies`, and it MUST stay +// there. It publishes six N-API prebuilds (darwin/linux/win32 x arm64/x64) and +// **no build sources at all** - the published tarball has no `binding.gyp`, no +// `src/`, no `deps/`. Its install script is `node-gyp-build`, which falls back +// to `node-gyp rebuild` when no prebuild matches, and that fallback cannot +// succeed without sources. So on a platform with no matching prebuild the +// install FAILS. +// +// Both Dockerfiles in this repo are `FROM node:24-alpine` + `npm ci` +// (`Dockerfile:1-4`, `integration/webmail.Dockerfile:15-19`). Alpine is musl; +// there is no `linuxmusl-*` prebuild (and the glibc prebuild could not load +// there anyway). As a hard `dependencies` entry this would break the +// production image build and the integration fixture's webmail container - +// neither of which wants this feature, they just need `npm ci` to exit 0. +// `optionalDependencies` makes npm treat that install failure as non-fatal and +// simply omit the package. +// +// The cost of that choice is exactly this module: the require must be guarded +// at runtime, because "installed" is no longer guaranteed. Callers get +// `null` and the feature turns itself off, which is the correct behaviour for +// a desktop-only search index in a server that may not be a desktop. + +/** + * Minimal structural type for the bits of `@signalapp/sqlcipher` we use. + * + * Deliberately hand-written rather than `typeof import('@signalapp/sqlcipher')`: + * the package is optional, so a type-only import would make `tsc` fail on any + * machine where the install was skipped - which is every Alpine CI container. + * + * NOTE the parameter shape. `@signalapp/sqlcipher` is NOT drop-in compatible + * with better-sqlite3 here: its `#checkParams` throws + * `TypeError: Params must be either object or array`, so `stmt.run(a, b, c)` + * (varargs, which better-sqlite3 accepts) is a runtime error. Always pass a + * single array or object. Found by executing it, not by reading the types. + */ +export interface SqlcipherStatement { + run(params?: readonly unknown[] | Record): { changes: number; lastInsertRowid: number }; + get(params?: readonly unknown[] | Record): Record | undefined; + all(params?: readonly unknown[] | Record): Array>; +} + +export interface SqlcipherDatabase { + exec(sql: string): void; + prepare(sql: string): SqlcipherStatement; + pragma(source: string): unknown; + close(): void; +} + +export interface SqlcipherConstructor { + new (path?: string): SqlcipherDatabase; +} + +let cached: SqlcipherConstructor | null | undefined; + +/** + * Returns the Database constructor, or `null` when the optional native binding + * is not installed / cannot load on this platform. Never throws. + * + * Memoised on both outcomes so a missing binding costs one failed require per + * process rather than one per request. + */ +export function loadSqlcipher(): SqlcipherConstructor | null { + if (cached !== undefined) return cached; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require('@signalapp/sqlcipher') as + | { default?: SqlcipherConstructor } + | SqlcipherConstructor; + const ctor = (mod as { default?: SqlcipherConstructor }).default ?? (mod as SqlcipherConstructor); + cached = typeof ctor === 'function' ? ctor : null; + } catch { + cached = null; + } + return cached; +} + +/** True when the local index can work at all in this process. */ +export function isSqlcipherAvailable(): boolean { + return loadSqlcipher() !== null; +} diff --git a/lib/mail-index/extract.ts b/lib/mail-index/extract.ts new file mode 100644 index 00000000..b209bb1c --- /dev/null +++ b/lib/mail-index/extract.ts @@ -0,0 +1,311 @@ +// PURE JMAP-object -> IndexDoc extractors. +// +// Deliberately free of database, network and store access so every shape +// decision here is unit-testable on its own. The JMAP shapes are awkward +// enough (JSContact keyed maps, JSCalendar participants, FileNode's `modified` +// rather than `updated`) that this is where the bugs would otherwise hide. + +import type { Email, CalendarEvent, ContactCard, FileNode, EmailAddress } from '@/lib/jmap/types'; +import type { IndexDoc } from './store'; + +/** Hard cap on indexed body text per document. Keeps one enormous mail from dominating the file. */ +export const MAX_BODY_CHARS = 32_000; + +/** + * Minimal HTML -> text, for mail that has no `text/plain` alternative. + * + * Not a sanitiser and not trying to be: this output is never rendered, only + * tokenised by FTS5 and possibly handed to an LLM as context. The repo's + * `dompurify` needs a DOM and this runs in Node, so a DOM-free reduction is the + * right tool. Order matters - script/style content must go before tags are + * stripped, or their contents would leak into the index as searchable text. + */ +export function htmlToText(html: string): string { + return html + .replace(//g, ' ') + .replace(/<(script|style|head)\b[\s\S]*?<\/\1>/gi, ' ') + .replace(//gi, '\n') + .replace(/<\/(p|div|tr|li|h[1-6]|blockquote)>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/&#(\d+);/g, (_m, d: string) => { + const code = Number(d); + return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : ' '; + }) + .replace(/&#x([0-9a-f]+);/gi, (_m, h: string) => { + const code = parseInt(h, 16); + return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : ' '; + }) + .replace(/[ \t\u00a0]+/g, ' ') + .replace(/\s*\n\s*/g, '\n') + .trim(); +} + +export function normaliseText(s: string | null | undefined): string { + if (!s) return ''; + return s.replace(/\r\n?/g, '\n').replace(/[ \t\u00a0]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); +} + +function clamp(s: string, max = MAX_BODY_CHARS): string { + return s.length <= max ? s : s.slice(0, max); +} + +function formatAddresses(list: readonly EmailAddress[] | undefined): string { + if (!list || list.length === 0) return ''; + return list + .map((a) => [a.name, a.email].filter((p) => typeof p === 'string' && p.length > 0).join(' ')) + .filter((s) => s.length > 0) + .join(', '); +} + +/** Values of a JSContact/JSCalendar keyed map, in a stable order. */ +function mapValues(m: Record | null | undefined): T[] { + if (!m || typeof m !== 'object') return []; + return Object.keys(m).sort().map((k) => m[k]); +} + +function joinUnique(parts: Array): string { + const seen = new Set(); + const out: string[] = []; + for (const p of parts) { + const v = typeof p === 'string' ? p.trim() : ''; + if (!v || seen.has(v)) continue; + seen.add(v); + out.push(v); + } + return out.join(', '); +} + +// ── mail ──────────────────────────────────────────────────────────────────── + +/** + * Resolves an Email's plain-text body from `bodyValues`, preferring the + * `text/plain` alternative and falling back to flattening the HTML one. + * + * `textBody`/`htmlBody` reference parts by `partId`; the text itself only + * arrives in `bodyValues` when the `Email/get` asked for it + * (`fetchTextBodyValues` / `fetchHTMLBodyValues`). A caller that forgets that + * gets an empty body rather than an error, which is exactly the kind of silent + * hole worth naming here. + */ +export function emailBodyText(email: Email): string { + const values = email.bodyValues ?? {}; + const fromParts = (parts: typeof email.textBody): string => + (parts ?? []) + .map((p) => values[p.partId]?.value ?? '') + .filter((v) => v.length > 0) + .join('\n\n'); + + const plain = fromParts(email.textBody); + if (plain.trim().length > 0) return normaliseText(plain); + + const html = fromParts(email.htmlBody); + if (html.trim().length > 0) return normaliseText(htmlToText(html)); + + // Last resort: the server-computed preview. Better than nothing for a search + // index, and it costs no extra round trip. + return normaliseText(email.preview); +} + +export function extractMail(jmapAccountId: string, email: Email): IndexDoc { + const body = clamp(emailBodyText(email)); + return { + jmapAccountId, + contentType: 'mail', + id: email.id, + title: normaliseText(email.subject) || '(no subject)', + people: joinUnique([ + formatAddresses(email.from), + formatAddresses(email.to), + formatAddresses(email.cc), + ]), + body, + occurredAt: email.receivedAt ?? null, + metadata: { + threadId: email.threadId, + from: email.from?.[0]?.email ?? null, + fromName: email.from?.[0]?.name ?? null, + hasAttachment: !!email.hasAttachment, + size: email.size ?? null, + mailboxIds: Object.keys(email.mailboxIds ?? {}), + preview: normaliseText(email.preview).slice(0, 300), + }, + }; +} + +// ── calendar ──────────────────────────────────────────────────────────────── + +export function extractCalendarEvent(jmapAccountId: string, event: CalendarEvent): IndexDoc { + const participants = mapValues(event.participants); + const participantText = joinUnique( + participants.flatMap((p) => [ + p?.name, + p?.email, + p?.calendarAddress?.replace(/^mailto:/i, ''), + ...Object.values(p?.sendTo ?? {}).map((v) => + typeof v === 'string' ? v.replace(/^mailto:/i, '') : '', + ), + ]), + ); + + const locations = mapValues(event.locations) + .map((l) => normaliseText(l?.name)) + .filter((s) => s.length > 0); + + // `descriptionContentType` can legitimately be text/html. + const rawDescription = normaliseText(event.description); + const description = /html/i.test(event.descriptionContentType ?? '') + ? normaliseText(htmlToText(rawDescription)) + : rawDescription; + + const keywords = Object.keys(event.keywords ?? {}); + const categories = Object.keys(event.categories ?? {}); + + return { + jmapAccountId, + contentType: 'calendar', + id: event.id, + title: normaliseText(event.title) || '(untitled event)', + people: joinUnique([event.organizerCalendarAddress?.replace(/^mailto:/i, ''), participantText]), + body: clamp( + [description, locations.join(', '), keywords.join(' '), categories.join(' ')] + .filter((s) => s.length > 0) + .join('\n\n'), + ), + // `utcStart` is the resolved instant the app computes; `start` is local + // wall-clock without a zone, so prefer utcStart for ordering. + occurredAt: event.utcStart ?? event.start ?? null, + metadata: { + start: event.start ?? null, + utcStart: event.utcStart ?? null, + utcEnd: event.utcEnd ?? null, + timeZone: event.timeZone ?? null, + showWithoutTime: !!event.showWithoutTime, + status: event.status ?? null, + locations, + calendarIds: Object.keys(event.calendarIds ?? {}), + participantCount: participants.length, + }, + }; +} + +// ── contacts ──────────────────────────────────────────────────────────────── + +export function contactDisplayName(card: ContactCard): string { + const full = normaliseText(card.name?.full); + if (full) return full; + const components = card.name?.components ?? []; + const ordered = ['prefix', 'given', 'given2', 'additional', 'middle', 'surname', 'surname2', 'suffix']; + const byKind = components + .slice() + .sort((a, b) => ordered.indexOf(a.kind) - ordered.indexOf(b.kind)) + .map((c) => c.value) + .filter((v) => typeof v === 'string' && v.trim().length > 0) + .join(' '); + if (byKind.trim()) return normaliseText(byKind); + const firstEmail = mapValues(card.emails)[0]?.address; + if (firstEmail) return firstEmail; + const org = mapValues(card.organizations)[0]?.name; + return normaliseText(org) || '(unnamed contact)'; +} + +export function extractContact(jmapAccountId: string, card: ContactCard): IndexDoc { + const emails = mapValues(card.emails).map((e) => e.address).filter(Boolean); + const phones = mapValues(card.phones).map((p) => p.number).filter(Boolean); + const nicknames = mapValues(card.nicknames) + .map((n) => n?.name) + .filter((v): v is string => typeof v === 'string' && v.length > 0); + const orgs = mapValues(card.organizations).map((o) => o.name).filter((v): v is string => !!v); + const titles = mapValues(card.titles).map((t) => t.name).filter(Boolean); + const notes = mapValues(card.notes).map((n) => n.note).filter(Boolean); + // `full` (RFC 9553) when present, else the legacy flat fields vCard import + // produces, else the ordered components. All three shapes occur in this type. + const addresses = mapValues(card.addresses) + .map((a) => + normaliseText( + a?.full || + [a?.street, a?.locality, a?.region, a?.postcode, a?.country] + .filter((p): p is string => typeof p === 'string' && p.length > 0) + .join(', ') || + (a?.components ?? []).map((c) => c.value).join(' '), + ), + ) + .filter((s) => s.length > 0); + + return { + jmapAccountId, + contentType: 'contact', + id: card.id, + title: contactDisplayName(card), + // Emails/phones go in `people` (weighted above body) because "who is + // this / what's their number" is the dominant contact lookup. + people: joinUnique([...emails, ...phones, ...nicknames]), + body: clamp([...orgs, ...titles, ...addresses, ...notes].filter(Boolean).join('\n')), + // A contact has no meaningful single date; JSContact `updated` is optional + // and not on this repo's type, so leave it null and rank by relevance only. + occurredAt: null, + metadata: { + kind: card.kind ?? null, + emails, + phones, + organizations: orgs, + addressBookIds: Object.keys(card.addressBookIds ?? {}), + }, + }; +} + +// ── files ─────────────────────────────────────────────────────────────────── + +/** + * METADATA ONLY - filename, path, dates, size, owner. Deliberately NOT file + * content: extracting searchable text from arbitrary PDFs / office documents / + * images is a materially bigger problem (per-format parsers, OCR, size limits, + * untrusted-input parsing in a process holding the user's mail) and is a + * separate piece of work. `path` is passed in by the caller because a FileNode + * only knows its `parentId`; resolving the chain is the caller's job. + */ +export function extractFile( + jmapAccountId: string, + node: FileNode, + opts: { path?: string; ownerName?: string } = {}, +): IndexDoc { + const dirPath = normaliseText(opts.path); + const isDirectory = node.type === 'd'; + return { + jmapAccountId, + contentType: 'file', + id: node.id, + title: normaliseText(node.name) || '(unnamed file)', + people: joinUnique([opts.ownerName, node.accountName]), + // The path is genuinely searchable text ("that thing in Invoices/2026"), + // and the extension is worth tokenising on its own. + body: clamp( + [dirPath, isDirectory ? 'folder' : node.type, fileExtension(node.name)] + .filter((s) => s && s.length > 0) + .join('\n'), + ), + // FileNode has `modified`, NOT `updated` - asking for the wrong name + // silently yields undefined (this repo hit that as #700). + occurredAt: node.modified ?? node.created ?? null, + metadata: { + path: dirPath || null, + mimeType: isDirectory ? null : node.type, + isDirectory, + size: typeof node.size === 'number' ? node.size : null, + created: node.created ?? null, + modified: node.modified ?? null, + parentId: node.parentId ?? null, + contentIndexed: false, + }, + }; +} + +function fileExtension(name: string | undefined): string { + if (!name) return ''; + const i = name.lastIndexOf('.'); + return i > 0 && i < name.length - 1 ? name.slice(i + 1).toLowerCase() : ''; +} diff --git a/lib/mail-index/jmap.ts b/lib/mail-index/jmap.ts new file mode 100644 index 00000000..c918d2d6 --- /dev/null +++ b/lib/mail-index/jmap.ts @@ -0,0 +1,357 @@ +// A deliberately tiny server-side JMAP client, used only by the indexer. +// +// WHY NOT REUSE lib/jmap/client.ts: that class is a 7400-line renderer object. +// It holds credentials in instance fields, uses `btoa`, opens EventSource / +// WebSocket push connections, and wires itself into Zustand stores and toast +// notifications. Importing it into an API route would drag all of that into the +// server bundle for the sake of four method calls. The existing server-side +// JMAP code in this repo (lib/auth/verify-jmap-auth.ts) already sets the +// precedent: plain fetch + an Authorization header. +// +// Everything here is stateless - the caller supplies the auth header per call, +// so there is no resident credential and nothing to invalidate. + +import type { CalendarEvent, ContactCard, Email, FileNode } from '@/lib/jmap/types'; + +const REQUEST_TIMEOUT_MS = 30_000; + +export const CAP_CORE = 'urn:ietf:params:jmap:core'; +export const CAP_MAIL = 'urn:ietf:params:jmap:mail'; +export const CAP_CALENDARS = 'urn:ietf:params:jmap:calendars'; +export const CAP_CONTACTS = 'urn:ietf:params:jmap:contacts'; +export const CAP_FILENODE = 'urn:ietf:params:jmap:filenode'; + +export class JmapIndexError extends Error { + status: number; + constructor(message: string, status = 502) { + super(message); + this.name = 'JmapIndexError'; + this.status = status; + } +} + +export interface JmapSessionInfo { + apiUrl: string; + /** Server-confirmed authenticated login (JMAP Session.username). */ + username?: string; + primaryAccounts: Record; + accounts: Record }>; + capabilities: Record; +} + +/** + * Pins a URL advertised by the session to the origin we authenticated against. + * + * `lib/jmap/client.ts` does the same thing in its rewriteSessionUrls() for the + * renderer's benefit. Server-side it is a security control, not a convenience: + * we attach the user's credentials to this URL, so a session document that + * advertised an `apiUrl` on someone else's host would turn this into a + * credential-leaking SSRF. Keep the path and query, take the origin from the + * server URL we were configured with. + */ +function pinToServerOrigin(advertised: string, serverUrl: string): string { + const base = new URL(serverUrl); + let target: URL; + try { + target = new URL(advertised, base); + } catch { + throw new JmapIndexError('JMAP session advertised an unusable apiUrl'); + } + return `${base.origin}${target.pathname}${target.search}`; +} + +async function fetchWithTimeout(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal, redirect: 'manual' }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new JmapIndexError('JMAP request timed out', 504); + } + throw new JmapIndexError(`JMAP request failed: ${String(error)}`); + } finally { + clearTimeout(timer); + } +} + +export async function fetchJmapSession(serverUrl: string, authHeader: string): Promise { + const response = await fetchWithTimeout(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { + method: 'GET', + headers: { Authorization: authHeader }, + }); + if (response.status === 401 || response.status === 403) { + throw new JmapIndexError('JMAP authentication failed', 401); + } + if (!response.ok) { + throw new JmapIndexError(`JMAP session fetch failed (${response.status})`); + } + const raw = (await response.json().catch(() => null)) as Record | null; + if (!raw || typeof raw.apiUrl !== 'string') { + throw new JmapIndexError('Invalid JMAP session response'); + } + return { + apiUrl: pinToServerOrigin(raw.apiUrl, serverUrl), + username: typeof raw.username === 'string' ? raw.username : undefined, + primaryAccounts: (raw.primaryAccounts as Record) ?? {}, + accounts: (raw.accounts as JmapSessionInfo['accounts']) ?? {}, + capabilities: (raw.capabilities as Record) ?? {}, + }; +} + +type MethodCall = [string, Record, string]; + +/** Raw method-response tuple, `[name, args, callId]`. `name` is 'error' on failure. */ +type MethodResponse = [string, Record, string]; + +export async function jmapRequest( + session: JmapSessionInfo, + authHeader: string, + using: readonly string[], + methodCalls: readonly MethodCall[], +): Promise { + const response = await fetchWithTimeout(session.apiUrl, { + method: 'POST', + headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify({ using, methodCalls }), + }); + if (response.status === 401 || response.status === 403) { + throw new JmapIndexError('JMAP authentication failed', 401); + } + if (response.status === 429) { + throw new JmapIndexError('JMAP server is rate limiting', 429); + } + if (!response.ok) { + throw new JmapIndexError(`JMAP request failed (${response.status})`); + } + const data = (await response.json().catch(() => null)) as { methodResponses?: MethodResponse[] } | null; + if (!data || !Array.isArray(data.methodResponses)) { + throw new JmapIndexError('Invalid JMAP response envelope'); + } + return data.methodResponses; +} + +function firstResult(responses: MethodResponse[], expected: string): Record | null { + for (const [name, args] of responses) { + if (name === expected) return args; + // A method-level error is not fatal for an INDEX: a server that doesn't + // support one data type should not fail the whole reindex. The caller + // treats null as "nothing to index for this type". + if (name === 'error') return null; + } + return null; +} + +function idsOf(args: Record | null): string[] { + const ids = args?.ids; + return Array.isArray(ids) ? ids.filter((v): v is string => typeof v === 'string') : []; +} + +function listOf(args: Record | null): T[] { + const list = args?.list; + return Array.isArray(list) ? (list as T[]) : []; +} + +export function accountIdFor(session: JmapSessionInfo, capability: string): string | null { + const id = session.primaryAccounts[capability]; + return typeof id === 'string' && id.length > 0 ? id : null; +} + +export function hasCapability(session: JmapSessionInfo, capability: string): boolean { + return Object.prototype.hasOwnProperty.call(session.capabilities, capability); +} + +/** Per-ACCOUNT capability, mirroring client.ts's supportsFiles() (#563: a server can advertise it while an account has it revoked). */ +export function accountHasCapability( + session: JmapSessionInfo, + accountId: string, + capability: string, +): boolean { + const account = session.accounts[accountId]; + if (!account) return false; + if (account.accountCapabilities && Object.prototype.hasOwnProperty.call(account.accountCapabilities, capability)) { + return true; + } + return account.isPersonal === false; +} + +// ── mail ──────────────────────────────────────────────────────────────────── + +/** Properties needed to build a mail IndexDoc. Bodies come via bodyValues. */ +const EMAIL_INDEX_PROPERTIES = [ + 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', + 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', + 'textBody', 'htmlBody', 'bodyValues', +] as const; + +export async function getEmailsForIndex( + session: JmapSessionInfo, + authHeader: string, + accountId: string, + ids: readonly string[], + maxBodyBytes: number, +): Promise { + if (ids.length === 0) return []; + const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_MAIL], [ + ['Email/get', { + accountId, + ids: [...ids], + properties: [...EMAIL_INDEX_PROPERTIES], + // Without these two the bodyValues map comes back EMPTY and every + // indexed body would silently fall back to `preview`. + fetchTextBodyValues: true, + fetchHTMLBodyValues: true, + maxBodyValueBytes: maxBodyBytes, + }, 'g'], + ]); + return listOf(firstResult(responses, 'Email/get')); +} + +export async function queryRecentEmailIds( + session: JmapSessionInfo, + authHeader: string, + accountId: string, + afterIso: string, + limit: number, +): Promise { + const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_MAIL], [ + ['Email/query', { + accountId, + filter: { after: afterIso }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit, + calculateTotal: false, + }, 'q'], + ]); + return idsOf(firstResult(responses, 'Email/query')); +} + +// ── calendar ──────────────────────────────────────────────────────────────── + +export async function getCalendarEventsForIndex( + session: JmapSessionInfo, + authHeader: string, + accountId: string, + ids: readonly string[], +): Promise { + if (ids.length === 0) return []; + const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CALENDARS], [ + ['CalendarEvent/get', { accountId, ids: [...ids] }, 'g'], + ]); + return listOf(firstResult(responses, 'CalendarEvent/get')); +} + +export async function queryCalendarEventIds( + session: JmapSessionInfo, + authHeader: string, + accountId: string, + afterIso: string, + beforeIso: string, + limit: number, +): Promise { + const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CALENDARS], [ + ['CalendarEvent/query', { + accountId, + // LocalDateTime, per the note in lib/jmap/client.ts:307-312 - Stalwart + // parses these without a zone suffix and ignores unparseable values. + filter: { after: toLocalDateTime(afterIso), before: toLocalDateTime(beforeIso) }, + limit, + calculateTotal: false, + }, 'q'], + ]); + return idsOf(firstResult(responses, 'CalendarEvent/query')); +} + +/** JSCalendar LocalDateTime: `YYYY-MM-DDTHH:MM:SS`, no zone designator. */ +function toLocalDateTime(iso: string): string { + return iso.replace(/\.\d+/, '').replace(/Z$/, '').slice(0, 19); +} + +// ── contacts ──────────────────────────────────────────────────────────────── + +export async function getContactsForIndex( + session: JmapSessionInfo, + authHeader: string, + accountId: string, + ids: readonly string[], +): Promise { + if (ids.length === 0) return []; + const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CONTACTS], [ + ['ContactCard/get', { accountId, ids: [...ids] }, 'g'], + ]); + return listOf(firstResult(responses, 'ContactCard/get')); +} + +export async function queryContactIds( + session: JmapSessionInfo, + authHeader: string, + accountId: string, + limit: number, +): Promise { + const responses = await jmapRequest(session, authHeader, [CAP_CORE, CAP_CONTACTS], [ + ['ContactCard/query', { accountId, limit, calculateTotal: false }, 'q'], + ]); + return idsOf(firstResult(responses, 'ContactCard/query')); +} + +// ── files ─────────────────────────────────────────────────────────────────── + +const FILENODE_INDEX_PROPERTIES = [ + 'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified', +] as const; + +export async function getFilesForIndex( + session: JmapSessionInfo, + authHeader: string, + accountId: string, + ids: readonly string[], +): Promise { + if (ids.length === 0) return []; + const responses = await jmapRequest(session, authHeader, [CAP_CORE], [ + ['FileNode/get', { accountId, ids: [...ids], properties: [...FILENODE_INDEX_PROPERTIES] }, 'g'], + ]); + return listOf(firstResult(responses, 'FileNode/get')); +} + +export async function queryFileIds( + session: JmapSessionInfo, + authHeader: string, + accountId: string, + limit: number, +): Promise { + const responses = await jmapRequest(session, authHeader, [CAP_CORE], [ + ['FileNode/query', { accountId, filter: {}, limit, calculateTotal: false }, 'q'], + ]); + return idsOf(firstResult(responses, 'FileNode/query')); +} + +/** + * Builds `id -> "Parent/Child"` paths for the given nodes, walking `parentId` + * upward. FileNode only knows its parent, so the caller has to assemble this; + * unresolvable ancestors just truncate the path rather than failing. + */ +export function buildFilePaths(nodes: readonly FileNode[]): Map { + const byId = new Map(nodes.map((n) => [n.id, n])); + const cache = new Map(); + + const resolve = (id: string, depth: number): string => { + if (depth > 32) return ''; + const cached = cache.get(id); + if (cached !== undefined) return cached; + const node = byId.get(id); + if (!node) return ''; + const parent = node.parentId ? resolve(node.parentId, depth + 1) : ''; + const full = parent ? `${parent}/${node.name}` : node.name; + cache.set(id, full); + return full; + }; + + const out = new Map(); + for (const n of nodes) { + // The document's own `path` metadata is its PARENT directory chain, so a + // search for "Invoices" matches files inside it without the filename + // being duplicated into the body. + out.set(n.id, n.parentId ? resolve(n.parentId, 0) : ''); + } + return out; +} diff --git a/lib/mail-index/key.ts b/lib/mail-index/key.ts new file mode 100644 index 00000000..35c1318b --- /dev/null +++ b/lib/mail-index/key.ts @@ -0,0 +1,174 @@ +// Server-side client for the main process's key service (electron/key-service.ts). +// +// Asks for an account's index key over the inherited fd only when a job needs +// it, and drops it as soon as the job finishes. There is deliberately no cache: +// a resident plaintext key in a long-lived process is exactly the thing the OS +// keychain exists to avoid, and a keychain round trip costs microseconds +// against a job that makes network calls. + +import net from 'node:net'; + +/** Set by electron/main.ts alongside VNCMAIL_DESKTOP_STORE_DIR. */ +export const KEY_FD_ENV = 'VNCMAIL_DESKTOP_KEY_FD'; + +const REQUEST_TIMEOUT_MS = 10_000; + +export type KeyErrorCode = + | 'no-channel' + | 'no-secure-storage' + | 'key-io-failed' + | 'key-unreadable' + | 'bad-request' + | 'timeout'; + +export class IndexKeyError extends Error { + code: KeyErrorCode; + constructor(code: KeyErrorCode, message: string) { + super(message); + this.name = 'IndexKeyError'; + this.code = code; + } +} + +interface Pending { + resolve: (value: { key?: string }) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +let socket: net.Socket | null = null; +let nextId = 1; +const pending = new Map(); +let readBuffer = ''; + +function failAll(error: Error): void { + for (const [, p] of pending) { + clearTimeout(p.timer); + p.reject(error); + } + pending.clear(); +} + +function getSocket(): net.Socket { + if (socket && !socket.destroyed) return socket; + + const raw = process.env[KEY_FD_ENV]?.trim(); + const fd = raw ? Number(raw) : NaN; + if (!Number.isInteger(fd) || fd < 3) { + throw new IndexKeyError( + 'no-channel', + `${KEY_FD_ENV} is not a usable file descriptor (got ${JSON.stringify(raw)}). ` + + `The local index only works inside the Electron desktop shell.`, + ); + } + + let created: net.Socket; + try { + created = new net.Socket({ fd, readable: true, writable: true }); + } catch (error) { + throw new IndexKeyError('no-channel', `Could not open fd ${fd}: ${String(error)}`); + } + // The channel outlives every individual request; don't let it hold the event + // loop open on its own. + created.unref(); + + created.on('data', (chunk: Buffer) => { + readBuffer += chunk.toString('utf8'); + if (readBuffer.length > 64 * 1024) readBuffer = ''; + let newline: number; + while ((newline = readBuffer.indexOf('\n')) >= 0) { + const line = readBuffer.slice(0, newline); + readBuffer = readBuffer.slice(newline + 1); + if (!line.trim()) continue; + let msg: { id?: unknown; ok?: unknown; key?: unknown; code?: unknown; error?: unknown }; + try { + msg = JSON.parse(line); + } catch { + continue; + } + const id = typeof msg.id === 'number' ? msg.id : null; + if (id === null) continue; + const p = pending.get(id); + if (!p) continue; + pending.delete(id); + clearTimeout(p.timer); + if (msg.ok === true) { + p.resolve({ key: typeof msg.key === 'string' ? msg.key : undefined }); + } else { + const code = typeof msg.code === 'string' ? (msg.code as KeyErrorCode) : 'key-io-failed'; + p.reject(new IndexKeyError(code, typeof msg.error === 'string' ? msg.error : 'Key request failed')); + } + } + }); + + const onGone = (error?: Error) => { + socket = null; + readBuffer = ''; + failAll(error ?? new IndexKeyError('no-channel', 'Key service channel closed')); + }; + created.on('close', () => onGone()); + created.on('error', (error) => onGone(new IndexKeyError('no-channel', String(error)))); + + socket = created; + return created; +} + +function request(op: 'getIndexKey' | 'deleteIndexKey', accountId: string): Promise<{ key?: string }> { + const sock = getSocket(); + const id = nextId++; + return new Promise<{ key?: string }>((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new IndexKeyError('timeout', `Key service did not answer within ${REQUEST_TIMEOUT_MS}ms`)); + }, REQUEST_TIMEOUT_MS); + // Don't let a pending key request keep the process alive either. + timer.unref?.(); + pending.set(id, { resolve, reject, timer }); + try { + sock.write(`${JSON.stringify({ id, op, accountId })}\n`); + } catch (error) { + pending.delete(id); + clearTimeout(timer); + reject(new IndexKeyError('no-channel', `Could not write to the key service: ${String(error)}`)); + } + }); +} + +/** + * Runs `fn` with the account's raw index key, then zeroes the buffer. + * + * Zeroing a Buffer is genuine (unlike a JS string, which cannot be scrubbed) - + * which is why the key crosses the boundary as hex and is converted to a Buffer + * exactly once, here. `store.ts` puts the hex into a `PRAGMA` string, so a copy + * does briefly exist in the JS heap; the buffer wipe bounds how long the + * long-lived copy lives, it does not pretend to eliminate every trace. + */ +export async function withIndexKey( + accountId: string, + fn: (key: Buffer) => Promise | T, +): Promise { + const { key: hex } = await request('getIndexKey', accountId); + if (!hex) throw new IndexKeyError('key-io-failed', 'Key service returned no key'); + const key = Buffer.from(hex, 'hex'); + if (key.length !== 32) { + key.fill(0); + throw new IndexKeyError('key-io-failed', `Key service returned ${key.length} bytes, expected 32`); + } + try { + return await fn(key); + } finally { + key.fill(0); + } +} + +/** Used when purging an account: the key goes FIRST, so an interrupted purge leaves unreadable data. */ +export async function deleteIndexKey(accountId: string): Promise { + await request('deleteIndexKey', accountId); +} + +/** True when this process has a key channel at all (i.e. is the desktop shell's server). */ +export function hasKeyChannel(): boolean { + const raw = process.env[KEY_FD_ENV]?.trim(); + const fd = raw ? Number(raw) : NaN; + return Number.isInteger(fd) && fd >= 3; +} diff --git a/lib/mail-index/paths.ts b/lib/mail-index/paths.ts new file mode 100644 index 00000000..20cc427b --- /dev/null +++ b/lib/mail-index/paths.ts @@ -0,0 +1,51 @@ +// The hosted-deployment gate, and where an account's index file lives. +// +// The standalone Next.js server in `electron/main.ts` is the SAME artifact the +// production `Dockerfile` ships to multi-tenant deployments. An index that +// activated unconditionally would have a shared server start writing every +// user's mail into a server-side SQLite file. So activation is keyed on an env +// var that ONLY `electron/main.ts` sets, and that same var supplies the path - +// one variable doing both jobs, so they cannot drift apart. + +import { createHash } from 'node:crypto'; +import path from 'node:path'; + +/** Set by electron/main.ts on spawn. Absent => the feature does not exist. */ +export const STORE_DIR_ENV = 'VNCMAIL_DESKTOP_STORE_DIR'; + +/** + * The index root, or `null` when this process is not the desktop shell's + * server. Every route must 404 on `null` - not 403, since nothing should learn + * the routes exist in a deployment that doesn't have the feature. + */ +export function getStoreDir(): string | null { + const dir = process.env[STORE_DIR_ENV]?.trim(); + if (!dir) return null; + // Must be absolute: a relative path would resolve against the server's cwd, + // which differs between `electron:dev` and a packaged build. + if (!path.isAbsolute(dir)) return null; + return dir; +} + +/** + * Filenames are a hash, not `username@host`, so a directory listing is not a + * plaintext inventory of the user's accounts. The account id itself lives only + * inside the encrypted file (and in the renderer's own `account-registry`, + * which already stores it in plain localStorage). + */ +export function accountFileToken(accountId: string): string { + return createHash('sha256').update(accountId, 'utf8').digest('hex').slice(0, 32); +} + +export function indexDbPath(storeDir: string, accountId: string): string { + return path.join(storeDir, 'index', `${accountFileToken(accountId)}.db`); +} + +export function keyFilePath(storeDir: string, accountId: string): string { + return path.join(storeDir, 'keys', `${accountFileToken(accountId)}.bin`); +} + +/** WAL siblings must be removed with the database, or a purge leaks readable pages. */ +export function dbSiblings(dbPath: string): string[] { + return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]; +} diff --git a/lib/mail-index/reindex.ts b/lib/mail-index/reindex.ts new file mode 100644 index 00000000..f5a5c4b4 --- /dev/null +++ b/lib/mail-index/reindex.ts @@ -0,0 +1,332 @@ +// The index jobs. +// +// TWO SHAPES, both plain request-scoped work - there is no background worker, +// no cursor, no retry ladder and no resident credential anywhere: +// +// 1. `indexDocuments()` - the PRIMARY path. The renderer's live JMAP push +// connection sees a StateChange, and calls the route with the ids that +// changed (or with no ids, meaning "refetch what's recent for this type"). +// One or a handful of objects, fetched and upserted. +// 2. `catchUpAll()` - the FALLBACK. On app launch, backfill a bounded recent +// window for every supported type, because anything that changed while the +// app was closed produced no push event. +// +// Staleness between refreshes is acceptable by design: this is a search index +// for a retrieval/AI feature, not a mail replica. + +import type { NextRequest } from 'next/server'; +import { generateAccountId } from '@/lib/account-utils'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { logger } from '@/lib/logger'; +import { + accountHasCapability, accountIdFor, buildFilePaths, CAP_CALENDARS, CAP_CONTACTS, + CAP_FILENODE, CAP_MAIL, fetchJmapSession, getCalendarEventsForIndex, getContactsForIndex, + getEmailsForIndex, getFilesForIndex, hasCapability, JmapIndexError, queryCalendarEventIds, + queryContactIds, queryFileIds, queryRecentEmailIds, type JmapSessionInfo, +} from './jmap'; +import { extractCalendarEvent, extractContact, extractFile, extractMail } from './extract'; +import { withIndexKey } from './key'; +import { getStoreDir } from './paths'; +import { MailIndex, type ContentType, type IndexDoc } from './store'; + +/** + * Bounded window. Small on purpose: this is the first cut of a retrieval index, + * and a wide window turns "index on every delivery" into a slow request. The + * event-driven path indexes single objects, so the window only bounds catch-up. + */ +export const INDEX_WINDOW_DAYS = 30; +/** Calendar looks forward as well as back - upcoming events are the useful ones. */ +export const CALENDAR_FORWARD_DAYS = 180; +/** Per-type ceiling for one catch-up pass. */ +export const CATCHUP_MAX_PER_TYPE = 500; +/** Ids accepted in one event-driven call. A push reports a handful, not thousands. */ +export const MAX_IDS_PER_CALL = 200; +/** Cap on body bytes requested per message from the server. */ +export const MAX_BODY_VALUE_BYTES = 256_000; +/** Contacts and files have no useful date filter, so they are simply capped. */ +export const CONTACTS_MAX = 2_000; +export const FILES_MAX = 2_000; + +export interface IndexSession { + serverUrl: string; + authHeader: string; + username: string; + slot: number; + /** `username@host` - the durable per-account key. NEVER the cookie slot. */ + accountId: string; +} + +export class IndexSessionError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'IndexSessionError'; + this.status = status; + } +} + +/** + * Resolves the calling request to an account and a usable Authorization header. + * + * Uses the SAME per-slot encrypted `jmap_stalwart_ctx` cookie that + * `/api/settings`, `/api/push/preview` and `/api/plugin-approval-status` + * already read (`lib/stalwart/credentials.ts`). That cookie is written by + * `/api/auth/stalwart-context`, which the renderer syncs on every login, + * session restore, SSO callback, account switch and token refresh + * (`stores/auth-store.ts`, 10 call sites), and it carries a ready-made header + * for BOTH basic and bearer accounts. + * + * Why this matters beyond convenience: it means the indexer never touches the + * OAuth refresh-token cookie. A server-side refresh would rotate the token into + * a response nobody reads while the browser kept the superseded one, and the + * next real refresh would then fail and log the user out. Reading an + * already-minted header cannot cause that. + */ +export async function resolveIndexSession(request: NextRequest): Promise { + const credentials = await getStalwartCredentials(request); + if (!credentials) { + throw new IndexSessionError('No JMAP auth context for this account; sign in again.', 401); + } + const accountId = generateAccountId(credentials.username, credentials.serverUrl); + return { ...credentials, accountId }; +} + +export interface IndexResult { + accountId: string; + /** Per-type counts of documents written. */ + written: Partial>; + /** Types the server (or this account) doesn't support, so nothing was attempted. */ + skipped: ContentType[]; + /** Non-fatal per-type failures. One broken type must not fail the whole call. */ + errors: Array<{ contentType: ContentType; message: string }>; + durationMs: number; +} + +function isoDaysFromNow(days: number): string { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); +} + +/** + * Which types this session can actually index. Calendar/contacts are session + * capabilities; files is a PER-ACCOUNT capability (a server can advertise + * filenode while a specific account has it revoked - #563). + */ +export function supportedTypes(session: JmapSessionInfo): { + supported: ContentType[]; + skipped: ContentType[]; + accountIds: Partial>; +} { + const supported: ContentType[] = []; + const skipped: ContentType[] = []; + const accountIds: Partial> = {}; + + const mailAccount = accountIdFor(session, CAP_MAIL); + if (mailAccount) { supported.push('mail'); accountIds.mail = mailAccount; } + else skipped.push('mail'); + + const calAccount = accountIdFor(session, CAP_CALENDARS); + if (calAccount && hasCapability(session, CAP_CALENDARS)) { + supported.push('calendar'); accountIds.calendar = calAccount; + } else skipped.push('calendar'); + + const contactAccount = accountIdFor(session, CAP_CONTACTS); + if (contactAccount && hasCapability(session, CAP_CONTACTS)) { + supported.push('contact'); accountIds.contact = contactAccount; + } else skipped.push('contact'); + + // Files fall back to the mail account id: Stalwart exposes FileNode on the + // same account and does not always list a primaryAccounts entry for it. + const fileAccount = accountIdFor(session, CAP_FILENODE) ?? mailAccount; + if (fileAccount && accountHasCapability(session, fileAccount, CAP_FILENODE)) { + supported.push('file'); accountIds.file = fileAccount; + } else skipped.push('file'); + + return { supported, skipped, accountIds }; +} + +interface FetchArgs { + session: JmapSessionInfo; + authHeader: string; + jmapAccountId: string; + ids: readonly string[] | null; +} + +/** Fetches and flattens one content type. `ids === null` means "the recent window". */ +async function fetchDocs(contentType: ContentType, args: FetchArgs): Promise { + const { session, authHeader, jmapAccountId, ids } = args; + + switch (contentType) { + case 'mail': { + const targetIds = ids ?? await queryRecentEmailIds( + session, authHeader, jmapAccountId, + isoDaysFromNow(-INDEX_WINDOW_DAYS), CATCHUP_MAX_PER_TYPE, + ); + const docs: IndexDoc[] = []; + // Chunked because bodies are big: one Email/get for 500 messages with + // full bodies would be an enormous response. + for (let i = 0; i < targetIds.length; i += 25) { + const emails = await getEmailsForIndex( + session, authHeader, jmapAccountId, targetIds.slice(i, i + 25), MAX_BODY_VALUE_BYTES, + ); + for (const email of emails) docs.push(extractMail(jmapAccountId, email)); + } + return docs; + } + case 'calendar': { + const targetIds = ids ?? await queryCalendarEventIds( + session, authHeader, jmapAccountId, + isoDaysFromNow(-INDEX_WINDOW_DAYS), isoDaysFromNow(CALENDAR_FORWARD_DAYS), + CATCHUP_MAX_PER_TYPE, + ); + const docs: IndexDoc[] = []; + for (let i = 0; i < targetIds.length; i += 50) { + const events = await getCalendarEventsForIndex( + session, authHeader, jmapAccountId, targetIds.slice(i, i + 50), + ); + for (const event of events) docs.push(extractCalendarEvent(jmapAccountId, event)); + } + return docs; + } + case 'contact': { + const targetIds = ids ?? await queryContactIds(session, authHeader, jmapAccountId, CONTACTS_MAX); + const docs: IndexDoc[] = []; + for (let i = 0; i < targetIds.length; i += 100) { + const cards = await getContactsForIndex( + session, authHeader, jmapAccountId, targetIds.slice(i, i + 100), + ); + for (const card of cards) docs.push(extractContact(jmapAccountId, card)); + } + return docs; + } + case 'file': { + const targetIds = ids ?? await queryFileIds(session, authHeader, jmapAccountId, FILES_MAX); + const nodes = []; + for (let i = 0; i < targetIds.length; i += 100) { + nodes.push(...await getFilesForIndex( + session, authHeader, jmapAccountId, targetIds.slice(i, i + 100), + )); + } + // Paths need the whole set in hand, so this one can't stream per chunk. + const paths = buildFilePaths(nodes); + return nodes + // Directories are indexed too: "what's in the Invoices folder" is a + // real query, and a folder row is a few bytes. + .map((node) => extractFile(jmapAccountId, node, { path: paths.get(node.id) })); + } + } +} + +export interface IndexRequest { + /** Types to touch. Empty means every supported type. */ + types?: readonly ContentType[]; + /** + * Per-type ids to index. Omitted/empty for a type means "refetch that type's + * recent window" (the catch-up shape). + */ + ids?: Partial>; + /** Per-type ids to REMOVE (a JMAP `destroyed`). */ + removed?: Partial>; + /** Drop documents outside the retention window after writing. */ + prune?: boolean; +} + +/** + * Runs one index pass. Opens the encrypted store, fetches, upserts, closes. + * + * The key is fetched from the main process for the duration of this call only + * (`withIndexKey`) and zeroed afterwards - there is no cached handle and no + * resident key. + */ +export async function runIndex( + indexSession: IndexSession, + req: IndexRequest, +): Promise { + const started = Date.now(); + const storeDir = getStoreDir(); + if (!storeDir) { + throw new IndexSessionError('The local index is not enabled in this deployment.', 404); + } + + const session = await fetchJmapSession(indexSession.serverUrl, indexSession.authHeader); + + // Identity cross-check. `generateAccountId` used the username from the auth + // context cookie; the server may canonicalise a short login (`linus`) to a + // full address (`linus@example.com`) - which is exactly why AccountEntry + // carries `serverIdentifiers`. Accept either form, reject anything else + // rather than writing one account's mail into another's file. + if (session.username) { + const serverAccountId = generateAccountId(session.username, indexSession.serverUrl); + if (serverAccountId !== indexSession.accountId) { + const shortMatches = session.username.split('@')[0] === indexSession.username.split('@')[0]; + if (!shortMatches) { + throw new IndexSessionError( + 'The JMAP session belongs to a different account than the request cookie.', + 409, + ); + } + } + } + + const { supported, skipped, accountIds } = supportedTypes(session); + const requested = req.types && req.types.length > 0 ? req.types : supported; + const types = requested.filter((t) => supported.includes(t)); + const notAttempted = [...new Set([...skipped, ...requested.filter((t) => !supported.includes(t))])]; + + const written: Partial> = {}; + const errors: IndexResult['errors'] = []; + + await withIndexKey(indexSession.accountId, async (key) => { + const index = MailIndex.open({ storeDir, accountId: indexSession.accountId, key }); + try { + for (const contentType of types) { + const jmapAccountId = accountIds[contentType]; + if (!jmapAccountId) continue; + try { + const removed = req.removed?.[contentType]; + if (removed && removed.length > 0) { + index.remove(jmapAccountId, contentType, removed.slice(0, MAX_IDS_PER_CALL)); + } + + const requestedIds = req.ids?.[contentType]; + const ids = requestedIds && requestedIds.length > 0 + ? requestedIds.slice(0, MAX_IDS_PER_CALL) + : null; + + const docs = await fetchDocs(contentType, { + session, authHeader: indexSession.authHeader, jmapAccountId, ids, + }); + written[contentType] = index.upsert(docs); + + if (req.prune && contentType === 'mail') { + // Only mail prunes by date: calendar's window looks forward, + // contacts have no date, and file rows are metadata-sized. + index.pruneOlderThan(jmapAccountId, 'mail', isoDaysFromNow(-INDEX_WINDOW_DAYS)); + } + } catch (error) { + // One unsupported or misbehaving type must not fail the others. + const message = error instanceof Error ? error.message : String(error); + errors.push({ contentType, message }); + if (error instanceof JmapIndexError && error.status === 401) throw error; + } + } + } finally { + index.close(); + } + }); + + const result: IndexResult = { + accountId: indexSession.accountId, + written, + skipped: notAttempted, + errors, + durationMs: Date.now() - started, + }; + logger.info('mail-index: pass complete', { + slot: indexSession.slot, + written: JSON.stringify(written), + skipped: notAttempted.join(',') || 'none', + errors: errors.length, + durationMs: result.durationMs, + }); + return result; +} diff --git a/lib/mail-index/store.ts b/lib/mail-index/store.ts new file mode 100644 index 00000000..4e89793d --- /dev/null +++ b/lib/mail-index/store.ts @@ -0,0 +1,444 @@ +// The encrypted local search index: schema, open/close, upsert, search. +// +// One SQLite (SQLCipher) file per account. Rows are ALSO account-scoped +// internally - `(jmap_account_id, content_type, id)` - because a single login +// exposes the user's own JMAP account plus every delegated/shared account, and +// JMAP ids are unique only WITHIN an account (this codebase already works +// around that collision in `lib/jmap/client.ts:388`'s namespaceMailboxIds). +// One file per account keeps purge trivial; the composite key keeps +// delegated accounts from merging inside it. +// +// This is a SEARCH INDEX, not a mail replica. It is allowed to be stale, it is +// allowed to be incomplete, and it can be discarded and rebuilt at any time - +// which is why the schema-version mismatch path below simply drops everything +// rather than migrating. + +import fs from 'node:fs'; +import path from 'node:path'; +import { loadSqlcipher, type SqlcipherDatabase } from './binding'; +import { dbSiblings, indexDbPath } from './paths'; + +export const SCHEMA_VERSION = 1; + +export type ContentType = 'mail' | 'calendar' | 'contact' | 'file'; + +export const CONTENT_TYPES: readonly ContentType[] = ['mail', 'calendar', 'contact', 'file']; + +export function isContentType(v: unknown): v is ContentType { + return typeof v === 'string' && (CONTENT_TYPES as readonly string[]).includes(v); +} + +/** + * One indexable thing, already flattened to text. Produced by the pure + * extractors in `extract.ts` so that every JMAP-shape decision is unit-testable + * without a database or a server. + */ +export interface IndexDoc { + jmapAccountId: string; + contentType: ContentType; + /** JMAP id. Unique only within (jmapAccountId, contentType). */ + id: string; + /** Subject / event title / contact display name / filename. */ + title: string; + /** Addresses and names: sender+recipients, attendees, contact emails/phones, owner. */ + people: string; + /** The bulk searchable text. Plain text only - never HTML. */ + body: string; + /** ISO 8601, or null when the type has no meaningful date. Drives recency ordering. */ + occurredAt: string | null; + /** Small type-specific extras returned verbatim to the caller (never searched). */ + metadata: Record; +} + +export interface SearchHit { + contentType: ContentType; + id: string; + jmapAccountId: string; + title: string; + people: string; + occurredAt: string | null; + metadata: Record; + /** FTS5 bm25 score. Lower is a better match (bm25 returns negative values). */ + score: number; + /** Highlighted excerpt from the body, for feeding an LLM as context. */ + snippet: string; +} + +const DDL = ` +CREATE TABLE IF NOT EXISTS doc ( + jmap_account_id TEXT NOT NULL, + content_type TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + people TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + occurred_at TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + indexed_at INTEGER NOT NULL, + PRIMARY KEY (jmap_account_id, content_type, id) +); +CREATE INDEX IF NOT EXISTS doc_recent + ON doc(jmap_account_id, content_type, occurred_at DESC); + +-- Standalone (not external-content) FTS5: the text is duplicated into this +-- table and kept in step manually on upsert. External content would avoid the +-- duplication but requires deleting the old FTS row using its OLD column +-- values, which an upsert does not have to hand - a well-known source of +-- silently-stale FTS rows. At this scale (a bounded recent window) the +-- duplication is the cheaper correctness trade. +CREATE VIRTUAL TABLE IF NOT EXISTS doc_fts USING fts5( + title, people, body, + tokenize='unicode61 remove_diacritics 2' +); + +CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL); +`; + +export class MailIndexUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'MailIndexUnavailableError'; + } +} + +/** + * Assert that the file we just opened is REALLY encrypted. + * + * This is not defensive boilerplate, it guards the sharpest landmine found + * while designing this: on both `node:sqlite` and plain `better-sqlite3`, + * `PRAGMA key = ...` is **silently accepted and does nothing** - no error, a + * working database, and the mail sitting on disk in cleartext. Verified by + * writing a file and recovering a canary string from the raw bytes. + * + * The check is on the VALUE, not the row count: a non-cipher binding returns + * ZERO ROWS for `PRAGMA cipher_version`, so a naive `!== ''` comparison over a + * missing row passes vacuously. Require a non-empty string. + */ +function assertEncrypted(db: SqlcipherDatabase, dbPath: string): void { + const rows = db.pragma('cipher_version'); + const value = + Array.isArray(rows) && rows.length > 0 && rows[0] && typeof rows[0] === 'object' + ? (rows[0] as Record).cipher_version + : undefined; + if (typeof value !== 'string' || value.trim().length === 0) { + db.close(); + throw new MailIndexUnavailableError( + `Refusing to use ${path.basename(dbPath)}: the SQLite binding reports no SQLCipher ` + + `support (PRAGMA cipher_version returned ${JSON.stringify(rows)}), so the index ` + + `would be written in cleartext.`, + ); + } +} + +export interface OpenOptions { + storeDir: string; + accountId: string; + /** Raw 32-byte key. Used as SQLCipher's raw key (no KDF) via `PRAGMA key = "x'..'"`. */ + key: Buffer; +} + +export class MailIndex { + private constructor( + private readonly db: SqlcipherDatabase, + readonly dbPath: string, + ) {} + + /** + * Opens (creating if needed) the account's index. Throws + * MailIndexUnavailableError when the native binding is absent or the file is + * not actually encrypted; the caller turns the feature off rather than + * falling back to something unencrypted. + */ + static open({ storeDir, accountId, key }: OpenOptions): MailIndex { + const Database = loadSqlcipher(); + if (!Database) { + throw new MailIndexUnavailableError( + '@signalapp/sqlcipher is not installed for this platform (it is an optional dependency).', + ); + } + if (key.length !== 32) { + throw new MailIndexUnavailableError(`Index key must be 32 bytes, got ${key.length}.`); + } + + const dbPath = indexDbPath(storeDir, accountId); + fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 }); + + let db = new Database(dbPath); + // The key pragma must be the FIRST statement on the connection. Hex form + // means SQLCipher uses these 32 bytes as the raw key with no KDF, which is + // right for a random key (a passphrase would want the KDF). + db.pragma(`key = "x'${key.toString('hex')}'"`); + assertEncrypted(db, dbPath); + + // A wrong key surfaces here rather than at open: SQLCipher only reads the + // header lazily. Treat it as "unreadable" and rebuild from scratch - the + // index is derived data, so there is nothing to recover and never anything + // to prompt the user for (the key was never a user secret). + let version: number | null; + try { + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + version = readSchemaVersion(db); + } catch { + db.close(); + for (const f of dbSiblings(dbPath)) { + try { fs.rmSync(f, { force: true }); } catch { /* best effort */ } + } + db = new Database(dbPath); + db.pragma(`key = "x'${key.toString('hex')}'"`); + assertEncrypted(db, dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + version = null; + } + + if (version !== null && version !== SCHEMA_VERSION) { + // Rebuildable derived data: drop, don't migrate. + db.exec('DROP TABLE IF EXISTS doc_fts; DROP TABLE IF EXISTS doc; DROP TABLE IF EXISTS meta;'); + version = null; + } + if (version === null) { + db.exec(DDL); + db.prepare('INSERT OR REPLACE INTO meta (k, v) VALUES (?, ?)').run([ + 'schema_version', + String(SCHEMA_VERSION), + ]); + } + + return new MailIndex(db, dbPath); + } + + close(): void { + try { this.db.close(); } catch { /* already closed */ } + } + + /** + * Upserts documents and keeps the FTS rows in step. Returns the number of + * rows written. One transaction for the whole batch - a partially-applied + * batch is harmless (it is an index) but a transaction is faster. + */ + upsert(docs: readonly IndexDoc[]): number { + if (docs.length === 0) return 0; + + const upsertDoc = this.db.prepare(` + INSERT INTO doc (jmap_account_id, content_type, id, title, people, body, + occurred_at, metadata_json, indexed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(jmap_account_id, content_type, id) DO UPDATE SET + title = excluded.title, people = excluded.people, body = excluded.body, + occurred_at = excluded.occurred_at, metadata_json = excluded.metadata_json, + indexed_at = excluded.indexed_at + RETURNING rowid + `); + const deleteFts = this.db.prepare('DELETE FROM doc_fts WHERE rowid = ?'); + const insertFts = this.db.prepare( + 'INSERT INTO doc_fts (rowid, title, people, body) VALUES (?, ?, ?, ?)', + ); + + const now = Date.now(); + let written = 0; + this.db.exec('BEGIN'); + try { + for (const d of docs) { + const row = upsertDoc.get([ + d.jmapAccountId, d.contentType, d.id, + d.title, d.people, d.body, + d.occurredAt, JSON.stringify(d.metadata ?? {}), now, + ]); + const rowid = row?.rowid; + if (typeof rowid !== 'number') continue; + // ON CONFLICT preserves the rowid, so delete-then-insert replaces the + // old FTS row rather than accumulating duplicates for one document. + deleteFts.run([rowid]); + insertFts.run([rowid, d.title, d.people, d.body]); + written++; + } + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + return written; + } + + /** Removes documents by id (a JMAP `destroyed` id, or a stale row). */ + remove(jmapAccountId: string, contentType: ContentType, ids: readonly string[]): number { + if (ids.length === 0) return 0; + const findRow = this.db.prepare( + 'SELECT rowid FROM doc WHERE jmap_account_id = ? AND content_type = ? AND id = ?', + ); + const deleteFts = this.db.prepare('DELETE FROM doc_fts WHERE rowid = ?'); + const deleteDoc = this.db.prepare( + 'DELETE FROM doc WHERE jmap_account_id = ? AND content_type = ? AND id = ?', + ); + let removed = 0; + this.db.exec('BEGIN'); + try { + for (const id of ids) { + const row = findRow.get([jmapAccountId, contentType, id]); + if (typeof row?.rowid === 'number') deleteFts.run([row.rowid]); + removed += deleteDoc.run([jmapAccountId, contentType, id]).changes; + } + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + return removed; + } + + /** + * Full-text search - the retrieval surface an AI feature calls to gather + * context. `types` empty/omitted searches everything. + */ + search(opts: { + query: string; + types?: readonly ContentType[]; + limit?: number; + snippetTokens?: number; + }): SearchHit[] { + const match = toFtsMatchQuery(opts.query); + if (!match) return []; + + const limit = Math.min(Math.max(opts.limit ?? 20, 1), 200); + const tokens = Math.min(Math.max(opts.snippetTokens ?? 24, 4), 64); + const types = opts.types && opts.types.length > 0 ? opts.types : null; + const typeFilter = types ? ` AND d.content_type IN (${types.map(() => '?').join(',')})` : ''; + + // bm25 weights: a hit in the title or in a name/address is a stronger + // signal than one in a long body, and for RAG the title is what makes a + // retrieved chunk recognisable. + const rows = this.db + .prepare(` + SELECT d.content_type, d.id, d.jmap_account_id, d.title, d.people, + d.occurred_at, d.metadata_json, + bm25(doc_fts, 8.0, 4.0, 1.0) AS score, + snippet(doc_fts, 2, '[', ']', '…', ${tokens}) AS snip + FROM doc_fts + JOIN doc d ON d.rowid = doc_fts.rowid + WHERE doc_fts MATCH ?${typeFilter} + ORDER BY score ASC, d.occurred_at DESC + LIMIT ? + `) + .all([match, ...(types ?? []), limit]); + + return rows.map((r) => ({ + contentType: String(r.content_type) as ContentType, + id: String(r.id), + jmapAccountId: String(r.jmap_account_id), + title: String(r.title ?? ''), + people: String(r.people ?? ''), + occurredAt: r.occurred_at === null || r.occurred_at === undefined ? null : String(r.occurred_at), + metadata: safeParseObject(r.metadata_json), + score: typeof r.score === 'number' ? r.score : 0, + snippet: String(r.snip ?? ''), + })); + } + + /** Per-type counts and freshness, for the Settings UI and for debugging. */ + stats(): Array<{ contentType: string; count: number; newest: string | null; indexedAt: number | null }> { + return this.db + .prepare(` + SELECT content_type, COUNT(*) AS n, MAX(occurred_at) AS newest, MAX(indexed_at) AS indexed + FROM doc GROUP BY content_type ORDER BY content_type + `) + .all() + .map((r) => ({ + contentType: String(r.content_type), + count: Number(r.n ?? 0), + newest: r.newest === null || r.newest === undefined ? null : String(r.newest), + indexedAt: typeof r.indexed === 'number' ? r.indexed : null, + })); + } + + /** Ids already present, so a catch-up pass can skip re-fetching bodies. */ + existingIds(jmapAccountId: string, contentType: ContentType): Set { + const rows = this.db + .prepare('SELECT id FROM doc WHERE jmap_account_id = ? AND content_type = ?') + .all([jmapAccountId, contentType]); + return new Set(rows.map((r) => String(r.id))); + } + + /** Drops documents older than the retention floor for a type. */ + pruneOlderThan(jmapAccountId: string, contentType: ContentType, isoFloor: string): number { + const rows = this.db + .prepare(` + SELECT rowid FROM doc + WHERE jmap_account_id = ? AND content_type = ? + AND occurred_at IS NOT NULL AND occurred_at < ? + `) + .all([jmapAccountId, contentType, isoFloor]); + if (rows.length === 0) return 0; + const deleteFts = this.db.prepare('DELETE FROM doc_fts WHERE rowid = ?'); + const deleteDoc = this.db.prepare('DELETE FROM doc WHERE rowid = ?'); + this.db.exec('BEGIN'); + try { + for (const r of rows) { + deleteFts.run([r.rowid]); + deleteDoc.run([r.rowid]); + } + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + return rows.length; + } +} + +function readSchemaVersion(db: SqlcipherDatabase): number | null { + try { + const row = db.prepare("SELECT v FROM meta WHERE k = 'schema_version'").get(); + if (!row || row.v === undefined) return null; + const n = Number(row.v); + return Number.isFinite(n) ? n : null; + } catch { + // `meta` doesn't exist yet - a fresh file. + return null; + } +} + +function safeParseObject(v: unknown): Record { + if (typeof v !== 'string') return {}; + try { + const parsed = JSON.parse(v); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +/** + * Turns arbitrary user text into a safe FTS5 MATCH expression. + * + * FTS5's query syntax is not SQL, so parameter binding does NOT protect it: a + * bare `"` or a stray `*`/`NEAR`/`:` in user input raises + * `fts5: syntax error`, which would turn a normal search box into a 500. Every + * token is quoted (making it a literal phrase) and a trailing `*` is added to + * the last token so typing continues to match as the user types. + * + * Exported for unit testing - it is the one piece of this file with no + * database dependency and the most ways to be wrong. + */ +export function toFtsMatchQuery(raw: string): string | null { + if (typeof raw !== 'string') return null; + // Split on anything that isn't a word character or an intra-word mark. Keeps + // unicode letters (so "Müller" and "東京" survive) via the u flag. + const tokens = raw + .normalize('NFC') + .split(/[^\p{L}\p{N}_@.'-]+/u) + .map((t) => t.replace(/^['-]+|['-]+$/g, '')) + .filter((t) => t.length > 0) + .slice(0, 24); + if (tokens.length === 0) return null; + return tokens + .map((t, i) => { + const quoted = `"${t.replace(/"/g, '""')}"`; + // Prefix-match only the final token, and only if it's long enough to not + // match half the mailbox. + return i === tokens.length - 1 && t.length >= 3 ? `${quoted}*` : quoted; + }) + .join(' AND '); +} diff --git a/next.config.ts b/next.config.ts index bfb6ec08..c578eebf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -50,7 +50,14 @@ const nextConfig: NextConfig = { // esbuild ships native binaries + a README the bundler can't parse; load // it from node_modules at runtime instead of trying to bundle it. Used by // PLUGIN_DEV_DIR's on-the-fly bundler. - serverExternalPackages: ["esbuild"], + // + // @signalapp/sqlcipher is a native N-API addon resolved at runtime by + // node-gyp-build (a directory scan of prebuilds/), which a bundler cannot + // follow. It is also an OPTIONAL dependency - absent on musl/Alpine, where + // both Dockerfiles build - so it must never be a hard build-time import. + // lib/mail-index/binding.ts guards the require; this keeps webpack from + // trying to resolve it at all. + serverExternalPackages: ["esbuild", "@signalapp/sqlcipher"], // Sibling repos checked out under ./repos/ are unrelated source trees that // Turbopack's NFT can otherwise rope into the trace when dynamic fs calls // confuse it. Keeps the build from ballooning memory tracing dead code. diff --git a/package-lock.json b/package-lock.json index 1f784acc..d976188c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,9 @@ "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "vitest": "^4.1.5" + }, + "optionalDependencies": { + "@signalapp/sqlcipher": "^4.0.3" } }, "node_modules/@acemir/cssom": { @@ -3372,6 +3375,18 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@signalapp/sqlcipher": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@signalapp/sqlcipher/-/sqlcipher-4.0.3.tgz", + "integrity": "sha512-Xp8H+pcOjBacqBh+ohE44gJUJIa/95JqBYWC70A08xhOcqogbnbvweu3gUmyKqNGnVehs7ukeSsuGO6QxdVTVw==", + "hasInstallScript": true, + "license": "AGPL-3.0-only", + "optional": true, + "dependencies": { + "node-addon-api": "*", + "node-gyp-build": "^4.8.4" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -9949,6 +9964,18 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", diff --git a/package.json b/package.json index acea95ea..2d5b0e16 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,9 @@ "webcrypto-liner": "^1.4.3", "zustand": "^5.0.12" }, + "optionalDependencies": { + "@signalapp/sqlcipher": "^4.0.3" + }, "devDependencies": { "@eslint/js": "^9.39.4", "@playwright/test": "^1.59.1", diff --git a/stores/email-store.ts b/stores/email-store.ts index 785a4966..f0b97c0e 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -2836,6 +2836,33 @@ export const useEmailStore = create((set, get) => ({ // Update last push update timestamp set({ lastPushUpdate: Date.now() }); + // Feed the desktop shell's encrypted local search index + // (lib/mail-index/**). This is the EVENT-DRIVEN trigger for indexing: the + // push connection is already type-generic (the WS/SSE handlers pass the + // whole `changed` map through, and the WS subscribes with + // dataTypes: null), so mail, calendar, contact and file changes all + // arrive here. Scheduled at the END of this handler, not here, so the + // mail ids it passes come from the ALREADY-REFRESHED list - reading them + // first would hand over the page as it was before the new message + // arrived, i.e. index everything except the delivery that triggered it. + const scheduleIndexUpdate = () => { + void (async () => { + try { + const { indexOnStateChange } = await import('@/lib/mail-index-client'); + const mailIds = get().emails.slice(0, 100).map((e) => e.id); + indexOnStateChange(change, { + // Empty (no mailbox selected yet, or a background account) means + // "no ids to offer" - the server then falls back to its own + // bounded recent-window query rather than indexing nothing. + mailIds: mailIds.length > 0 ? mailIds : undefined, + slot: useAccountStore.getState().getActiveAccount()?.cookieSlot, + }); + } catch { + /* the index is optional; never let it affect mail handling */ + } + })(); + }; + // Get the current account ID from the client (assuming primary account) const accountId = client.getAccountId(); @@ -2896,6 +2923,9 @@ export const useEmailStore = create((set, get) => ({ }); } } + + // Local search index last, with the refreshed ids (see above). + scheduleIndexUpdate(); } catch (error) { console.error('Failed to handle state change:', error); set({ From 7e9aefcfa1ae244635468e73a3a3c467cdc61717 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 23:20:12 +0200 Subject: [PATCH 18/21] test(mail-index): unit tests for the extractors, FTS query builder and store 48 assertions. The pure extractors and toFtsMatchQuery need no database; the store tests run against REAL SQLCipher and skip themselves when the optional native binding is absent (e.g. Alpine/musl), which is the same guard the runtime uses. The two that matter most: * "writes an ENCRYPTED file" reads the raw bytes back and asserts a canary string is absent. This is the assertion that catches `PRAGMA key` silently doing nothing - a plain-SQLite binding leaves the mailbox in cleartext with no error anywhere, so a functional test alone would pass. * "upserting the same id REPLACES the FTS row" - the FTS table is maintained by hand (standalone, not external-content), so a missed delete leaves the OLD body permanently searchable. The test asserts the old text stops matching, not just that the new text starts. Also covered: FTS5 MATCH injection (its grammar is not protected by SQL parameter binding, so a bare quote would 500 the search route), account-scoped keys not merging two accounts' identical JMAP ids, title-over-body bm25 weighting, and the hosted-deployment env gate rejecting a relative path. Note: lib/__tests__/builtin-themes.test.ts has 2 pre-existing failures on this branch (theme author "VNC" vs. expected "Built-in", from the earlier rebrand) - verified failing identically at b15098a6, before any of this work. Co-Authored-By: Claude Sonnet 5 --- lib/mail-index/__tests__/extract.test.ts | 283 +++++++++++++++++++++++ lib/mail-index/__tests__/store.test.ts | 261 +++++++++++++++++++++ 2 files changed, 544 insertions(+) create mode 100644 lib/mail-index/__tests__/extract.test.ts create mode 100644 lib/mail-index/__tests__/store.test.ts diff --git a/lib/mail-index/__tests__/extract.test.ts b/lib/mail-index/__tests__/extract.test.ts new file mode 100644 index 00000000..11317fed --- /dev/null +++ b/lib/mail-index/__tests__/extract.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from 'vitest'; +import type { + CalendarEvent, CalendarParticipant, ContactCard, Email, EmailBodyPart, FileNode, +} from '@/lib/jmap/types'; +import { + contactDisplayName, emailBodyText, extractCalendarEvent, extractContact, extractFile, + extractMail, htmlToText, MAX_BODY_CHARS, normaliseText, +} from '../extract'; +import { buildFilePaths } from '../jmap'; + +describe('htmlToText', () => { + it('drops script and style CONTENT, not just the tags', () => { + // The important case: a naive `<[^>]+>` strip leaves the script body behind + // as searchable text, so a page full of JS would pollute the index. + const out = htmlToText('

Hello

'); + expect(out).toContain('Hello'); + expect(out).not.toContain('secretToken'); + expect(out).not.toContain('abc123'); + expect(out).not.toContain('color:red'); + }); + + it('turns block boundaries into newlines and decodes entities', () => { + expect(htmlToText('

one

two

')).toBe('one\ntwo'); + expect(htmlToText('a
b')).toBe('a\nb'); + expect(htmlToText('R&D <tag> "q"  x')).toBe('R&D "q" x'); + expect(htmlToText('€10 €20')).toBe('€10 €20'); + }); + + it('ignores comments and out-of-range numeric entities without throwing', () => { + expect(htmlToText('ab')).toBe('a b'); + expect(() => htmlToText('� �')).not.toThrow(); + }); +}); + +describe('normaliseText', () => { + it('collapses runs of spaces, tabs and non-breaking spaces', () => { + expect(normaliseText('a \t   b')).toBe('a b'); + }); + it('caps blank-line runs and handles null/undefined', () => { + expect(normaliseText('a\n\n\n\n\nb')).toBe('a\n\nb'); + expect(normaliseText(undefined)).toBe(''); + expect(normaliseText(null)).toBe(''); + }); +}); + +function baseEmail(overrides: Partial = {}): Email { + return { + id: 'M1', threadId: 'T1', mailboxIds: { mb1: true }, keywords: {}, + size: 100, receivedAt: '2026-08-01T10:00:00Z', hasAttachment: false, + ...overrides, + } as Email; +} + +describe('emailBodyText', () => { + it('prefers the text/plain part', () => { + const email = baseEmail({ + textBody: [{ partId: 'p1' } as EmailBodyPart], + htmlBody: [{ partId: 'p2' } as EmailBodyPart], + bodyValues: { p1: { value: 'plain wins' }, p2: { value: 'html loses' } }, + }); + expect(emailBodyText(email)).toBe('plain wins'); + }); + + it('falls back to flattened HTML when there is no plain alternative', () => { + const email = baseEmail({ + htmlBody: [{ partId: 'p2' } as EmailBodyPart], + bodyValues: { p2: { value: '

hello

world

' } }, + }); + expect(emailBodyText(email)).toBe('hello\nworld'); + }); + + it('falls back to preview when bodyValues is missing entirely', () => { + // This is the shape a caller gets when the Email/get omitted + // fetchTextBodyValues - a silent empty body if we did not handle it. + const email = baseEmail({ + textBody: [{ partId: 'p1' } as EmailBodyPart], + preview: 'server preview text', + }); + expect(emailBodyText(email)).toBe('server preview text'); + }); + + it('treats a whitespace-only plain part as absent', () => { + const email = baseEmail({ + textBody: [{ partId: 'p1' } as EmailBodyPart], + htmlBody: [{ partId: 'p2' } as EmailBodyPart], + bodyValues: { p1: { value: ' \n ' }, p2: { value: 'real content' } }, + }); + expect(emailBodyText(email)).toBe('real content'); + }); +}); + +describe('extractMail', () => { + it('flattens addresses into `people` and keeps metadata', () => { + const doc = extractMail('acc1', baseEmail({ + subject: 'Quarterly budget', + from: [{ name: 'Sophie Müller', email: 'sophie@example.com' }], + to: [{ email: 'me@example.com' }], + cc: [{ name: 'Bob', email: 'bob@example.com' }], + preview: 'hi', + })); + expect(doc.contentType).toBe('mail'); + expect(doc.title).toBe('Quarterly budget'); + expect(doc.people).toContain('Sophie Müller sophie@example.com'); + expect(doc.people).toContain('bob@example.com'); + expect(doc.occurredAt).toBe('2026-08-01T10:00:00Z'); + expect(doc.metadata.threadId).toBe('T1'); + expect(doc.metadata.mailboxIds).toEqual(['mb1']); + }); + + it('substitutes a placeholder title rather than indexing an empty one', () => { + expect(extractMail('acc1', baseEmail()).title).toBe('(no subject)'); + }); + + it('clamps a huge body', () => { + const doc = extractMail('acc1', baseEmail({ + textBody: [{ partId: 'p1' } as EmailBodyPart], + bodyValues: { p1: { value: 'x'.repeat(MAX_BODY_CHARS * 2) } }, + })); + expect(doc.body.length).toBe(MAX_BODY_CHARS); + }); +}); + +function baseEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'E1', calendarIds: { c1: true }, isDraft: false, isOrigin: true, + utcStart: '2026-08-10T09:00:00Z', utcEnd: '2026-08-10T10:00:00Z', + '@type': 'Event', uid: 'u1', title: 'Standup', description: '', + descriptionContentType: 'text/plain', created: null, updated: '2026-08-01T00:00:00Z', + sequence: 0, start: '2026-08-10T11:00:00', duration: 'PT1H', timeZone: 'Europe/Zurich', + showWithoutTime: false, status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public', + color: null, keywords: null, categories: null, locale: null, replyTo: null, + organizerCalendarAddress: null, participants: null, mayInviteSelf: false, + mayInviteOthers: false, hideAttendees: false, recurrenceId: null, + recurrenceIdTimeZone: null, recurrenceRules: null, recurrenceOverrides: null, + excludedRecurrenceRules: null, useDefaultAlerts: false, alerts: null, + locations: null, virtualLocations: null, links: null, relatedTo: null, + ...overrides, + } as CalendarEvent; +} + +describe('extractCalendarEvent', () => { + it('indexes description, location, attendees and organizer', () => { + const doc = extractCalendarEvent('acc1', baseEvent({ + title: 'Lease decision', + description: 'Zurich office lease renewal', + locations: { l1: { '@type': 'Location', name: 'Room 3.14', description: null, locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } }, + organizerCalendarAddress: 'mailto:boss@example.com', + // A partial participant on purpose: servers omit most JSCalendar fields, + // and the extractor must cope with exactly this shape. + participants: { + p1: { name: 'Ana', email: 'ana@example.com', sendTo: { imip: 'mailto:ana@example.com' } } as unknown as CalendarParticipant, + }, + })); + expect(doc.title).toBe('Lease decision'); + expect(doc.body).toContain('Zurich office lease renewal'); + expect(doc.body).toContain('Room 3.14'); + // mailto: prefixes stripped so the address tokenises like every other one. + expect(doc.people).toContain('boss@example.com'); + expect(doc.people).not.toContain('mailto:'); + expect(doc.people).toContain('ana@example.com'); + expect(doc.metadata.participantCount).toBe(1); + }); + + it('flattens an HTML description', () => { + const doc = extractCalendarEvent('acc1', baseEvent({ + description: '

agenda

', + descriptionContentType: 'text/html', + })); + expect(doc.body).toContain('agenda'); + expect(doc.body).not.toContain('bad()'); + }); + + it('prefers utcStart over the zone-less local start for ordering', () => { + expect(extractCalendarEvent('acc1', baseEvent()).occurredAt).toBe('2026-08-10T09:00:00Z'); + expect(extractCalendarEvent('acc1', baseEvent({ utcStart: null })).occurredAt) + .toBe('2026-08-10T11:00:00'); + }); +}); + +describe('extractContact', () => { + const card = (overrides: Partial = {}): ContactCard => + ({ id: 'C1', addressBookIds: { a1: true }, ...overrides }) as ContactCard; + + it('uses name.full when present', () => { + expect(contactDisplayName(card({ name: { full: 'Ada Lovelace' } }))).toBe('Ada Lovelace'); + }); + + it('assembles components in the right order when full is absent', () => { + expect(contactDisplayName(card({ + name: { components: [{ kind: 'surname', value: 'Hopper' }, { kind: 'given', value: 'Grace' }] }, + }))).toBe('Grace Hopper'); + }); + + it('degrades to an email, then an org, then a placeholder', () => { + expect(contactDisplayName(card({ emails: { e: { address: 'x@y.z' } } }))).toBe('x@y.z'); + expect(contactDisplayName(card({ organizations: { o: { name: 'ACME' } } }))).toBe('ACME'); + expect(contactDisplayName(card())).toBe('(unnamed contact)'); + }); + + it('puts emails and phones in `people` and notes/orgs in `body`', () => { + const doc = extractContact('acc1', card({ + name: { full: 'Ada Lovelace' }, + emails: { e1: { address: 'ada@example.com' } }, + phones: { p1: { number: '+41 44 000 00 00' } }, + organizations: { o1: { name: 'Analytical Engines' } }, + notes: { n1: { note: 'met at the Zurich conference' } }, + nicknames: { k1: { name: 'The Countess' } }, + })); + expect(doc.people).toContain('ada@example.com'); + expect(doc.people).toContain('+41 44 000 00 00'); + expect(doc.people).toContain('The Countess'); + expect(doc.body).toContain('Analytical Engines'); + expect(doc.body).toContain('met at the Zurich conference'); + // A contact has no single meaningful date; ranking is relevance-only. + expect(doc.occurredAt).toBeNull(); + }); + + it('handles both RFC 9553 and legacy flat address shapes', () => { + expect(extractContact('acc1', card({ addresses: { a: { full: 'Bahnhofstrasse 1, Zurich' } } })).body) + .toContain('Bahnhofstrasse 1, Zurich'); + expect(extractContact('acc1', card({ addresses: { a: { street: 'Bahnhofstrasse 1', locality: 'Zurich' } } })).body) + .toContain('Bahnhofstrasse 1, Zurich'); + }); +}); + +describe('extractFile', () => { + const node = (overrides: Partial = {}): FileNode => + ({ + id: 'F1', parentId: null, name: 'invoice.pdf', type: 'application/pdf', + blobId: 'b1', size: 1234, created: '2026-07-01T00:00:00Z', + modified: '2026-07-15T00:00:00Z', ...overrides, + }) as FileNode; + + it('indexes metadata only and says so', () => { + const doc = extractFile('acc1', node(), { path: 'Finance/2026' }); + expect(doc.title).toBe('invoice.pdf'); + expect(doc.body).toContain('Finance/2026'); + expect(doc.body).toContain('pdf'); + expect(doc.metadata.contentIndexed).toBe(false); + expect(doc.metadata.mimeType).toBe('application/pdf'); + expect(doc.metadata.size).toBe(1234); + }); + + it('uses `modified` (FileNode has no `updated`) and falls back to `created`', () => { + expect(extractFile('acc1', node()).occurredAt).toBe('2026-07-15T00:00:00Z'); + expect(extractFile('acc1', node({ modified: undefined as unknown as string })).occurredAt) + .toBe('2026-07-01T00:00:00Z'); + }); + + it('marks directories', () => { + const doc = extractFile('acc1', node({ name: 'Finance', type: 'd', blobId: null })); + expect(doc.metadata.isDirectory).toBe(true); + expect(doc.metadata.mimeType).toBeNull(); + expect(doc.body).toContain('folder'); + }); +}); + +describe('buildFilePaths', () => { + it('resolves the PARENT chain, excluding the node itself', () => { + const nodes = [ + { id: 'root', parentId: null, name: 'Finance' }, + { id: 'year', parentId: 'root', name: '2026' }, + { id: 'file', parentId: 'year', name: 'invoice.pdf' }, + ] as FileNode[]; + const paths = buildFilePaths(nodes); + expect(paths.get('file')).toBe('Finance/2026'); + expect(paths.get('year')).toBe('Finance'); + expect(paths.get('root')).toBe(''); + }); + + it('truncates rather than failing when an ancestor is not in the set', () => { + const nodes = [{ id: 'file', parentId: 'missing', name: 'x.txt' }] as FileNode[]; + expect(buildFilePaths(nodes).get('file')).toBe(''); + }); + + it('terminates on a parent cycle', () => { + const nodes = [ + { id: 'a', parentId: 'b', name: 'A' }, + { id: 'b', parentId: 'a', name: 'B' }, + ] as FileNode[]; + expect(() => buildFilePaths(nodes)).not.toThrow(); + }); +}); diff --git a/lib/mail-index/__tests__/store.test.ts b/lib/mail-index/__tests__/store.test.ts new file mode 100644 index 00000000..610e03b6 --- /dev/null +++ b/lib/mail-index/__tests__/store.test.ts @@ -0,0 +1,261 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { isSqlcipherAvailable } from '../binding'; +import { accountFileToken, getStoreDir, indexDbPath, STORE_DIR_ENV } from '../paths'; +import { MailIndex, toFtsMatchQuery, type IndexDoc } from '../store'; + +describe('toFtsMatchQuery', () => { + it('quotes every token so FTS5 operators in user input cannot break the query', () => { + // FTS5's MATCH grammar is NOT protected by SQL parameter binding: a bare + // quote or a stray NEAR/AND/* raises `fts5: syntax error`, which would turn + // a search box into a 500. + expect(toFtsMatchQuery('a" OR b')).toBe('"a" AND "OR" AND "b"'); + // No trailing `*` here: the final token is one character, below the + // prefix-match threshold (see the next test). + expect(toFtsMatchQuery('NEAR(x y)')).toBe('"NEAR" AND "x" AND "y"'); + expect(toFtsMatchQuery('NEAR(x yes)')).toBe('"NEAR" AND "x" AND "yes"*'); + expect(toFtsMatchQuery('foo*')).toBe('"foo"*'); + expect(toFtsMatchQuery('a AND NOT b')).toContain('"NOT"'); + }); + + it('prefix-matches only the final token, and only when it is long enough', () => { + expect(toFtsMatchQuery('zurich lea')).toBe('"zurich" AND "lea"*'); + // Two characters would match too much of a mailbox to be useful. + expect(toFtsMatchQuery('zurich le')).toBe('"zurich" AND "le"'); + }); + + it('keeps unicode letters, emails and hyphenated words', () => { + expect(toFtsMatchQuery('Müller')).toBe('"Müller"*'); + expect(toFtsMatchQuery('東京')).toBe('"東京"'); + expect(toFtsMatchQuery('a@b.com')).toBe('"a@b.com"*'); + expect(toFtsMatchQuery("O'Brien-Smith")).toBe('"O\'Brien-Smith"*'); + }); + + it('returns null for input with no usable tokens', () => { + expect(toFtsMatchQuery('')).toBeNull(); + expect(toFtsMatchQuery(' ')).toBeNull(); + expect(toFtsMatchQuery('***')).toBeNull(); + expect(toFtsMatchQuery(undefined as unknown as string)).toBeNull(); + }); + + it('bounds the token count', () => { + const many = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' '); + expect((toFtsMatchQuery(many) ?? '').split(' AND ')).toHaveLength(24); + }); +}); + +describe('paths', () => { + const original = process.env[STORE_DIR_ENV]; + afterEach(() => { + if (original === undefined) delete process.env[STORE_DIR_ENV]; + else process.env[STORE_DIR_ENV] = original; + }); + + it('is disabled unless the env var is set - the hosted-deployment gate', () => { + delete process.env[STORE_DIR_ENV]; + expect(getStoreDir()).toBeNull(); + process.env[STORE_DIR_ENV] = ''; + expect(getStoreDir()).toBeNull(); + }); + + it('rejects a relative path, which would resolve against the server cwd', () => { + process.env[STORE_DIR_ENV] = 'offline'; + expect(getStoreDir()).toBeNull(); + process.env[STORE_DIR_ENV] = '/abs/offline'; + expect(getStoreDir()).toBe('/abs/offline'); + }); + + it('hashes the filename so the directory is not an account inventory', () => { + const token = accountFileToken('linus@example.com'); + expect(token).toMatch(/^[0-9a-f]{32}$/); + expect(token).not.toContain('linus'); + expect(indexDbPath('/s', 'linus@example.com')).toBe(`/s/index/${token}.db`); + // Deterministic - the same account must resolve to the same file forever. + expect(accountFileToken('linus@example.com')).toBe(token); + }); +}); + +function doc(overrides: Partial = {}): IndexDoc { + return { + jmapAccountId: 'acc1', + contentType: 'mail', + id: 'M1', + title: 'Quarterly budget review', + people: 'Sophie Müller sophie@example.com', + body: 'The Zurich office lease renewal needs a decision before September.', + occurredAt: '2026-08-01T10:00:00Z', + metadata: { threadId: 'T1' }, + ...overrides, + }; +} + +// The native binding is an OPTIONAL dependency, so these skip rather than fail +// on a platform with no prebuild (e.g. Alpine/musl in CI containers). +describe.skipIf(!isSqlcipherAvailable())('MailIndex (real SQLCipher)', () => { + let storeDir: string; + const accountId = 'linus@example.com'; + const key = randomBytes(32); + + beforeEach(() => { + storeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mail-index-test-')); + }); + afterEach(() => { + fs.rmSync(storeDir, { recursive: true, force: true }); + }); + + const open = () => MailIndex.open({ storeDir, accountId, key }); + + it('writes an ENCRYPTED file - no plaintext recoverable from the raw bytes', () => { + const index = open(); + index.upsert([doc()]); + index.close(); + + const bytes = fs.readFileSync(indexDbPath(storeDir, accountId)); + // The canary check, not just a header check: this is the assertion that + // would have caught `PRAGMA key` being a silent no-op. + expect(bytes.includes('Zurich office lease')).toBe(false); + expect(bytes.includes('Quarterly budget')).toBe(false); + expect(bytes.subarray(0, 15).toString('latin1')).not.toBe('SQLite format 3'); + }); + + it('rejects a wrong key and rebuilds instead of throwing at the caller', () => { + const index = open(); + index.upsert([doc()]); + index.close(); + + // A different key cannot read the data; the store recreates the file rather + // than surfacing an unrecoverable error, because the index is derived data + // and the key was never a user secret. + const other = MailIndex.open({ storeDir, accountId, key: randomBytes(32) }); + expect(other.search({ query: 'Zurich' })).toHaveLength(0); + other.close(); + }); + + it('refuses a key of the wrong length', () => { + expect(() => MailIndex.open({ storeDir, accountId, key: randomBytes(16) })).toThrow(/32 bytes/); + }); + + it('finds documents by body, title and people', () => { + const index = open(); + index.upsert([doc()]); + expect(index.search({ query: 'Zurich' }).map((h) => h.id)).toEqual(['M1']); + expect(index.search({ query: 'quarterly' }).map((h) => h.id)).toEqual(['M1']); + expect(index.search({ query: 'sophie@example.com' }).map((h) => h.id)).toEqual(['M1']); + expect(index.search({ query: 'nonexistentword' })).toHaveLength(0); + index.close(); + }); + + it('returns a snippet for use as LLM context', () => { + const index = open(); + index.upsert([doc()]); + const [hit] = index.search({ query: 'Zurich' }); + expect(hit.snippet).toContain('[Zurich]'); + expect(hit.metadata.threadId).toBe('T1'); + index.close(); + }); + + it('upserting the same id REPLACES the FTS row rather than duplicating it', () => { + const index = open(); + index.upsert([doc()]); + index.upsert([doc({ body: 'Completely different content about Geneva.' })]); + + // One row, and the OLD text must no longer match - the classic + // stale-FTS-row bug when the index is maintained by hand. + expect(index.search({ query: 'Geneva' })).toHaveLength(1); + expect(index.search({ query: 'Zurich' })).toHaveLength(0); + expect(index.stats().find((s) => s.contentType === 'mail')?.count).toBe(1); + index.close(); + }); + + it('scopes rows by JMAP account, so delegated accounts cannot merge', () => { + const index = open(); + index.upsert([ + doc({ jmapAccountId: 'acc1', id: 'X', body: 'shared secret alpha' }), + // Same JMAP id under a different account - legal, since JMAP ids are only + // unique within an account (see namespaceMailboxIds in lib/jmap/client.ts). + doc({ jmapAccountId: 'acc2', id: 'X', body: 'shared secret beta' }), + ]); + expect(index.stats().find((s) => s.contentType === 'mail')?.count).toBe(2); + const hits = index.search({ query: 'secret' }); + expect(hits).toHaveLength(2); + expect(new Set(hits.map((h) => h.jmapAccountId))).toEqual(new Set(['acc1', 'acc2'])); + index.close(); + }); + + it('filters by content type and searches across all four by default', () => { + const index = open(); + index.upsert([ + doc({ contentType: 'mail', id: 'm', title: 'Zurich mail' }), + doc({ contentType: 'calendar', id: 'c', title: 'Zurich meeting' }), + doc({ contentType: 'contact', id: 'k', title: 'Zurich person', occurredAt: null }), + doc({ contentType: 'file', id: 'f', title: 'Zurich file' }), + ]); + expect(index.search({ query: 'Zurich' })).toHaveLength(4); + expect(index.search({ query: 'Zurich', types: ['calendar'] }).map((h) => h.id)).toEqual(['c']); + expect(new Set(index.search({ query: 'Zurich', types: ['mail', 'file'] }).map((h) => h.id))) + .toEqual(new Set(['m', 'f'])); + index.close(); + }); + + it('weights a title hit above a body-only hit', () => { + const index = open(); + index.upsert([ + doc({ id: 'body-only', title: 'unrelated', body: 'mentions lease once' }), + doc({ id: 'in-title', title: 'lease renewal', body: 'unrelated text' }), + ]); + // bm25 is negative and lower is better, so the title hit must come first. + expect(index.search({ query: 'lease' })[0].id).toBe('in-title'); + index.close(); + }); + + it('removes documents and their FTS rows', () => { + const index = open(); + index.upsert([doc()]); + expect(index.remove('acc1', 'mail', ['M1'])).toBe(1); + expect(index.search({ query: 'Zurich' })).toHaveLength(0); + expect(index.remove('acc1', 'mail', ['does-not-exist'])).toBe(0); + index.close(); + }); + + it('prunes by date without touching newer rows', () => { + const index = open(); + index.upsert([ + doc({ id: 'old', occurredAt: '2020-01-01T00:00:00Z', body: 'ancient lease' }), + doc({ id: 'new', occurredAt: '2026-08-01T00:00:00Z', body: 'current lease' }), + ]); + expect(index.pruneOlderThan('acc1', 'mail', '2026-01-01T00:00:00Z')).toBe(1); + expect(index.search({ query: 'lease' }).map((h) => h.id)).toEqual(['new']); + index.close(); + }); + + it('reports existing ids and per-type stats', () => { + const index = open(); + index.upsert([doc({ id: 'a' }), doc({ id: 'b' }), doc({ contentType: 'file', id: 'f' })]); + expect(index.existingIds('acc1', 'mail')).toEqual(new Set(['a', 'b'])); + const stats = index.stats(); + expect(stats.find((s) => s.contentType === 'mail')?.count).toBe(2); + expect(stats.find((s) => s.contentType === 'file')?.count).toBe(1); + index.close(); + }); + + it('survives reopening and keeps the data', () => { + const first = open(); + first.upsert([doc()]); + first.close(); + const second = open(); + expect(second.search({ query: 'Zurich' })).toHaveLength(1); + second.close(); + }); + + it('tolerates a hostile query string end to end', () => { + const index = open(); + index.upsert([doc()]); + for (const q of ['"', '*', 'a" OR "b', 'NEAR(', ')', 'AND', '^', ':', '-']) { + expect(() => index.search({ query: q })).not.toThrow(); + } + index.close(); + }); +}); From 0271df43385c39ab43619a92a2f19666f84d4bdd Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 23:43:52 +0200 Subject: [PATCH 19/21] fix(mail-index): real end-to-end verification, and the three bugs it found Adds integration/tests/12-electron-mail-index.spec.ts (3 tests, all passing against the real Stalwart fixture) and fixes what running it exposed. None of these were visible from reading the code. 1. JMAP session fetch never followed a redirect. Stalwart 307-redirects /.well-known/jmap to /jmap/session, and fetchJmapSession used `redirect: 'manual'` and treated any non-2xx as failure - so every reindex died with "JMAP session fetch failed (307)". Now follows up to 3 hops and REFUSES to follow off-origin, because the user's credentials ride on every hop; a blind `redirect: 'follow'` would hand the Authorization header to whatever host a misconfigured session pointed at. Same bound and same reasoning as lib/auth/verify-jmap-auth.ts. 2. The fd-3 key channel could only be adopted once per process, but its state was module-scoped. Next re-evaluates route modules, so a second instance hit `Could not open fd 3: Error: open EEXIST` from libuv. State moved to a Symbol on globalThis - the one place in a Node process that survives module re-evaluation. 3. Next's output file tracing does NOT carry @signalapp/sqlcipher's prebuilds/ into .next/standalone. It traced the package's JS and its node-gyp-build dependency, but node-gyp-build resolves the .node binary by scanning a directory at runtime, which no static tracer can follow - so `require()` would have failed in every packaged build. scripts/assemble-standalone.mjs now copies it, alongside the public/ and .next/static copies it already does for the same "standalone output omits things" reason. All six platform/arch prebuilds are copied, not just this host's, because electron-builder cross-builds the x64 and arm64 macOS targets from one runner. The three tests, and why it takes three - two constraints made a single configuration impossible, and both were measured rather than assumed: * The renderer cannot reach this fixture from a production build. Its CSP pins connect-src to `'self' https: wss:` and the fixture's Stalwart is plain HTTP. NODE_ENV=development at RUNTIME does not help: `next build` INLINES process.env.NODE_ENV into the compiled middleware, so proxy.ts's `isDev` is frozen at build time (observed: a standalone server started with NODE_ENV=development still served the production CSP). * The fd-3 channel cannot survive `next dev`, which forks its server with an IPC channel that claims fd 3 (EEXIST); fd 4 there is not a pipe either (ENOTTY). So: PIPELINE drives the real standalone server over HTTP from Node with a real fd-3 key channel (no browser, so no CSP) and asserts a real SMTP delivery is findable by a word from its BODY, with a real snippet and contextBlock, idempotent catch-up, working type filters, and - reading the raw bytes of the .db AND its -wal - that nothing is recoverable in cleartext. TRIGGER proves the event-driven wiring: a real delivery makes the renderer POST /api/offline/reindex off its live push. WIRING launches the real shell with no ELECTRON_LOAD_URL and asserts the routes are reachable (401, not 404 or 503) with real safeStorage behind them. Each test now gets its own --user-data-dir. That is load-bearing, not hygiene: Electron reuses one profile across launches, and a leftover jmap_stalwart_ctx cookie from an earlier run made the WIRING test's 401 assertion pass as a 200. Verified: typecheck clean; unit suite 2379 tests with the SAME 3 pre-existing failures as the base commit b15098a6 (2 builtin-themes, 1 jmap-client- resilience) and 48 net new passing; both `docker build`s succeed; the hosted-deployment gate returns 404 with an empty body and materialises no file in the production image; e2e/electron-smoke 4/4; 11-electron-notification still passes. Co-Authored-By: Claude Sonnet 5 --- .../tests/12-electron-mail-index.spec.ts | 508 ++++++++++++++++++ lib/mail-index/jmap.ts | 39 +- lib/mail-index/key.ts | 75 ++- playwright.integration-electron.config.ts | 6 +- playwright.integration.config.ts | 6 +- scripts/assemble-standalone.mjs | 32 ++ 6 files changed, 635 insertions(+), 31 deletions(-) create mode 100644 integration/tests/12-electron-mail-index.spec.ts diff --git a/integration/tests/12-electron-mail-index.spec.ts b/integration/tests/12-electron-mail-index.spec.ts new file mode 100644 index 00000000..c0a638e3 --- /dev/null +++ b/integration/tests/12-electron-mail-index.spec.ts @@ -0,0 +1,508 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import type { ElectronApplication, Page } from '@playwright/test'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { get as httpGet } from 'node:http'; +import { createHash, randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ACCOUNTS, JMAP_URL } from './helpers/config'; +import { sendMail } from './helpers/smtp'; +import { JmapClient } from './helpers/jmap'; +import { expectFolderUnread } from './helpers/app'; + +/** + * The encrypted local search index (lib/mail-index/**) against the real + * Stalwart fixture. THREE tests, because no single configuration can cover the + * whole feature - the reasons are specific and worth reading before changing + * any of them. + * + * Constraint 1 - the renderer cannot reach this fixture from a production + * build. The renderer talks JMAP DIRECTLY to Stalwart, and this fixture's + * Stalwart is deliberately plain HTTP (integration/webmail.Dockerfile explains + * why). The production CSP pins `connect-src` to `'self' https: wss:`. Setting + * NODE_ENV=development at RUNTIME does not help: `next build` INLINES + * process.env.NODE_ENV into the compiled middleware, so proxy.ts's `isDev` is + * frozen at build time. Verified by watching a standalone server started with + * NODE_ENV=development still serve the production CSP, and the login fail with + * "Refused to connect ... violates connect-src 'self' https: wss:". + * + * Constraint 2 - the fd-3 key channel cannot survive `next dev`. `next dev` + * forks its server process with an IPC channel that claims fd 3, so adopting it + * fails with EEXIST; fd 4 in that process is not a pipe either (ENOTTY). Both + * were observed, not assumed. Extra file descriptors simply are not plumbed + * through `npx -> next dev -> forked server`. The real standalone server is a + * single process and has no such problem (test 3 proves it). + * + * So each test takes the configuration that lets it prove its own half: + * + * 1. PIPELINE - drives the REAL standalone server over HTTP from Node, with a + * real fd-3 key channel. CSP is irrelevant here because there is no + * browser: a Node client with a real session cookie exercises the real + * routes. This is the test that proves a real delivery becomes searchable + * by a word from its BODY, and that the file on disk is really encrypted. + * + * 2. TRIGGER - proves the EVENT-DRIVEN wiring: a real SMTP delivery makes the + * renderer POST /api/offline/reindex off the back of its live JMAP push. + * Runs against `next dev` (constraint 1), and asserts the request is made - + * the indexing itself is test 1's job. + * + * 3. WIRING - launches the REAL shell with no ELECTRON_LOAD_URL, so + * electron/main.ts boots the real standalone artifact and stands up the real + * fd-3 key service on real safeStorage. Asserts the index routes are + * REACHABLE in a real build (401 "sign in", not 404 "feature absent", not + * 503 "no native binding / no key channel"). + * + * Nothing is mocked anywhere: real SMTP, real Stalwart, real Electron, real + * SQLCipher, real safeStorage. + */ +const alice = ACCOUNTS.alice; +const projectRoot = path.resolve(__dirname, '../..'); + +/** Mirrors lib/mail-index/paths.ts's accountFileToken(). */ +function accountFileToken(accountId: string): string { + return createHash('sha256').update(accountId, 'utf8').digest('hex').slice(0, 32); +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address && typeof address === 'object') { + const { port } = address; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error('Could not allocate a free localhost port'))); + } + }); + }); +} + +function waitForServerReady(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const req = httpGet(url, (res) => { + res.resume(); + resolve(); + }); + req.on('error', () => { + if (Date.now() > deadline) { + reject(new Error(`Server never became reachable at ${url}`)); + return; + } + setTimeout(attempt, 300); + }); + }; + attempt(); + }); +} + +/** + * Serves the key protocol of electron/key-service.ts over the child's inherited + * fd. The key and the encryption are real; only safeStorage's wrapping of it is + * out of the picture here, which is what test 3 covers. + */ +function serveKeyChannel(child: ChildProcess, fdIndex: number, key: Buffer): void { + const channel = child.stdio[fdIndex] as NodeJS.ReadWriteStream | null; + if (!channel) throw new Error(`child has no fd ${fdIndex} - key channel missing`); + let buffer = ''; + channel.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + let newline: number; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line.trim()) continue; + const req = JSON.parse(line) as { id?: number; op?: string }; + const reply = + req.op === 'getIndexKey' + ? { id: req.id, ok: true, key: key.toString('hex') } + : req.op === 'deleteIndexKey' + ? { id: req.id, ok: true } + : { id: req.id, ok: false, code: 'bad-request', error: 'unknown op' }; + channel.write(`${JSON.stringify(reply)}\n`); + } + }); +} + +/** Minimal cookie jar - the index routes are cookie-authenticated. */ +class Jar { + private cookies = new Map(); + + absorb(response: Response): void { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const eq = pair.indexOf('='); + if (eq <= 0) continue; + this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + } + + header(): string { + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; '); + } +} + +interface SearchHit { + contentType: string; + id: string; + title: string; + snippet: string; +} + +interface SearchResponse { + ok?: boolean; + count?: number; + hits?: SearchHit[]; + contextBlock?: string; + stats?: Array<{ contentType: string; count: number }>; + error?: string; +} + +test.describe('Electron desktop shell - encrypted local search index', () => { + test('pipeline: a real delivery becomes searchable by a body word, and the file is encrypted', async () => { + const jmap = await JmapClient.connect(alice.email, alice.password); + await jmap.reset(); + + const stamp = Date.now(); + const subject = `IT index subject ${stamp}`; + // Appears ONLY in the body, so a hit proves the body was actually fetched + // and indexed - not merely the subject, which any list view already holds. + const bodyPhrase = `zurichlease${stamp}`; + + // Deliver BEFORE indexing, so the catch-up path has something real to find. + await sendMail({ + from: alice.email, + authPass: alice.password, + to: alice.email, + subject, + body: `Please review the ${bodyPhrase} renewal before September.`, + }); + + const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-index-it-')); + const key = randomBytes(32); + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const serverEntry = path.join(projectRoot, '.next', 'standalone', 'server.js'); + expect( + fs.existsSync(serverEntry), + `missing ${serverEntry} - run "npm run build:standalone" first`, + ).toBe(true); + + // The REAL standalone artifact, spawned exactly as electron/main.ts spawns + // it (including the fd-3 key channel), just with plain node rather than + // ELECTRON_RUN_AS_NODE - the server code is identical either way. + const server = spawn(process.execPath, [serverEntry], { + cwd: path.dirname(serverEntry), + env: { + ...process.env, + PORT: String(port), + HOSTNAME: '127.0.0.1', + NODE_ENV: 'production', + JMAP_SERVER_URL: JMAP_URL, + SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', + VNCMAIL_DESKTOP_STORE_DIR: storeDir, + VNCMAIL_DESKTOP_KEY_FD: '3', + }, + stdio: ['pipe', 'pipe', 'pipe', 'pipe'], + }); + server.stderr?.on('data', (chunk) => process.stderr.write(`[standalone] ${chunk}`)); + serveKeyChannel(server, 3, key); + + const jar = new Jar(); + const call = async (url: string, init?: RequestInit): Promise => { + const response = await fetch(`${baseUrl}${url}`, { + ...init, + headers: { ...(init?.headers ?? {}), cookie: jar.header() }, + }); + jar.absorb(response); + return response; + }; + const search = async (query: string, types?: string): Promise => { + const params = new URLSearchParams({ q: query, stats: 'true' }); + if (types) params.set('types', types); + const response = await call(`/api/offline/search?${params.toString()}`); + if (!response.ok) return { error: `HTTP ${response.status}: ${await response.text()}` }; + return (await response.json()) as SearchResponse; + }; + + try { + await waitForServerReady(baseUrl, 60000); + + // Server-side login. This route verifies the credentials against Stalwart + // from Node and writes BOTH the session cookie and the jmap_stalwart_ctx + // auth context the index routes read (app/api/auth/session/route.ts:94). + const login = await call('/api/auth/session?slot=0', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverUrl: JMAP_URL, + username: alice.email, + password: alice.password, + slot: 0, + }), + }); + expect(login.status, `login failed: ${await login.text()}`).toBe(200); + + // The gate must be open and the native binding loaded, or every assertion + // below would fail for an unrelated reason. + const reachable = await call('/api/offline/search?stats=true&q='); + expect( + reachable.status, + `index routes unreachable: ${(await reachable.text()).slice(0, 300)}`, + ).toBe(200); + + // Index it. This is the catch-up shape (no ids), which is what the app + // runs at launch. + const reindex = await call('/api/offline/reindex', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ catchUp: true }), + }); + const reindexBody = await reindex.json(); + expect(reindex.status, JSON.stringify(reindexBody)).toBe(200); + expect( + reindexBody.written?.mail, + `no mail indexed: ${JSON.stringify(reindexBody)}`, + ).toBeGreaterThan(0); + + // THE assertion: found by a word that exists only in the message body. + const hit = await search(bodyPhrase); + expect(hit.error).toBeUndefined(); + expect(hit.count, `search for a body word found nothing: ${JSON.stringify(hit)}`) + .toBeGreaterThan(0); + expect(hit.hits?.[0].contentType).toBe('mail'); + expect(hit.hits?.[0].title).toBe(subject); + expect(hit.hits?.[0].snippet).toContain(bodyPhrase); + // The prompt-ready retrieval surface an AI feature would consume. + expect(hit.contextBlock).toContain('[EMAIL]'); + expect(hit.contextBlock).toContain(subject); + + // Also findable by sender address, which lives in the `people` column. + expect((await search(alice.email)).count).toBeGreaterThan(0); + + // Type filtering must filter, and a word in no message must not match - + // otherwise the hit above proves nothing about relevance. + expect((await search(bodyPhrase, 'calendar')).count).toBe(0); + expect((await search(bodyPhrase, 'mail')).count).toBeGreaterThan(0); + expect((await search(`absent${stamp}`)).count).toBe(0); + + // Catch-up must be idempotent: a second pass must not duplicate rows. + const before = ((await search(bodyPhrase)).stats ?? []) + .find((s) => s.contentType === 'mail')?.count ?? 0; + const second = await call('/api/offline/reindex', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ catchUp: true }), + }); + expect(second.status).toBe(200); + const after = ((await search(bodyPhrase)).stats ?? []) + .find((s) => s.contentType === 'mail')?.count ?? 0; + expect(after).toBe(before); + expect((await search(bodyPhrase)).count).toBe(1); + + // Calendar/contacts/files: assert they were ATTEMPTED and did not error, + // rather than asserting counts - this fixture provisions mailboxes only, + // so an empty calendar is the correct result and a count assertion would + // be testing the fixture rather than the code. + const errors = (reindexBody.errors ?? []) as Array<{ contentType: string; message: string }>; + expect(errors, `per-type failures during reindex: ${JSON.stringify(errors)}`).toEqual([]); + const attempted = Object.keys(reindexBody.written ?? {}); + const skipped = (reindexBody.skipped ?? []) as string[]; + expect( + [...attempted, ...skipped].sort(), + 'every content type must be either attempted or explicitly skipped', + ).toEqual(['calendar', 'contact', 'file', 'mail']); + } finally { + server.kill(); + // Let the process release its WAL files before reading them. + await new Promise((r) => setTimeout(r, 500)); + } + + // ── the file on disk is genuinely encrypted ────────────────────────────── + const accountId = `${alice.email}@${new URL(JMAP_URL).hostname}`; + const dbPath = path.join(storeDir, 'index', `${accountFileToken(accountId)}.db`); + expect(fs.existsSync(dbPath), `no index database at ${dbPath}`).toBe(true); + + // Read every file the store wrote, WAL included: the newest rows can still + // be sitting in the -wal, so checking only the main database could miss + // plaintext that is genuinely on disk. + const onDisk = Buffer.concat( + ['', '-wal', '-shm'] + .map((suffix) => `${dbPath}${suffix}`) + .filter((f) => fs.existsSync(f)) + .map((f) => fs.readFileSync(f)), + ); + expect(onDisk.length).toBeGreaterThan(0); + // The assertions that catch a silently-UNENCRYPTED store. `PRAGMA key` is a + // no-op on a non-SQLCipher binding - no error, working database, mailbox in + // cleartext - so every functional assertion above would pass either way. + expect( + fs.readFileSync(dbPath).subarray(0, 15).toString('latin1'), + 'the index file has a plain SQLite header - it is NOT encrypted', + ).not.toBe('SQLite format 3'); + expect( + onDisk.includes(bodyPhrase), + 'the message body is recoverable from the raw database bytes - not encrypted', + ).toBe(false); + expect( + onDisk.includes(subject), + 'the subject is recoverable from the raw database bytes - not encrypted', + ).toBe(false); + + fs.rmSync(storeDir, { recursive: true, force: true }); + }); + + test('trigger: a real delivery makes the renderer ask the index to update', async () => { + const jmap = await JmapClient.connect(alice.email, alice.password); + await jmap.reset(); + + const devPort = await getFreePort(); + const devUrl = `http://127.0.0.1:${devPort}`; + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-trigger-profile-')); + + // `next dev` for the CSP reason in the header comment. No key channel here: + // this test asserts the REQUEST is made, which is the wiring it owns; the + // indexing itself is test 1's job. (Extra fds don't survive next dev + // anyway - constraint 2 above.) + const devServer = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(devPort)], { + cwd: projectRoot, + env: { + ...process.env, + JMAP_SERVER_URL: JMAP_URL, + SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', + NODE_ENV: 'development', + // Enough for the route to exist and pass its gate; it fails later on the + // absent key channel, which this test deliberately does not assert on. + VNCMAIL_DESKTOP_STORE_DIR: path.join(userDataDir, 'offline'), + VNCMAIL_DESKTOP_KEY_FD: '3', + }, + stdio: 'pipe', + }); + devServer.stderr?.on('data', (chunk) => process.stderr.write(`[next dev] ${chunk}`)); + + let electronApp: ElectronApplication | undefined; + try { + await waitForServerReady(devUrl, 90000); + + electronApp = await electron.launch({ + args: [projectRoot, `--user-data-dir=${userDataDir}`], + env: { ...process.env, ELECTRON_LOAD_URL: devUrl }, + }); + + const appWindow: Page = await electronApp.firstWindow(); + await appWindow.waitForLoadState('domcontentloaded'); + + await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + await appWindow.fill('#username', alice.email); + await appWindow.fill('#password', alice.password); + await appWindow.click('button[type="submit"]'); + await appWindow + .locator('[data-testid="account-switcher"]') + .first() + .waitFor({ state: 'visible', timeout: 60000 }); + + // An actively-selected inbox is a precondition for the push handler's + // refresh, which is what schedules the index update - the same reason + // 11-electron-notification.spec.ts waits here. + await expectFolderUnread(appWindow, { role: 'inbox' }, 0); + + const reindexCalls: string[] = []; + appWindow.on('request', (request) => { + if (request.method() === 'POST' && request.url().includes('/api/offline/reindex')) { + reindexCalls.push(request.postData() ?? ''); + } + }); + // Let the launch-time catch-up land first so it is not mistaken for the + // delivery-driven call below. + await appWindow.waitForTimeout(8000); + const baseline = reindexCalls.length; + + await sendMail({ + from: alice.email, + authPass: alice.password, + to: alice.email, + subject: `IT index trigger ${Date.now()}`, + body: 'a delivery should make the renderer ask the index to update', + }); + + await expect + .poll(() => reindexCalls.length, { + timeout: 60000, + message: + 'a real delivery did not make the renderer POST /api/offline/reindex - ' + + 'the push -> handleStateChange -> indexOnStateChange wiring is broken', + }) + .toBeGreaterThan(baseline); + + // The delivery-driven call must name the mail type, rather than being an + // unconditional full catch-up. + const triggered = reindexCalls.slice(baseline); + expect( + triggered.some((body) => body.includes('"mail"')), + `no reindex call mentioned the mail type: ${JSON.stringify(triggered)}`, + ).toBe(true); + } finally { + await electronApp?.close(); + devServer.kill(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + }); + + test('wiring: the real standalone boot reaches the index with a real safeStorage key', async () => { + // A FRESH profile is load-bearing, not hygiene: the 401 this test asserts is + // "nobody is signed in", and a leftover jmap_stalwart_ctx cookie from any + // previous run turns it into a 200. That actually happened while writing this. + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-wiring-profile-')); + const electronApp = await electron.launch({ + args: [projectRoot, `--user-data-dir=${userDataDir}`], + env: { + ...process.env, + JMAP_SERVER_URL: JMAP_URL, + SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', + }, + }); + + try { + const appWindow: Page = await electronApp.firstWindow(); + await appWindow.waitForLoadState('domcontentloaded'); + await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + + // safeStorage must be usable, or main.ts deliberately refuses to enable + // the feature at all (electron/key-service.ts's checkEncryptionAvailable). + const encryptionAvailable = await electronApp.evaluate(({ safeStorage }) => + safeStorage.isEncryptionAvailable(), + ); + expect( + encryptionAvailable, + 'safeStorage reports no encryption available on this host, so main.ts ' + + 'correctly disabled the index - this assertion cannot pass here', + ).toBe(true); + + const probe = await appWindow.evaluate(async () => { + const response = await fetch('/api/offline/search?q=anything'); + return { status: response.status, body: (await response.text()).slice(0, 300) }; + }); + + // 401 = the gate opened, the native binding loaded and the fd-3 key + // channel is present; it refuses only because nobody is signed in (this + // build cannot log in against a plain-HTTP Stalwart - constraint 1). + // 404 => VNCMAIL_DESKTOP_STORE_DIR was never set (gate closed, or + // main.ts refused because no OS keyring is available) + // 503 => the native binding or the key channel is missing from the real + // artifact - the class of failure only a real build reveals + expect( + probe.status, + `expected 401 (reachable, unauthenticated) but got ${probe.status}: ${probe.body}`, + ).toBe(401); + } finally { + await electronApp.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/lib/mail-index/jmap.ts b/lib/mail-index/jmap.ts index c918d2d6..35ae4acd 100644 --- a/lib/mail-index/jmap.ts +++ b/lib/mail-index/jmap.ts @@ -75,14 +75,45 @@ async function fetchWithTimeout(url: string, init: RequestInit): Promise { - const response = await fetchWithTimeout(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { - method: 'GET', - headers: { Authorization: authHeader }, - }); + const base = serverUrl.replace(/\/+$/, ''); + const origin = new URL(base).origin; + let currentUrl = `${base}/.well-known/jmap`; + let response: Response | undefined; + + // Redirects must be followed EXPLICITLY, not with `redirect: 'follow'`: we + // attach the user's credentials to every hop, so each one has to be checked to + // still be on the origin we authenticated against. A blind follow would hand + // the Authorization header to whatever host a misconfigured or hostile session + // pointed at. Same reasoning (and same bound) as lib/auth/verify-jmap-auth.ts. + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + response = await fetchWithTimeout(currentUrl, { + method: 'GET', + headers: { Authorization: authHeader }, + }); + if (response.status < 300 || response.status >= 400) break; + + const location = response.headers.get('location'); + if (!location) throw new JmapIndexError('JMAP session redirect had no Location header'); + const next = new URL(location, currentUrl); + if (next.origin !== origin) { + throw new JmapIndexError( + `JMAP session redirected off-origin (${next.origin}); refusing to send credentials there`, + ); + } + currentUrl = next.toString(); + } + + if (!response) throw new JmapIndexError('JMAP session fetch produced no response'); if (response.status === 401 || response.status === 403) { throw new JmapIndexError('JMAP authentication failed', 401); } + if (response.status >= 300 && response.status < 400) { + throw new JmapIndexError('Too many redirects fetching the JMAP session'); + } if (!response.ok) { throw new JmapIndexError(`JMAP session fetch failed (${response.status})`); } diff --git a/lib/mail-index/key.ts b/lib/mail-index/key.ts index 35c1318b..bded791a 100644 --- a/lib/mail-index/key.ts +++ b/lib/mail-index/key.ts @@ -36,21 +36,49 @@ interface Pending { timer: NodeJS.Timeout; } -let socket: net.Socket | null = null; -let nextId = 1; -const pending = new Map(); -let readBuffer = ''; +/** + * Channel state lives on `globalThis`, NOT in module scope. + * + * A file descriptor can be adopted as a socket exactly ONCE per process: a + * second `new net.Socket({ fd })` for an fd this process already owns throws + * `EEXIST` from libuv's uv_pipe_open. Module scope is not once-per-process - + * Next re-evaluates route modules (dev HMR, and separate module instances + * across route bundles), so a module-scoped `let socket` produced exactly that + * crash: `Could not open fd 3: Error: open EEXIST`, found by the integration + * test rather than by reading the code. + * + * A Symbol key on globalThis is the one place in a Node process that survives + * module re-evaluation, so adoption genuinely happens once. + */ +interface ChannelState { + socket: net.Socket | null; + nextId: number; + pending: Map; + readBuffer: string; +} -function failAll(error: Error): void { - for (const [, p] of pending) { +const STATE_KEY = Symbol.for('vncmail.mailIndex.keyChannel'); + +function state(): ChannelState { + const holder = globalThis as unknown as Record; + const existing = holder[STATE_KEY]; + if (existing) return existing; + const created: ChannelState = { socket: null, nextId: 1, pending: new Map(), readBuffer: '' }; + holder[STATE_KEY] = created; + return created; +} + +function failAll(s: ChannelState, error: Error): void { + for (const [, p] of s.pending) { clearTimeout(p.timer); p.reject(error); } - pending.clear(); + s.pending.clear(); } function getSocket(): net.Socket { - if (socket && !socket.destroyed) return socket; + const s = state(); + if (s.socket && !s.socket.destroyed) return s.socket; const raw = process.env[KEY_FD_ENV]?.trim(); const fd = raw ? Number(raw) : NaN; @@ -73,12 +101,12 @@ function getSocket(): net.Socket { created.unref(); created.on('data', (chunk: Buffer) => { - readBuffer += chunk.toString('utf8'); - if (readBuffer.length > 64 * 1024) readBuffer = ''; + s.readBuffer += chunk.toString('utf8'); + if (s.readBuffer.length > 64 * 1024) s.readBuffer = ''; let newline: number; - while ((newline = readBuffer.indexOf('\n')) >= 0) { - const line = readBuffer.slice(0, newline); - readBuffer = readBuffer.slice(newline + 1); + while ((newline = s.readBuffer.indexOf('\n')) >= 0) { + const line = s.readBuffer.slice(0, newline); + s.readBuffer = s.readBuffer.slice(newline + 1); if (!line.trim()) continue; let msg: { id?: unknown; ok?: unknown; key?: unknown; code?: unknown; error?: unknown }; try { @@ -88,9 +116,9 @@ function getSocket(): net.Socket { } const id = typeof msg.id === 'number' ? msg.id : null; if (id === null) continue; - const p = pending.get(id); + const p = s.pending.get(id); if (!p) continue; - pending.delete(id); + s.pending.delete(id); clearTimeout(p.timer); if (msg.ok === true) { p.resolve({ key: typeof msg.key === 'string' ? msg.key : undefined }); @@ -102,32 +130,33 @@ function getSocket(): net.Socket { }); const onGone = (error?: Error) => { - socket = null; - readBuffer = ''; - failAll(error ?? new IndexKeyError('no-channel', 'Key service channel closed')); + s.socket = null; + s.readBuffer = ''; + failAll(s, error ?? new IndexKeyError('no-channel', 'Key service channel closed')); }; created.on('close', () => onGone()); created.on('error', (error) => onGone(new IndexKeyError('no-channel', String(error)))); - socket = created; + s.socket = created; return created; } function request(op: 'getIndexKey' | 'deleteIndexKey', accountId: string): Promise<{ key?: string }> { const sock = getSocket(); - const id = nextId++; + const s = state(); + const id = s.nextId++; return new Promise<{ key?: string }>((resolve, reject) => { const timer = setTimeout(() => { - pending.delete(id); + s.pending.delete(id); reject(new IndexKeyError('timeout', `Key service did not answer within ${REQUEST_TIMEOUT_MS}ms`)); }, REQUEST_TIMEOUT_MS); // Don't let a pending key request keep the process alive either. timer.unref?.(); - pending.set(id, { resolve, reject, timer }); + s.pending.set(id, { resolve, reject, timer }); try { sock.write(`${JSON.stringify({ id, op, accountId })}\n`); } catch (error) { - pending.delete(id); + s.pending.delete(id); clearTimeout(timer); reject(new IndexKeyError('no-channel', `Could not write to the key service: ${String(error)}`)); } diff --git a/playwright.integration-electron.config.ts b/playwright.integration-electron.config.ts index 3fc7c093..7a0a7b2f 100644 --- a/playwright.integration-electron.config.ts +++ b/playwright.integration-electron.config.ts @@ -21,7 +21,11 @@ import { defineConfig } from '@playwright/test'; */ export default defineConfig({ testDir: './integration/tests', - testMatch: '11-electron-notification.spec.ts', + // 11 asserts the native notification bridge fires from a real push; 12 + // asserts a real delivery reaches the encrypted local search index. 12 runs + // the REAL standalone-server boot (no ELECTRON_LOAD_URL), because that boot + // is what wires the index's store directory and its fd-3 key channel. + testMatch: /1[12]-electron-.*\.spec\.ts/, timeout: 90_000, expect: { timeout: 20_000 }, fullyParallel: false, diff --git a/playwright.integration.config.ts b/playwright.integration.config.ts index a2a071d7..0aeb9979 100644 --- a/playwright.integration.config.ts +++ b/playwright.integration.config.ts @@ -24,13 +24,13 @@ const VIDEO: VideoMode = VIDEO_MODES.includes(process.env.IT_VIDEO as VideoMode) export default defineConfig({ testDir: './integration/tests', - // Electron's own spec runs under playwright.integration-electron.config.ts + // The Electron specs run under playwright.integration-electron.config.ts // instead (see that file's header comment for why): the dockerized run // this config drives (integration/run-tests.sh, inside the official // Playwright image) has no Electron binary compatible with that - // container's platform, so it must never be swept in by this config's + // container's platform, so they must never be swept in by this config's // default testDir glob. - testIgnore: '11-electron-notification.spec.ts', + testIgnore: ['11-electron-notification.spec.ts', '12-electron-mail-index.spec.ts'], // next dev compiles routes lazily and each test logs in fresh, so give // individual tests and their polling assertions generous headroom. timeout: 90_000, diff --git a/scripts/assemble-standalone.mjs b/scripts/assemble-standalone.mjs index 4422327f..e404beaf 100644 --- a/scripts/assemble-standalone.mjs +++ b/scripts/assemble-standalone.mjs @@ -26,4 +26,36 @@ const staticDest = path.join(standaloneDir, ".next", "static"); rmSync(staticDest, { recursive: true, force: true }); cpSync(staticSrc, staticDest, { recursive: true }); +// The native SQLCipher prebuilds for the local search index (lib/mail-index/**). +// +// Next's output file tracing DOES pick up @signalapp/sqlcipher's JS +// (package.json + dist/index.cjs) and its node-gyp-build dependency, but NOT +// the prebuilds/ directory holding the actual .node binaries - node-gyp-build +// resolves those by scanning the directory at runtime, which no static tracer +// can follow. Verified by inspecting a real `build:standalone` output: the +// package was present, `prebuilds/` was absent, so `require()` would have +// failed at runtime in every packaged build. +// +// Copying the WHOLE prebuilds directory (all six platform/arch pairs, ~11 MB) +// rather than just this host's is deliberate: electron-builder cross-builds the +// x64 and arm64 macOS targets from one runner (electron-builder.config.js), so +// the artifact has to contain a prebuild for an arch this machine isn't. +// +// Skipped silently when absent - the package is an OPTIONAL dependency and is +// legitimately missing on musl/Alpine, where both Dockerfiles build. +const sqlcipherSrc = path.join(rootDir, "node_modules", "@signalapp", "sqlcipher", "prebuilds"); +if (existsSync(sqlcipherSrc)) { + const sqlcipherDest = path.join( + standaloneDir, "node_modules", "@signalapp", "sqlcipher", "prebuilds", + ); + rmSync(sqlcipherDest, { recursive: true, force: true }); + cpSync(sqlcipherSrc, sqlcipherDest, { recursive: true }); + console.log("Copied @signalapp/sqlcipher prebuilds into the standalone output"); +} else { + console.log( + "@signalapp/sqlcipher not installed (optional dependency) - " + + "the encrypted local index will be disabled at runtime", + ); +} + console.log("Assembled standalone server at", standaloneDir); From 31b4ea2ecdb61ca7a3b043f1cac21441a7a69050 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 23:44:57 +0200 Subject: [PATCH 20/21] docs: mark the offline-engine design + review as superseded Both describe a full offline mail replica with a persistent cursor-based sync engine. That scope was dropped in favour of "a SQLite index we can prompt against" - see the notes prepended to each file for what shipped instead (lib/mail-index/** + app/api/offline/{reindex,search}). Kept rather than deleted because several findings are still accurate and still load-bearing: the SQLCipher binding investigation, the PRAGMA-key silent-no-op landmine, the safeStorage Linux basic_text hazard, the hosted-deployment gate, and the codebase survey. The review's note also records the disposition of every CRITICAL/HIGH finding. Most became MOOT rather than fixed - C2, C3, C4, H1 and H2 were all consequences of a long-lived worker holding credentials, and the new shape has no worker. C1 (the Docker build breakage) and H2's env-vs-fd point were fixed as specified, and the review's two corrections to the design (the cipher_version check needing a non-empty string, getSelectedStorageBackend being Linux-only) are both in the shipped code. Also recorded: two things the design got wrong beyond the scope change - its claim that the chosen process needs no new secret handling (the review was right) and its assumption that Next's file tracing would carry the native module (it does not). Co-Authored-By: Claude Sonnet 5 --- docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md | 39 ++++++++++++++++++++++++-- docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md | 27 ++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md b/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md index 70c5590d..e84b5ece 100644 --- a/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md +++ b/docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md @@ -1,7 +1,42 @@ +> # ⚠️ SUPERSEDED — this is not what was built +> +> This document designs a **full offline mail replica**: a persistent background sync engine with +> JMAP `Foo/changes` cursors, three state machines, a retry ladder, reconcile/sweep logic and an +> epoch-fenced multi-account registry. **That scope was dropped.** After the adversarial review +> (`ELECTRON-OFFLINE-ENGINE-REVIEW.md`), the human narrowed the requirement to *"a SQLite index we +> can prompt against"* — retrieval to feed an LLM, refreshed on each delivery/change event. +> +> **What was actually built:** `lib/mail-index/**` + `app/api/offline/{reindex,search}` — an +> encrypted SQLite/FTS5 index over mail, calendar, contacts and file *metadata*, written by an +> ordinary request-scoped API route that the renderer's existing live JMAP push connection calls +> when something changes. No background worker, no cursors, no resident credentials. Staleness +> between refreshes is acceptable by design. +> +> Most of the review's CRITICAL and HIGH findings **stopped existing** rather than being fixed: C2, +> C3, C4, H1 and H2 were all consequences of a long-lived worker holding credentials, and there is +> no worker. +> +> **Still accurate and still worth reading here:** +> - §3 — the SQLite/SQLCipher binding investigation. `@signalapp/sqlcipher` is what shipped, for the +> reasons given, and the `PRAGMA key` silent-no-op landmine is real (the shipped code asserts +> `cipher_version` returns a non-empty *string*, per the review's correction). +> - §6 — `safeStorage`, including the Linux `basic_text` hazard. Shipped as described, with +> `getSelectedStorageBackend()` correctly guarded to Linux only (a review finding). +> - §1 — the codebase survey (auth model, push pipeline, CSP, account model). All verified. +> - §2.4's hosted-deployment gate (`VNCMAIL_DESKTOP_STORE_DIR`) — shipped, and now covered by a test. +> - §14 — what was and was not empirically verified. +> +> **Wrong in hindsight, beyond the scope change:** §2.1's claim that Option A needs no new secret +> handling (the review's C2 is right — credentials are request-scoped, not resident); and §2.1's +> assumption that Next's output file tracing would carry the native module (it does not — the +> standalone build needs an explicit copy step, now in `scripts/assemble-standalone.mjs`). + # Electron Offline Engine — Design -Status: **design only, not implemented.** Nothing outside this file has been changed on this -branch. `electron/main.ts`, `electron/preload.ts` and `lib/jmap/client.ts` are untouched. +Status: **superseded design, never implemented.** See the note above. Nothing outside this file was +changed by the pass that wrote it; `electron/main.ts`, `electron/preload.ts` and +`lib/jmap/client.ts` were untouched *at that time* (`main.ts` has since gained the index's store-dir +and key-channel wiring, which is a small fraction of what this document describes). Repo: `brvncde-dotcom/vncmail-plus`, branch `claude/electron-offline-design`, worktree `~/worktrees/vncmail-electron-sqlite`. Based on `claude/electron-desktop` (the working desktop diff --git a/docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md b/docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md index 4f5bf7a6..e5460bb6 100644 --- a/docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md +++ b/docs/ELECTRON-OFFLINE-ENGINE-REVIEW.md @@ -1,3 +1,30 @@ +> # ⚠️ SUPERSEDED — reviews a design that was not built +> +> This reviews `ELECTRON-OFFLINE-ENGINE-DESIGN.md`, which was **dropped**. Its findings were the +> direct cause: seeing them, the human narrowed the requirement from a full offline mail replica to +> *"a SQLite index we can prompt against"*, refreshed on each delivery/change event. What shipped is +> `lib/mail-index/**` + `app/api/offline/{reindex,search}` — see that doc's superseded note. +> +> **This review did its job.** Most of its severe findings were resolved by the scope change +> removing the thing they were about, which is the strongest outcome a review can have: +> +> | Finding | Outcome | +> |---|---| +> | **C1** — `@signalapp/sqlcipher` in `dependencies` breaks both Alpine `docker build`s | **FIXED as specified.** It is an `optionalDependencies` entry with a guarded runtime require (`lib/mail-index/binding.ts`). Both `docker build`s verified passing, and the require verified failing cleanly with MODULE_NOT_FOUND inside the musl image. | +> | **C2** — credentials are request-scoped, so no persistent worker can hold them | **MOOT.** There is no worker. Indexing is a normal API route using the request's own `jmap_stalwart_ctx` cookie, via the existing `lib/stalwart/credentials.ts`. | +> | **C3** — the OAuth-refresh mitigation is itself the bug | **MOOT, and avoided by construction.** The indexer never touches the refresh-token cookie; it only reads an already-minted auth header, so it cannot rotate a token into a response nobody reads. | +> | **C4** — shared `registry.json` breaks the multi-account safety premise | **MOOT.** No registry, no epochs, no concurrent workers. | +> | **H1** — a server-side engine can't read a renderer-only setting | **MOOT.** The renderer decides when to index. | +> | **H2** — key handoff sequencing, and a nonce via env is readable by same-user processes | **FIXED.** The key crosses on an **inherited file descriptor**, never env, and is fetched per job and zeroed after — not held. Sequencing is moot: the key is fetched when a job runs, not at spawn. | +> | **H3** — local unread-count arithmetic needs a coherence story | **MOOT.** A retrieval index does not need to stay coherent with live unread counts. | +> | **H4** — no cap on concurrent multi-account sync | **MOOT.** One request, one account. | +> | *medium/low:* `getSelectedStorageBackend()` is Linux-only and would crash elsewhere | **FIXED** — platform-guarded. | +> | *medium/low:* `cipher_version` check would pass vacuously on zero rows | **FIXED** — the shipped assertion requires a non-empty *string*, and a test reads the raw file bytes for a plaintext canary. | +> | *medium/low:* the two bindings are not "the same code either way" | **CONFIRMED true, the hard way.** `@signalapp/sqlcipher` rejects varargs params (`TypeError: Params must be either object or array`) where better-sqlite3 accepts them. Documented in `binding.ts`. | +> +> Reviewing this file's own accuracy: its two re-executed claims (the binding working in Electron 43, +> and `PRAGMA key` being a silent no-op) both held up and both shaped the shipped code. + # Adversarial review: `docs/ELECTRON-OFFLINE-ENGINE-DESIGN.md` Reviewer: independent agent, fresh context, no relation to the design's author. 2026-08-04. From a10ee48ef3054f0f745a5f33c532065e93dc58ae Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Wed, 5 Aug 2026 11:02:35 +0200 Subject: [PATCH 21/21] fix(jmap): poll ContactCard/FileNode state too, not just Mailbox/Email/Calendar The mail-index's event-driven reindex depends on this poll to notice contacts/files changes when SSE/WS isn't available - found during the mail-index build's push-wiring investigation (the WS/SSE transport is already type-generic, but this poll fallback wasn't). Mirrors the existing Calendar branch exactly, same accountId resolution pattern. Confirmed the one pre-existing test failure this touches (jmap-client-resilience) is flaky independent of this change - ran the full suite twice with this edit stashed out, got 3 failed then 2 failed with no edit present. --- lib/jmap/client.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index f7c88a44..16502ccd 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6071,6 +6071,8 @@ export class JMAPClient implements IJMAPClient { 'Calendar/get': 'Calendar', 'CalendarEvent/get': 'CalendarEvent', 'SieveScript/get': 'SieveScript', + 'ContactCard/get': 'ContactCard', + 'FileNode/get': 'FileNode', }; private static readonly POLLING_INTERVAL = 3_000; @@ -6588,6 +6590,23 @@ export class JMAPClient implements IJMAPClient { ); } + // Contacts and files get no push at all today (mail-index's event-driven + // reindex depends on this poll to notice them when SSE/WS isn't + // available) - mirrors the Calendar branch above, same accountId caveat. + if (this.supportsContacts()) { + using.push('urn:ietf:params:jmap:contacts'); + methodCalls.push( + ['ContactCard/get', { accountId: this.getContactsAccountId(), ids: [], properties: ['id'] }, 'f'], + ); + } + + if (this.hasCapability('urn:ietf:params:jmap:filenode')) { + using.push('urn:ietf:params:jmap:filenode'); + methodCalls.push( + ['FileNode/get', { accountId: this.getFilesAccountId(), ids: [], properties: ['id'] }, 'g'], + ); + } + return { using, methodCalls }; }