mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1331,6 +1331,7 @@ export function EmailList({
|
||||
{/* Context Menu */}
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
rowMenu
|
||||
email={contextMenuEmail}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user