diff --git a/components/email/__tests__/email-context-menu-row.test.tsx b/components/email/__tests__/email-context-menu-row.test.tsx new file mode 100644 index 000000000..87eb9c323 --- /dev/null +++ b/components/email/__tests__/email-context-menu-row.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { rememberTrashOrigins } from '@/lib/trash-origin'; + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); + +import { EmailContextMenu } from '../email-context-menu'; + +// A row's right-click menu had 22 entries and was 640 px tall. It now has what +// people use from a row, as Outlook and Gmail have it, and the rest under +// "More actions". Spam's "Not spam" and Trash's "Restore" stay at the top. + +const email = { + id: 'e1', + blobId: 'b1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + hasAttachment: false, + size: 100, +} as unknown as Email; + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, + { id: 'work', name: 'Work', role: null, parentId: null, myRights: { mayAddItems: true } }, + { id: 'junk', name: 'Spam', role: 'junk', parentId: null, myRights: { mayAddItems: true } }, + { id: 'trash', name: 'Trash', role: 'trash', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +type MenuProps = React.ComponentProps; + +function renderMenu(props: Partial = {}) { + const onClose = vi.fn(); + render( + , + ); + return { onClose }; +} + +const topLevel = () => { + const menu = screen.getAllByRole('menu')[0]; + return Array.from(menu.querySelectorAll(':scope > div > [role="menuitem"], :scope > div > div > [role="menuitem"]')) + .map((row) => row.querySelector('[data-menu-label]')?.textContent ?? ''); +}; + +afterEach(() => { + localStorage.clear(); +}); + +describe("a row's right-click menu", () => { + it('has the usual few at the top and the rest under More actions', () => { + renderMenu(); + const top = topLevel(); + expect(top).toEqual(expect.arrayContaining(['reply', 'reply_all', 'forward', 'archive', 'delete', 'move_to', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'open_in_window', 'more_actions'])); + for (const label of ['forward_as_attachment', 'copy_message', 'pin', 'print', 'view_source', 'export_email', 'rules.title']) { + expect(top).not.toContain(label); + } + fireEvent.click(screen.getByTestId('ctx-more')); + const more = screen.getByRole('menu', { name: 'more_actions' }); + for (const id of ['ctx-print', 'ctx-view-source', 'ctx-export']) expect(within(more).getByTestId(id)).toBeTruthy(); + expect(within(more).getByText('forward_as_attachment')).toBeTruthy(); + expect(within(more).getByText('pin')).toBeTruthy(); + }); + + it('shows Not spam at the top in Spam', () => { + renderMenu({ selectedMailbox: 'junk', currentMailboxRole: 'junk', email: { ...email, mailboxIds: { junk: true } } as Email }); + expect(topLevel()).toContain('not_spam'); + }); + + it('the reader\'s ⋮ keeps every entry at the top: it is the "more" already', () => { + renderMenu({ rowMenu: false }); + expect(screen.queryByTestId('ctx-more')).toBeNull(); + expect(screen.getByTestId('ctx-print')).toBeTruthy(); + }); +}); + +describe('Restore in Trash', () => { + const trashed = { ...email, mailboxIds: { trash: true } } as Email; + + it('puts a message back in the folder it was deleted from', () => { + rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }], mailboxes); + const onMoveToMailbox = vi.fn(); + renderMenu({ email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', onMoveToMailbox }); + expect(topLevel()).toContain('restore'); + fireEvent.click(screen.getByTestId('ctx-restore')); + expect(onMoveToMailbox).toHaveBeenCalledWith('work'); + }); + + it('puts it in the Inbox when nothing says where it came from', () => { + const onMoveToMailbox = vi.fn(); + renderMenu({ email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', onMoveToMailbox }); + fireEvent.click(screen.getByTestId('ctx-restore')); + expect(onMoveToMailbox).toHaveBeenCalledWith('inbox'); + }); + + it('is in the selection menu too', () => { + rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }, { id: 'e2', mailboxIds: { work: true } }], mailboxes); + const onBatchMoveToMailbox = vi.fn(); + renderMenu({ + email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', isMultiSelect: true, selectedCount: 2, + batchEmails: [trashed, { ...trashed, id: 'e2' } as Email], onBatchMoveToMailbox, + }); + fireEvent.click(screen.getByTestId('ctx-restore')); + expect(onBatchMoveToMailbox).toHaveBeenCalledWith('work'); + }); +}); + +describe('Mark read and unread on a selection', () => { + it('offers both when the selection has read and unread mail', () => { + const onBatchMarkAsRead = vi.fn(); + renderMenu({ + isMultiSelect: true, selectedCount: 2, onBatchMarkAsRead, + batchEmails: [email, { ...email, id: 'e2', keywords: {} } as Email], + }); + expect(screen.getByTestId('ctx-mark-unread')).toBeTruthy(); + fireEvent.click(screen.getByTestId('ctx-mark-read')); + expect(onBatchMarkAsRead).toHaveBeenCalledWith(true); + }); + + it('offers only Mark as unread when all are read', () => { + renderMenu({ isMultiSelect: true, selectedCount: 2, onBatchMarkAsRead: vi.fn(), batchEmails: [email, { ...email, id: 'e2' } as Email] }); + expect(screen.queryByTestId('ctx-mark-read')).toBeNull(); + expect(screen.getByTestId('ctx-mark-unread')).toBeTruthy(); + }); +}); + diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 42d529e73..3ee1bd823 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -28,8 +28,11 @@ import { Paperclip, Copy, Fish, + MoreHorizontal, + Undo, } from "@/components/icons"; -import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; +import { tidySeparators, useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; +import { restoreTarget, restoreTargetForAll } from "@/lib/trash-origin"; import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu"; import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; @@ -143,12 +146,30 @@ interface EmailContextMenuProps { extraEntries?: React.ReactNode; /** The menu's accessible name, e.g. "More actions" from the reader's ⋮. */ label?: string; + /** + * A list row's right-click menu: what people use from a row, and the rest under + * "More actions". The reader's ⋮ is that "more" already, so it shows them all. + */ + rowMenu?: boolean; } +/** + * What a row's menu shows at its top level, as Outlook and Gmail have it. The rest + * goes under "More actions". + */ +export const ROW_MENU_KEYS = new Set([ + "reschedule", "cancel_and_edit", "cancel_send", "edit_draft", + "reply", "reply_all", "forward", + "restore", "archive", "delete", "move", + "read", "star", "tag", + "spam", + "open_window", +]); + /** Entries that change the message on the server, or queue the change offline. */ const CHANGES_MAIL = new Set([ "archive", "delete", "read", "remove_from_folder", "star", "pin", "spam", - "reschedule", "cancel_send", "cancel_and_edit", + "reschedule", "cancel_send", "cancel_and_edit", "restore", ]); export function EmailContextMenu({ @@ -207,8 +228,11 @@ export function EmailContextMenu({ rowPlugins = true, extraEntries, label, + rowMenu = false, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); + const tViewer = useTranslations("email_viewer"); + const tRecover = useTranslations("recover_deleted"); const shortcutOf = useMessageActionShortcut(); // Offline, a change to a message waits in the outbox only where the account keeps // its mail on this device; otherwise it would fail, so it's greyed out. Rules and @@ -286,6 +310,13 @@ export function EmailContextMenu({ onClose(); }; + // In Trash: back to the folder it came from, or its account's Inbox. + const inTrash = currentMailboxRole === "trash"; + const restoreTo = inTrash && onMoveToMailbox ? restoreTarget(email, mailboxes, selectedMailbox) : undefined; + const batchRestoreTo = inTrash && onBatchMoveToMailbox && showBatchActions + ? restoreTargetForAll(batchEmails ?? [], mailboxes, selectedMailbox) + : undefined; + // One message: the shared action list (R6), the same the reader's ⋮ and // the phone panel show. const singleEntries = useMessageActionList({ @@ -315,6 +346,7 @@ export function EmailContextMenu({ onExport, onPrint, onViewSource, + onRestore: restoreTo ? () => onMoveToMailbox?.(restoreTo) : undefined, omit, views, extras, @@ -374,19 +406,46 @@ export function EmailContextMenu({ /> ); + // A selection of read and unread mail gets both; otherwise the one that changes something. + const selection = batchEmails?.length ? batchEmails : [email]; + const someUnread = selection.some((e) => !e.keywords?.$seen); + const someRead = selection.some((e) => !!e.keywords?.$seen); const readItem = ( <> - {/* Mark as read/unread */} - handleAction(() => onBatchMarkAsRead?.(isUnread))} - /> + {someUnread && ( + handleAction(() => onBatchMarkAsRead?.(true))} + /> + )} + {someRead && ( + handleAction(() => onBatchMarkAsRead?.(false))} + /> + )} ); + const batchRestoreItem = batchRestoreTo ? ( + <> + handleAction(() => onBatchMoveToMailbox?.(batchRestoreTo))} + /> + + + ) : null; const moveItem = ( <> {/* Move to submenu */} @@ -502,8 +561,8 @@ export function EmailContextMenu({ } }; - const singleMenu = singleEntries.map((entry, index) => { - if (entry.kind === "separator") return ; + const renderEntries = (entries: MessageActionEntry[], keyPrefix = "") => entries.map((entry, index) => { + if (entry.kind === "separator") return ; if (entry.kind === "submenu") return renderSubmenu(entry); return ( ); }); + // A row's menu: the usual few, the rest one level down. Not Spam in Spam + // and Restore in Trash stay at the top. + const rowTop = rowMenu + ? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || ROW_MENU_KEYS.has(entry.key))) + : singleEntries; + const rowMore = rowMenu + ? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || !ROW_MENU_KEYS.has(entry.key))) + : []; + const singleMenu = ( + <> + {renderEntries(rowTop)} + {rowMore.length > 0 && ( + <> + + + {renderEntries(rowMore, "more-")} + + + )} + + ); return ( {!isScheduled && (<> + {batchRestoreItem} {/* Compose-style actions first, above the archive/delete group, mirroring the single menu's ordering. */} void handleExportEmail(), onPrint: devices.noPrint ? undefined : () => handlePrint(), onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true), + // The phone panel's Restore; the ⋮ menu works it out from onMoveToMailbox. + onRestore: currentMailboxRole === 'trash' && onMoveToMailbox + ? () => { const to = restoreTarget(email, mailboxes, selectedMailbox); if (to) onMoveToMailbox(to); } + : undefined, omit: readerMenuOmit, views: readerViews, }; diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts index b9078819a..6fdfb03f7 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -39,6 +39,7 @@ import { Printer, Code, Fish, + Undo, } from "@/components/icons"; type Icon = ComponentType<{ className?: string }>; @@ -70,6 +71,7 @@ export type MessageActionKey = | "export" | "print" | "view_source" + | "restore" | MessageSubmenuKey // A host's own entries (the reader's view toggles), appended after the list. | `extra:${string}`; @@ -123,6 +125,8 @@ export interface MessageActionOptions { onExport?: () => void; onPrint?: () => void; onViewSource?: () => void; + /** In Trash: back to the folder it came from, or the Inbox (lib/trash-origin.ts). */ + onRestore?: () => void; /** Entries the host already shows elsewhere (the reader's toolbar). */ omit?: ReadonlySet; /** How the host shows the message (the reader's view toggles): a group before Export, Print and View source. */ @@ -156,6 +160,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr const tDeepLink = useTranslations("deep_link"); const tPhishing = useTranslations("phishing_report"); const tWindows = useTranslations("pwaWindows"); + const tRecover = useTranslations("recover_deleted"); const copyLink = useCopyLink(); const archiveEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); @@ -233,6 +238,10 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr })] : []), ]; + // In Trash the first thing after answering: getting it back. + const restore = o.onRestore && o.currentMailboxRole === "trash" + ? [item("restore", Undo, tRecover("restore"), o.onRestore, { testId: "ctx-restore" }), SEPARATOR] + : []; const archive = archiveEnabled ? [item("archive", Archive, t("archive"), o.onArchive)] : []; const del = item("delete", Trash2, t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" }); const read = item("read", isUnread ? MailOpen : Mail, isUnread ? t("mark_read") : t("mark_unread"), () => o.onMarkAsRead?.(isUnread), { @@ -283,13 +292,13 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr // open and link; how it shows; save, print, source. list = mountainView ? [ - ...draft, ...replies, ...archive, del, read, SEPARATOR, + ...draft, ...replies, ...restore, ...archive, del, read, SEPARATOR, ...move, tag, ...rules, ...copy, SEPARATOR, star, ...pin, ...spam, ...reportPhishing, ...find, SEPARATOR, ...links, SEPARATOR, ...views, SEPARATOR, ...openWindow, ...file, ] : [ - ...draft, ...replies, ...archive, del, SEPARATOR, + ...draft, ...replies, ...restore, ...archive, del, SEPARATOR, ...move, ...copy, ...rules, SEPARATOR, read, star, ...pin, tag, SEPARATOR, ...spam, ...reportPhishing, SEPARATOR, diff --git a/lib/__tests__/trash-origin.test.ts b/lib/__tests__/trash-origin.test.ts new file mode 100644 index 000000000..5abed9baa --- /dev/null +++ b/lib/__tests__/trash-origin.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { rememberTrashOrigins, restoreTarget, restoreTargetForAll } from '../trash-origin'; + +const mailboxes = [ + { id: 'inbox', role: 'inbox', myRights: { mayAddItems: true } }, + { id: 'work', role: null, myRights: { mayAddItems: true } }, + { id: 'trash', role: 'trash', myRights: { mayAddItems: true } }, + { id: 'team:inbox', role: 'inbox', isShared: true, accountId: 'team', myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +afterEach(() => localStorage.clear()); + +describe('where Restore puts a message', () => { + it('back in the folder it went to Trash from', () => { + rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }], mailboxes); + expect(restoreTarget({ id: 'e1', mailboxIds: { trash: true } }, mailboxes, 'trash')).toBe('work'); + }); + + it('the Inbox when nothing was noted, or the folder is gone', () => { + expect(restoreTarget({ id: 'e2', mailboxIds: { trash: true } }, mailboxes, 'trash')).toBe('inbox'); + rememberTrashOrigins([{ id: 'e3', mailboxIds: { gone: true } }], mailboxes); + expect(restoreTarget({ id: 'e3', mailboxIds: { trash: true } }, mailboxes, 'trash')).toBe('inbox'); + }); + + it('a shared account\'s message goes to that account\'s Inbox', () => { + expect(restoreTarget({ id: 'e4', sourceAccountId: 'team', mailboxIds: {} }, mailboxes, 'trash')).toBe('team:inbox'); + }); + + it('several: where they all came from, or the Inbox when that differs', () => { + rememberTrashOrigins([{ id: 'a', mailboxIds: { work: true } }, { id: 'b', mailboxIds: { work: true } }], mailboxes); + expect(restoreTargetForAll([{ id: 'a', mailboxIds: {} }, { id: 'b', mailboxIds: {} }], mailboxes, 'trash')).toBe('work'); + expect(restoreTargetForAll([{ id: 'a', mailboxIds: {} }, { id: 'c', mailboxIds: {} }], mailboxes, 'trash')).toBe('inbox'); + }); + + it('Trash itself is never noted as where a message came from', () => { + rememberTrashOrigins([{ id: 'e5', mailboxIds: { trash: true } }], mailboxes); + expect(localStorage.getItem('bulwark.trashOrigin.v1')).toBeNull(); + }); +}); diff --git a/lib/trash-origin.ts b/lib/trash-origin.ts new file mode 100644 index 000000000..d64b0174b --- /dev/null +++ b/lib/trash-origin.ts @@ -0,0 +1,91 @@ +/** + * Where a message was before it went to Trash, so "Restore" can put it back there. + * JMAP doesn't keep that, so this device notes it when Bulwark moves mail to Trash. + * A message deleted elsewhere, or one whose folder is gone, goes back to the Inbox + * of its own account. + */ + +import type { Email, Mailbox } from "./jmap/types"; +import { resolveMoveOwnerAccountId } from "./move-targets"; + +const KEY = "bulwark.trashOrigin.v1"; +const KEPT = 2000; + +type Store = Record; + +function read(): Store { + try { + const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(KEY); + const parsed: unknown = raw ? JSON.parse(raw) : null; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: Store = {}; + for (const [id, mailboxId] of Object.entries(parsed as Record)) { + if (typeof mailboxId === "string") out[id] = mailboxId; + } + return out; + } catch { + return {}; + } +} + +/** Notes the folder each message is in, as it goes to Trash. */ +export function rememberTrashOrigins( + emails: Array>, + mailboxes: Array>, +): void { + const store = read(); + let changed = false; + for (const email of emails) { + const from = Object.keys(email.mailboxIds ?? {}).find((id) => { + const mailbox = mailboxes.find((m) => m.id === id); + return !mailbox || (mailbox.role !== "trash" && mailbox.role !== "junk"); + }); + if (!from) continue; + // Newest last, so the oldest go first when the list is full. + delete store[email.id]; + store[email.id] = from; + changed = true; + } + if (!changed) return; + const ids = Object.keys(store); + for (const id of ids.slice(0, Math.max(0, ids.length - KEPT))) delete store[id]; + try { + localStorage.setItem(KEY, JSON.stringify(store)); + } catch { + // Storage full or off: Restore goes to the Inbox. + } +} + +/** The Inbox of the account a message lives in. */ +function inboxFor(email: Pick, mailboxes: Mailbox[], selectedMailboxId?: string | null): Mailbox | undefined { + const accountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailboxId); + const shared = mailboxes.find((m) => m.isShared && m.role === "inbox" && m.accountId === accountId); + return shared ?? mailboxes.find((m) => !m.isShared && m.role === "inbox"); +} + +/** + * Where Restore puts a message: the folder it was in, if that still exists and takes + * mail, else its account's Inbox. + */ +export function restoreTarget( + email: Pick, + mailboxes: Mailbox[], + selectedMailboxId?: string | null, +): string | undefined { + const origin = read()[email.id]; + const mailbox = origin ? mailboxes.find((m) => m.id === origin) : undefined; + if (mailbox && mailbox.role !== "trash" && mailbox.myRights?.mayAddItems !== false) return mailbox.id; + return inboxFor(email, mailboxes, selectedMailboxId)?.id; +} + +/** One place for several messages: where they all came from, or the Inbox when they differ. */ +export function restoreTargetForAll( + emails: Array>, + mailboxes: Mailbox[], + selectedMailboxId?: string | null, +): string | undefined { + if (emails.length === 0) return undefined; + const targets = new Set(emails.map((email) => restoreTarget(email, mailboxes, selectedMailboxId))); + if (targets.size === 1) return [...targets][0]; + return inboxFor(emails[0], mailboxes, selectedMailboxId)?.id; +} diff --git a/stores/__tests__/email-store-trash-origin.test.ts b/stores/__tests__/email-store-trash-origin.test.ts new file mode 100644 index 000000000..9d49b3e44 --- /dev/null +++ b/stores/__tests__/email-store-trash-origin.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { emailKeyFor } from '@/lib/thread-utils'; +import { restoreTarget } from '@/lib/trash-origin'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// "Restore" in Trash puts a message back where it was. JMAP doesn't keep that, +// so the store notes it as it moves mail to Trash, one message or a selection. + +function makeMailbox(overrides: Partial): Mailbox { + return { + id: 'inbox', + name: 'Inbox', + sortOrder: 0, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + isSubscribed: true, + isShared: false, + ...overrides, + } as Mailbox; +} + +function makeEmail(id: string, overrides: Partial = {}): Email { + return { + id, + threadId: `t-${id}`, + subject: id, + receivedAt: '2026-01-01T00:00:00Z', + keywords: { $seen: true }, + mailboxIds: { inbox: true }, + ...overrides, + } as Email; +} + +const mailboxes = [ + makeMailbox({ id: 'inbox', role: 'inbox' }), + makeMailbox({ id: 'custom', name: 'Projects' }), + makeMailbox({ id: 'archive', name: 'Archive', role: 'archive' }), + makeMailbox({ id: 'archive-2026', name: '2026', parentId: 'archive' }), + makeMailbox({ id: 'trash', name: 'Trash', role: 'trash' }), + makeMailbox({ id: 'junk', name: 'Junk', role: 'junk' }), +]; + +function makeClient(): IJMAPClient { + return { + getAccountId: () => 'me', + moveToTrash: vi.fn().mockResolvedValue(undefined), + deleteEmail: vi.fn().mockResolvedValue(undefined), + batchDeleteEmails: vi.fn().mockResolvedValue(undefined), + moveEmail: vi.fn().mockResolvedValue(undefined), + batchMoveEmails: vi.fn().mockResolvedValue(undefined), + batchArchiveEmails: vi.fn().mockResolvedValue(undefined), + markAsRead: vi.fn().mockResolvedValue(undefined), + batchMarkAsRead: vi.fn().mockResolvedValue(undefined), + toggleStar: vi.fn().mockResolvedValue(undefined), + batchUpdateKeywords: vi.fn().mockResolvedValue(undefined), + setKeyword: vi.fn().mockResolvedValue(undefined), + markAsSpam: vi.fn().mockResolvedValue(undefined), + undoSpam: vi.fn().mockResolvedValue(undefined), + getMailboxes: vi.fn().mockResolvedValue(mailboxes), + getAllMailboxes: vi.fn().mockResolvedValue(mailboxes), + getEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0 }), + getMaxObjectsInGet: () => 500, + getThreads: vi.fn().mockResolvedValue([]), + } as unknown as IJMAPClient; +} + +let client: IJMAPClient; + +function openFolder(folderId: string, emails: Email[]) { + useEmailStore.setState({ + mailboxes, + accountMailboxes: {}, + viewingAccountId: null, + selectedMailbox: folderId, + selectedKeyword: null, + isUnifiedView: false, + unifiedRole: null, + crossView: null, + searchQuery: '', + searchMailboxId: folderId, + emails, + selectedEmail: null, + selectedEmailKeys: new Set(), + threadEmailsCache: new Map(), + processingReadStatus: new Set(), + error: null, + }); +} + +beforeEach(() => { + clearAllHooks(); + client = makeClient(); + useAuthStore.setState({ + activeAccountId: 'acct', + getClientForAccount: () => client as never, + } as never); + useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false, archiveMode: 'single' } as never); +}); + +afterEach(() => { clearAllHooks(); localStorage.clear(); }); + +describe('moving to Trash notes where the mail was', () => { + it('one message', async () => { + openFolder('custom', [makeEmail('e1', { mailboxIds: { custom: true } })]); + expect(await useEmailStore.getState().deleteEmail(client, 'e1')).toBe(true); + expect(restoreTarget({ id: 'e1', mailboxIds: { trash: true } }, mailboxes, 'trash')).toBe('custom'); + }); + + it('a selection', async () => { + const a = makeEmail('a', { mailboxIds: { custom: true } }); + const b = makeEmail('b', { mailboxIds: { custom: true } }); + openFolder('custom', [a, b]); + useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a), emailKeyFor(b)]) }); + await useEmailStore.getState().batchDelete(client); + expect(restoreTarget({ id: 'b', mailboxIds: { trash: true } }, mailboxes, 'trash')).toBe('custom'); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 96d37325e..a85f4975f 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1,5 +1,6 @@ import { isSharedOut } from '@/lib/mailbox-shared-out'; import { IncompleteDeleteError } from "@/lib/jmap/delete-error"; +import { rememberTrashOrigins } from "@/lib/trash-origin"; import { ArchiveEmailsError } from "@/lib/jmap/archive-error"; import { RescheduleRestoredError } from "@/lib/jmap/reschedule-error"; import { create } from "zustand"; @@ -4519,6 +4520,8 @@ export const useEmailStore = create((set, get) => ({ if (trashMailbox) { // Use originalId for shared mailboxes if available const trashId = trashMailbox.originalId || trashMailbox.id; + // So Restore can put it back where it was (lib/trash-origin.ts). + rememberTrashOrigins([email], mailboxes); await effectiveClient.moveToTrash(emailId, trashId, accountId, alsoMarkRead); // After marking read in the same request, the email arrives in trash as read. @@ -6061,6 +6064,8 @@ export const useEmailStore = create((set, get) => ({ // Undecorated emails (normal single-mailbox view) form one group for the // active client (groupBySource). const batches = groupBySource(selectedEmails); + // So Restore can put them back where they were (lib/trash-origin.ts). + rememberTrashOrigins(selectedEmails, mailboxes); // Undecorated emails viewed in a shared/group folder directly (non-unified) // are reached through the active client but must carry the owner accountId,