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 <noreply@anthropic.com>
270 lines
9.9 KiB
TypeScript
270 lines
9.9 KiB
TypeScript
// 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, 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";
|
|
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
|
|
* 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<number> {
|
|
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<void> {
|
|
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<string> {
|
|
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}`;
|
|
|
|
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,
|
|
ELECTRON_RUN_AS_NODE: "1",
|
|
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: 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})`);
|
|
}
|
|
serverProcess = null;
|
|
});
|
|
|
|
await waitForServerReady(url);
|
|
return url;
|
|
}
|
|
|
|
function stopStandaloneServer(): void {
|
|
if (serverProcess && !serverProcess.killed) {
|
|
serverProcess.kill();
|
|
}
|
|
serverProcess = null;
|
|
}
|
|
|
|
async function createMainWindow(): Promise<void> {
|
|
// 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,
|
|
height: 860,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.js"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
},
|
|
});
|
|
|
|
mainWindow.on("closed", () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
await mainWindow.loadURL(url);
|
|
}
|
|
|
|
// --- Native notification bridge --------------------------------------------
|
|
// 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 };
|
|
}
|
|
const notification = new Notification({
|
|
title,
|
|
body: options?.body ?? "",
|
|
});
|
|
notification.show();
|
|
return { shown: true };
|
|
},
|
|
);
|
|
|
|
// --- 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", () => {
|
|
stopStandaloneServer();
|
|
if (process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on("before-quit", () => {
|
|
stopStandaloneServer();
|
|
});
|
|
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
void createMainWindow();
|
|
}
|
|
});
|