fix(electron): packaged app shipped without a session secret — index/AI auth was dead on real installs; add AI entry point to the mail view

Root cause of "No local mail index available in this session" on a real
mailbox in the packaged .app, found by probing the live packaged build:
getSessionSecret() has four sources (env, env file, wizard config,
config file) and the desktop shell provided NONE — getDesktopDefaults()
sets JMAP_SERVER_URL (which also skips the setup wizard that would have
persisted a secret) but never a SESSION_SECRET. So every login's POST
/api/auth/stalwart-context 500'd, the jmap_stalwart_ctx cookie was never
minted, and every server-side-identity feature 401'd forever: encrypted
local index, offline replica, S/MIME enrolment, AI server class. The AI
retrieval leg renders any non-OK as "no local index", so the failure was
completely silent. Every test had masked this by injecting its own
SESSION_SECRET into the child env.

Fix 1 — electron/main.ts ensureSessionSecretFile(): a 64-hex-char secret
generated once per install, persisted 0600 under userData, handed to the
server as SESSION_SECRET_FILE (value stays out of the env block; an
operator-provided SESSION_SECRET env var still wins by resolution order).

Fix 2 — page.tsx boot catch-up now RETRIES (4s/20s/60s) instead of one
silent shot: the first attempt races login's own auth-context POST, and a
401 on that race used to mean an empty index until the next app restart.
requestIndex() already separates permanent (404/503 unavailable) from
retryable failures, so the retry is cheap and self-limiting.

Fix 3 — new components/ai/ai-ask-button.tsx: the AI Assistant finally has
an entry point in the MAIN mail view (Sparkles button next to the search
filter) opening a compact Ask dialog — same askMail client, same persisted
provider settings as the Settings pane. When nothing is configured it
deep-links to Settings → AI Assistant, where local-discovery's one-click
Connect does setup.

e2e hardened to prove the whole thing honestly: SESSION_SECRET explicitly
EMPTY in the launch env (the per-install secret must carry auth), the
manual sync/reindex calls removed (the automatic boot catch-up must build
the index on its own — polled, not triggered), and the toolbar entry
point asserted. Passing: auto-built index, discovery banner, Connect, and
a grounded answer citing the one email containing the fact.

Gate: tsc clean, eslint clean, 2502/2502 unit tests, e2e passing.
This commit is contained in:
Bernd Rodler
2026-08-06 19:03:48 +02:00
parent 2ac022df50
commit b648c1c267
4 changed files with 390 additions and 63 deletions
+52
View File
@@ -9,6 +9,7 @@
import { app, BrowserWindow, ipcMain, Notification } from "electron";
import { autoUpdater } from "electron-updater";
import { spawn, type ChildProcess } from "node:child_process";
import { randomBytes } from "node:crypto";
import { createServer } from "node:net";
import { get as httpGet } from "node:http";
import path from "node:path";
@@ -115,6 +116,50 @@ function getDesktopDefaults(): Record<string, string> {
};
}
/**
* Per-install session secret for the standalone server.
*
* The server's cookie crypto (lib/auth/crypto.ts) refuses to mint the
* `jmap_stalwart_ctx` auth-context cookie without a >=32-char SESSION_SECRET,
* and every server-side-identity feature hangs off that cookie: the encrypted
* local search index and offline replica (their routes 401 without it),
* S/MIME enrolment, and the AI `server` class. A web deployment gets the
* secret from an operator (env var or the setup wizard); the desktop shell
* has NO operator, and up to 1.7.8 the packaged app simply shipped without
* one — so every login's stalwart-context POST failed with 500, the index
* stayed permanently empty, and the AI assistant answered "No local mail
* index available in this session" against real accounts. Caught live on a
* real mailbox, not by tests: every test run had injected its own
* SESSION_SECRET into the child env, masking exactly this.
*
* Generated once per install (64 hex chars, comfortably over the minimum),
* persisted 0600 under userData next to the rest of the per-user state, and
* handed to the server as SESSION_SECRET_FILE rather than SESSION_SECRET so
* the value itself stays out of the child's environment block. A
* deployment-provided SESSION_SECRET env var still wins — getSessionSecret()
* resolves the env var before the file.
*/
function ensureSessionSecretFile(): string | null {
const secretPath = path.join(app.getPath("userData"), "session-secret");
try {
const existing = fs.readFileSync(secretPath, "utf8").trim();
if (existing.length >= 32) return secretPath;
} catch {
/* first run — generate below */
}
try {
fs.mkdirSync(path.dirname(secretPath), { recursive: true });
fs.writeFileSync(secretPath, randomBytes(32).toString("hex"), { mode: 0o600 });
return secretPath;
} catch (cause) {
// Loud, because the downstream symptom is otherwise "index/AI features
// return 401" with no hint of why — but never fatal: reading mail does
// not depend on this cookie.
console.error("[electron] could not persist a session secret:", cause);
return null;
}
}
/**
* Locates the standalone server's entrypoint. Packaged builds ship it as an
* extraResource (see electron-builder.config.js) because .next/standalone
@@ -210,12 +255,19 @@ async function startStandaloneServer(): Promise<string> {
// 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.
const sessionSecretFile = ensureSessionSecretFile();
serverProcess = spawn(process.execPath, [serverEntry], {
env: {
// First, so any real deployment env (a future per-install override,
// or this same binary run somewhere JMAP_SERVER_URL is already set)
// wins over these desktop-shell defaults - see getDesktopDefaults().
...getDesktopDefaults(),
// Also before ...process.env: an operator-provided SESSION_SECRET or
// SESSION_SECRET_FILE must win over the per-install default (and a
// SESSION_SECRET env var outranks any file in getSessionSecret()'s
// resolution order regardless).
...(sessionSecretFile ? { SESSION_SECRET_FILE: sessionSecretFile } : {}),
...process.env,
ELECTRON_RUN_AS_NODE: "1",
PORT: String(port),