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:
@@ -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<AiPolicy>(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<AiLocalSettings>(() => loadAiSettings());
|
||||
|
||||
const [question, setQuestion] = useState('');
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [askResult, setAskResult] = useState<AskResult | null>(null);
|
||||
const [askError, setAskError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDialog}
|
||||
className="flex-shrink-0 p-2 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="AI Assistant"
|
||||
aria-label="AI Assistant"
|
||||
data-tour="ai-assistant"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[10vh]"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AI Assistant"
|
||||
>
|
||||
<div className="w-full max-w-xl rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">AI Assistant</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{!configured ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<div>
|
||||
<Button size="sm" onClick={goToSettings}>
|
||||
<Settings2 className="w-3.5 h-3.5 me-1.5" />
|
||||
Open AI settings
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void runAsk();
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="Ask a question about your mail…"
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 resize-y"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" onClick={() => void runAsk()} disabled={!canAsk}>
|
||||
{asking && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
|
||||
Ask
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">⌘⏎ to send</span>
|
||||
</div>
|
||||
|
||||
{askError && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{askError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askResult && (
|
||||
<div className={cn('flex flex-col gap-2 rounded-lg border border-border p-4', 'max-h-[45vh] overflow-y-auto')}>
|
||||
{askResult.unaugmented && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No local mail index available in this session — answered without retrieval context.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
||||
{askResult.sources.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border pt-2 mt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Sources</span>
|
||||
{askResult.sources.map((s, i) => (
|
||||
<span key={s.id} className="text-xs text-muted-foreground truncate">
|
||||
[{i + 1}] {s.subject}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user