From f828b6dffe5818ef6239084764036b0fe04ea944 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:37:20 +0100 Subject: [PATCH] fix: show Not spam in Spam and Restore and Delete forever in Trash on the reader toolbar --- .../reader-spam-trash-actions.test.tsx | 105 ++++++++++++++++++ components/email/email-viewer.tsx | 67 +++++++++-- lib/__tests__/trash-restore-target.test.ts | 34 ++++++ lib/trash-restore-target.ts | 29 +++++ locales/ar/common.json | 5 + locales/ca/common.json | 5 + locales/cs/common.json | 5 + locales/da/common.json | 5 + locales/de/common.json | 5 + locales/en/common.json | 5 + locales/es/common.json | 5 + locales/et/common.json | 5 + locales/fa/common.json | 5 + locales/fr/common.json | 5 + locales/he/common.json | 5 + locales/hu/common.json | 5 + locales/it/common.json | 5 + locales/ja/common.json | 5 + locales/ko/common.json | 5 + locales/lv/common.json | 5 + locales/mn/common.json | 5 + locales/nb/common.json | 5 + locales/nl/common.json | 5 + locales/pl/common.json | 5 + locales/pt/common.json | 5 + locales/ro/common.json | 5 + locales/ru/common.json | 5 + locales/sk/common.json | 5 + locales/th/common.json | 5 + locales/tr/common.json | 5 + locales/uk/common.json | 5 + locales/vi/common.json | 5 + locales/zh-TW/common.json | 5 + locales/zh/common.json | 5 + 34 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 components/email/__tests__/reader-spam-trash-actions.test.tsx create mode 100644 lib/__tests__/trash-restore-target.test.ts create mode 100644 lib/trash-restore-target.ts diff --git a/components/email/__tests__/reader-spam-trash-actions.test.tsx b/components/email/__tests__/reader-spam-trash-actions.test.tsx new file mode 100644 index 000000000..b806a9eea --- /dev/null +++ b/components/email/__tests__/reader-spam-trash-actions.test.tsx @@ -0,0 +1,105 @@ +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; + +// Spam and Trash showed the Inbox's toolbar. Spam now has "Not spam" in +// Archive's place and no quick reply; Trash has "Restore" there and its +// Delete says "Delete forever". + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, + { id: 'junk', name: 'Junk', role: 'junk', parentId: null, myRights: { mayAddItems: true } }, + { id: 'trash', name: 'Trash', role: 'trash', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const message = (folder: string) => ({ + id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { [folder]: true }, keywords: { $seen: true }, + from: [{ name: 'Dave', email: 'dave@example.net' }], to: [{ name: 'Alice', email: 'alice@example.org' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [], + bodyValues: { '1': { value: 'Plain body' } }, +}) as unknown as Email; + +const handlers = () => ({ + onArchive: vi.fn(), + onDelete: vi.fn(), + onMarkAsSpam: vi.fn(), + onUndoSpam: vi.fn(), + onMoveToMailbox: vi.fn(), + onQuickReply: vi.fn(async () => true), +}); + +async function viewer(folder: string, h = handlers()) { + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + return h; +} + +const archiveButton = () => document.querySelector('button[title="tooltips.archive"]'); + +beforeEach(() => { + useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false, quickReplyEnabled: true }); +}); + +describe('reader toolbar in Spam', () => { + it('offers Not spam in place of Archive', async () => { + const h = await viewer('junk'); + expect(archiveButton()).toBeNull(); + fireEvent.click(screen.getByTestId('reader-not-spam')); + expect(h.onUndoSpam).toHaveBeenCalled(); + // Only once: the old spam button no longer says it as well. + expect(document.querySelectorAll('button[title="spam.not_spam_title"]')).toHaveLength(1); + }); + + it('has no quick reply', async () => { + await viewer('junk'); + expect(screen.queryByPlaceholderText('quick_reply_placeholder')).toBeNull(); + }); +}); + +describe('reader toolbar in Trash', () => { + it('offers Restore in place of Archive, back to the Inbox', async () => { + const h = await viewer('trash'); + expect(archiveButton()).toBeNull(); + fireEvent.click(screen.getByTestId('reader-restore')); + expect(h.onMoveToMailbox).toHaveBeenCalledWith('inbox'); + }); + + it('says Delete forever', async () => { + await viewer('trash'); + expect(screen.getByTestId('reader-delete').textContent).toContain('delete_forever'); + }); +}); + +describe('reader toolbar in the Inbox', () => { + it('keeps Archive, Delete and quick reply', async () => { + await viewer('inbox'); + expect(archiveButton()).not.toBeNull(); + expect(screen.queryByTestId('reader-restore')).toBeNull(); + expect(screen.queryByTestId('reader-not-spam')).toBeNull(); + expect(screen.getByTestId('reader-delete').textContent).not.toContain('delete_forever'); + expect(screen.getByPlaceholderText('quick_reply_placeholder')).toBeTruthy(); + }); +}); + diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 2727ea72d..06017e6e1 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -95,6 +95,7 @@ import { Minus, ShieldCheck, ShieldAlert, + RotateCcw, Copy, Brain, Keyboard, @@ -140,7 +141,9 @@ import { useDeviceDetection } from "@/hooks/use-media-query"; import { useIsPaneScoped } from "@/hooks/use-pane-context"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; -import { useEmailStore, resolveEmailBlobContext, emailWriteSource, type EmailBlobContext } from "@/stores/email-store"; +import { useEmailStore, resolveEmailBlobContext, emailWriteSource, bulkDeleteIsPermanent, type EmailBlobContext } from "@/stores/email-store"; +import { trashRestoreTarget } from "@/lib/trash-restore-target"; +import { localizeMailboxName } from "@/lib/mailbox-label"; import { useListSelection } from "@/lib/plugin-sandbox/assistant/b.list-selection"; import { readability } from "@/lib/plugin-sandbox/assistant/b.projection"; import { useThemeStore } from "@/stores/theme-store"; @@ -1463,6 +1466,15 @@ export function EmailViewer({ // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; + // Trash offers Restore in Archive's place; there and wherever a delete + // destroys the mail, Delete says "Delete forever". + const isInTrashFolder = currentMailboxRole === 'trash'; + const tActions = useTranslations('reader_actions'); + const tSidebarNames = useTranslations('sidebar'); + // Read for the re-render: bulkDeleteIsPermanent reads them from the store. + useSettingsStore((state) => state.deleteAction); + useSettingsStore((state) => state.permanentlyDeleteJunk); + const deleteIsPermanent = bulkDeleteIsPermanent({ role: currentMailboxRole }); // Marking your own outgoing mail as spam makes no sense - hide the action // in Sent, Drafts and Scheduled. const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); @@ -1999,6 +2011,8 @@ export function EmailViewer({ [mailboxes, selectedMailbox, moveOwnerAccountId, email], ); + const restoreTarget = isInTrashFolder && email ? trashRestoreTarget(email, mailboxes, identities, moveOwnerAccountId) : undefined; + // Close dropdown menus on click outside useEffect(() => { if (!moreMenuOpen) return; @@ -3469,7 +3483,8 @@ export function EmailViewer({ const [iframeReadyFor, setIframeReadyFor] = useState(null); const latestSrcDocRef = useRef(null); const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc; - const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !isDraft && !isScheduled && !isBodyLoading + // Not on spam: a reply confirms the address to the sender. + const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder && (!effectiveEmailContent.isHtml || iframeReady); // The plain-text body, where reader-above-quote mounts between text and quote. const [plainBodyEl, setPlainBodyEl] = useState(null); @@ -4250,7 +4265,10 @@ ${printWatermark ? printWatermark.html : ''} const toolbarShownActions = (): ReadonlySet => { const shown = new Set(['delete', 'edit_draft']); toolbarRef.current?.querySelectorAll('[data-overflow-item]').forEach((item) => { - const key = TOOLBAR_PRIORITY_ACTIONS[item.dataset.overflowPriority ?? '']; + // A button standing in for another (Not spam in Archive's place) says which entry it is. + const key = item.dataset.overflowAction !== undefined + ? item.dataset.overflowAction as MessageActionKey + : TOOLBAR_PRIORITY_ACTIONS[item.dataset.overflowPriority ?? '']; if (key && item.getClientRects().length > 0) shown.add(key); }); if (isMobile && !mvMobileViewer) { @@ -4467,8 +4485,40 @@ ${printWatermark ? printWatermark.html : ''} {/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */} {!isScheduled && (
- {/* Archive */} - {archiveActionEnabled && ( + {/* Archive. In Spam "Not spam" takes its place, in Trash "Restore". */} + {isInJunkFolder && onUndoSpam && ( + + )} + {isInTrashFolder && restoreTarget && onMoveToMailbox && ( + + )} + {archiveActionEnabled && !isInJunkFolder && !isInTrashFolder && ( {/* Move to folder */} {moveTree.length > 0 && onMoveToMailbox && ( @@ -4558,7 +4609,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Spam */} - {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( + {spamApplicable && !isInJunkFolder && onMarkAsSpam && (