From b9a5370e81ffd657e327493ebcda7b9efa8987a7 Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Wed, 3 Jun 2026 13:25:05 -0400 Subject: [PATCH 1/3] fix snooze in all inboxes --- src/renderer/App.tsx | 29 ++++++++---- src/renderer/components/UndoActionToast.tsx | 3 +- src/renderer/store/index.ts | 6 +++ tests/e2e/snooze.spec.ts | 52 +++++++++++++++++++++ 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a14a72f8..78d534c9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2335,8 +2335,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 +2357,20 @@ 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: Record = { + [snoozedEmail.threadId]: snoozedEmail.accountId || selectedAccountId, + }; + for (const tid of threadIdsToSnooze) { + const thread = currentThreads.find((t) => t.threadId === tid); + const accountId = thread?.latestEmail.accountId ?? threadAccountById[tid]; + if (accountId) threadAccountById[tid] = accountId; + } // Close menu and clear selection immediately useAppStore.setState({ @@ -2390,13 +2399,14 @@ function SnoozeOverlay() { // Add remaining threads with unique ids and explicit accountId for (const tid of otherThreadIds) { const thread = currentThreads.find((t) => t.threadId === tid); - if (thread) { + const accountId = thread?.latestEmail.accountId ?? threadAccountById[tid]; + if (thread && accountId) { newSnoozedIds.add(tid); newSnoozedMap.set(tid, { id: `snooze-${tid}-${Date.now()}`, emailId: thread.latestEmail.id, threadId: tid, - accountId: currentAccountId, + accountId, snoozeUntil: snoozedEmail.snoozeUntil, snoozedAt: snoozedEmail.snoozedAt, }); @@ -2411,19 +2421,21 @@ 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 for (const tid of otherThreadIds) { const thread = currentThreads.find((t) => t.threadId === tid); - if (thread) { + const accountId = thread?.latestEmail.accountId ?? threadAccountById[tid]; + if (thread && accountId) { window.api.snooze - .snooze(thread.latestEmail.id, tid, currentAccountId, snoozeUntil) + .snooze(thread.latestEmail.id, tid, accountId, snoozeUntil) .catch((err: unknown) => console.error("Batch snooze failed for thread", tid, err), ); @@ -2469,11 +2481,12 @@ function SnoozeOverlay() { id: `snooze-${snoozedThreadId}-${Date.now()}`, type: "snooze", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: [], scheduledAt: Date.now(), delayMs: 5000, snoozedThreadIds: [snoozedThreadId], + snoozedThreadAccounts: { [snoozedThreadId]: selectedAccountId }, }); } }} 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/tests/e2e/snooze.spec.ts b/tests/e2e/snooze.spec.ts index 497401e9..914b64f1 100644 --- a/tests/e2e/snooze.spec.ts +++ b/tests/e2e/snooze.spec.ts @@ -155,6 +155,58 @@ 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 }; + }); + + expect(selected.accountId).toBeTruthy(); + + await page.keyboard.press("h"); + + await expect(page.locator("text=Later Today")).toBeVisible({ timeout: 3000 }); + }); +}); + test.describe("Snooze Feature — Natural Language Input", () => { test.describe.configure({ mode: "serial" }); let electronApp: ElectronApplication; From 32e8689a61d7db67d67f928c8245e5b2b067ab4a Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Mon, 15 Jun 2026 15:29:07 -0400 Subject: [PATCH 2/3] address review bot feedback (reviewloop iteration 1) --- tests/e2e/snooze.spec.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/e2e/snooze.spec.ts b/tests/e2e/snooze.spec.ts index 914b64f1..0a93b7fa 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 @@ -196,7 +196,7 @@ test.describe("Snooze Feature — All Inboxes", () => { viewMode: "split", }); - return { accountId: email.accountId }; + return { accountId: email.accountId, threadId: email.threadId }; }); expect(selected.accountId).toBeTruthy(); @@ -204,6 +204,29 @@ test.describe("Snooze Feature — All Inboxes", () => { 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); }); }); From 655119d535b482c65c95c6e7821f71c5cf551243 Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Wed, 17 Jun 2026 11:24:51 -0400 Subject: [PATCH 3/3] Harden cross-account batch snooze account resolution From code review of the All-Inboxes snooze fix: - Extract the per-thread account mapping into a pure, unit-tested helper (buildSnoozeThreadAccounts). It always resolves an account for every thread via an explicit fallback, so a thread can no longer be silently dropped from a batch snooze when its account isn't directly resolvable. - Single-thread path now records the actually-snoozed email's account (snoozedEmail.accountId ?? selectedAccountId), matching the batch path, instead of selectedAccountId alone. - Add unit coverage for the cross-account mapping. A faithful cross-account batch E2E isn't possible in demo mode (the snooze IPC only accepts the single real demo account), so the mapping is covered at the unit level; documented with a NOTE in the e2e spec. Co-Authored-By: Claude Opus 4.8 --- src/renderer/App.tsx | 41 +++++++++------ src/renderer/utils/snooze-accounts.ts | 41 +++++++++++++++ tests/e2e/snooze.spec.ts | 5 ++ tests/unit/snooze-accounts.spec.ts | 75 +++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 src/renderer/utils/snooze-accounts.ts create mode 100644 tests/unit/snooze-accounts.spec.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 78d534c9..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 { @@ -2363,14 +2364,16 @@ function SnoozeOverlay() { // Batch snooze: snooze all selected threads using the same snoozeUntil time const snoozeUntil = snoozedEmail.snoozeUntil; const threadIdsToSnooze = Array.from(selectedThreadIds); - const threadAccountById: Record = { - [snoozedEmail.threadId]: snoozedEmail.accountId || selectedAccountId, - }; - for (const tid of threadIdsToSnooze) { - const thread = currentThreads.find((t) => t.threadId === tid); - const accountId = thread?.latestEmail.accountId ?? threadAccountById[tid]; - if (accountId) threadAccountById[tid] = accountId; - } + 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({ @@ -2396,17 +2399,18 @@ 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); - const accountId = thread?.latestEmail.accountId ?? threadAccountById[tid]; - if (thread && accountId) { + if (thread) { newSnoozedIds.add(tid); newSnoozedMap.set(tid, { id: `snooze-${tid}-${Date.now()}`, emailId: thread.latestEmail.id, threadId: tid, - accountId, + accountId: threadAccountById[tid], snoozeUntil: snoozedEmail.snoozeUntil, snoozedAt: snoozedEmail.snoozedAt, }); @@ -2432,10 +2436,9 @@ function SnoozeOverlay() { // Fire API calls for remaining threads in background for (const tid of otherThreadIds) { const thread = currentThreads.find((t) => t.threadId === tid); - const accountId = thread?.latestEmail.accountId ?? threadAccountById[tid]; - if (thread && accountId) { + if (thread) { window.api.snooze - .snooze(thread.latestEmail.id, tid, accountId, snoozeUntil) + .snooze(thread.latestEmail.id, tid, threadAccountById[tid], snoozeUntil) .catch((err: unknown) => console.error("Batch snooze failed for thread", tid, err), ); @@ -2476,17 +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: selectedAccountId, + accountId: snoozedAccountId, emails: [], scheduledAt: Date.now(), delayMs: 5000, snoozedThreadIds: [snoozedThreadId], - snoozedThreadAccounts: { [snoozedThreadId]: selectedAccountId }, + snoozedThreadAccounts: { [snoozedThreadId]: snoozedAccountId }, }); } }} 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 0a93b7fa..004a00ff 100644 --- a/tests/e2e/snooze.spec.ts +++ b/tests/e2e/snooze.spec.ts @@ -228,6 +228,11 @@ test.describe("Snooze Feature — All Inboxes", () => { 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", () => { 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" }); + }); +});