From 768f3fe19bb8199ac146d49b1aefddaa54c65a41 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:40:17 +0100 Subject: [PATCH] fix: make Undo after Report spam put the message back and say Moved back, and name the Spam folder as the sidebar shows it --- .../__tests__/mail-app-spam-undo.test.tsx | 139 ++++++++++++++++++ components/mail/mail-app.tsx | 27 +--- locales/ca/common.json | 4 +- locales/en/common.json | 4 +- locales/es/common.json | 4 +- locales/fr/common.json | 4 +- locales/he/common.json | 4 +- locales/it/common.json | 4 +- locales/mn/common.json | 4 +- locales/nl/common.json | 4 +- locales/ro/common.json | 6 +- locales/tr/common.json | 4 +- locales/uk/common.json | 2 +- 13 files changed, 168 insertions(+), 42 deletions(-) create mode 100644 components/mail/__tests__/mail-app-spam-undo.test.tsx diff --git a/components/mail/__tests__/mail-app-spam-undo.test.tsx b/components/mail/__tests__/mail-app-spam-undo.test.tsx new file mode 100644 index 000000000..bdbfb9209 --- /dev/null +++ b/components/mail/__tests__/mail-app-spam-undo.test.tsx @@ -0,0 +1,139 @@ +import React, { Suspense, lazy } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useToastStore } from '@/stores/toast-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { clearAllHooks } from '@/lib/plugin-hooks'; + +// Report spam on the open message offered an Undo that moved it to the Inbox +// and then said "Email moved", where every other Undo puts the message back +// in its own folders and says "Moved back". + +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ onMarkAsSpam, onUndoSpam }: { onMarkAsSpam?: () => void; onUndoSpam?: () => void }) => ( + <> + + + + ), +})); +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +function mailbox(id: string, role: string): Mailbox { + return { + id, name: role, role, sortOrder: 0, totalEmails: 1, unreadEmails: 1, totalThreads: 1, unreadThreads: 1, isSubscribed: true, + } as Mailbox; +} +const folders = [mailbox('inbox', 'inbox'), mailbox('trash', 'trash'), mailbox('junk', 'junk')]; +const unread = { + id: 'm1', threadId: 't1', subject: 'Hello', receivedAt: '2026-10-26T10:00:00Z', + keywords: {}, mailboxIds: { inbox: true }, +} as unknown as Email; + +let client: Record | (() => unknown)>; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + useToastStore.setState({ toasts: [] }); + clearAllHooks(); + client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getMailboxes: vi.fn().mockResolvedValue(folders), + moveToTrash: vi.fn().mockResolvedValue(undefined), + moveEmail: vi.fn().mockResolvedValue(undefined), + deleteEmail: vi.fn().mockResolvedValue(undefined), + markAsSpam: vi.fn().mockResolvedValue(undefined), + undoSpam: vi.fn().mockResolvedValue(undefined), + restoreEmails: vi.fn().mockResolvedValue(undefined), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', permanentlyDeleteJunk: false } as never); + useEmailStore.setState({ + emails: [unread], selectedEmail: unread, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: folders, + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', + selectedEmailKeys: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const toastTitled = (title: string) => useToastStore.getState().toasts.find(t => t.title === title); + +describe('Undo after Report spam', () => { + it('puts the message back in its folder, unread, and says "Moved back"', async () => { + render(); + fireEvent.click(await screen.findByText('stub spam', {}, { timeout: 5000 })); + await waitFor(() => expect(toastTitled('email_viewer.spam.toast_success')).toBeTruthy()); + const reported = toastTitled('email_viewer.spam.toast_success')!; + expect(reported.action?.label).toBe('undo'); + reported.action!.onClick(); + await waitFor(() => expect(client.restoreEmails).toHaveBeenCalled()); + expect(client.restoreEmails).toHaveBeenCalledWith([{ id: 'm1', mailboxIds: { inbox: true }, seen: false }], undefined); + expect(client.undoSpam).not.toHaveBeenCalled(); + await waitFor(() => expect(toastTitled('undo_done')).toBeTruthy()); + expect(toastTitled('notifications.email_moved')).toBeUndefined(); + }); + + it('offers the same Undo after Not spam', async () => { + useEmailStore.setState({ selectedMailbox: 'junk', emails: [{ ...unread, mailboxIds: { junk: true } }], selectedEmail: { ...unread, mailboxIds: { junk: true } } } as never); + render(); + fireEvent.click(await screen.findByText('stub not spam', {}, { timeout: 5000 })); + await waitFor(() => expect(toastTitled('email_viewer.spam.toast_not_spam_success')).toBeTruthy()); + const restored = toastTitled('email_viewer.spam.toast_not_spam_success')!; + expect(restored.action?.label).toBe('undo'); + restored.action!.onClick(); + await waitFor(() => expect(client.restoreEmails).toHaveBeenCalledWith([{ id: 'm1', mailboxIds: { junk: true }, seen: false }], undefined)); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index a385636a2..1f0b77b61 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -3050,25 +3050,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => { if (!client || !emailToMark) return; - const emailId = emailToMark.id; - + // Undo puts it back in its own folders, read state included, as every + // other Undo does. + const restore = restoreBatchesFor([emailToMark], client); try { - if (!(await markAsSpam(client, emailId, emailKeyFor(emailToMark)))) return; - - toast.success(t('email_viewer.spam.toast_success'), { - action: { - label: t('email_viewer.spam.toast_undo'), - onClick: async () => { - try { - if (await undoSpam(client, emailId, emailKeyFor(emailToMark))) toast.success(t('notifications.email_moved')); - } catch (_error) { - console.error("Failed to undo spam:", _error); - toast.error(t('email_viewer.spam.error')); - } - }, - }, - duration: 5000, - }); + if (!(await markAsSpam(client, emailToMark.id, emailKeyFor(emailToMark)))) return; + toastWithUndo(t('email_viewer.spam.toast_success'), undoMoveAction(client, restore, undo)); } catch (_error) { console.error("Failed to mark as spam:", _error); toast.error(t('email_viewer.spam.error')); @@ -3078,10 +3065,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => { if (!client || !emailToRestore) return; + const restore = restoreBatchesFor([emailToRestore], client); try { if (!(await undoSpam(client, emailToRestore.id, emailKeyFor(emailToRestore)))) return; - - toast.success(t('email_viewer.spam.toast_not_spam_success')); + toastWithUndo(t('email_viewer.spam.toast_not_spam_success'), undoMoveAction(client, restore, undo)); } catch (_error) { console.error("Failed to restore email:", _error); toast.error(t('email_viewer.spam.error_not_spam')); diff --git a/locales/ca/common.json b/locales/ca/common.json index 73673373c..b916f26d2 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -555,8 +555,8 @@ "spam": { "button_title": "Denuncia com a brossa", "not_spam_title": "Marca com a legítim", - "toast_success": "S'ha mogut a la brossa", - "toast_batch": "{count} correus moguts a la brossa", + "toast_success": "S'ha mogut a Correu brossa", + "toast_batch": "{count} correus moguts a Correu brossa", "toast_undo": "Desfés", "toast_not_spam_success": "S'ha mogut a la safata d'entrada", "toast_not_spam_batch": "{count} correus moguts a la safata d'entrada", diff --git a/locales/en/common.json b/locales/en/common.json index 57ece13bb..71d4a3189 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -555,8 +555,8 @@ "spam": { "button_title": "Report spam", "not_spam_title": "Not spam", - "toast_success": "Moved to Junk", - "toast_batch": "{count} emails moved to Junk", + "toast_success": "Moved to Spam", + "toast_batch": "{count} emails moved to Spam", "toast_undo": "Undo", "toast_not_spam_success": "Moved to Inbox", "toast_not_spam_batch": "{count} emails moved to Inbox", diff --git a/locales/es/common.json b/locales/es/common.json index 3e77e02de..25fadc102 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -528,8 +528,8 @@ "spam": { "button_title": "Reportar spam", "not_spam_title": "Marcar como legítimo", - "toast_success": "Movido a Correo no deseado", - "toast_batch": "{count} correos movidos a Correo no deseado", + "toast_success": "Movido a Spam", + "toast_batch": "{count} correos movidos a Spam", "toast_undo": "Deshacer", "toast_not_spam_success": "Movido a Bandeja de entrada", "toast_not_spam_batch": "{count} correos movidos a Bandeja de entrada", diff --git a/locales/fr/common.json b/locales/fr/common.json index 4e2e789c5..d7b7added 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -528,8 +528,8 @@ "spam": { "button_title": "Signaler comme spam", "not_spam_title": "Marquer comme légitime", - "toast_success": "Déplacé vers Indésirables", - "toast_batch": "{count} e-mails déplacés vers Indésirables", + "toast_success": "Déplacé vers Spam", + "toast_batch": "{count} e-mails déplacés vers Spam", "toast_undo": "Annuler", "toast_not_spam_success": "Déplacé vers Boîte de réception", "toast_not_spam_batch": "{count} e-mails déplacés vers Boîte de réception", diff --git a/locales/he/common.json b/locales/he/common.json index 8dff936b8..9cbf1c112 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -477,8 +477,8 @@ "spam": { "button_title": "דווח על ספאם", "not_spam_title": "סמן כלגיטימי", - "toast_success": "עבר לזבל", - "toast_batch": "הודעות דוא\"ל{count}הועברו ל-Junk", + "toast_success": "הועבר לספאם", + "toast_batch": "{count} הודעות הועברו לספאם", "toast_undo": "בטל", "toast_not_spam_success": "הועבר לתיבת הדואר הנכנס", "toast_not_spam_batch": "הודעות דוא\"ל{count}הועברו ל-Inbox", diff --git a/locales/it/common.json b/locales/it/common.json index 54da3bc61..2ca302eaf 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -528,8 +528,8 @@ "spam": { "button_title": "Segnala come spam", "not_spam_title": "Segna come legittimo", - "toast_success": "Spostato in Posta indesiderata", - "toast_batch": "{count} messaggi spostati in Posta indesiderata", + "toast_success": "Spostato in Spam", + "toast_batch": "{count} messaggi spostati in Spam", "toast_undo": "Annulla", "toast_not_spam_success": "Spostato in Posta in arrivo", "toast_not_spam_batch": "{count} messaggi spostati in Posta in arrivo", diff --git a/locales/mn/common.json b/locales/mn/common.json index 0a74f3713..e8b0d5a8e 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -555,8 +555,8 @@ "spam": { "button_title": "Спам мэдээлэх", "not_spam_title": "Хууль ёсны гэж тэмдэглэх", - "toast_success": "Хог хаяг руу шилжсэн", - "toast_batch": "{count} имэйлийг хог хаяг руу зөөсөн", + "toast_success": "Спам руу шилжсэн", + "toast_batch": "{count} имэйлийг Спам руу зөөсөн", "toast_undo": "Буцаах", "toast_not_spam_success": "Ирсэн имэйл рүү шилжсэн", "toast_not_spam_batch": "{count} имэйлийг Ирсэн имэйл рүү зөөсөн", diff --git a/locales/nl/common.json b/locales/nl/common.json index d39409009..2c2e37e1d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -528,8 +528,8 @@ "spam": { "button_title": "Spam melden", "not_spam_title": "Markeren als legitiem", - "toast_success": "Verplaatst naar Ongewenst", - "toast_batch": "{count} e-mails verplaatst naar Ongewenst", + "toast_success": "Verplaatst naar Spam", + "toast_batch": "{count} e-mails verplaatst naar Spam", "toast_undo": "Ongedaan maken", "toast_not_spam_success": "Verplaatst naar Postvak IN", "toast_not_spam_batch": "{count} e-mails verplaatst naar Postvak IN", diff --git a/locales/ro/common.json b/locales/ro/common.json index 35d93304c..05fbe4348 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -555,10 +555,10 @@ "spam": { "button_title": "Raportează spamul", "not_spam_title": "Marcați ca legitim", - "toast_success": "Mutate în folderul „Junk”", - "toast_batch": "{count} e-mailuri mutate în folderul „Junk”", + "toast_success": "Mutat în Spam", + "toast_batch": "{count} e-mailuri mutate în Spam", "toast_undo": "Anulează", - "toast_not_spam_success": "Mutată în Inbox", + "toast_not_spam_success": "Mutat în Primite", "toast_not_spam_batch": "{count} e-mailuri mutate în Inbox", "error": "Nu s-a reușit raportarea spamului", "error_not_spam": "Nu s-a putut restaura e-mailul" diff --git a/locales/tr/common.json b/locales/tr/common.json index 655762b61..7103ce04f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -530,8 +530,8 @@ "spam": { "button_title": "Spam olarak bildir", "not_spam_title": "Meşru olarak işaretle", - "toast_success": "Önemsiz'e taşındı", - "toast_batch": "{count} e-posta Önemsiz'e taşındı", + "toast_success": "Spam'e taşındı", + "toast_batch": "{count} e-posta Spam'e taşındı", "toast_undo": "Geri al", "toast_not_spam_success": "Gelen Kutusu'na taşındı", "toast_not_spam_batch": "{count} e-posta Gelen Kutusu'na taşındı", diff --git a/locales/uk/common.json b/locales/uk/common.json index 7e28b8639..6308102ac 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -530,7 +530,7 @@ "spam": { "button_title": "Повідомити про спам", "not_spam_title": "Позначити як законне", - "toast_success": "Перенесено в сміття", + "toast_success": "Перенесено до спаму", "toast_batch": "{count} електронних листів переміщено до спаму", "toast_undo": "Скасувати", "toast_not_spam_success": "Переміщено до папки \"Вхідні\".",