mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: undo a delete to Trash, archive, move, spam report or remove from folder from its toast
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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": "تعذّر نقل الرسائل إلى المجلد المحدد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "انتقال ایمیلها به پوشه انتخاب شده ممکن نیست",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "לא ניתן היה להעביר הודעות דוא\"ל לתיקיה שנבחרה",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "選択したフォルダにメールを移動できませんでした",
|
||||
|
||||
@@ -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": "선택한 폴더로 메일을 이동할 수 없어요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Имэйлүүдийг сонгосон хавтас руу зөөж чадсангүй",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Не удалось переместить письма в выбранную папку",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ไม่สามารถย้ายอีเมลไปยังโฟลเดอร์ที่เลือกได้",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Не вдалося перемістити електронні листи до вибраної папки",
|
||||
|
||||
@@ -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": "無法將郵件移至選取的信件匣",
|
||||
|
||||
@@ -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": "无法将邮件移至所选文件夹",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user