From b648c1c267a0cfd3114ae09f0aa289b78bc9c7a8 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Thu, 6 Aug 2026 19:03:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(electron):=20packaged=20app=20shipped=20wit?= =?UTF-8?q?hout=20a=20session=20secret=20=E2=80=94=20index/AI=20auth=20was?= =?UTF-8?q?=20dead=20on=20real=20installs;=20add=20AI=20entry=20point=20to?= =?UTF-8?q?=20the=20mail=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/(main)/[locale]/page.tsx | 79 +++++---- components/ai/ai-ask-button.tsx | 250 ++++++++++++++++++++++++++++ e2e/electron-ai-local-index.spec.ts | 72 ++++---- electron/main.ts | 52 ++++++ 4 files changed, 390 insertions(+), 63 deletions(-) create mode 100644 components/ai/ai-ask-button.tsx diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 74843917..c7aab870 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -67,6 +67,7 @@ import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; +import { AiAskButton } from "@/components/ai/ai-ask-button"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; import { useConfig } from "@/hooks/use-config"; @@ -1070,37 +1071,58 @@ export default function Home() { // 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 */ + // + // RETRIED, not one-shot. The first attempt races the login flow's own + // POST /api/auth/stalwart-context (stores/auth-store.ts's + // syncStalwartAuthContext) - if the index route is hit before that cookie + // is minted it 401s, and a single silent attempt would leave the index + // empty until the next app restart with nothing telling anyone why (this + // exact silence hid the packaged app's missing-SESSION_SECRET bug against + // a real mailbox). requestIndex() already distinguishes the permanent + // cases (404/503 -> unavailable) from the retryable ones, so retrying is + // cheap and self-limiting. + const catchUpRetryDelaysMs = [4000, 20000, 60000]; + let catchUpCancelled = false; + let catchUpTimer: ReturnType | undefined; + + const runCatchUp = async (attempt: number) => { + if (catchUpCancelled) return; + try { + const { catchUpIndex } = await import('@/lib/mail-index-client'); + const result = await catchUpIndex( + useAccountStore.getState().getActiveAccount()?.cookieSlot, + ); + if (!result.ok && !result.unavailable && attempt + 1 < catchUpRetryDelaysMs.length) { + catchUpTimer = setTimeout(() => void runCatchUp(attempt + 1), catchUpRetryDelaysMs[attempt + 1]); + return; } - // The offline REPLICA's launch catch-up. Same reasoning as the index's, - // plus one of its own: a `/changes` cursor cannot tell us about anything - // that happened while the process was dead, so a cycle at launch is what - // drains the backlog. One cycle is bounded, so a first sync of a large - // mailbox needs several - `chainSync` runs them with a hard cap. - // - // Sequenced AFTER the index rather than in parallel: both write the same - // SQLite file, and although `busy_timeout` makes concurrent writers safe, - // there is no reason to spend the contention during first paint. - try { - const { chainSync } = await import('@/lib/offline-replica-client'); - await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot }); - } catch { - /* the replica is optional */ - } - })(); - // Deliberately after the initial mailbox fetch settles: the catch-up is a - // background nicety and must not compete with first paint. - }, 4000); + } catch { + /* the index is optional */ + } + // The offline REPLICA's launch catch-up. Same reasoning as the index's, + // plus one of its own: a `/changes` cursor cannot tell us about anything + // that happened while the process was dead, so a cycle at launch is what + // drains the backlog. One cycle is bounded, so a first sync of a large + // mailbox needs several - `chainSync` runs them with a hard cap. + // + // Sequenced AFTER the index (including its retries) rather than in + // parallel: both write the same SQLite file, and although `busy_timeout` + // makes concurrent writers safe, there is no reason to spend the + // contention during first paint. + if (catchUpCancelled) return; + try { + const { chainSync } = await import('@/lib/offline-replica-client'); + await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot }); + } catch { + /* the replica is optional */ + } + }; + // Deliberately after the initial mailbox fetch settles: the catch-up is a + // background nicety and must not compete with first paint. + catchUpTimer = setTimeout(() => void runCatchUp(0), catchUpRetryDelaysMs[0]); return () => { + catchUpCancelled = true; clearTimeout(catchUpTimer); cleanups.forEach((fn) => fn()); }; @@ -3164,6 +3186,7 @@ export default function Home() { )} + diff --git a/components/ai/ai-ask-button.tsx b/components/ai/ai-ask-button.tsx new file mode 100644 index 00000000..91f616df --- /dev/null +++ b/components/ai/ai-ask-button.tsx @@ -0,0 +1,250 @@ +'use client'; + +// The AI Assistant's entry point in the MAIN mail view — a Sparkles button in +// the search toolbar that opens a compact Ask dialog. Until this existed, the +// only way to ask the assistant anything was the "Try it" box buried in +// Settings → AI Assistant, which is a configuration screen, not a workflow. +// +// Deliberately reuses the exact same wire client (lib/ai/local-client's +// askMail) and the exact same persisted provider settings as the Settings +// pane — this is a second door to the same room, not a second room. When no +// provider is configured yet, the dialog deep-links to the Settings pane +// (where local-discovery offers the one-click Connect) instead of duplicating +// that setup flow here. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { AlertTriangle, Loader2, Settings2, Sparkles, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/browser-navigation'; +import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; +import { supportsLocalLlm } from '@/lib/platform-capabilities'; +import { getAiApiKey } from '@/lib/ai/key-store'; +import { loadAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings'; +import { askMail, type AskResult } from '@/lib/ai/local-client'; + +function useAiPolicy(): { policy: AiPolicy; loaded: boolean } { + const [policy, setPolicy] = useState(DEFAULT_AI_POLICY); + const [loaded, setLoaded] = useState(false); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await apiFetch('/api/ai/policy'); + if (res.ok && !cancelled) setPolicy(await res.json()); + } catch { + /* stays at DEFAULT (disabled) — the button simply doesn't render */ + } finally { + if (!cancelled) setLoaded(true); + } + })(); + return () => { + cancelled = true; + }; + }, []); + return { policy, loaded }; +} + +/** Mirrors the Settings pane's canAsk gating: is any provider actually ready? */ +function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolean { + const classes = policy.entitlement.classes; + switch (settings.provider) { + case 'local': + return supportsLocalLlm() && classes.includes('local') && !!settings.localModel; + case 'server': + return classes.includes('server') && !!settings.serverModel; + case 'public': { + const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; + return classes.includes('public') && !!active && settings.publicConsentAccepted; + } + default: + return false; + } +} + +export function AiAskButton() { + const router = useRouter(); + const { policy, loaded } = useAiPolicy(); + const [open, setOpen] = useState(false); + // Re-read on every open: the user may have just configured a provider in + // Settings and come straight back here — a mount-time snapshot would still + // say "not configured". + const [settings, setSettings] = useState(() => loadAiSettings()); + + const [question, setQuestion] = useState(''); + const [asking, setAsking] = useState(false); + const [askResult, setAskResult] = useState(null); + const [askError, setAskError] = useState(null); + const textareaRef = useRef(null); + + const openDialog = useCallback(() => { + setSettings(loadAiSettings()); + setAskResult(null); + setAskError(null); + setOpen(true); + }, []); + + useEffect(() => { + if (!open) return; + textareaRef.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open]); + + const configured = providerConfigured(settings, policy); + const canAsk = configured && question.trim().length > 0 && !asking; + + const runAsk = useCallback(async () => { + if (!canAsk) return; + setAsking(true); + setAskError(null); + setAskResult(null); + try { + const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; + const key = activeProfile ? getAiApiKey(activeProfile.id) : null; + const result = await askMail(question.trim(), { + provider: settings.provider as 'local' | 'server' | 'public', + localBaseUrl: settings.localBaseUrl, + localModel: settings.localModel, + serverModel: settings.serverModel, + publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, + }); + setAskResult(result); + } catch (err) { + setAskError(err instanceof Error ? err.message : String(err)); + } finally { + setAsking(false); + } + }, [canAsk, question, settings]); + + const goToSettings = useCallback(() => { + // The Settings page's one-shot deep-link channel (see readPersistedTab in + // app/(main)/[locale]/settings/page.tsx) — lands directly on the AI pane, + // where local-discovery's Connect banner does the actual setup. + try { + sessionStorage.setItem('settings-deep-link-tab', 'ai_assistant'); + } catch { + /* private mode — the settings page just opens on its default tab */ + } + setOpen(false); + router.push('/settings'); + }, [router]); + + // Hidden entirely when the admin gate is off or no provider class is + // allowed — same visibility rule as the Settings pane itself. + if (!loaded || !policy.enabled || policy.entitlement.classes.length === 0) return null; + + return ( + <> + + + {open && ( +
{ + if (e.target === e.currentTarget) setOpen(false); + }} + role="dialog" + aria-modal="true" + aria-label="AI Assistant" + > +
+
+
+ +

AI Assistant

+
+ +
+ +
+ {!configured ? ( + <> +

+ No AI provider is set up yet. Pick one in Settings — if Ollama is running on this machine, a + one-click Connect is waiting there. +

+
+ +
+ + ) : ( + <> +