fix: cut a row's right-click menu to the usual actions, with Restore in Trash and read and unread for mixed selections

This commit is contained in:
Linus Rath
2026-11-04 07:00:54 +01:00
parent 59d655260c
commit e8d8486b85
9 changed files with 529 additions and 15 deletions
@@ -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<typeof EmailContextMenu>;
function renderMenu(props: Partial<MenuProps> = {}) {
const onClose = vi.fn();
render(
<EmailContextMenu
rowMenu
email={email}
position={{ x: 10, y: 10 }}
isOpen
onClose={onClose}
menuRef={{ current: null }}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
onReply={vi.fn()}
onReplyAll={vi.fn()}
onForward={vi.fn()}
onForwardAsAttachment={vi.fn()}
onArchive={vi.fn()}
onDelete={vi.fn()}
onMarkAsRead={vi.fn()}
onToggleStar={vi.fn()}
onTogglePinned={vi.fn()}
onSetTag={vi.fn()}
onMoveToMailbox={vi.fn()}
onMarkAsSpam={vi.fn()}
onUndoSpam={vi.fn()}
onOpenInWindow={vi.fn()}
onExport={vi.fn()}
onPrint={vi.fn()}
onViewSource={vi.fn()}
{...props}
/>,
);
return { onClose };
}
const topLevel = () => {
const menu = screen.getAllByRole('menu')[0];
return Array.from(menu.querySelectorAll<HTMLElement>(':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();
});
});
+94 -13
View File
@@ -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<MessageActionKey>([
"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<MessageActionKey>([
"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 */}
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
shortcut={shortcutOf("read", isUnread)}
disabled={!canChangeMail}
onClick={() => handleAction(() => onBatchMarkAsRead?.(isUnread))}
/>
{someUnread && (
<ContextMenuItem
icon={MailOpen}
label={t("mark_read")}
testId="ctx-mark-read"
shortcut={shortcutOf("read", true)}
disabled={!canChangeMail}
onClick={() => handleAction(() => onBatchMarkAsRead?.(true))}
/>
)}
{someRead && (
<ContextMenuItem
icon={Mail}
label={t("mark_unread")}
testId="ctx-mark-unread"
shortcut={shortcutOf("read", false)}
disabled={!canChangeMail}
onClick={() => handleAction(() => onBatchMarkAsRead?.(false))}
/>
)}
</>
);
const batchRestoreItem = batchRestoreTo ? (
<>
<ContextMenuItem
icon={Undo}
label={tRecover("restore")}
testId="ctx-restore"
disabled={!canChangeMail}
onClick={() => handleAction(() => onBatchMoveToMailbox?.(batchRestoreTo))}
/>
<ContextMenuSeparator />
</>
) : null;
const moveItem = (
<>
{/* Move to submenu */}
@@ -502,8 +561,8 @@ export function EmailContextMenu({
}
};
const singleMenu = singleEntries.map((entry, index) => {
if (entry.kind === "separator") return <ContextMenuSeparator key={`sep-${index}`} />;
const renderEntries = (entries: MessageActionEntry[], keyPrefix = "") => entries.map((entry, index) => {
if (entry.kind === "separator") return <ContextMenuSeparator key={`${keyPrefix}sep-${index}`} />;
if (entry.kind === "submenu") return renderSubmenu(entry);
return (
<ContextMenuItem
@@ -518,6 +577,27 @@ export function EmailContextMenu({
/>
);
});
// 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 && (
<>
<ContextMenuSeparator />
<ContextMenuSubMenu icon={MoreHorizontal} label={tViewer("more_actions")} testId="ctx-more">
{renderEntries(rowMore, "more-")}
</ContextMenuSubMenu>
</>
)}
</>
);
return (
<ContextMenu
@@ -535,6 +615,7 @@ export function EmailContextMenu({
</ContextMenuHeader>
{!isScheduled && (<>
{batchRestoreItem}
{/* Compose-style actions first, above the archive/delete group,
mirroring the single menu's ordering. */}
<ContextMenuItem
+1
View File
@@ -1331,6 +1331,7 @@ export function EmailList({
{/* Context Menu */}
{contextMenuEmail && (
<EmailContextMenu
rowMenu
email={contextMenuEmail}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
+5
View File
@@ -33,6 +33,7 @@ import { Avatar } from "@/components/ui/avatar";
import { ContextMenu, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-menu";
import { EmailContextMenu } from "./email-context-menu";
import { restoreTarget } from "@/lib/trash-origin";
import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions";
import { PhishingMarkBanner } from "./phishing-mark-banner";
import { turnOffLinks } from "@/lib/phishing-links";
@@ -4282,6 +4283,10 @@ ${printWatermark ? printWatermark.html : ''}
onExport: devices.noExport ? undefined : () => 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,
};
+11 -2
View File
@@ -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<MessageActionKey>;
/** 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,
+40
View File
@@ -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();
});
});
+91
View File
@@ -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<string, string>;
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<string, unknown>)) {
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<Pick<Email, "id" | "mailboxIds">>,
mailboxes: Array<Pick<Mailbox, "id" | "role">>,
): 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<Email, "sourceAccountId" | "mailboxIds">, 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<Email, "id" | "sourceAccountId" | "mailboxIds">,
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<Pick<Email, "id" | "sourceAccountId" | "mailboxIds">>,
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;
}
@@ -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>): 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> = {}): 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');
});
});
+5
View File
@@ -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<EmailStore>((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<EmailStore>((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,