feat: undo a delete to Trash, archive, move, spam report or remove from folder from its toast

This commit is contained in:
Linus Rath
2026-10-28 03:11:43 +01:00
parent b6d986797e
commit 6c5aead20b
44 changed files with 469 additions and 108 deletions
@@ -62,8 +62,9 @@ async function clickDelete(surface: string) {
fireEvent.contextMenu(await screen.findByText('ok')); fireEvent.click(await screen.findByTestId('ctx-delete'));
} else {
fireEvent.click(surface === 'mountain' ? screen.getByLabelText('delete') : screen.getByTitle('batch_actions.delete'));
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'permanent_delete' }));
}
// A permanent delete asks first, from every place it is offered.
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'permanent_delete' }));
}
for (const surface of ['mountain', 'default', 'context']) describe(surface + ' permanent bulk-delete feedback', () => {
+71 -50
View File
@@ -15,7 +15,7 @@ import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, Calenda
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, type ListRow } from "@/stores/email-store";
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, restoreBatchesForSelection, isAggregateListView, type ListRow } from "@/stores/email-store";
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { useShallow } from "zustand/react/shallow";
import { useAuthStore } from "@/stores/auth-store";
@@ -29,7 +29,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
import { toast } from "@/stores/toast-store";
import { runBatchEmailAction } from "@/lib/email-action-toast";
import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { useVirtualizer } from "@tanstack/react-virtual";
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { SearchChips } from "@/components/search/search-chips";
@@ -168,6 +168,7 @@ export function EmailList({
const tSpam = useTranslations('email_viewer.spam');
const tNotifications = useTranslations('notifications');
const tViewer = useTranslations('email_viewer');
const undo = undoLabels(tNotifications);
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const {
selectedEmailKeys,
@@ -605,7 +606,10 @@ export function EmailList({
setIsProcessing(true);
try {
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
if (await batchUndoSpam(client, emailIds)) toast.success(tSpam('toast_not_spam_batch', { count: emailIds.length }));
const restore = restoreBatchesForSelection(client);
if (await batchUndoSpam(client, emailIds)) {
toastWithUndo(tSpam('toast_not_spam_batch', { count: emailIds.length }), undoMoveAction(client, restore, undo));
}
} catch {
toast.error(tSpam('error_not_spam'));
} finally {
@@ -613,6 +617,63 @@ export function EmailList({
}
};
const handleBatchMarkAsSpam = async () => {
if (!client || isProcessing) return;
setIsProcessing(true);
try {
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
const restore = restoreBatchesForSelection(client);
if (await batchMarkAsSpam(client, emailIds)) {
toastWithUndo(tSpam('toast_batch', { count: emailIds.length }), undoMoveAction(client, restore, undo));
}
} catch {
toast.error(tSpam('error'));
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
const handleBatchArchive = async () => {
if (!client || isProcessing) return;
setIsProcessing(true);
const count = selectedEmailKeys.size;
const restore = restoreBatchesForSelection(client);
try {
await runBatchEmailAction(() => batchArchive(client), {
success: tNotifications('emails_archived', { count }),
error: tNotifications('error_archiving'),
describeError: (error) => error instanceof ArchiveMailboxNotFoundError
? tViewer('archive_mailbox_not_found')
: undefined,
undo: undoMoveAction(client, restore, undo),
});
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
const handleBatchMoveToMailbox = async (mailboxId: string) => {
if (!client || isProcessing) return;
setIsProcessing(true);
const count = selectedEmailKeys.size;
// A move to another account's folder copies the messages there, which
// Undo can't take back.
const destination = mailboxes.find(m => m.id === mailboxId);
const source = mailboxes.find(m => m.id === selectedMailbox);
const crossAccount = isAggregateListView()
|| (!!destination?.accountId && !!source?.accountId && destination.accountId !== source.accountId);
const restore = crossAccount ? null : restoreBatchesForSelection(client);
try {
await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId), {
success: tNotifications('emails_moved', { count }),
error: tNotifications('move_failed'),
undo: undoMoveAction(client, restore, undo),
});
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
const handleBatchDelete = async () => {
if (!client || isProcessing) return;
@@ -637,10 +698,12 @@ export function EmailList({
setIsProcessing(true);
const count = selectedEmailKeys.size;
const restore = isInTrash ? null : restoreBatchesForSelection(client);
try {
await runBatchEmailAction(() => batchDelete(client, isInTrash), {
success: tNotifications('emails_deleted', { count }),
error: tNotifications('error_deleting'),
undo: undoMoveAction(client, restore, undo),
});
} finally {
setTimeout(() => setIsProcessing(false), 500);
@@ -1171,57 +1234,15 @@ export function EmailList({
batchTagIds={selectionTags.all}
batchPartialTagIds={selectionTags.some}
onBatchToggleTag={handleBatchToggleTag}
onBatchDelete={async () => {
if (!client) return;
const count = selectedEmailKeys.size;
await runBatchEmailAction(() => batchDelete(client), {
success: tNotifications('emails_deleted', { count }),
error: tNotifications('error_deleting'),
});
}}
onBatchArchive={async () => {
if (!client) return;
const count = selectedEmailKeys.size;
await runBatchEmailAction(() => batchArchive(client), {
success: tNotifications('emails_archived', { count }),
error: tNotifications('error_archiving'),
describeError: (error) => error instanceof ArchiveMailboxNotFoundError
? tViewer('archive_mailbox_not_found')
: undefined,
});
}}
onBatchDelete={() => void handleBatchDelete()}
onBatchArchive={() => void handleBatchArchive()}
batchEmails={selectedEmailsForRemoval}
onBatchRemoveFromFolder={() => void handleBatchRemoveFromFolder()}
onBatchMoveToMailbox={async (mailboxId) => {
if (!client) return;
const count = selectedEmailKeys.size;
await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId), {
success: tNotifications('emails_moved', { count }),
error: tNotifications('move_failed'),
});
}}
onBatchMarkAsSpam={async () => {
if (client) {
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
try {
if (await batchMarkAsSpam(client, emailIds)) toast.success(
tSpam('toast_batch', { count: emailIds.length })
);
} catch {
toast.error(tSpam('error'));
}
}
}}
onBatchMoveToMailbox={(mailboxId) => void handleBatchMoveToMailbox(mailboxId)}
onBatchMarkAsSpam={() => void handleBatchMarkAsSpam()}
onBatchUndoSpam={async () => {
if (client) {
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
try {
if (await batchUndoSpam(client, emailIds)) toast.success(
tSpam('toast_not_spam_batch', { count: emailIds.length })
);
} catch {
toast.error(tSpam('error_not_spam'));
}
await handleBatchUndoSpam();
}
}}
/>
+15 -10
View File
@@ -22,9 +22,9 @@ import {
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { useShallow } from "zustand/react/shallow";
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, restoreBatchesForSelection } from "@/stores/email-store";
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { runBatchEmailAction } from "@/lib/email-action-toast";
import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { toast } from "@/stores/toast-store";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
@@ -117,6 +117,7 @@ export function MvListToolbar({
const tViewer = useTranslations("email_viewer");
const tNotifications = useTranslations("notifications");
const tSpam = useTranslations("email_viewer.spam");
const undo = undoLabels(tNotifications);
const client = useAuthStore((s) => s.client);
const {
@@ -232,7 +233,7 @@ export function MvListToolbar({
// rejected promise or a store error turns into an error toast instead of
// an unhandled rejection with nothing on screen.
const run = async (
action: () => Promise<void>,
action: () => Promise<boolean | void>,
messages?: Parameters<typeof runBatchEmailAction>[1],
) => {
if (!client || isProcessing) return;
@@ -279,9 +280,11 @@ export function MvListToolbar({
});
if (!confirmed) return;
}
await runBatchEmailAction(() => batchDelete(client, isInTrash), {
const restore = permanent ? null : restoreBatchesForSelection(client);
await runBatchEmailAction(() => batchDelete(client, permanent), {
success: tNotifications("emails_deleted", { count: selectionCount }),
error: tNotifications("error_deleting"),
undo: undoMoveAction(client, restore, undo),
});
});
@@ -360,11 +363,11 @@ export function MvListToolbar({
const handleMove = (mailboxId: string) => {
setMoveOpen(false);
const count = selectionCount;
void run(async () => {
if (client) await batchMoveToMailbox(client, mailboxId);
}, {
const restore = client ? restoreBatchesForSelection(client) : null;
void run(async () => (client ? batchMoveToMailbox(client, mailboxId) : false), {
success: tNotifications("emails_moved", { count }),
error: tNotifications("move_failed"),
undo: undoMoveAction(client, restore, undo),
});
};
@@ -381,11 +384,12 @@ export function MvListToolbar({
run(async () => {
if (!client) return;
const ids = resolveSelectedEmails(useEmailStore.getState()).map((email) => email.id);
const restore = restoreBatchesForSelection(client);
try {
if (isInJunk) {
if (await batchUndoSpam(client, ids)) toast.success(tSpam("toast_not_spam_batch", { count: ids.length }));
if (await batchUndoSpam(client, ids)) toastWithUndo(tSpam("toast_not_spam_batch", { count: ids.length }), undoMoveAction(client, restore, undo));
} else {
if (await batchMarkAsSpam(client, ids)) toast.success(tSpam("toast_batch", { count: ids.length }));
if (await batchMarkAsSpam(client, ids)) toastWithUndo(tSpam("toast_batch", { count: ids.length }), undoMoveAction(client, restore, undo));
}
} catch {
toast.error(isInJunk ? tSpam("error_not_spam") : tSpam("error"));
@@ -508,12 +512,13 @@ export function MvListToolbar({
<ToolbarButton
icon={Archive}
label={tActions("archive")}
onClick={() => run(async () => { if (client) await batchArchive(client); }, {
onClick={() => run(async () => (client ? batchArchive(client) : false), {
success: tNotifications("emails_archived", { count: selectionCount }),
error: tNotifications("error_archiving"),
describeError: (error) => error instanceof ArchiveMailboxNotFoundError
? tViewer("archive_mailbox_not_found")
: undefined,
undo: undoMoveAction(client, client ? restoreBatchesForSelection(client) : null, undo),
})}
disabled={isProcessing}
busy={isProcessing}
+7 -12
View File
@@ -72,6 +72,7 @@ import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"
import { usePolicyStore } from "@/stores/policy-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { debug } from "@/lib/debug";
import { AccountSwitcher } from "./account-switcher";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
@@ -634,18 +635,12 @@ function MailboxTreeItem({
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
mailbox: node,
accountId: dragAccountId ?? undefined,
onSuccess: (count, mailboxName) => {
if (count === 1) {
toast.success(
tNotifications('email_moved'),
tNotifications('moved_to_mailbox', { mailbox: mailboxName })
);
} else {
toast.success(
tNotifications('emails_moved', { count }),
tNotifications('moved_to_mailbox', { mailbox: mailboxName })
);
}
onSuccess: (count, mailboxName, restore) => {
toastWithUndo(
count === 1 ? tNotifications('email_moved') : tNotifications('emails_moved', { count }),
undoMoveAction(useAuthStore.getState().client, restore, undoLabels(tNotifications)),
tNotifications('moved_to_mailbox', { mailbox: mailboxName }),
);
},
onError: () => {
toast.error(tNotifications('move_failed'), tNotifications('move_error'));
@@ -136,3 +136,27 @@ describe('Delete on a selection', () => {
expect(bulkDeleteIsPermanent({ role: 'inbox' })).toBe(false);
});
});
describe('Undo after a delete to Trash', () => {
it('puts the message back where it was, unread again', async () => {
useSettingsStore.setState({ deleteAction: 'trash-and-read' } as never);
client.restoreEmails = vi.fn().mockResolvedValue(undefined);
render(<MailApp />);
fireEvent.click(await screen.findByText('stub delete', {}, { timeout: 5000 }));
await waitFor(() => expect(useToastStore.getState().toasts.some(t => t.title === 'notifications.email_deleted')).toBe(true));
const deleted = useToastStore.getState().toasts.find(t => t.title === 'notifications.email_deleted')!;
expect(deleted.action?.label).toBe('undo');
deleted.action!.onClick();
await waitFor(() => expect(client.restoreEmails).toHaveBeenCalled());
expect(client.restoreEmails).toHaveBeenCalledWith([{ id: 'm1', mailboxIds: { inbox: true }, seen: false }], undefined);
});
it('is not offered for a permanent delete', async () => {
useSettingsStore.setState({ deleteAction: 'permanent' } as never);
render(<MailApp />);
fireEvent.click(await screen.findByText('stub delete', {}, { timeout: 5000 }));
fireEvent.click(await screen.findByText('email_list.permanent_delete', { selector: 'button' }));
await waitFor(() => expect(useToastStore.getState().toasts.some(t => t.title === 'notifications.email_deleted')).toBe(true));
expect(useToastStore.getState().toasts.find(t => t.title === 'notifications.email_deleted')!.action).toBeUndefined();
});
});
@@ -185,7 +185,7 @@ describe('the open message of a group account in a tag view', () => {
render(<MailApp />);
fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 }));
await waitFor(() => expect(moveThreadToMailbox).toHaveBeenCalled());
expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:k', 'login-a/ce:eaaaaab');
expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:k', 'login-a/ce:eaaaaab', expect.any(Function));
});
it('is deleted into the group\'s Deleted Items, not the own one', async () => {
@@ -215,7 +215,7 @@ describe('archiving a group message by month', () => {
fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 }));
await waitFor(() => expect(moveThreadToMailbox).toHaveBeenCalled());
expect(client.createMailbox).not.toHaveBeenCalled();
expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'login-a/ce:eaaaaab');
expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'login-a/ce:eaaaaab', expect.any(Function));
});
it('reuses them with the group\'s Inbox open', async () => {
@@ -225,7 +225,7 @@ describe('archiving a group message by month', () => {
fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 }));
await waitFor(() => expect(moveThreadToMailbox).toHaveBeenCalled());
expect(client.createMailbox).not.toHaveBeenCalled();
expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'eaaaaab');
expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'eaaaaab', expect.any(Function));
});
// The own account has a folder with the server id of the group's Archive,
@@ -242,7 +242,7 @@ describe('archiving a group message by month', () => {
render(<MailApp />);
fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 }));
await waitFor(() => expect(moveThreadToMailbox).toHaveBeenCalled());
expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'eaaaaab');
expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'eaaaaab', expect.any(Function));
});
it('creates a missing year folder in the group account with the group\'s Inbox open', async () => {
+32 -10
View File
@@ -37,7 +37,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
import { messagePriorityFromHeaders, type MessagePriority } from "@/lib/message-priority";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, type EmailRestoreBatch } from "@/stores/email-store";
import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type DraftSource } from "@/lib/draft-source";
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
@@ -46,7 +46,7 @@ import { cancelFailureToast, partlyRefusedWarning, rescheduleFailureToast } from
import { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client";
import { subscribePushStateChanges } from "@/lib/push-events";
import { createMailPushListener } from "@/lib/mail-push-listener";
import { runBatchEmailAction } from "@/lib/email-action-toast";
import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster";
import { CalendarEventNotificationToaster } from "@/components/layout/calendar-event-notification-toaster";
@@ -214,6 +214,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('mail', routeSegments);
const t = useTranslations();
const tNotifications = useTranslations('notifications');
// The toasts' Undo after a delete to Trash, archive, move or spam report.
const undo = undoLabels(tNotifications);
const tCommon = useTranslations('common');
const appName = productName(useConfig().appName);
const tQuote = useTranslations('quote_header');
@@ -936,8 +939,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (isScheduledView) return;
if (shortcutsTargetSelection && client) {
const count = selectedEmailKeys.size;
const restore = restoreBatchesForSelection(client);
await runBatchEmailAction(() => batchArchive(client), {
success: t('notifications.emails_archived', { count }),
undo: undoMoveAction(client, restore, undo),
error: t('notifications.error_archiving'),
describeError: (error) => error instanceof ArchiveMailboxNotFoundError
? t('email_viewer.archive_mailbox_not_found')
@@ -968,9 +973,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!confirmed) return;
}
const count = selectedEmailKeys.size;
const restore = permanent ? null : restoreBatchesForSelection(client);
await runBatchEmailAction(() => batchDelete(client, permanent), {
success: t('notifications.emails_deleted', { count }),
error: t('notifications.error_deleting'),
undo: undoMoveAction(client, restore, undo),
});
} else if (selectedEmail) {
handleDelete();
@@ -1009,11 +1016,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// The spam actions take bare ids of the viewed account: resolve the
// selection keys through the loaded rows and thread members.
const ids = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
const restore = restoreBatchesForSelection(client);
try {
if (isInJunk) {
if (await batchUndoSpam(client, ids)) toast.success(t('email_viewer.spam.toast_not_spam_batch', { count: ids.length }));
if (await batchUndoSpam(client, ids)) toastWithUndo(t('email_viewer.spam.toast_not_spam_batch', { count: ids.length }), undoMoveAction(client, restore, undo));
} else {
if (await batchMarkAsSpam(client, ids)) toast.success(t('email_viewer.spam.toast_batch', { count: ids.length }));
if (await batchMarkAsSpam(client, ids)) toastWithUndo(t('email_viewer.spam.toast_batch', { count: ids.length }), undoMoveAction(client, restore, undo));
}
} catch (error) {
console.error("Failed to batch toggle spam:", error);
@@ -2809,7 +2817,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const isInTrash = currentMailbox?.role === 'trash';
// Trash, Junk with "permanently delete junk", or the "Delete permanently"
// setting: the mail is destroyed, so ask first, as the batch delete does.
// A move to Trash gets Undo instead.
const permanent = bulkDeleteIsPermanent(currentMailbox);
const restore = permanent ? null : restoreBatchesFor([emailToDelete], client);
if (permanent) {
const confirmed = await confirmDialog({
@@ -2826,7 +2836,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// it. In Trash it always destroys. (#281 for the account routing)
try {
// false: a plugin stopped it and said why, so no "deleted" here.
if (await deleteEmail(client, emailToDelete.id, isInTrash, emailKeyFor(emailToDelete))) toast.success(t('notifications.email_deleted'));
if (await deleteEmail(client, emailToDelete.id, isInTrash, emailKeyFor(emailToDelete))) {
toastWithUndo(t('notifications.email_deleted'), undoMoveAction(client, restore, undo));
}
} catch (error) {
console.error("Failed to delete email:", error);
toast.error(t('notifications.error_deleting'), error instanceof Error ? error.message : undefined);
@@ -2880,8 +2892,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!archiveMailbox) return; // a plugin refused the Archive folder
let archived: boolean;
let restore: EmailRestoreBatch[] | null = null;
const keepRestore = (batches: EmailRestoreBatch[]) => { restore = batches; };
if (archiveMode === 'single') {
archived = await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id, emailKeyFor(emailToArchive));
archived = await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id, emailKeyFor(emailToArchive), keepRestore);
} else {
const emailDate = new Date(emailToArchive.receivedAt);
const year = emailDate.getFullYear().toString();
@@ -2906,7 +2920,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
if (archiveMode === 'year') {
archived = await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id, emailKeyFor(emailToArchive));
archived = await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id, emailKeyFor(emailToArchive), keepRestore);
} else {
const yearId = yearMailbox.originalId || yearMailbox.id;
const afterYear = readMailboxes();
@@ -2916,7 +2930,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!monthMailbox) return; // a plugin refused the folder
await refreshMailboxes();
}
archived = await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id, emailKeyFor(emailToArchive));
archived = await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id, emailKeyFor(emailToArchive), keepRestore);
}
}
if (!archived) return;
@@ -2928,7 +2942,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setConversationEmails([]);
}
toast.success(t('notifications.email_archived'));
toastWithUndo(t('notifications.email_archived'), undoMoveAction(client, restore, undo));
void refreshMailboxes();
} catch (error) {
console.error("Failed to archive email:", error);
@@ -2951,10 +2965,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const destinationList = [mailboxes, ...Object.values(accountMailboxes)]
.find(list => list.some(m => m.id === mailboxId));
const destination = destinationList?.find(m => m.id === mailboxId);
// Undo for a move within the message's own account; a move to another
// account copies the message there, which Undo can't take back.
const email = useEmailStore.getState().emails.find(e => (emailKey ? emailKeyFor(e) === emailKey : e.id === emailId));
const sourceFolder = [mailboxes, ...Object.values(accountMailboxes)].flat().find(m => m.id === selectedMailbox);
const sourceAccount = email?.sourceAccountId ?? sourceFolder?.accountId;
const crossAccount = !!destination?.accountId && !!sourceAccount && destination.accountId !== sourceAccount;
const restore = email && !crossAccount ? restoreBatchesFor([email], client) : null;
try {
if (!(await moveToMailboxCrossAware(client, emailId, mailboxId, emailKey))) return;
toast.success(
toastWithUndo(
t('notifications.email_moved'),
undoMoveAction(client, restore, undo),
destination && destinationList
? t('notifications.moved_to_mailbox', { mailbox: getMailboxPath(destination, destinationList) })
: undefined,
+12 -4
View File
@@ -2,7 +2,7 @@
import { useCallback, useState, DragEvent } from "react";
import { Mailbox, Email } from "@/lib/jmap/types";
import { useEmailStore, isAggregateListView } from "@/stores/email-store";
import { useEmailStore, isAggregateListView, restoreBatchesFor, type EmailRestoreBatch } from "@/stores/email-store";
import { useShallow } from "zustand/react/shallow";
import { emailKeyFor } from "@/lib/thread-utils";
import { useAuthStore } from "@/stores/auth-store";
@@ -35,8 +35,10 @@ interface UseMailboxDropOptions {
*/
accountId?: string;
onDropComplete?: () => void;
// Translation callbacks for toast messages
onSuccess?: (count: number, mailboxName: string) => void;
// Translation callbacks for toast messages. `restore` says where the
// messages were, for the toast's Undo; null when the move went to another
// account, which Undo can't take back.
onSuccess?: (count: number, mailboxName: string, restore: EmailRestoreBatch[] | null) => void;
onError?: (error: string) => void;
}
@@ -132,7 +134,12 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess,
let isCrossAccount = false;
let moved: boolean;
let restore: EmailRestoreBatch[] | null = null;
if (isAggregateListView()) {
// Undo only when every row moved inside its own account.
const inPlace = draggedEmails.length > 0 && draggedEmails.every(em =>
em.sourceAccountId === mailbox.accountId && (!em.sourceClientAccountId || em.sourceClientAccountId === destAccountId));
if (inPlace) restore = restoreBatchesFor(draggedEmails, client);
// A list that mixes accounts (unified, tag and "All folders" views)
// stamps every row with its login and account. The store moves each
// of those groups apart - in place only in the folder's own account,
@@ -190,6 +197,7 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess,
moved = await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride, sourceJmapOverride);
} else {
// Single-account or same-account-shared move: bulk JMAP request.
restore = restoreBatchesFor(draggedEmails.filter(em => emailIds.includes(em.id)), client);
moved = await moveEmailsToMailbox(client, emailIds, mailbox.id);
}
}
@@ -218,7 +226,7 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess,
const mailboxPath = getMailboxPath(mailbox, mailboxes);
if (onSuccess) {
onSuccess(emailIds.length, mailboxPath);
onSuccess(emailIds.length, mailboxPath, restore);
} else {
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailboxPath}`);
+5 -3
View File
@@ -3,14 +3,14 @@
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore, removalFolder } from "@/stores/email-store";
import { runBatchEmailAction } from "@/lib/email-action-toast";
import { useEmailStore, removalFolder, restoreBatchesFor } from "@/stores/email-store";
import { runBatchEmailAction, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { localizeMailboxName } from "@/lib/mailbox-label";
import type { Email } from "@/lib/jmap/types";
/**
* "Remove from this folder" (#615): takes the messages out of the open folder
* only, keeps them in their other folders, and toasts the outcome.
* only, keeps them in their other folders, and toasts the outcome with Undo.
*/
export function useRemoveFromFolder(): (emails: Email[]) => Promise<void> {
const t = useTranslations("notifications");
@@ -24,9 +24,11 @@ export function useRemoveFromFolder(): (emails: Email[]) => Promise<void> {
const mailbox = folder
? localizeMailboxName(folder.role, folder.name, (key) => tSidebar(`mailboxes.${key}`))
: "";
const restore = restoreBatchesFor(emails, client);
await runBatchEmailAction(() => removeFromMailbox(client, emails), {
success: t("removed_from_folder", { mailbox }),
error: t("remove_from_folder_failed"),
undo: undoMoveAction(client, restore, undoLabels(t)),
});
}, [client, removeFromMailbox, t, tSidebar]);
}
+13
View File
@@ -652,6 +652,19 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async restoreEmails(emails: Array<{ id: string; mailboxIds: Record<string, boolean>; seen?: boolean }>): Promise<void> {
for (const { id, mailboxIds, seen } of emails) {
const email = this.data.emails.find(e => e.id === id);
if (!email) continue;
email.mailboxIds = { ...mailboxIds };
if (seen !== undefined) {
if (seen) email.keywords.$seen = true;
else delete email.keywords.$seen;
}
}
this.recalcMailboxCounts();
}
async emptyMailbox(mailboxId: string, _accountId?: string): Promise<number> {
const before = this.data.emails.length;
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
+59 -4
View File
@@ -1,5 +1,58 @@
import { toast } from "@/stores/toast-store";
import { useEmailStore } from "@/stores/email-store";
import { useEmailStore, type EmailRestoreBatch } from "@/stores/email-store";
import type { ToastAction } from "@/components/ui/toast";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
/** How long a toast with Undo stays, so there is time to reach the button. */
export const UNDO_TOAST_DURATION = 8000;
/** The words the Undo action needs, from the `notifications` namespace. */
export interface UndoLabels {
undo: string;
done: string;
failed: string;
}
/** `t` of the `notifications` namespace → UndoLabels. */
export function undoLabels(t: (key: string) => string): UndoLabels {
return { undo: t("undo"), done: t("undo_done"), failed: t("undo_failed") };
}
/**
* The toast's Undo for a reversible move (delete to Trash, archive, move,
* report spam, remove from folder): puts the messages back where `batches`
* says they were. None when there is nothing to put back.
*/
export function undoMoveAction(
client: IJMAPClient | null | undefined,
batches: EmailRestoreBatch[] | null | undefined,
labels: UndoLabels,
): ToastAction | undefined {
if (!client || !batches || !batches.some(b => b.points.length > 0)) return undefined;
return {
label: labels.undo,
onClick: () => {
void (async () => {
try {
await useEmailStore.getState().restoreEmails(client, batches);
toast.success(labels.done);
} catch (error) {
console.error("Failed to undo:", error);
toast.error(labels.failed);
}
})();
},
};
}
/** A success toast, with Undo when the action can be undone. */
export function toastWithUndo(title: string, undo: ToastAction | undefined, message?: string): void {
if (!undo) {
toast.success(title, message);
return;
}
toast.success(title, { message, action: undo, duration: UNDO_TOAST_DURATION });
}
/**
* Runs a bulk email-store action and toasts the outcome: a failure always, a
@@ -7,11 +60,13 @@ import { useEmailStore } from "@/stores/email-store";
* through `state.error` instead of throwing (batchArchive does both), so a
* resolved promise alone does not mean the mail moved. An action that
* resolves `false` did nothing: a plugin vetoed it (and told the user why) or
* there was nothing to act on, so there is no success to report.
* there was nothing to act on, so there is no success to report. `undo` goes
* on the success toast only: after a partial failure the list is reloaded and
* the error says what stayed.
*/
export async function runBatchEmailAction(
run: () => Promise<boolean | void>,
messages: { success?: string; error: string; describeError?: (error: unknown) => string | undefined },
messages: { success?: string; error: string; describeError?: (error: unknown) => string | undefined; undo?: ToastAction },
): Promise<void> {
// An error left over from an earlier action is not this action's outcome.
useEmailStore.setState({ error: null });
@@ -28,6 +83,6 @@ export async function runBatchEmailAction(
if (storeError) {
toast.error(messages.error, storeError);
} else if (ran !== false && messages.success) {
toast.success(messages.success);
toastWithUndo(messages.success, messages.undo);
}
}
+6
View File
@@ -360,6 +360,12 @@ export interface IJMAPClient {
* least one mailbox, so the server refuses this for a message only in it.
*/
removeEmailsFromMailbox(emailIds: string[], mailboxId: string, accountId?: string): Promise<void>;
/**
* Put messages back where they were before a move, delete to Trash,
* archive or "remove from folder": each one's whole `mailboxIds`, and its
* read state when `seen` is given. Backs the toasts' Undo.
*/
restoreEmails(emails: Array<{ id: string; mailboxIds: Record<string, boolean>; seen?: boolean }>, accountId?: string): Promise<void>;
emptyMailbox(mailboxId: string, accountId?: string): Promise<number>;
moveMailboxContents(fromMailboxId: string, toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
+14
View File
@@ -3600,6 +3600,20 @@ export class JMAPClient implements IJMAPClient {
}
}
async restoreEmails(emails: Array<{ id: string; mailboxIds: Record<string, boolean>; seen?: boolean }>, accountId?: string): Promise<void> {
if (emails.length === 0) return;
for (const batch of batched(emails, this.getMaxObjectsInSet())) {
const update = Object.fromEntries(batch.map(email => [email.id, {
mailboxIds: email.mailboxIds,
...(email.seen === undefined ? {} : { 'keywords/$seen': email.seen ? true : null }),
}]));
const response = await this.request([
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
]);
assertSetSucceeded(response, 'Failed to restore emails');
}
}
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const batchSize = Math.min(500, this.getMaxObjectsInSet());
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "فشل النقل",
"removed_from_folder": "تمت الإزالة من {mailbox}",
"remove_from_folder_failed": "تعذرت الإزالة من هذا المجلد",
"undo": "تراجع",
"undo_done": "تمت الإعادة إلى مكانها",
"undo_failed": "تعذّر التراجع",
"emails_copied": "تم نسخ {count} رسالة",
"copy_failed": "فشل النسخ",
"move_error": "تعذّر نقل الرسائل إلى المجلد المحدد",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "No s'ha pogut moure",
"removed_from_folder": "S'ha tret de {mailbox}",
"remove_from_folder_failed": "No s'ha pogut treure d'aquesta carpeta",
"undo": "Desfés",
"undo_done": "S'ha tornat al lloc",
"undo_failed": "No s'ha pogut desfer",
"emails_copied": "{count} correus copiats",
"copy_failed": "No s'ha pogut copiar",
"move_error": "No s'han pogut moure els correus a la carpeta seleccionada",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Přesun selhal",
"removed_from_folder": "Odebráno ze složky {mailbox}",
"remove_from_folder_failed": "Odebrání z této složky se nezdařilo",
"undo": "Zpět",
"undo_done": "Vráceno zpět",
"undo_failed": "Nepodařilo se vrátit zpět",
"emails_copied": "{count} zpráv bylo zkopírováno",
"copy_failed": "Kopírování selhalo",
"move_error": "Zprávu nelze přesunout do vybrané složky",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Flytning mislykkedes",
"removed_from_folder": "Fjernet fra {mailbox}",
"remove_from_folder_failed": "Kunne ikke fjerne fra denne mappe",
"undo": "Fortryd",
"undo_done": "Flyttet tilbage",
"undo_failed": "Kunne ikke fortrydes",
"emails_copied": "{count} e-mails kopieret",
"copy_failed": "Kopiering mislykkedes",
"move_error": "Kunne ikke flytte e-mails til den valgte mappe",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Verschieben fehlgeschlagen",
"removed_from_folder": "Aus {mailbox} entfernt",
"remove_from_folder_failed": "Konnte nicht aus diesem Ordner entfernt werden",
"undo": "Rückgängig",
"undo_done": "Zurückverschoben",
"undo_failed": "Konnte nicht rückgängig gemacht werden",
"emails_copied": "{count} E-Mails kopiert",
"copy_failed": "Kopieren fehlgeschlagen",
"move_error": "E-Mails konnten nicht in den ausgewählten Ordner verschoben werden",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Move failed",
"removed_from_folder": "Removed from {mailbox}",
"remove_from_folder_failed": "Could not remove from this folder",
"undo": "Undo",
"undo_done": "Moved back",
"undo_failed": "Couldn't undo",
"emails_copied": "{count} emails copied",
"copy_failed": "Copy failed",
"move_error": "Could not move emails to the selected folder",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Error al mover",
"removed_from_folder": "Quitado de {mailbox}",
"remove_from_folder_failed": "No se pudo quitar de esta carpeta",
"undo": "Deshacer",
"undo_done": "Devuelto a su sitio",
"undo_failed": "No se pudo deshacer",
"emails_copied": "{count} correos copiados",
"copy_failed": "Error al copiar",
"move_error": "No se pudieron mover los correos a la carpeta seleccionada",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Liigutamine ebaõnnestus",
"removed_from_folder": "Eemaldatud kaustast {mailbox}",
"remove_from_folder_failed": "Sellest kaustast ei õnnestunud eemaldada",
"undo": "Võta tagasi",
"undo_done": "Tõstetud tagasi",
"undo_failed": "Tagasivõtmine ei õnnestunud",
"emails_copied": "{count} kirja kopeeritud",
"copy_failed": "Kopeerimine ebaõnnestus",
"move_error": "E-kirju ei õnnestunud valitud kausta liigutada",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "انتقال ناموفق بود",
"removed_from_folder": "از {mailbox} حذف شد",
"remove_from_folder_failed": "حذف از این پوشه انجام نشد",
"undo": "بازگردانی",
"undo_done": "به جای قبلی برگردانده شد",
"undo_failed": "بازگردانی ممکن نشد",
"emails_copied": "{count} ایمیل کپی شد",
"copy_failed": "کپی ناموفق بود",
"move_error": "انتقال ایمیل‌ها به پوشه انتخاب شده ممکن نیست",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Échec du déplacement",
"removed_from_folder": "Retiré de {mailbox}",
"remove_from_folder_failed": "Impossible de retirer de ce dossier",
"undo": "Annuler",
"undo_done": "Remis à sa place",
"undo_failed": "Impossible d'annuler",
"emails_copied": "{count} emails copiés",
"copy_failed": "Échec de la copie",
"move_error": "Impossible de déplacer les emails vers le dossier sélectionné",
+3
View File
@@ -905,6 +905,9 @@
"move_failed": "המהלך נכשל",
"removed_from_folder": "הוסר מ-{mailbox}",
"remove_from_folder_failed": "לא ניתן להסיר מתיקייה זו",
"undo": "בטל",
"undo_done": "הוחזר למקומו",
"undo_failed": "לא ניתן היה לבטל",
"emails_copied": "{count} הודעות הועתקו",
"copy_failed": "ההעתקה נכשלה",
"move_error": "לא ניתן היה להעביר הודעות דוא\"ל לתיקיה שנבחרה",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Áthelyezés sikertelen",
"removed_from_folder": "Eltávolítva innen: {mailbox}",
"remove_from_folder_failed": "Nem sikerült eltávolítani ebből a mappából",
"undo": "Visszavonás",
"undo_done": "Visszahelyezve",
"undo_failed": "Nem sikerült visszavonni",
"emails_copied": "{count} e-mail másolva",
"copy_failed": "Másolás sikertelen",
"move_error": "Nem sikerült áthelyezni az e-maileket a kiválasztott mappába",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Spostamento non riuscito",
"removed_from_folder": "Rimosso da {mailbox}",
"remove_from_folder_failed": "Impossibile rimuovere da questa cartella",
"undo": "Annulla",
"undo_done": "Rimesso al suo posto",
"undo_failed": "Impossibile annullare",
"emails_copied": "{count} messaggi copiati",
"copy_failed": "Copia non riuscita",
"move_error": "Impossibile spostare i messaggi nella cartella selezionata",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "移動に失敗しました",
"removed_from_folder": "{mailbox} から削除しました",
"remove_from_folder_failed": "このフォルダから削除できませんでした",
"undo": "元に戻す",
"undo_done": "元の場所に戻しました",
"undo_failed": "元に戻せませんでした",
"emails_copied": "{count}件のメールをコピーしました",
"copy_failed": "コピーに失敗しました",
"move_error": "選択したフォルダにメールを移動できませんでした",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "이동에 실패했어요",
"removed_from_folder": "{mailbox}에서 제거됨",
"remove_from_folder_failed": "이 폴더에서 제거하지 못했습니다",
"undo": "실행 취소",
"undo_done": "원래 위치로 되돌렸어요",
"undo_failed": "실행 취소하지 못했어요",
"emails_copied": "{count}개의 메일이 복사되었어요",
"copy_failed": "복사에 실패했어요",
"move_error": "선택한 폴더로 메일을 이동할 수 없어요",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Pārvietošanas kļūda",
"removed_from_folder": "Noņemts no {mailbox}",
"remove_from_folder_failed": "Neizdevās noņemt no šīs mapes",
"undo": "Atsaukt",
"undo_done": "Pārvietots atpakaļ",
"undo_failed": "Neizdevās atsaukt",
"emails_copied": "{count} vēstules nokopētas",
"copy_failed": "Kopēšanas kļūda",
"move_error": "Neizdevās pārvietot vēstules uz izvēlēto mapi",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Зөөж чадсангүй",
"removed_from_folder": "{mailbox}-с хасагдлаа",
"remove_from_folder_failed": "Энэ хавтаснаас хасаж чадсангүй",
"undo": "Буцаах",
"undo_done": "Буцааж шилжүүллээ",
"undo_failed": "Буцааж чадсангүй",
"emails_copied": "{count} имэйлийг хуулсан",
"copy_failed": "Хуулж чадсангүй",
"move_error": "Имэйлүүдийг сонгосон хавтас руу зөөж чадсангүй",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Kunne ikke flytte e-posten",
"removed_from_folder": "Fjernet fra {mailbox}",
"remove_from_folder_failed": "Kunne ikke fjerne fra denne mappen",
"undo": "Angre",
"undo_done": "Flyttet tilbake",
"undo_failed": "Kunne ikke angre",
"emails_copied": "{count} e-poster er kopiert",
"copy_failed": "Kunne ikke kopiere",
"move_error": "Kunne ikke flytte e-poster til den valgte mappen",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Verplaatsen mislukt",
"removed_from_folder": "Verwijderd uit {mailbox}",
"remove_from_folder_failed": "Kon niet uit deze map verwijderen",
"undo": "Ongedaan maken",
"undo_done": "Teruggezet",
"undo_failed": "Kon niet ongedaan worden gemaakt",
"emails_copied": "{count} e-mails gekopieerd",
"copy_failed": "Kopiëren mislukt",
"move_error": "Kan e-mails niet verplaatsen naar de geselecteerde map",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Przenoszenie nie powiodło się",
"removed_from_folder": "Usunięto z folderu {mailbox}",
"remove_from_folder_failed": "Nie udało się usunąć z tego folderu",
"undo": "Cofnij",
"undo_done": "Przeniesiono z powrotem",
"undo_failed": "Nie udało się cofnąć",
"emails_copied": "{count} wiadomości skopiowanych",
"copy_failed": "Kopiowanie nie powiodło się",
"move_error": "Nie można przenieść wiadomości do wybranego folderu",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Falha ao mover",
"removed_from_folder": "Removido de {mailbox}",
"remove_from_folder_failed": "Não foi possível remover desta pasta",
"undo": "Desfazer",
"undo_done": "Devolvido ao lugar",
"undo_failed": "Não foi possível desfazer",
"emails_copied": "{count} e-mails copiados",
"copy_failed": "Falha ao copiar",
"move_error": "Não foi possível mover os e-mails para a pasta selecionada",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Mutarea a eșuat",
"removed_from_folder": "Eliminat din {mailbox}",
"remove_from_folder_failed": "Nu s-a putut elimina din acest dosar",
"undo": "Anulează",
"undo_done": "Mutat înapoi",
"undo_failed": "Nu s-a putut anula",
"emails_copied": "{count} e-mailuri copiate",
"copy_failed": "Copierea a eșuat",
"move_error": "Nu s-au putut muta e-mailurile în folderul selectat",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Ошибка перемещения",
"removed_from_folder": "Убрано из папки {mailbox}",
"remove_from_folder_failed": "Не удалось убрать из этой папки",
"undo": "Отменить",
"undo_done": "Возвращено на место",
"undo_failed": "Не удалось отменить",
"emails_copied": "{count} писем скопировано",
"copy_failed": "Ошибка копирования",
"move_error": "Не удалось переместить письма в выбранную папку",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Presun zlyhal",
"removed_from_folder": "Odstránené z priečinka {mailbox}",
"remove_from_folder_failed": "Odstránenie z tohto priečinka zlyhalo",
"undo": "Späť",
"undo_done": "Presunuté späť",
"undo_failed": "Nepodarilo sa vrátiť späť",
"emails_copied": "{count} e-mailov bolo skopírovaných",
"copy_failed": "Kopírovanie zlyhalo",
"move_error": "E-maily sa nedá presunúť do vybratej priečinky",
+3
View File
@@ -382,6 +382,9 @@
"move_failed": "ย้ายไม่สำเร็จ",
"removed_from_folder": "นำออกจาก {mailbox} แล้ว",
"remove_from_folder_failed": "ไม่สามารถนำออกจากโฟลเดอร์นี้ได้",
"undo": "เลิกทำ",
"undo_done": "ย้ายกลับแล้ว",
"undo_failed": "ไม่สามารถเลิกทำได้",
"emails_copied": "{count} อีเมลถูกคัดลอกแล้ว",
"copy_failed": "คัดลอกไม่สำเร็จ",
"move_error": "ไม่สามารถย้ายอีเมลไปยังโฟลเดอร์ที่เลือกได้",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Taşıma başarısız",
"removed_from_folder": "{mailbox} klasöründen kaldırıldı",
"remove_from_folder_failed": "Bu klasörden kaldırılamadı",
"undo": "Geri al",
"undo_done": "Geri taşındı",
"undo_failed": "Geri alınamadı",
"emails_copied": "{count} e-posta kopyalandı",
"copy_failed": "Kopyalama başarısız",
"move_error": "E-postalar seçilen klasöre taşınamadı",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "Не вдалося перемістити",
"removed_from_folder": "Прибрано з папки {mailbox}",
"remove_from_folder_failed": "Не вдалося прибрати з цієї папки",
"undo": "Скасувати",
"undo_done": "Повернуто на місце",
"undo_failed": "Не вдалося скасувати",
"emails_copied": "{count} електронних листів скопійовано",
"copy_failed": "Не вдалося скопіювати",
"move_error": "Не вдалося перемістити електронні листи до вибраної папки",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "移動郵件失敗",
"removed_from_folder": "已從 {mailbox} 移除",
"remove_from_folder_failed": "無法從此資料夾移除",
"undo": "復原",
"undo_done": "已移回",
"undo_failed": "無法復原",
"emails_copied": "{count} 封郵件已複製",
"copy_failed": "複製失敗",
"move_error": "無法將郵件移至選取的信件匣",
+3
View File
@@ -940,6 +940,9 @@
"move_failed": "移动失败",
"removed_from_folder": "已从 {mailbox} 中移除",
"remove_from_folder_failed": "无法从此文件夹中移除",
"undo": "撤销",
"undo_done": "已移回",
"undo_failed": "无法撤销",
"emails_copied": "{count} 封邮件已复制",
"copy_failed": "复制失败",
"move_error": "无法将邮件移至所选文件夹",
+28
View File
@@ -108,4 +108,32 @@ describe('email-store archive thread behavior', () => {
expect(client.batchMoveEmails).toHaveBeenCalledWith(['email-1', 'email-2'], 'server-archive', 'shared-account');
});
it('tells Undo where the archived messages were, and Undo puts them back', async () => {
const client = {
getThread: vi.fn().mockResolvedValue({ id: 'thread-1', emailIds: ['email-1', 'email-2', 'sent-1'] }),
getEmailFields: vi.fn().mockResolvedValue([
{ id: 'email-1', mailboxIds: { 'server-inbox': true }, keywords: { $seen: true } },
{ id: 'email-2', mailboxIds: { 'server-inbox': true, 'server-work': true }, keywords: {} },
{ id: 'sent-1', mailboxIds: { 'server-sent': true }, keywords: { $seen: true } },
]),
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
restoreEmails: vi.fn().mockResolvedValue(undefined),
} as unknown as IJMAPClient;
const onMoved = vi.fn();
useEmailStore.setState({ refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined), fetchMailboxes: vi.fn().mockResolvedValue(undefined) });
await useEmailStore.getState().moveThreadToMailbox(client, 'email-1', 'archive-local', undefined, onMoved);
expect(onMoved).toHaveBeenCalledTimes(1);
const [batches] = onMoved.mock.calls[0];
expect(batches).toEqual([{ client, accountId: 'shared-account', points: [
{ id: 'email-1', mailboxIds: { 'server-inbox': true }, seen: true },
{ id: 'email-2', mailboxIds: { 'server-inbox': true, 'server-work': true }, seen: false },
] }]);
await useEmailStore.getState().restoreEmails(client, batches);
expect(client.restoreEmails).toHaveBeenCalledWith(batches[0].points, 'shared-account');
expect(useEmailStore.getState().refreshCurrentMailbox).toHaveBeenCalled();
});
});
+90 -10
View File
@@ -96,6 +96,59 @@ export function resolveSelectedEmails(state: {
return [...found.values()];
}
/** A message's folders and read state before an action moved it. */
export interface EmailRestorePoint {
id: string;
/** The server's own folder ids, not the list's `account:id` names. */
mailboxIds: Record<string, boolean>;
seen: boolean;
}
/** Restore points of one login and JMAP account, as the action reached them. */
export interface EmailRestoreBatch {
client: IJMAPClient;
accountId?: string;
points: EmailRestorePoint[];
}
/**
* Where `emails` are now, so the toast's Undo can put them back after a
* delete to Trash, an archive, a move or "remove from folder". Taken before
* the action, routed the way the action is (resolveEmailActionContext).
*/
export function restoreBatchesFor(emails: Email[], client: IJMAPClient): EmailRestoreBatch[] {
const batches: EmailRestoreBatch[] = [];
for (const email of emails) {
if (!email.mailboxIds) continue;
let context: ReturnType<typeof resolveEmailActionContext>;
try {
context = resolveEmailActionContext(email, client);
} catch {
continue;
}
const prefix = context.accountId ? `${context.accountId}:` : null;
const mailboxIds: Record<string, boolean> = {};
for (const [id, inIt] of Object.entries(email.mailboxIds)) {
if (inIt) mailboxIds[prefix && id.startsWith(prefix) ? id.slice(prefix.length) : id] = true;
}
if (Object.keys(mailboxIds).length === 0) continue;
let batch = batches.find(b => b.client === context.client && b.accountId === context.accountId);
if (!batch) {
batch = { client: context.client, accountId: context.accountId, points: [] };
batches.push(batch);
}
if (!batch.points.some(p => p.id === email.id)) {
batch.points.push({ id: email.id, mailboxIds, seen: !!email.keywords?.$seen });
}
}
return batches;
}
/** restoreBatchesFor the selected messages, taken before a batch action. */
export function restoreBatchesForSelection(client: IJMAPClient): EmailRestoreBatch[] {
return restoreBatchesFor(resolveSelectedEmails(useEmailStore.getState()), client);
}
/** Enough of an email to key a selection by its owning account. */
type SelectableEmail = Pick<Email, 'id' | 'sourceClientAccountId' | 'sourceAccountId'>;
@@ -367,9 +420,11 @@ interface EmailStore {
* (the open folder, else the one folder the message is in); the others,
* such as your own message in Sent, stay where they are. `emailKey`
* (emailKeyFor of the row) picks the right row when the id repeats across
* accounts.
* accounts. `onMoved` gets where the moved messages were, for Undo.
*/
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string) => Promise<boolean>;
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string, onMoved?: (restore: EmailRestoreBatch[]) => void) => Promise<boolean>;
/** Undo for a move: put the messages back (restoreBatchesFor) and reload the list and counts. */
restoreEmails: (client: IJMAPClient, batches: EmailRestoreBatch[]) => Promise<void>;
/**
* Take messages out of the open folder only, leaving them in their other
* folders (#615). Acts on those of `emails` that removalFolder and
@@ -4844,15 +4899,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
moveThreadToMailbox: async (client, emailId, destinationMailboxId, emailKey): Promise<boolean> => {
moveThreadToMailbox: async (client, emailId, destinationMailboxId, emailKey, onMoved): Promise<boolean> => {
const isTarget = singleEmailMatcher(emailId, emailKey);
try {
const state = get();
const email = state.emails.find(isTarget)
?? ((!!state.selectedEmail && isTarget(state.selectedEmail)) ? state.selectedEmail : null);
// The one message moves on its own: Undo puts back what the row says.
const moveOne = async () => {
const restore = email ? restoreBatchesFor([email], client) : [];
const moved = await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey);
if (moved) onMoved?.(restore);
return moved;
};
if (!email?.threadId) {
return await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey);
return await moveOne();
}
// In unified view route to the email's own account (client + that
@@ -4870,16 +4932,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const source = sourceId ? mailboxes.find(mb => mb.id === sourceId) : undefined;
const sourceJmapId = source ? (source.originalId || source.id) : null;
let threadEmailIds = [emailId];
let restorePoints: EmailRestorePoint[] = [];
if (sourceJmapId && memberIds.length > 1) {
const members = await effectiveClient.getEmailFields(memberIds, ['id', 'mailboxIds'], accountId);
const inSource = members
.filter(m => (m.mailboxIds as Record<string, boolean> | undefined)?.[sourceJmapId])
.map(m => m.id as string);
threadEmailIds = [...new Set([emailId, ...inSource])];
const members = await effectiveClient.getEmailFields(memberIds, ['id', 'mailboxIds', 'keywords'], accountId);
const inSource = members.filter(m => (m.mailboxIds as Record<string, boolean> | undefined)?.[sourceJmapId]);
threadEmailIds = [...new Set([emailId, ...inSource.map(m => m.id as string)])];
restorePoints = inSource.map(m => ({
id: m.id as string,
mailboxIds: { ...(m.mailboxIds as Record<string, boolean>) },
seen: !!(m.keywords as Record<string, boolean> | undefined)?.$seen,
}));
}
if (threadEmailIds.length <= 1) {
return await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey);
return await moveOne();
}
const move = { emailIds: threadEmailIds, fromMailboxId: sourceId, toMailboxId: destinationMailboxId };
@@ -4910,6 +4976,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
};
});
emitEmailMove(move, mailboxes);
if (onMoved) {
const picked = restoreBatchesFor([email], client)[0]?.points ?? [];
const points = [...restorePoints, ...picked.filter(p => !restorePoints.some(r => r.id === p.id))];
onMoved([{ client: effectiveClient, accountId, points }]);
}
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move email thread"
@@ -4919,6 +4990,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return true;
},
restoreEmails: async (client, batches): Promise<void> => {
for (const batch of batches) {
if (batch.points.length > 0) await batch.client.restoreEmails(batch.points, batch.accountId);
}
// Back in the list the user is looking at, and the folder counts with it.
await get().refreshCurrentMailbox(client);
void get().fetchMailboxes(client);
},
searchEmails: async (client, typedQuery) => {
const { searchAbortController } = get();