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:
@@ -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<typeof setTimeout> | 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() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<AiAskButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user