diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a14a72f8..4b7f1668 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -58,6 +58,7 @@ import type { } from "../shared/types"; import type { ScopedAgentEvent, AgentProviderConfig } from "../shared/agent-types"; import { mergeAndThreadSearchResults } from "./utils/searchResults"; +import { buildSnoozeThreadAccounts } from "./utils/snooze-accounts"; import type { EmailThread } from "./store"; function decodeHtmlEntities(text: string): string { @@ -2335,8 +2336,9 @@ function SnoozeOverlay() { const { threads: currentThreads } = useThreadedEmails(); const selectedEmail = emails.find((e) => e.id === selectedEmailId); + const selectedAccountId = currentAccountId ?? selectedEmail?.accountId ?? null; - if (!showSnoozeMenu || !selectedEmail || !currentAccountId) return null; + if (!showSnoozeMenu || !selectedEmail || !selectedAccountId) return null; // Determine if we're in batch mode (any multi-select, even 1 thread via 'x') const isBatchSnooze = selectedThreadIds.size > 0; @@ -2356,12 +2358,22 @@ function SnoozeOverlay() { { if (isBatchSnooze) { // Batch snooze: snooze all selected threads using the same snoozeUntil time const snoozeUntil = snoozedEmail.snoozeUntil; const threadIdsToSnooze = Array.from(selectedThreadIds); + const threadAccountById = buildSnoozeThreadAccounts( + threadIdsToSnooze, + (tid) => { + const thread = currentThreads.find((t) => t.threadId === tid); + return thread ? { threadId: tid, accountId: thread.latestEmail.accountId } : undefined; + }, + snoozedEmail.threadId, + snoozedEmail.accountId || selectedAccountId, + selectedAccountId, + ); // Close menu and clear selection immediately useAppStore.setState({ @@ -2387,7 +2399,9 @@ function SnoozeOverlay() { newSnoozedIds.add(snoozedEmail.threadId); newSnoozedMap.set(snoozedEmail.threadId, snoozedEmail); - // Add remaining threads with unique ids and explicit accountId + // Add remaining threads with unique ids and explicit accountId. + // buildSnoozeThreadAccounts guarantees an entry for every thread, + // so the only skip is a thread that's no longer in the list. for (const tid of otherThreadIds) { const thread = currentThreads.find((t) => t.threadId === tid); if (thread) { @@ -2396,7 +2410,7 @@ function SnoozeOverlay() { id: `snooze-${tid}-${Date.now()}`, emailId: thread.latestEmail.id, threadId: tid, - accountId: currentAccountId, + accountId: threadAccountById[tid], snoozeUntil: snoozedEmail.snoozeUntil, snoozedAt: snoozedEmail.snoozedAt, }); @@ -2411,11 +2425,12 @@ function SnoozeOverlay() { id: `snooze-batch-${Date.now()}`, type: "snooze", threadCount: threadIdsToSnooze.length, - accountId: currentAccountId, + accountId: selectedAccountId, emails: [], scheduledAt: Date.now(), delayMs: 5000, snoozedThreadIds: threadIdsToSnooze, + snoozedThreadAccounts: threadAccountById, }); // Fire API calls for remaining threads in background @@ -2423,7 +2438,7 @@ function SnoozeOverlay() { const thread = currentThreads.find((t) => t.threadId === tid); if (thread) { window.api.snooze - .snooze(thread.latestEmail.id, tid, currentAccountId, snoozeUntil) + .snooze(thread.latestEmail.id, tid, threadAccountById[tid], snoozeUntil) .catch((err: unknown) => console.error("Batch snooze failed for thread", tid, err), ); @@ -2464,16 +2479,21 @@ function SnoozeOverlay() { return { snoozedThreadIds: newSnoozedIds, snoozedThreads: newSnoozedMap }; }); + // Prefer the account of the email that was actually snoozed, matching + // the batch path; fall back to the selected account. + const snoozedAccountId = snoozedEmail.accountId || selectedAccountId; + // Queue undo synchronously to avoid race with other actions in the rAF delay addUndoAction({ id: `snooze-${snoozedThreadId}-${Date.now()}`, type: "snooze", threadCount: 1, - accountId: currentAccountId, + accountId: snoozedAccountId, emails: [], scheduledAt: Date.now(), delayMs: 5000, snoozedThreadIds: [snoozedThreadId], + snoozedThreadAccounts: { [snoozedThreadId]: snoozedAccountId }, }); } }} diff --git a/src/renderer/components/UndoActionToast.tsx b/src/renderer/components/UndoActionToast.tsx index 4a2b0493..d87fd4d0 100644 --- a/src/renderer/components/UndoActionToast.tsx +++ b/src/renderer/components/UndoActionToast.tsx @@ -257,8 +257,9 @@ function UndoActionToastItem({ item }: { item: UndoActionItem }) { const store = useAppStore.getState(); for (const threadId of item.snoozedThreadIds) { store.removeSnoozedThread(threadId); + const threadAccountId = item.snoozedThreadAccounts?.[threadId] ?? item.accountId; api() - .snooze.unsnooze(threadId, item.accountId) + .snooze.unsnooze(threadId, threadAccountId) .catch((err: unknown) => { console.error("Failed to unsnooze:", err); }); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 315aa23c..ac135cf1 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -184,6 +184,8 @@ export type UndoActionItem = { archiveReadyThreadIds?: string[]; // For snooze undo: thread IDs to unsnooze snoozedThreadIds?: string[]; + // For cross-account snooze batches: thread ID -> owning account ID. + snoozedThreadAccounts?: Record; // For block: the bare sender email that was blocked. The block IPC is // deferred (commitAction in UndoActionToast calls emails:block-sender when // the timer elapses), and undo within the window simply restores the @@ -1400,6 +1402,10 @@ export const useAppStore = create((set, get) => ({ existing.snoozedThreadIds || item.snoozedThreadIds ? [...(existing.snoozedThreadIds || []), ...(item.snoozedThreadIds || [])] : undefined, + snoozedThreadAccounts: + existing.snoozedThreadAccounts || item.snoozedThreadAccounts + ? { ...(existing.snoozedThreadAccounts || {}), ...(item.snoozedThreadAccounts || {}) } + : undefined, }; return { undoActionQueue: state.undoActionQueue.map((i) => (i.id === existing.id ? merged : i)), diff --git a/src/renderer/utils/snooze-accounts.ts b/src/renderer/utils/snooze-accounts.ts new file mode 100644 index 00000000..02089218 --- /dev/null +++ b/src/renderer/utils/snooze-accounts.ts @@ -0,0 +1,41 @@ +// Resolve the owning account for each thread in a batch snooze. +// +// In the "All Inboxes" view there is no active account filter, so each selected +// thread can belong to a different account. We map every thread to its own +// account (from its latest email) so the snooze IPC and the undo/unsnooze flow +// target the correct account per thread, falling back to the triggering email's +// account when a thread's account can't be resolved. + +export interface ThreadAccountLookup { + threadId: string; + accountId: string | undefined; +} + +/** + * Build a `threadId -> accountId` map for a batch snooze. + * + * @param threadIds all thread IDs being snoozed + * @param lookup resolves a thread ID to its latest email's account + * @param triggerThreadId the thread whose snooze opened the menu + * @param triggerAccountId that thread's account (already known) + * @param fallbackAccountId account to use when a thread can't be resolved + */ +export function buildSnoozeThreadAccounts( + threadIds: string[], + lookup: (threadId: string) => ThreadAccountLookup | undefined, + triggerThreadId: string, + triggerAccountId: string, + fallbackAccountId: string, +): Record { + const map: Record = { + [triggerThreadId]: triggerAccountId || fallbackAccountId, + }; + + for (const threadId of threadIds) { + if (map[threadId]) continue; + const resolved = lookup(threadId)?.accountId ?? fallbackAccountId; + if (resolved) map[threadId] = resolved; + } + + return map; +} diff --git a/tests/e2e/snooze.spec.ts b/tests/e2e/snooze.spec.ts index 497401e9..004a00ff 100644 --- a/tests/e2e/snooze.spec.ts +++ b/tests/e2e/snooze.spec.ts @@ -1,5 +1,5 @@ import { test, expect, Page, ElectronApplication } from "@playwright/test"; -import { launchElectronApp , closeApp } from "./launch-helpers"; +import { launchElectronApp, closeApp } from "./launch-helpers"; /** * E2E Tests for Snooze Feature @@ -155,6 +155,86 @@ test.describe("Snooze Feature — Menu & Presets", () => { }); }); +test.describe("Snooze Feature — All Inboxes", () => { + let electronApp: ElectronApplication; + let page: Page; + + test.beforeAll(async ({}, testInfo) => { + const result = await launchElectronApp({ workerIndex: testInfo.workerIndex }); + electronApp = result.app; + page = result.page; + + await page.locator("[data-thread-id]").first().waitFor({ timeout: 15000 }); + }); + + test.afterAll(async () => { + if (electronApp) { + await closeApp(electronApp); + } + }); + + test("opens snooze menu for a selected email in All Inboxes", async () => { + const selected = await page.evaluate(() => { + const store = ( + window as unknown as { + __ZUSTAND_STORE__: { + getState: () => { + emails: Array<{ id: string; threadId: string; accountId?: string }>; + }; + setState: (state: Record) => void; + }; + } + ).__ZUSTAND_STORE__; + const state = store.getState(); + const email = state.emails.find((item) => item.accountId) ?? state.emails[0]; + + store.setState({ + currentAccountId: null, + selectedEmailId: email.id, + selectedThreadId: email.threadId, + showSnoozeMenu: false, + viewMode: "split", + }); + + return { accountId: email.accountId, threadId: email.threadId }; + }); + + expect(selected.accountId).toBeTruthy(); + + await page.keyboard.press("h"); + + await expect(page.locator("text=Later Today")).toBeVisible({ timeout: 3000 }); + + await page.locator("button:has-text('Tomorrow')").first().click(); + await expect(page.locator("text=Later Today")).not.toBeVisible({ timeout: 5000 }); + + const undoAccountId = await page.evaluate((threadId) => { + const store = ( + window as unknown as { + __ZUSTAND_STORE__: { + getState: () => { + undoActionQueue: Array<{ + type: string; + snoozedThreadAccounts?: Record; + }>; + }; + }; + } + ).__ZUSTAND_STORE__; + + const snoozeUndo = store.getState().undoActionQueue.find((item) => item.type === "snooze"); + return snoozeUndo?.snoozedThreadAccounts?.[threadId] ?? null; + }, selected.threadId); + + expect(undoAccountId).toBe(selected.accountId); + }); + + // NOTE: A faithful cross-account *batch* E2E isn't possible in demo mode — the + // snooze IPC only accepts the single real demo account, so a second account + // can't round-trip. The cross-account thread→account mapping is covered by a + // unit test instead (tests/unit/snooze-accounts.spec.ts). +}); + test.describe("Snooze Feature — Natural Language Input", () => { test.describe.configure({ mode: "serial" }); let electronApp: ElectronApplication; diff --git a/tests/unit/snooze-accounts.spec.ts b/tests/unit/snooze-accounts.spec.ts new file mode 100644 index 00000000..cd58f2f5 --- /dev/null +++ b/tests/unit/snooze-accounts.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "@playwright/test"; +import { + buildSnoozeThreadAccounts, + type ThreadAccountLookup, +} from "../../src/renderer/utils/snooze-accounts"; + +function lookupFrom(map: Record) { + return (threadId: string): ThreadAccountLookup | undefined => + threadId in map ? { threadId, accountId: map[threadId] } : undefined; +} + +test.describe("buildSnoozeThreadAccounts", () => { + test("maps each thread to its own account across accounts", () => { + const result = buildSnoozeThreadAccounts( + ["t1", "t2", "t3"], + lookupFrom({ t1: "account-a", t2: "account-b", t3: "account-c" }), + "t1", + "account-a", + "fallback", + ); + + expect(result).toEqual({ t1: "account-a", t2: "account-b", t3: "account-c" }); + }); + + test("uses the trigger account for the triggering thread even if lookup differs", () => { + // The triggering thread's account is already known (from the snoozed email), + // so it must win over a (possibly stale) lookup value. + const result = buildSnoozeThreadAccounts( + ["t1", "t2"], + lookupFrom({ t1: "stale", t2: "account-b" }), + "t1", + "account-a", + "fallback", + ); + + expect(result.t1).toBe("account-a"); + expect(result.t2).toBe("account-b"); + }); + + test("falls back when a thread's account can't be resolved", () => { + const result = buildSnoozeThreadAccounts( + ["t1", "t2"], + lookupFrom({ t1: undefined, t2: undefined }), // present but no accountId + "t0", + "account-a", + "fallback", + ); + + expect(result).toEqual({ t0: "account-a", t1: "fallback", t2: "fallback" }); + }); + + test("falls back when a thread is missing from the lookup entirely", () => { + const result = buildSnoozeThreadAccounts( + ["t1"], + lookupFrom({}), // no threads known + "t0", + "account-a", + "fallback", + ); + + expect(result).toEqual({ t0: "account-a", t1: "fallback" }); + }); + + test("uses the fallback for the trigger thread when its account is empty", () => { + const result = buildSnoozeThreadAccounts( + [], + lookupFrom({}), + "t0", + "", + "fallback", + ); + + expect(result).toEqual({ t0: "fallback" }); + }); +});