-
-
- API Key
-
-
setApiKey(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && !isLoading && handleSaveApiKey()}
- placeholder="sk-ant-api03-..."
- className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- />
+ {llmProvider === "codex" ? (
+
+
+
+ Use your ChatGPT-backed Codex login
+
+
+ Exo will use the local Codex CLI login already configured on this machine.
+
+
+
+
+
+
Codex CLI
+
+ {codexCliAvailable
+ ? codexAuthenticated
+ ? "Authenticated"
+ : "Installed, but not logged in"
+ : "Not detected"}
+
+
+
+ Refresh
+
+
+
+ {!codexAuthenticated && (
+
+ Run{" "}
+
+ codex login
+ {" "}
+ on this machine, then refresh and continue.
+
+ )}
-
+ ) : (
+ <>
+
+
+ Get your API key:
+
+
+
+ Go to{" "}
+
+ console.anthropic.com
+
+
+ Create a new API key (or use an existing one)
+ Paste it below
+
+
+
+
+
+
+ API Key
+
+ setApiKey(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && !isLoading && handleSaveApiKey()}
+ placeholder="sk-ant-api03-..."
+ className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ />
+
+
+ >
+ )}
{error && (
@@ -403,11 +531,15 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
)}
- {isLoading ? "Saving..." : "Continue"}
+ {isLoading
+ ? "Saving..."
+ : llmProvider === "codex"
+ ? "Continue with Codex"
+ : "Continue"}
>
)}
diff --git a/src/renderer/components/SnippetsEditor.tsx b/src/renderer/components/SnippetsEditor.tsx
index 22357c05..52a17bf8 100644
--- a/src/renderer/components/SnippetsEditor.tsx
+++ b/src/renderer/components/SnippetsEditor.tsx
@@ -6,7 +6,10 @@ type SuperhumanAccount = { email: string; snippetCount: number };
type ImportResult = { imported: number; warnings: string[] };
export function SnippetsEditor() {
- const { snippets: allSnippets, setSnippets, currentAccountId, accounts } = useAppStore();
+ const allSnippets = useAppStore((s) => s.snippets);
+ const setSnippets = useAppStore((s) => s.setSnippets);
+ const currentAccountId = useAppStore((s) => s.currentAccountId);
+ const accounts = useAppStore((s) => s.accounts);
const [editingSnippet, setEditingSnippet] = useState
(null);
const [isCreating, setIsCreating] = useState(false);
const [isLoading, setIsLoading] = useState(true);
diff --git a/src/renderer/components/SplitConfigEditor.tsx b/src/renderer/components/SplitConfigEditor.tsx
index d8d4650b..fb44cd11 100644
--- a/src/renderer/components/SplitConfigEditor.tsx
+++ b/src/renderer/components/SplitConfigEditor.tsx
@@ -235,7 +235,10 @@ type SuperhumanAccount = { email: string; splitCount: number };
type ImportResult = { imported: number; warnings: string[] };
export function SplitConfigEditor() {
- const { splits: allSplits, setSplits, currentAccountId, accounts } = useAppStore();
+ const allSplits = useAppStore((s) => s.splits);
+ const setSplits = useAppStore((s) => s.setSplits);
+ const currentAccountId = useAppStore((s) => s.currentAccountId);
+ const accounts = useAppStore((s) => s.accounts);
const [editingSplit, setEditingSplit] = useState(null);
const [isCreating, setIsCreating] = useState(false);
const [isLoading, setIsLoading] = useState(true);
diff --git a/src/renderer/hooks/useBatchActions.ts b/src/renderer/hooks/useBatchActions.ts
index 220c704a..ef09c2b4 100644
--- a/src/renderer/hooks/useBatchActions.ts
+++ b/src/renderer/hooks/useBatchActions.ts
@@ -7,104 +7,100 @@ import { trackEvent } from "../services/posthog";
* Safe to call from event handlers, useCallback bodies, or keyboard shortcuts.
*/
-export function batchArchive() {
- const {
- selectedThreadIds,
- emails,
- removeEmails,
- clearSelectedThreads,
- addUndoAction,
- currentAccountId,
- } = useAppStore.getState();
- if (!currentAccountId || selectedThreadIds.size === 0) return;
+function groupSelectedThreadsByAccount(
+ threadIds: Iterable,
+ emails: DashboardEmail[],
+): Map {
+ const grouped = new Map();
- const threadIds = Array.from(selectedThreadIds);
- const allEmailIds: string[] = [];
- const emailsByThread = new Map();
for (const threadId of threadIds) {
- const threadEmails = emails.filter((e) => e.threadId === threadId);
- emailsByThread.set(threadId, threadEmails);
- for (const email of threadEmails) {
- allEmailIds.push(email.id);
- }
+ const threadEmails = emails.filter((email) => email.threadId === threadId);
+ if (threadEmails.length === 0) continue;
+
+ const accountId = threadEmails[0].accountId;
+ const existing = grouped.get(accountId) ?? { threadIds: [], emails: [] };
+ existing.threadIds.push(threadId);
+ existing.emails.push(...threadEmails);
+ grouped.set(accountId, existing);
}
+ return grouped;
+}
+
+export function batchArchive() {
+ const { selectedThreadIds, emails, removeEmails, clearSelectedThreads, addUndoAction } =
+ useAppStore.getState();
+ if (selectedThreadIds.size === 0) return;
+
+ const threadIds = Array.from(selectedThreadIds);
+ const groupedByAccount = groupSelectedThreadsByAccount(threadIds, emails);
+ const allEmailIds = Array.from(groupedByAccount.values()).flatMap((group) =>
+ group.emails.map((email) => email.id),
+ );
+
// Optimistic UI: remove all emails from selected threads
- const allEmails = threadIds.flatMap((tid) => emailsByThread.get(tid) || []);
removeEmails(allEmailIds);
clearSelectedThreads();
- // Queue a single undo action for all threads
- addUndoAction({
- id: `archive-batch-${Date.now()}`,
- type: "archive",
- threadCount: threadIds.length,
- accountId: currentAccountId,
- emails: [...allEmails],
- scheduledAt: Date.now(),
- delayMs: 5000,
- });
+ for (const [accountId, group] of groupedByAccount) {
+ addUndoAction({
+ id: `archive-batch-${accountId}-${Date.now()}`,
+ type: "archive",
+ threadCount: group.threadIds.length,
+ accountId,
+ emails: [...group.emails],
+ scheduledAt: Date.now(),
+ delayMs: 5000,
+ });
+ }
// Tracks intent — user may still undo within 5 s
trackEvent("email_archived", { thread_count: threadIds.length, source: "batch" });
}
export function batchTrash() {
- const {
- selectedThreadIds,
- emails,
- removeEmails,
- clearSelectedThreads,
- addUndoAction,
- currentAccountId,
- } = useAppStore.getState();
- if (!currentAccountId || selectedThreadIds.size === 0) return;
+ const { selectedThreadIds, emails, removeEmails, clearSelectedThreads, addUndoAction } =
+ useAppStore.getState();
+ if (selectedThreadIds.size === 0) return;
const threadIds = Array.from(selectedThreadIds);
- const allEmailIds: string[] = [];
- const emailsByThread = new Map();
- for (const threadId of threadIds) {
- const threadEmails = emails.filter((e) => e.threadId === threadId);
- emailsByThread.set(threadId, threadEmails);
- for (const email of threadEmails) {
- allEmailIds.push(email.id);
- }
- }
+ const groupedByAccount = groupSelectedThreadsByAccount(threadIds, emails);
+ const allEmailIds = Array.from(groupedByAccount.values()).flatMap((group) =>
+ group.emails.map((email) => email.id),
+ );
- const allEmails = threadIds.flatMap((tid) => emailsByThread.get(tid) || []);
removeEmails(allEmailIds);
clearSelectedThreads();
- // Queue a single undo action for all threads
- addUndoAction({
- id: `trash-batch-${Date.now()}`,
- type: "trash",
- threadCount: threadIds.length,
- accountId: currentAccountId,
- emails: [...allEmails],
- scheduledAt: Date.now(),
- delayMs: 5000,
- });
+ for (const [accountId, group] of groupedByAccount) {
+ addUndoAction({
+ id: `trash-batch-${accountId}-${Date.now()}`,
+ type: "trash",
+ threadCount: group.threadIds.length,
+ accountId,
+ emails: [...group.emails],
+ scheduledAt: Date.now(),
+ delayMs: 5000,
+ });
+ }
// Tracks intent — user may still undo within 5 s
trackEvent("email_trashed", { thread_count: threadIds.length, source: "batch" });
}
export function batchToggleStar() {
- const {
- selectedThreadIds,
- emails,
- clearSelectedThreads,
- updateEmail,
- addUndoAction,
- currentAccountId,
- } = useAppStore.getState();
- if (!currentAccountId || selectedThreadIds.size === 0) return;
+ const { selectedThreadIds, emails, clearSelectedThreads, updateEmail, addUndoAction } =
+ useAppStore.getState();
+ if (selectedThreadIds.size === 0) return;
// Group emails by thread for the selected threads
- const selectedThreadEmails: Array<{ threadId: string; emails: typeof emails }> = [];
- for (const threadId of selectedThreadIds) {
- const threadEmails = emails.filter((e) => e.threadId === threadId);
- selectedThreadEmails.push({ threadId, emails: threadEmails });
- }
+ const selectedThreadEmails = Array.from(
+ groupSelectedThreadsByAccount(selectedThreadIds, emails),
+ ).flatMap(([accountId, group]) =>
+ group.threadIds.map((threadId) => ({
+ threadId,
+ accountId,
+ emails: group.emails.filter((email) => email.threadId === threadId),
+ })),
+ );
// If any thread is unstarred, star all; otherwise unstar all
const anyUnstarred = selectedThreadEmails.some(
@@ -140,16 +136,33 @@ export function batchToggleStar() {
if (changedEmails.length > 0) {
const actionType = anyUnstarred ? "star" : "unstar";
- addUndoAction({
- id: `${actionType}-batch-${Date.now()}`,
- type: actionType,
- threadCount: selectedThreadIds.size,
- accountId: currentAccountId,
- emails: changedEmails,
- scheduledAt: Date.now(),
- delayMs: 5000,
- previousLabels,
- });
+ const changedByAccount = new Map();
+ for (const email of changedEmails) {
+ const existing = changedByAccount.get(email.accountId) ?? [];
+ existing.push(email);
+ changedByAccount.set(email.accountId, existing);
+ }
+
+ for (const [accountId, accountEmails] of changedByAccount) {
+ const accountPreviousLabels = Object.fromEntries(
+ accountEmails
+ .map((email) => {
+ const labels = previousLabels[email.id];
+ return labels ? ([email.id, labels] as const) : null;
+ })
+ .filter((entry): entry is readonly [string, string[]] => entry !== null),
+ );
+ addUndoAction({
+ id: `${actionType}-batch-${accountId}-${Date.now()}`,
+ type: actionType,
+ threadCount: new Set(accountEmails.map((email) => email.threadId)).size,
+ accountId,
+ emails: accountEmails,
+ scheduledAt: Date.now(),
+ delayMs: 5000,
+ previousLabels: accountPreviousLabels,
+ });
+ }
const changedThreadCount = new Set(changedEmails.map((e) => e.threadId)).size;
trackEvent(anyUnstarred ? "email_starred" : "email_unstarred", {
thread_count: changedThreadCount,
@@ -158,15 +171,9 @@ export function batchToggleStar() {
}
export function batchMarkUnread() {
- const {
- selectedThreadIds,
- emails,
- clearSelectedThreads,
- updateEmail,
- addUndoAction,
- currentAccountId,
- } = useAppStore.getState();
- if (!currentAccountId || selectedThreadIds.size === 0) return;
+ const { selectedThreadIds, emails, clearSelectedThreads, updateEmail, addUndoAction } =
+ useAppStore.getState();
+ if (selectedThreadIds.size === 0) return;
const changedEmails: DashboardEmail[] = [];
const previousLabels: Record = {};
@@ -189,16 +196,33 @@ export function batchMarkUnread() {
clearSelectedThreads();
if (changedEmails.length > 0) {
- addUndoAction({
- id: `mark-unread-batch-${Date.now()}`,
- type: "mark-unread",
- threadCount: changedEmails.length,
- accountId: currentAccountId,
- emails: changedEmails,
- scheduledAt: Date.now(),
- delayMs: 5000,
- previousLabels,
- });
+ const changedByAccount = new Map();
+ for (const email of changedEmails) {
+ const existing = changedByAccount.get(email.accountId) ?? [];
+ existing.push(email);
+ changedByAccount.set(email.accountId, existing);
+ }
+
+ for (const [accountId, accountEmails] of changedByAccount) {
+ const accountPreviousLabels = Object.fromEntries(
+ accountEmails
+ .map((email) => {
+ const labels = previousLabels[email.id];
+ return labels ? ([email.id, labels] as const) : null;
+ })
+ .filter((entry): entry is readonly [string, string[]] => entry !== null),
+ );
+ addUndoAction({
+ id: `mark-unread-batch-${accountId}-${Date.now()}`,
+ type: "mark-unread",
+ threadCount: accountEmails.length,
+ accountId,
+ emails: accountEmails,
+ scheduledAt: Date.now(),
+ delayMs: 5000,
+ previousLabels: accountPreviousLabels,
+ });
+ }
trackEvent("email_marked_unread", {
thread_count: new Set(changedEmails.map((e) => e.threadId)).size,
});
diff --git a/src/renderer/hooks/useEmails.ts b/src/renderer/hooks/useEmails.ts
index 8a7c6a83..92e0009b 100644
--- a/src/renderer/hooks/useEmails.ts
+++ b/src/renderer/hooks/useEmails.ts
@@ -3,13 +3,9 @@ import { useAppStore } from "../store";
export function useEmails() {
const _queryClient = useQueryClient();
- const {
- setEmails,
- setLoading: _setLoading,
- setError: _setError,
- updateEmail,
- currentAccountId,
- } = useAppStore();
+ const setEmails = useAppStore((s) => s.setEmails);
+ const updateEmail = useAppStore((s) => s.updateEmail);
+ const currentAccountId = useAppStore((s) => s.currentAccountId);
const fetchEmailsQuery = useQuery({
queryKey: ["emails", currentAccountId],
diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts
index 913f6bec..8d9cc0fd 100644
--- a/src/renderer/hooks/useKeyboardShortcuts.ts
+++ b/src/renderer/hooks/useKeyboardShortcuts.ts
@@ -144,6 +144,15 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
state.currentSplitId === "__drafts__"
? currentThreads.filter((t) => t.draft && t.draft.body)
: currentThreads;
+ const selectedEmail = emails.find((email) => email.id === selectedEmailId);
+ const selectedAccountId = selectedEmail?.accountId ?? currentAccountId;
+ const currentUserEmailLookup = currentAccountId
+ ? accounts.find((account) => account.id === currentAccountId)?.email
+ : new Map(
+ accounts
+ .map((account) => [account.id, account.email] as const)
+ .filter((entry) => entry[1].length > 0),
+ );
// Always allow Escape to close modals or go back in view modes
if (e.key === "Escape") {
@@ -445,11 +454,12 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
};
// --- Helper: merge+dedup+thread search results (same order as rendered list) ---
- const currentUserEmail = accounts.find(
- (a: { id: string }) => a.id === currentAccountId,
- )?.email;
const getSearchThreads = () =>
- mergeAndThreadSearchResults(activeSearchResults, remoteSearchResults, currentUserEmail);
+ mergeAndThreadSearchResults(
+ activeSearchResults,
+ remoteSearchResults,
+ currentUserEmailLookup,
+ );
// --- Helper: navigate search results up/down (by thread) ---
const navigateSearchResults = (direction: "up" | "down") => {
@@ -473,16 +483,24 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
// --- Helper: get thread emails, falling back to search results if not in global store ---
const getThreadEmails = (threadId: string) => {
- const storeEmails = emails.filter((item) => item.threadId === threadId);
+ const storeEmails = emails.filter(
+ (item) =>
+ item.threadId === threadId &&
+ (selectedAccountId ? item.accountId === selectedAccountId : true),
+ );
if (storeEmails.length > 0) return storeEmails;
// Fallback: thread may only exist in search results, not yet in the global store
- const searchThread = getSearchThreads().find((t) => t.threadId === threadId);
+ const searchThread = getSearchThreads().find(
+ (t) =>
+ t.threadId === threadId &&
+ (selectedAccountId ? t.latestEmail.accountId === selectedAccountId : true),
+ );
return searchThread?.emails ?? [];
};
// --- Helper: archive selected thread (all messages) ---
const archiveSelected = () => {
- if (!selectedEmailId || !selectedThreadId || !currentAccountId) return;
+ if (!selectedEmailId || !selectedThreadId || !selectedAccountId) return;
// Collect ALL emails in the thread for optimistic removal
const threadEmails = getThreadEmails(selectedThreadId);
@@ -536,7 +554,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
id: `archive-${selectedThreadId}-${Date.now()}`,
type: "archive",
threadCount: 1,
- accountId: currentAccountId,
+ accountId: selectedAccountId,
emails: [...threadEmails],
scheduledAt: Date.now(),
delayMs: 5000,
@@ -549,7 +567,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
// --- Helper: trash selected thread ---
const trashSelected = () => {
- if (!selectedEmailId || !selectedThreadId || !currentAccountId) return;
+ if (!selectedEmailId || !selectedThreadId || !selectedAccountId) return;
const threadEmails = getThreadEmails(selectedThreadId);
const threadEmailIds = threadEmails.map((item) => item.id);
@@ -600,7 +618,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
id: `trash-${selectedThreadId}-${Date.now()}`,
type: "trash",
threadCount: 1,
- accountId: currentAccountId,
+ accountId: selectedAccountId,
emails: [...threadEmails],
scheduledAt: Date.now(),
delayMs: 5000,
@@ -611,9 +629,11 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
// --- Helper: mark selected thread as unread ---
const markSelectedUnread = () => {
- if (!selectedThreadId || !currentAccountId) return;
+ if (!selectedThreadId || !selectedAccountId) return;
- const threadEmails = emails.filter((item) => item.threadId === selectedThreadId);
+ const threadEmails = emails.filter(
+ (item) => item.threadId === selectedThreadId && item.accountId === selectedAccountId,
+ );
if (threadEmails.length === 0) return;
const latestEmail = threadEmails.reduce((a, b) =>
@@ -630,7 +650,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
id: `mark-unread-${selectedThreadId}-${Date.now()}`,
type: "mark-unread",
threadCount: 1,
- accountId: currentAccountId,
+ accountId: selectedAccountId,
emails: [latestEmail],
scheduledAt: Date.now(),
delayMs: 5000,
@@ -959,7 +979,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
// Shift+I: mark as read and return to list (Gmail only)
case "I":
if (isGmail && e.shiftKey) {
- if (selectedThreadId && currentAccountId) {
+ if (selectedThreadId) {
e.preventDefault();
markThreadAsRead(selectedThreadId);
if (viewMode === "full") {
@@ -974,9 +994,9 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
if (isMultiSelect) {
e.preventDefault();
batchToggleStar();
- } else if (isGmail && selectedThreadId && currentAccountId) {
+ } else if (isGmail && selectedThreadId && selectedAccountId) {
e.preventDefault();
- const threadEmails = emails.filter((item) => item.threadId === selectedThreadId);
+ const threadEmails = getThreadEmails(selectedThreadId);
if (threadEmails.length === 0) break;
const latestEmail = threadEmails.reduce((a, b) =>
new Date(a.date).getTime() >= new Date(b.date).getTime() ? a : b,
@@ -1000,7 +1020,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
id: `unstar-${selectedThreadId}-${Date.now()}`,
type: "unstar",
threadCount: 1,
- accountId: currentAccountId,
+ accountId: selectedAccountId,
emails: starredEmails,
scheduledAt: Date.now(),
delayMs: 5000,
@@ -1016,7 +1036,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
id: `star-${selectedThreadId}-${Date.now()}`,
type: "star",
threadCount: 1,
- accountId: currentAccountId,
+ accountId: selectedAccountId,
emails: [latestEmail],
scheduledAt: Date.now(),
delayMs: 5000,
@@ -1077,9 +1097,15 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
// Shift+N: force refresh/sync current account (Gmail only)
case "N":
- if (isGmail && e.shiftKey && currentAccountId) {
+ if (isGmail && e.shiftKey) {
e.preventDefault();
- window.api.sync.now(currentAccountId).catch(console.error);
+ if (currentAccountId) {
+ window.api.sync.now(currentAccountId).catch(console.error);
+ } else {
+ accounts.forEach((account) => {
+ window.api.sync.now(account.id).catch(console.error);
+ });
+ }
}
break;
diff --git a/src/renderer/hooks/useSyncBuffer.ts b/src/renderer/hooks/useSyncBuffer.ts
index 36e05986..1b099975 100644
--- a/src/renderer/hooks/useSyncBuffer.ts
+++ b/src/renderer/hooks/useSyncBuffer.ts
@@ -130,6 +130,28 @@ function hasPending(): boolean {
return pendingAdds.length > 0 || pendingRemoveIds.length > 0 || pendingUpdates.size > 0;
}
+function applySparseUpdates(
+ emails: DashboardEmail[],
+ updates: ReadonlyMap>,
+): DashboardEmail[] {
+ if (updates.size === 0) return emails;
+
+ let nextEmails: DashboardEmail[] | null = null;
+
+ for (let index = 0; index < emails.length; index += 1) {
+ const changes = updates.get(emails[index].id);
+ if (!changes) continue;
+
+ if (nextEmails === null) {
+ nextEmails = emails.slice();
+ }
+
+ nextEmails[index] = { ...emails[index], ...changes };
+ }
+
+ return nextEmails ?? emails;
+}
+
function flush(): void {
flushHandle = null;
@@ -168,10 +190,7 @@ function flush(): void {
// 2. In-place updates (label changes, analysis, etc.)
if (updates.size > 0) {
- emails = emails.map((email) => {
- const changes = updates.get(email.id);
- return changes ? { ...email, ...changes } : email;
- });
+ emails = applySparseUpdates(emails, updates);
}
// 3. Additions — deduplicate against current store AND pending removals
@@ -232,10 +251,7 @@ function flush(): void {
// Apply in-place merges for re-emitted emails
if (reEmitUpdates.size > 0) {
- emails = emails.map((email) => {
- const changes = reEmitUpdates.get(email.id);
- return changes ? { ...email, ...changes } : email;
- });
+ emails = applySparseUpdates(emails, reEmitUpdates);
}
if (brandNew.length > 0) {
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 91f14ab1..a2dbf5d1 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -1,4 +1,4 @@
-
+
@@ -7,7 +7,7 @@
If you use a custom/self-hosted PostHog host, update these domains too. -->
Exo
diff --git a/src/renderer/services/email-body-cache.ts b/src/renderer/services/email-body-cache.ts
index 979a82ca..76a6a874 100644
--- a/src/renderer/services/email-body-cache.ts
+++ b/src/renderer/services/email-body-cache.ts
@@ -1,4 +1,5 @@
import DOMPurify from "dompurify";
+import { replaceRemoteImageSources } from "../../shared/email-image-privacy";
/**
* Checks if content appears to be HTML.
@@ -356,7 +357,8 @@ class EmailBodyCache {
const needsPreLine = isPlainTextInHtml(stripped);
const clean = DOMPurify.sanitize(stripped, SANITIZE_CONFIG);
- const htmlContent = buildIframeHtml(clean, useLightMode, needsPreLine);
+ const privacySafe = replaceRemoteImageSources(clean, useLightMode);
+ const htmlContent = buildIframeHtml(privacySafe, useLightMode, needsPreLine);
return { isHtml: true, htmlContent };
}
diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts
index 7096cec1..437715c3 100644
--- a/src/renderer/store/index.ts
+++ b/src/renderer/store/index.ts
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { create } from "zustand";
+import { useStoreWithEqualityFn } from "zustand/traditional";
import { clearPendingLabelUpdates } from "../hooks-bridge";
import { applyOptimisticReads, addOptimisticReads } from "../optimistic-reads";
import type {
@@ -81,6 +82,44 @@ export type EmailThread = {
displaySender: string;
};
+type CurrentUserEmailLookup = string | ReadonlyMap | undefined;
+
+function areThreadingEmailsEqual(prev: DashboardEmail[], next: DashboardEmail[]): boolean {
+ if (prev === next) return true;
+ if (prev.length !== next.length) return false;
+
+ for (let index = 0; index < prev.length; index += 1) {
+ const previousEmail = prev[index];
+ const nextEmail = next[index];
+
+ if (previousEmail === nextEmail) continue;
+
+ if (
+ previousEmail.id !== nextEmail.id ||
+ previousEmail.threadId !== nextEmail.threadId ||
+ previousEmail.accountId !== nextEmail.accountId ||
+ previousEmail.subject !== nextEmail.subject ||
+ previousEmail.from !== nextEmail.from ||
+ previousEmail.to !== nextEmail.to ||
+ previousEmail.cc !== nextEmail.cc ||
+ previousEmail.bcc !== nextEmail.bcc ||
+ previousEmail.date !== nextEmail.date ||
+ previousEmail.snippet !== nextEmail.snippet ||
+ previousEmail.labelIds !== nextEmail.labelIds ||
+ previousEmail.isUnread !== nextEmail.isUnread ||
+ previousEmail.attachments !== nextEmail.attachments ||
+ previousEmail.messageId !== nextEmail.messageId ||
+ previousEmail.inReplyTo !== nextEmail.inReplyTo ||
+ previousEmail.analysis !== nextEmail.analysis ||
+ previousEmail.draft !== nextEmail.draft
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
// Account representation
export type Account = {
id: string;
@@ -346,7 +385,7 @@ interface AppState {
setShowSettings: (show: boolean, initialTab?: SettingsTab) => void;
updateEmail: (id: string, updates: Partial) => void;
// Multi-account actions
- setAccounts: (accounts: Account[]) => void;
+ setAccounts: (accounts: Account[], currentAccountId?: string | null) => void;
addAccount: (account: Account) => void;
removeAccount: (accountId: string) => void;
setCurrentAccountId: (accountId: string | null) => void;
@@ -805,19 +844,37 @@ export const useAppStore = create((set, get) => ({
highlightMemoryIds: show ? get().highlightMemoryIds : [],
}),
updateEmail: (id, updates) =>
- set((state) => ({
- emails: state.emails.map((email) => (email.id === id ? { ...email, ...updates } : email)),
- sentEmails: state.sentEmails.map((email) =>
- email.id === id ? { ...email, ...updates } : email,
- ),
- })),
+ set((state) => {
+ const nextState: Partial = {};
+
+ const emailIndex = state.emails.findIndex((email) => email.id === id);
+ if (emailIndex !== -1) {
+ const emails = state.emails.slice();
+ emails[emailIndex] = { ...emails[emailIndex], ...updates };
+ nextState.emails = emails;
+ }
+
+ const sentEmailIndex = state.sentEmails.findIndex((email) => email.id === id);
+ if (sentEmailIndex !== -1) {
+ const sentEmails = state.sentEmails.slice();
+ sentEmails[sentEmailIndex] = { ...sentEmails[sentEmailIndex], ...updates };
+ nextState.sentEmails = sentEmails;
+ }
+
+ return Object.keys(nextState).length > 0 ? nextState : state;
+ }),
// Multi-account actions
- setAccounts: (accounts) =>
- set({
- accounts,
- // Set current to primary or first account if not set
- currentAccountId:
- get().currentAccountId || accounts.find((a) => a.isPrimary)?.id || accounts[0]?.id || null,
+ setAccounts: (accounts, currentAccountId) =>
+ set((state) => {
+ let nextCurrentAccountId =
+ currentAccountId !== undefined ? currentAccountId : state.currentAccountId;
+ if (
+ nextCurrentAccountId !== null &&
+ !accounts.some((account) => account.id === nextCurrentAccountId)
+ ) {
+ nextCurrentAccountId = accounts.find((a) => a.isPrimary)?.id || accounts[0]?.id || null;
+ }
+ return { accounts, currentAccountId: nextCurrentAccountId };
}),
addAccount: (account) =>
set((state) => {
@@ -1586,10 +1643,9 @@ export const useAppStore = create((set, get) => ({
markThreadAsRead: (threadId) => {
const state = get();
- const accountId = state.currentAccountId;
- if (!accountId) return;
-
const threadEmails = state.emails.filter((e) => e.threadId === threadId);
+ const accountId = threadEmails[0]?.accountId ?? state.currentAccountId;
+ if (!accountId) return;
const unreadEmails = threadEmails.filter((e) => e.labelIds?.includes("UNREAD"));
if (unreadEmails.length === 0) return;
@@ -1684,16 +1740,27 @@ export function getAppStateSnapshot(): Record {
}
// Check if an email is sent by the user (not received)
-function isSentEmail(email: DashboardEmail, currentUserEmail?: string): boolean {
+function resolveCurrentUserEmail(
+ email: DashboardEmail,
+ currentUserEmail: CurrentUserEmailLookup,
+): string | undefined {
+ if (typeof currentUserEmail === "string") {
+ return currentUserEmail;
+ }
+ return currentUserEmail?.get(email.accountId);
+}
+
+function isSentEmail(email: DashboardEmail, currentUserEmail?: CurrentUserEmailLookup): boolean {
// Check labelIds first (most reliable)
if (email.labelIds?.includes("SENT")) {
return true;
}
// Fall back to checking the from field
- if (!currentUserEmail) return false;
+ const resolvedCurrentUserEmail = resolveCurrentUserEmail(email, currentUserEmail);
+ if (!resolvedCurrentUserEmail) return false;
const fromLower = email.from.toLowerCase();
- const userEmailLower = currentUserEmail.toLowerCase();
+ const userEmailLower = resolvedCurrentUserEmail.toLowerCase();
// Extract email from "Name " format if present
const emailMatch = fromLower.match(/<([^>]+)>/) || [null, fromLower];
const fromEmail = emailMatch[1] || fromLower;
@@ -1701,7 +1768,10 @@ function isSentEmail(email: DashboardEmail, currentUserEmail?: string): boolean
}
// Helper to group emails by thread
-export function groupByThread(emails: DashboardEmail[], currentUserEmail?: string): EmailThread[] {
+export function groupByThread(
+ emails: DashboardEmail[],
+ currentUserEmail?: CurrentUserEmailLookup,
+): EmailThread[] {
const threadMap = new Map();
// Pre-compute timestamps once to avoid creating Date objects in every sort
@@ -1795,7 +1865,11 @@ const REPLY_GRACE_PERIOD_MS = 3 * 60 * 1000; // 3 minutes
// Selector for threaded and filtered emails
export function useThreadedEmails() {
- const emails = useAppStore((state) => state.emails);
+ const emails = useStoreWithEqualityFn(
+ useAppStore,
+ (state) => state.emails,
+ areThreadingEmailsEqual,
+ );
const currentAccountId = useAppStore((state) => state.currentAccountId);
const accounts = useAppStore((state) => state.accounts);
const snoozedThreadIds = useAppStore((state) => state.snoozedThreadIds);
@@ -1804,6 +1878,15 @@ export function useThreadedEmails() {
// Get current user's email for sent detection
const currentAccount = accounts.find((a) => a.id === currentAccountId);
const currentUserEmail = currentAccount?.email;
+ const currentUserEmailsByAccount = useMemo(
+ () =>
+ new Map(
+ accounts
+ .map((account) => [account.id, account.email] as const)
+ .filter((entry) => entry[1].length > 0),
+ ),
+ [accounts],
+ );
// Memoize the expensive thread computation. j/k navigation only changes
// selectedEmailId — none of these deps change, so the memo short-circuits
@@ -1828,7 +1911,8 @@ export function useThreadedEmails() {
// Then filter out sent-only threads — threads where no email has the INBOX label.
// Sent emails within inbox threads are kept (for conversation context), but threads
// consisting solely of sent emails belong in the Sent view, not the inbox.
- const allThreads = groupByThread(accountEmails, currentUserEmail).filter((t) =>
+ const userEmailLookup = currentAccountId ? currentUserEmail : currentUserEmailsByAccount;
+ const allThreads = groupByThread(accountEmails, userEmailLookup).filter((t) =>
t.emails.some((e) => !e.labelIds || e.labelIds.includes("INBOX")),
);
@@ -1886,7 +1970,14 @@ export function useThreadedEmails() {
snoozed,
snoozedCount: snoozed.length,
};
- }, [emails, currentAccountId, currentUserEmail, snoozedThreadIds, recentlyRepliedThreadIds]);
+ }, [
+ emails,
+ currentAccountId,
+ currentUserEmail,
+ currentUserEmailsByAccount,
+ snoozedThreadIds,
+ recentlyRepliedThreadIds,
+ ]);
}
function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean {
@@ -1905,6 +1996,15 @@ export function useSplitFilteredThreads() {
const recentlyUnsnoozedThreadIds = useAppStore((state) => state.recentlyUnsnoozedThreadIds);
const unsnoozedReturnTimes = useAppStore((state) => state.unsnoozedReturnTimes);
const sentEmails = useAppStore((state) => state.sentEmails);
+ const currentUserEmailsByAccount = useMemo(
+ () =>
+ new Map(
+ accounts
+ .map((account) => [account.id, account.email] as const)
+ .filter((entry) => entry[1].length > 0),
+ ),
+ [accounts],
+ );
return useMemo(() => {
// Filter splits for current account
@@ -1942,7 +2042,8 @@ export function useSplitFilteredThreads() {
const sentAccountEmails = currentAccountId
? sentEmails.filter((e) => e.accountId === currentAccountId)
: sentEmails;
- const sentThreads = groupByThread(sentAccountEmails, currentUserEmail).sort(
+ const userEmailLookup = currentAccountId ? currentUserEmail : currentUserEmailsByAccount;
+ const sentThreads = groupByThread(sentAccountEmails, userEmailLookup).sort(
(a, b) => new Date(b.latestEmail.date).getTime() - new Date(a.latestEmail.date).getTime(),
);
diff --git a/src/renderer/utils/searchResults.ts b/src/renderer/utils/searchResults.ts
index b6a1d8b8..2fc02520 100644
--- a/src/renderer/utils/searchResults.ts
+++ b/src/renderer/utils/searchResults.ts
@@ -39,7 +39,7 @@ export function mergeAndSortSearchResults(
export function mergeAndThreadSearchResults(
localResults: readonly DashboardEmail[],
remoteResults: readonly DashboardEmail[],
- currentUserEmail?: string,
+ currentUserEmail?: string | ReadonlyMap,
): EmailThread[] {
const merged = mergeAndSortSearchResults(localResults, remoteResults);
const threads = groupByThread(merged, currentUserEmail);
diff --git a/src/shared/email-image-privacy.ts b/src/shared/email-image-privacy.ts
new file mode 100644
index 00000000..3ae39762
--- /dev/null
+++ b/src/shared/email-image-privacy.ts
@@ -0,0 +1,99 @@
+const TRACKING_PIXEL_MAX_SIZE = 4;
+const DEFAULT_PLACEHOLDER_WIDTH = 320;
+const DEFAULT_PLACEHOLDER_HEIGHT = 72;
+const MAX_PLACEHOLDER_WIDTH = 640;
+const MAX_PLACEHOLDER_HEIGHT = 240;
+
+function extractNumericAttribute(tag: string, attr: "width" | "height"): number | null {
+ const attrMatch = tag.match(new RegExp(`\\b${attr}\\s*=\\s*["']?(\\d+)(?:px)?["']?`, "i"));
+ if (attrMatch) return Number(attrMatch[1]);
+
+ const styleMatch = tag.match(/\bstyle\s*=\s*["']([^"']+)["']/i);
+ if (!styleMatch) return null;
+
+ const styleValue = styleMatch[1];
+ const cssMatch = styleValue.match(new RegExp(`${attr}\\s*:\\s*(\\d+)(?:px)?`, "i"));
+ return cssMatch ? Number(cssMatch[1]) : null;
+}
+
+function clampDimension(value: number | null, fallback: number, max: number): number {
+ if (!value || Number.isNaN(value) || value <= 0) return fallback;
+ return Math.min(value, max);
+}
+
+function isLikelyTrackingPixel(tag: string): boolean {
+ const width = extractNumericAttribute(tag, "width");
+ const height = extractNumericAttribute(tag, "height");
+ return (
+ width !== null &&
+ height !== null &&
+ width <= TRACKING_PIXEL_MAX_SIZE &&
+ height <= TRACKING_PIXEL_MAX_SIZE
+ );
+}
+
+function buildPrivacyPlaceholderDataUri(
+ width: number,
+ height: number,
+ useLightMode: boolean,
+): string {
+ const fill = useLightMode ? "#f9fafb" : "#1f2937";
+ const stroke = useLightMode ? "#d1d5db" : "#4b5563";
+ const text = useLightMode ? "#6b7280" : "#9ca3af";
+ const safeWidth = Math.max(width, 160);
+ const safeHeight = Math.max(height, 48);
+ const svg =
+ `` +
+ ` ` +
+ `` +
+ `Remote image blocked for privacy` +
+ ` `;
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
+}
+
+/**
+ * Replace remote loads with a local placeholder.
+ * Tiny tracking pixels are removed outright.
+ */
+export function replaceRemoteImageSources(html: string, useLightMode: boolean): string {
+ if (!html.includes(" ]*\bsrc\s*=\s*(["'])(https?:\/\/[^"']+)\1[^>]*>/gi,
+ (fullTag: string) => {
+ if (isLikelyTrackingPixel(fullTag)) {
+ return "";
+ }
+
+ const width = clampDimension(
+ extractNumericAttribute(fullTag, "width"),
+ DEFAULT_PLACEHOLDER_WIDTH,
+ MAX_PLACEHOLDER_WIDTH,
+ );
+ const height = clampDimension(
+ extractNumericAttribute(fullTag, "height"),
+ DEFAULT_PLACEHOLDER_HEIGHT,
+ MAX_PLACEHOLDER_HEIGHT,
+ );
+ const placeholderSrc = buildPrivacyPlaceholderDataUri(width, height, useLightMode);
+
+ let replaced = fullTag.replace(
+ /(\bsrc\s*=\s*["'])https?:\/\/[^"']+(["'])/i,
+ `$1${placeholderSrc}$2`,
+ );
+
+ const missingAttrs: string[] = [];
+ if (!/\balt\s*=/.test(replaced)) {
+ missingAttrs.push('alt="Remote image blocked for privacy"');
+ }
+ if (!/\btitle\s*=/.test(replaced)) {
+ missingAttrs.push('title="Remote image blocked for privacy"');
+ }
+ if (missingAttrs.length > 0) {
+ replaced = replaced.replace(/ ;
+
+/**
+ * Resolve which LLM backend should be used for built-in Exo features.
+ *
+ * Existing installs may not have an explicit provider set yet, so we infer one:
+ * prefer Anthropic when an API key is configured, otherwise fall back to Codex.
+ */
+export function resolveConfiguredLlmProvider(config: {
+ llmProvider?: LlmProvider;
+ anthropicApiKey?: string;
+}): LlmProvider {
+ if (config.llmProvider) return config.llmProvider;
+ return config.anthropicApiKey ? "anthropic" : "codex";
+}
+
+/**
+ * Map the configured built-in LLM backend to the matching built-in interactive agent provider.
+ *
+ * Anthropic-backed agent tasks run through the Claude provider, while Codex-backed
+ * agent tasks run through the Codex provider.
+ */
+export function resolveDefaultBuiltInAgentProviderId(config: {
+ llmProvider?: LlmProvider;
+ anthropicApiKey?: string;
+}): "claude" | "codex" {
+ return resolveConfiguredLlmProvider(config) === "codex" ? "codex" : "claude";
+}
+
// Config schema
export const ConfigSchema = z.object({
maxEmails: z.number().default(50),
@@ -365,7 +442,9 @@ export const ConfigSchema = z.object({
// via getModelIdForFeature(). Kept in the schema so existing config files parse without error.
model: z.string().default("claude-sonnet-4-20250514"),
modelConfig: ModelConfigSchema.optional(),
+ codexModel: z.string().trim().min(1).optional(),
dryRun: z.boolean().default(false),
+ llmProvider: LlmProviderSchema.optional(),
anthropicApiKey: z.string().optional(),
analysisPrompt: z.string().default(DEFAULT_ANALYSIS_PROMPT),
draftPrompt: z.string().default(DEFAULT_DRAFT_PROMPT),
@@ -378,6 +457,7 @@ export const ConfigSchema = z.object({
theme: z.enum(["light", "dark", "system"]).default("system"),
inboxDensity: z.enum(["default", "compact"]).default("compact"),
undoSendDelay: z.number().min(0).max(30).default(5), // seconds; 0 = disabled
+ selectedAccountId: z.string().nullable().optional(), // null = unified "All accounts" view
signatures: z.array(SignatureSchema).optional(),
showExoBranding: z.boolean().default(true),
stylePrompt: z.string().optional(),
diff --git a/tests/e2e/inline-images.spec.ts b/tests/e2e/inline-images.spec.ts
index 7c6fce02..d95adddc 100644
--- a/tests/e2e/inline-images.spec.ts
+++ b/tests/e2e/inline-images.spec.ts
@@ -2,7 +2,7 @@ import { test, expect, Page, ElectronApplication } from "@playwright/test";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
-import { launchElectronApp, takeScreenshot , closeApp } from "./launch-helpers";
+import { launchElectronApp, takeScreenshot, closeApp } from "./launch-helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -88,12 +88,12 @@ test.describe("Inline Images - Reading", () => {
);
});
- test("rich HTML email with external image also displays", async () => {
+ test("rich HTML email replaces external images with privacy placeholders", async () => {
// Navigate back to inbox (press Escape to deselect current email)
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
- // Click the Q3 report email which has an external image (https://via.placeholder.com)
+ // Click the Q3 report email which contains a remote image in the HTML body
const emailItem = page
.locator("button")
.filter({ hasText: /Garry|Q3 Quarterly/i })
@@ -108,17 +108,24 @@ test.describe("Inline Images - Reading", () => {
const frame = iframe.contentFrame();
expect(frame).not.toBeNull();
- // This email has the TechCorp logo image
+ // The remote image should be replaced with a local placeholder
const images = frame!.locator("img");
const imgCount = await images.count();
console.log(`Found ${imgCount} images in the Q3 report email`);
expect(imgCount).toBeGreaterThanOrEqual(1);
+ const src = await images.first().getAttribute("src");
+ expect(src).toBeTruthy();
+ expect(src!.startsWith("data:image/")).toBe(true);
+
+ const title = await images.first().getAttribute("title");
+ expect(title).toContain("Remote image blocked for privacy");
+
await takeScreenshot(
electronApp,
page,
- "inline-images-external",
- "Email with external image (TechCorp logo)",
+ "inline-images-external-blocked",
+ "Email with external image replaced by a privacy placeholder",
);
});
});
diff --git a/tests/unit/codex-cli.spec.ts b/tests/unit/codex-cli.spec.ts
new file mode 100644
index 00000000..6d441d84
--- /dev/null
+++ b/tests/unit/codex-cli.spec.ts
@@ -0,0 +1,81 @@
+import { expect, test } from "@playwright/test";
+import {
+ buildCodexPrompt,
+ buildCodexThreadOptions,
+ resolveCodexModel,
+} from "../../src/main/services/codex-cli";
+import type { LlmRequest } from "../../src/main/services/llm-types";
+import {
+ resolveConfiguredLlmProvider,
+ resolveDefaultBuiltInAgentProviderId,
+} from "../../src/shared/types";
+
+test.describe("Codex SDK adapter", () => {
+ test("buildCodexPrompt preserves system and conversation structure", () => {
+ const request: LlmRequest = {
+ model: "claude-sonnet-4-5-20250929",
+ max_tokens: 256,
+ system: [{ type: "text", text: "Return only JSON." }],
+ messages: [
+ { role: "user", content: "Analyze this email." },
+ { role: "assistant", content: [{ type: "text", text: "Previous answer." }] },
+ { role: "user", content: [{ type: "text", text: "Refine it." }] },
+ ],
+ };
+
+ const prompt = buildCodexPrompt(request);
+
+ expect(prompt).toContain("");
+ expect(prompt).toContain("Return only JSON.");
+ expect(prompt).toContain("\nAnalyze this email.\n ");
+ expect(prompt).toContain("\nPrevious answer.\n ");
+ expect(prompt).toContain("\nRefine it.\n ");
+ });
+
+ test("buildCodexPrompt allows web search when requested", () => {
+ const request: LlmRequest = {
+ model: "claude-sonnet-4-5-20250929",
+ max_tokens: 256,
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 1 }],
+ messages: [{ role: "user", content: "Look up this sender." }],
+ };
+
+ const prompt = buildCodexPrompt(request);
+
+ expect(prompt).toContain("You may use built-in web search when it helps answer accurately.");
+ expect(prompt).toContain("Do not inspect local files, do not run shell commands");
+ expect(prompt).toContain("do not use any tool other than web search");
+ });
+
+ test("buildCodexThreadOptions keeps the sandbox tight and strips Claude model ids", () => {
+ const options = buildCodexThreadOptions({
+ model: "claude-sonnet-4-5-20250929",
+ webSearchEnabled: true,
+ });
+
+ expect(options.model).toBe("gpt-5.3-codex-spark");
+ expect(options.sandboxMode).toBe("read-only");
+ expect(options.approvalPolicy).toBe("never");
+ expect(options.webSearchMode).toBe("live");
+ expect(options.skipGitRepoCheck).toBe(true);
+ });
+
+ test("resolveCodexModel preserves OpenAI models and falls back from Claude-specific ones", () => {
+ expect(resolveCodexModel("claude-opus-4-6")).toBe("gpt-5.3-codex-spark");
+ expect(resolveCodexModel("gpt-5.3-codex")).toBe("gpt-5.3-codex");
+ });
+
+ test("resolveConfiguredLlmProvider prefers Anthropic when a key exists", () => {
+ expect(resolveConfiguredLlmProvider({ anthropicApiKey: "sk-ant-123" })).toBe("anthropic");
+ expect(resolveConfiguredLlmProvider({})).toBe("codex");
+ expect(
+ resolveConfiguredLlmProvider({ llmProvider: "codex", anthropicApiKey: "sk-ant-123" }),
+ ).toBe("codex");
+ });
+
+ test("resolveDefaultBuiltInAgentProviderId follows the configured LLM backend", () => {
+ expect(resolveDefaultBuiltInAgentProviderId({})).toBe("codex");
+ expect(resolveDefaultBuiltInAgentProviderId({ anthropicApiKey: "sk-ant-123" })).toBe("claude");
+ expect(resolveDefaultBuiltInAgentProviderId({ llmProvider: "codex" })).toBe("codex");
+ });
+});
diff --git a/tests/unit/email-image-privacy.spec.ts b/tests/unit/email-image-privacy.spec.ts
new file mode 100644
index 00000000..cd615f0f
--- /dev/null
+++ b/tests/unit/email-image-privacy.spec.ts
@@ -0,0 +1,32 @@
+import { test, expect } from "@playwright/test";
+import { replaceRemoteImageSources } from "../../src/shared/email-image-privacy";
+
+test.describe("replaceRemoteImageSources", () => {
+ test("replaces remote image URLs with local privacy placeholders", () => {
+ const input =
+ '';
+ const output = replaceRemoteImageSources(input, true);
+
+ expect(output).not.toContain("https://tracker.example.com/logo.png");
+ expect(output).toContain("data:image/svg+xml,");
+ expect(output).toContain('alt="Remote image blocked for privacy"');
+ expect(output).toContain('title="Remote image blocked for privacy"');
+ });
+
+ test("removes likely tracking pixels entirely", () => {
+ const input =
+ 'Hello
';
+ const output = replaceRemoteImageSources(input, true);
+
+ expect(output).not.toContain(" Hello ");
+ });
+
+ test("leaves local and inline image sources untouched", () => {
+ const input =
+ '
';
+ const output = replaceRemoteImageSources(input, false);
+
+ expect(output).toBe(input);
+ });
+});
diff --git a/tests/unit/shared-types.spec.ts b/tests/unit/shared-types.spec.ts
index 0425f09c..a3d73d08 100644
--- a/tests/unit/shared-types.spec.ts
+++ b/tests/unit/shared-types.spec.ts
@@ -14,6 +14,9 @@ import {
resolveModelId,
MODEL_TIER_IDS,
DEFAULT_MODEL_CONFIG,
+ CODEX_DEFAULT_MODEL,
+ CODEX_MODEL_OPTIONS,
+ resolveCodexModelId,
DEFAULT_ANALYSIS_PROMPT,
DEFAULT_DRAFT_PROMPT,
type ModelTier,
@@ -217,6 +220,22 @@ test.describe("resolveModelId", () => {
});
});
+test.describe("resolveCodexModelId", () => {
+ test("uses Spark as the default fallback", () => {
+ expect(resolveCodexModelId()).toBe(CODEX_DEFAULT_MODEL);
+ expect(resolveCodexModelId("")).toBe(CODEX_DEFAULT_MODEL);
+ });
+
+ test("preserves configured Codex model IDs", () => {
+ expect(resolveCodexModelId("gpt-5.3-codex")).toBe("gpt-5.3-codex");
+ });
+
+ test("ships known preset options", () => {
+ expect(CODEX_MODEL_OPTIONS.map((option) => option.id)).toContain(CODEX_DEFAULT_MODEL);
+ expect(CODEX_MODEL_OPTIONS.map((option) => option.id)).toContain("gpt-5.3-codex");
+ });
+});
+
// ============================================================
// EAConfig validation
// ============================================================
@@ -265,6 +284,7 @@ test.describe("ConfigSchema", () => {
theme: "dark",
undoSendDelay: 10,
inboxDensity: "default",
+ selectedAccountId: null,
enableSenderLookup: false,
modelConfig: {
analysis: "haiku",
@@ -294,6 +314,12 @@ test.describe("ConfigSchema", () => {
});
expect(result.success).toBe(false);
});
+
+ test("validates persisted account selection", () => {
+ expect(ConfigSchema.safeParse({ selectedAccountId: null }).success).toBe(true);
+ expect(ConfigSchema.safeParse({ selectedAccountId: "default" }).success).toBe(true);
+ expect(ConfigSchema.safeParse({ selectedAccountId: 123 }).success).toBe(false);
+ });
});
// ============================================================
diff --git a/vite.worker.config.ts b/vite.worker.config.ts
index ffe5ee49..1ea2cb03 100644
--- a/vite.worker.config.ts
+++ b/vite.worker.config.ts
@@ -1,5 +1,5 @@
-import { resolve } from 'path'
-import { defineConfig } from 'vite'
+import { resolve } from "path";
+import { defineConfig } from "vite";
/**
* Separate build config for the agent-worker utility process.
@@ -14,26 +14,32 @@ import { defineConfig } from 'vite'
*/
export default defineConfig({
build: {
- outDir: 'out/worker',
+ outDir: "out/worker",
emptyOutDir: false,
lib: {
- entry: resolve(__dirname, 'src/main/agents/agent-worker.ts'),
- formats: ['cjs'],
- fileName: () => 'agent-worker.cjs',
+ entry: resolve(__dirname, "src/main/agents/agent-worker.ts"),
+ formats: ["cjs"],
+ fileName: () => "agent-worker.cjs",
},
rollupOptions: {
- external: [
- 'electron',
- 'better-sqlite3',
- // Externalize all bare imports (node_modules)
- /^[^./]/,
- ],
+ external: (id) => {
+ if (id === "@openai/codex-sdk" || id.startsWith("@openai/codex-sdk/")) {
+ return false;
+ }
+
+ if (id === "electron" || id === "better-sqlite3") {
+ return true;
+ }
+
+ // Externalize all other bare imports from node_modules.
+ return /^[^./]/.test(id);
+ },
},
- target: 'node20',
+ target: "node20",
minify: false,
sourcemap: true,
},
resolve: {
- conditions: ['node'],
+ conditions: ["node"],
},
-})
+});