diff --git a/app/globals.css b/app/globals.css index b9aee76aa..a7e851e20 100644 --- a/app/globals.css +++ b/app/globals.css @@ -566,6 +566,11 @@ optgroup { animation: toast-exit 0.28s cubic-bezier(0.06, 0.71, 0.55, 1) forwards; } +/* The reader toolbar with no room for its labels: icons only, the names stay in the tooltips. */ +[data-toolbar-compact] [data-toolbar-label] { + display: none !important; +} + /* Mobile Responsive Utilities */ /* diff --git a/components/email/__tests__/email-identity-badge.test.tsx b/components/email/__tests__/email-identity-badge.test.tsx index 7d2457acf..d08d78049 100644 --- a/components/email/__tests__/email-identity-badge.test.tsx +++ b/components/email/__tests__/email-identity-badge.test.tsx @@ -4,7 +4,8 @@ import React from 'react'; import { EmailIdentityBadge } from '../email-identity-badge'; import type { Email, Identity } from '@/lib/jmap/types'; -// A draft or sent mail from "User A" showed "User A via User A". +// A draft or sent mail from "User A" showed "User A via User A", and any +// other own message "via ". const identity = { id: 'id-1', name: 'User A', email: 'usera@example.org' } as Identity; const fromUser = (name: string) => ({ from: [{ name, email: 'usera@example.org' }], to: [] }) as unknown as Email; @@ -15,9 +16,16 @@ describe('EmailIdentityBadge', () => { expect(container.textContent).toBe(''); }); - it('names the identity when the sender name differs', () => { + it('names no identity on your own message, whatever the sender name', () => { + // "via " read like a delegation warning; the address under the + // name already says which of your addresses sent it. const { container } = render(); - // The translation mock returns the key of the "via {name}" text. - expect(container.textContent).toContain('identity_short'); + expect(container.textContent).toBe(''); + }); + + it('still shows the sub-address tag', () => { + const email = { from: [{ name: 'Support Desk', email: 'usera+news@example.org' }], to: [] } as unknown as Email; + const { container } = render(); + expect(container.textContent).toContain('news'); }); }); 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..815449deb --- /dev/null +++ b/components/email/__tests__/reader-spam-trash-actions.test.tsx @@ -0,0 +1,112 @@ +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(); + }); +}); + +describe('reader header', () => { + it('shows no message size (the details have it)', async () => { + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + expect(document.body.textContent).not.toContain('2.1 KB'); + }); +}); diff --git a/components/email/__tests__/reader-toolbar-labels-first.test.tsx b/components/email/__tests__/reader-toolbar-labels-first.test.tsx new file mode 100644 index 000000000..ea51036a2 --- /dev/null +++ b/components/email/__tests__/reader-toolbar-labels-first.test.tsx @@ -0,0 +1,131 @@ +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// The reader toolbar hid whole buttons (Forward, Archive, Move, at 1024 px +// even Reply) while their labels stayed. Now the labels go first, and Reply, +// Reply all, Forward, Archive and Move stay while anything else can give way. + +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: 'clients', name: 'Clients', role: null, parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', + preview: '', hasAttachment: false, size: 1, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, +} as unknown as Email; + +const REQUIRED = ['1', '2', '3', '4', '5']; + +/** + * A toolbar 600 px wide whose two groups need `labelled` px each with labels + * and `compact` px each without; every item is 40 px. + */ +function layout(labelled: number, compact: number) { + const spies = [ + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { + return this.closest('[data-toolbar-compact]') ? compact : labelled; + }), + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(600), + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(40), + ]; + return () => spies.forEach((spy) => spy.mockRestore()); +} + +let restore: (() => void) | null = null; +afterEach(() => { restore?.(); restore = null; }); + +const viewer = (props: Partial> = {}) => render( + , +); +const toolbar = (container: HTMLElement) => container.querySelector('[class*="@container/reader-toolbar"]')!; +const hiddenPriorities = (container: HTMLElement) => + Array.from(container.querySelectorAll('[data-overflow-item]')) + .filter((item) => item.style.display === 'none') + .map((item) => item.dataset.overflowPriority!); + +describe('reader toolbar overflow', () => { + it('keeps its labels when everything fits', () => { + restore = layout(250, 150); + const { container } = viewer(); + expect(toolbar(container).hasAttribute('data-toolbar-compact')).toBe(false); + expect(hiddenPriorities(container)).toEqual([]); + }); + + it('drops the labels before any button', () => { + restore = layout(400, 250); + const { container } = viewer(); + expect(toolbar(container).hasAttribute('data-toolbar-compact')).toBe(true); + expect(hiddenPriorities(container)).toEqual([]); + // Every label is marked, so the compact toolbar shows icons only. + const labels = Array.from(container.querySelectorAll('[data-toolbar-label]')).map((label) => label.textContent); + expect(labels).toEqual(expect.arrayContaining(['reply', 'reply_all', 'forward', 'archive', 'delete', 'move'])); + }); + + it('then hides the optional buttons, never Reply, Reply all, Forward, Archive or Move', () => { + restore = layout(500, 400); + const { container } = viewer(); + const hidden = hiddenPriorities(container); + expect(hidden.length).toBeGreaterThan(0); + expect(hidden.filter((p) => REQUIRED.includes(p))).toEqual([]); + // The least important first: the dark-mode toggle before the read state. + expect(hidden).toContain('11'); + }); + + it('hides the required ones only in a reader too narrow for their icons, Reply last', () => { + restore = layout(2000, 1000); + const { container } = viewer(); + const hidden = hiddenPriorities(container); + expect(hidden).toEqual(expect.arrayContaining(REQUIRED)); + restore(); + // A little more room keeps Reply. + restore = layout(2000, 470); + const { container: roomier } = viewer(); + const kept = hiddenPriorities(roomier); + expect(kept).not.toContain('1'); + }); + + it('offers Open in new window and Fullscreen in the more menu when they have no room', async () => { + restore = layout(500, 400); + viewer({ onOpenInWindow: vi.fn(), onToggleFullscreen: vi.fn() }); + expect(screen.getByTestId('viewer-open-in-window').hasAttribute('data-overflow-item')).toBe(true); + // jsdom lays nothing out, so every toolbar button reads as hidden here. + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); }); + expect(await screen.findByRole('menuitem', { name: 'open_in_window' })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: 'fullscreen' })).toBeTruthy(); + }); +}); diff --git a/components/email/__tests__/viewer-toolbar-measure.test.tsx b/components/email/__tests__/viewer-toolbar-measure.test.tsx index f5b2ec13d..0f1d8c11b 100644 --- a/components/email/__tests__/viewer-toolbar-measure.test.tsx +++ b/components/email/__tests__/viewer-toolbar-measure.test.tsx @@ -50,7 +50,11 @@ let widthReads = 0; beforeEach(() => { clearAllHooks(); widthReads = 0; - vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => { widthReads++; return 40; }); + // A width read in the toolbar: its groups' (every measuring pass) and its + // items' (only a pass that has to hide some). + const inToolbar = (el: HTMLElement) => !!el.closest('[class*="@container/reader-toolbar"]'); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { if (inToolbar(this)) widthReads++; return 40; }); + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { if (inToolbar(this)) widthReads++; return 0; }); }); afterEach(() => vi.restoreAllMocks()); diff --git a/components/email/email-identity-badge.tsx b/components/email/email-identity-badge.tsx index 708991641..2b05413ce 100644 --- a/components/email/email-identity-badge.tsx +++ b/components/email/email-identity-badge.tsx @@ -111,7 +111,11 @@ export function EmailIdentityBadge({ return null; } - // Full view for email viewer - now shows compact badges only + // Full view for email viewer - the sub-address tag only. A message of your + // own named its identity ("via "), which read like a delegation + // warning; the sender's address, shown right under the name, already says + // which of your addresses sent it. + if (!displayTag) return null; return (
{/* Sub-address tag badge */} @@ -130,21 +134,6 @@ export function EmailIdentityBadge({
)} - {/* Identity badge (only if identity has a name and no sub-address tag) */} - {!displayTag && matchingIdentity && showIdentityName && ( -
- - {t('identity_short', { name: matchingIdentity.name })} -
- )} ); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0fb24e4ed..7b3d245d7 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -49,7 +49,7 @@ import { QuickReplyPluginActions } from "./quick-reply-plugin-actions"; import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope"; import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils"; import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; -import { itemsToHide } from "@/lib/toolbar-overflow"; +import { itemsToHide, toolbarHideOrder } from "@/lib/toolbar-overflow"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { emailDisplayDate } from "@/lib/email-date"; @@ -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"; @@ -210,8 +213,19 @@ const TOOLBAR_PRIORITY_ACTIONS: Record = { '7': 'spam', '8': 'read', '9': 'print', + '10': 'open_window', '11': 'extra:view_mode', + '12': 'extra:fullscreen', }; +/** + * The reader toolbar's buttons that stay: Reply (1), Reply all (2), Forward + * (3), Archive (4, or what stands in its place in Spam and Trash) and Move + * (5), with Delete, which never gives way. Only a reader too narrow for their + * icons hides them, in this order. + */ +const REQUIRED_TOOLBAR_PRIORITIES = [5, 3, 2, 4, 1] as const; +/** Set on the toolbar while it shows icons without their labels. */ +const TOOLBAR_COMPACT_ATTRIBUTE = 'data-toolbar-compact'; const NO_ACTION_KEYS: ReadonlySet = new Set(); const NO_PLUGIN_KEYS: ReadonlySet = new Set(); @@ -1160,6 +1174,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo onExport={options.onExport} onPrint={options.onPrint} onViewSource={options.onViewSource} + onOpenInWindow={options.onOpenInWindow} omit={options.omit} extras={options.extras} rowPlugins={false} @@ -1457,6 +1472,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 || ''); @@ -1808,7 +1832,7 @@ export function EmailViewer({ // looked like (see layoutKey), and the width it last had: an open that // brings back a toolbar already measured at this width needs no measuring. // A loading message and a loaded one can differ, so a few are kept. - const measuredToolbarRef = useRef(new Map>()); + const measuredToolbarRef = useRef(new Map; compact: boolean }>()); const toolbarWidthRef = useRef(undefined); const currentTagIds = getEmailTagIds(email?.keywords); const sortedTagIds = sortTagIds(currentTagIds); @@ -1993,6 +2017,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; @@ -2066,17 +2092,14 @@ export function EmailViewer({ const key = layoutKey(items); const known = toolbarWidthRef.current !== undefined ? measuredToolbarRef.current.get(key) : undefined; if (known) { - for (const item of items) item.style.display = known.has(Number(item.dataset.overflowPriority)) ? 'none' : ''; - applyHidden(known); + el.toggleAttribute(TOOLBAR_COMPACT_ATTRIBUTE, known.compact); + for (const item of items) item.style.display = known.hidden.has(Number(item.dataset.overflowPriority)) ? 'none' : ''; + applyHidden(known.hidden); return; } - // Sort descending by priority so highest number (least important) is hidden first - items.sort((a, b) => - Number(b.dataset.overflowPriority || 0) - Number(a.dataset.overflowPriority || 0) - ); - // Show all items to measure their natural widths + // Show all items, with their labels, to measure their natural widths items.forEach(item => { item.style.display = ''; }); - const containerWidth = el.clientWidth; + el.removeAttribute(TOOLBAR_COMPACT_ATTRIBUTE); const leftGroup = el.firstElementChild as HTMLElement; const rightGroup = el.lastElementChild as HTMLElement; const mainGap = parseFloat(getComputedStyle(el).gap) || 0; @@ -2084,25 +2107,37 @@ export function EmailViewer({ leftGroup.style.flexShrink = '0'; rightGroup.style.flexShrink = '0'; el.style.overflow = 'hidden'; - // Measure every width in one pass and decide from the numbers (see - // itemsToHide) rather than hiding items one by one and re-reading. - const groupIds = new Map([[leftGroup, 'left'], [rightGroup, 'right']]); - const groupOf = (item: HTMLElement) => { - const parent = item.parentElement; - if (!parent) return ''; - if (!groupIds.has(parent)) groupIds.set(parent, String(groupIds.size)); - return groupIds.get(parent)!; - }; - const measured = items.map((item) => ({ width: item.offsetWidth, group: groupOf(item) })); - const overflow = leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1); - const gapOf = { - left: parseFloat(getComputedStyle(leftGroup).gap) || 0, - right: parseFloat(getComputedStyle(rightGroup).gap) || 0, - }; + const containerWidth = el.clientWidth; + const overflowNow = () => leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1); + let overflow = overflowNow(); + // The labels go before any button does (icons keep their tooltips). + const compact = overflow > 0; + if (compact) { + el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, ''); + overflow = overflowNow(); + } const hidden = new Set(); - for (const i of itemsToHide(measured, overflow, gapOf)) { - items[i].style.display = 'none'; - hidden.add(Number(items[i].dataset.overflowPriority)); + if (overflow > 0) { + // Measure every width in one pass and decide from the numbers (see + // itemsToHide) rather than hiding items one by one and re-reading. + const order = toolbarHideOrder(items.map((item) => Number(item.dataset.overflowPriority || 0)), REQUIRED_TOOLBAR_PRIORITIES); + const ordered = order.map((i) => items[i]); + const groupIds = new Map([[leftGroup, 'left'], [rightGroup, 'right']]); + const groupOf = (item: HTMLElement) => { + const parent = item.parentElement; + if (!parent) return ''; + if (!groupIds.has(parent)) groupIds.set(parent, String(groupIds.size)); + return groupIds.get(parent)!; + }; + const measured = ordered.map((item) => ({ width: item.offsetWidth, group: groupOf(item) })); + const gapOf = { + left: parseFloat(getComputedStyle(leftGroup).gap) || 0, + right: parseFloat(getComputedStyle(rightGroup).gap) || 0, + }; + for (const i of itemsToHide(measured, overflow, gapOf)) { + ordered[i].style.display = 'none'; + hidden.add(Number(ordered[i].dataset.overflowPriority)); + } } // Restore layout leftGroup.style.flexShrink = ''; @@ -2110,7 +2145,7 @@ export function EmailViewer({ el.style.overflow = ''; const answers = measuredToolbarRef.current; if (answers.size >= 8) answers.delete(answers.keys().next().value!); - answers.set(key, hidden); + answers.set(key, { hidden, compact }); applyHidden(hidden); }; @@ -3454,7 +3489,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); @@ -4238,7 +4274,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) { @@ -4276,6 +4315,10 @@ ${printWatermark ? printWatermark.html : ''} ...(onShowShortcuts && !isMobile ? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }] : []), + // Only while the toolbar has no room for its button (see toolbarShownActions). + ...(onToggleFullscreen && !isFullscreen && !mvViewer && !isMobile + ? [{ kind: 'item' as const, key: 'extra:fullscreen' as const, icon: Maximize2, label: t('fullscreen'), run: onToggleFullscreen }] + : []), ]; const readerActionOptions: MessageActionOptions = { email, @@ -4299,6 +4342,8 @@ ${printWatermark ? printWatermark.html : ''} onExport: devices.noExport ? undefined : () => void handleExportEmail(), onPrint: devices.noPrint ? undefined : () => handlePrint(), onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true), + // Only while the toolbar has no room for its button (see toolbarShownActions). + onOpenInWindow: onOpenInWindow && !mvViewer && !isMobile ? onOpenInWindow : undefined, omit: readerMenuOmit, extras: readerExtras.filter((entry) => entry.kind !== 'item' || !readerMenuOmit.has(entry.key)), }; @@ -4367,16 +4412,16 @@ ${printWatermark ? printWatermark.html : ''} title={t('reschedule_send')} > - {showToolbarLabels && {t('reschedule_send')}} + {showToolbarLabels && {t('reschedule_send')}} {onCancelScheduledSend && !email.isSmimeScheduled && ( )} @@ -4405,7 +4450,7 @@ ${printWatermark ? printWatermark.html : ''} title={t('tooltips.reply')} > - {showToolbarLabels && {t('reply')}} + {showToolbarLabels && {t('reply')}} )} @@ -4449,8 +4494,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 && ( )} {/* Delete */} @@ -4470,10 +4547,11 @@ ${printWatermark ? printWatermark.html : ''} size="sm" onClick={onDelete} className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-3")} - title={t('tooltips.delete')} + title={deleteIsPermanent ? tActions('delete_forever') : t('tooltips.delete')} + data-testid="reader-delete" > - {showToolbarLabels && {t('delete')}} + {showToolbarLabels && {deleteIsPermanent ? tActions('delete_forever') : t('delete')}} {/* Move to folder */} {moveTree.length > 0 && onMoveToMailbox && ( @@ -4493,7 +4571,7 @@ ${printWatermark ? printWatermark.html : ''} aria-expanded={moveMenuOpen} > - {showToolbarLabels && {t('move')}} + {showToolbarLabels && {t('move')}}
@@ -4525,7 +4603,7 @@ ${printWatermark ? printWatermark.html : ''} aria-expanded={tagMenuOpen} > - {showToolbarLabels && {t('tag')}} + {showToolbarLabels && {t('tag')}}
@@ -4540,7 +4618,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Spam */} - {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( + {spamApplicable && !isInJunkFolder && onMarkAsSpam && ( )} @@ -4582,7 +4660,7 @@ ${printWatermark ? printWatermark.html : ''} > {isUnread ? : } {showToolbarLabels && ( - + {t('read')} {t('unread')} @@ -4602,7 +4680,7 @@ ${printWatermark ? printWatermark.html : ''} title={t('print')} > - {showToolbarLabels && {t('print')}} + {showToolbarLabels && {t('print')}} )} @@ -4629,6 +4707,8 @@ ${printWatermark ? printWatermark.html : ''} variant="ghost" size="sm" onClick={onOpenInWindow} + data-overflow-item + data-overflow-priority="10" className="hidden sm:inline-flex h-8 gap-1.5" title={tWindows('open_in_window')} aria-label={tWindows('open_in_window')} @@ -4639,13 +4719,14 @@ ${printWatermark ? printWatermark.html : ''} )} {/* Fullscreen toggle - hidden on mobile (already fullscreen there). - Never overflows into the More menu: in fullscreen this button is - the way back out, so it must stay visible. */} + The first to give way to the More menu, but never in fullscreen: + there this button is the way back out, so it must stay visible. */} {onToggleFullscreen && !mvViewer && (
@@ -5343,18 +5420,13 @@ ${printWatermark ? printWatermark.html : ''} - {/* Date/time + size on the right (mobile) */} + {/* Date/time on the right (mobile); the size is in the details */}
{mvMobileViewer ? formatDate(emailDisplayDate(email)) : formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} - {email.size > 0 && !mvMobileViewer && ( -
- {formatFileSize(email.size)} -
- )}
{/* Mountain View phone: reply from the top of the message, not only from the row under a long body (#1475). */} diff --git a/components/files/__tests__/eml-attachment-title.test.tsx b/components/files/__tests__/eml-attachment-title.test.tsx new file mode 100644 index 000000000..c4ec6987a --- /dev/null +++ b/components/files/__tests__/eml-attachment-title.test.tsx @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); +const saveBlobAs = vi.fn(() => true); +vi.mock('@/lib/download-guard', async (importOriginal) => ({ + ...(await importOriginal()), + saveBlobAs: (...args: unknown[]) => saveBlobAs(...(args as [])), +})); + +import { FilePreviewModal } from '../file-preview-modal'; +import { EmlPreview } from '../eml-preview'; +import { useSettingsStore } from '@/stores/settings-store'; + +// An attached message without a file name opened as "attachment", saved as +// "attachment", in a monospace font with a 12-hour date and seconds. + +const EML = [ + 'From: Tobias Brenner ', + 'To: Mara Lindqvist ', + 'Subject: Original: room booking', + 'Date: Thu, 15 Oct 2026 05:40:37 +0200', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Room 2.14 is booked.', + '', +].join('\r\n'); + +const preview = (name: string, onDownload = vi.fn()) => { + render( + {}} + onDownload={onDownload} + getFileContent={async () => ({ blob: new Blob([EML], { type: 'message/rfc822' }), contentType: 'message/rfc822' })} + />, + ); + return onDownload; +}; + +afterEach(() => { + saveBlobAs.mockClear(); + useSettingsStore.setState({ timeFormat: '24h' }); +}); + +describe('attached message preview', () => { + it('is titled and saved by its subject when the part has no name', async () => { + const onDownload = preview('attachment'); + await waitFor(() => expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Original: room booking')); + fireEvent.click(screen.getByRole('button', { name: 'download' })); + expect(onDownload).not.toHaveBeenCalled(); + expect(saveBlobAs).toHaveBeenCalledTimes(1); + expect((saveBlobAs.mock.calls[0] as unknown[])[1]).toMatch(/^Original.*room booking\.eml$/); + }); + + it("keeps the sender's own file name", async () => { + const onDownload = preview('booking-thread.eml'); + await waitFor(() => expect(screen.getByRole('heading', { level: 2 }).textContent).toBe('Original: room booking')); + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('booking-thread.eml'); + fireEvent.click(screen.getByRole('button', { name: 'download' })); + expect(onDownload).toHaveBeenCalled(); + expect(saveBlobAs).not.toHaveBeenCalled(); + }); + + it("shows the body in the reader's font and the date as the reader does", () => { + useSettingsStore.setState({ timeFormat: '24h' }); + const { container } = render( + , + ); + const doc = container.querySelector('iframe')?.getAttribute('srcdoc') ?? ''; + expect(doc).not.toContain('monospace'); + expect(doc).toContain('-apple-system'); + expect(container.textContent).not.toContain(':37'); + expect(container.textContent).not.toMatch(/AM|PM/); + }); +}); diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx index 1d7749128..13aeaf84f 100644 --- a/components/files/eml-preview.tsx +++ b/components/files/eml-preview.tsx @@ -5,7 +5,7 @@ import { Paperclip, Download } from "@/components/icons"; import { saveBlobAs } from "@/lib/download-guard"; import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization"; import { turnOffLinks } from "@/lib/phishing-links"; -import { getEffectiveTimeZone } from "@/lib/timezone"; +import { formatDateTime } from "@/lib/utils"; import { stripBidiControls } from "@/lib/bidi"; import { isFilePreviewable } from "@/lib/file-preview"; import { useSettingsStore } from "@/stores/settings-store"; @@ -66,6 +66,7 @@ export function EmlPreview({ message, onOpenAttachment, linksOff, downloadsOff = const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere(); const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; const tFiles = useTranslations("files"); + const timeFormat = useSettingsStore((state) => state.timeFormat); // dir="auto" on a wrapper we control lets the first strong character pick // the direction, matching the main viewer. @@ -74,11 +75,14 @@ export function EmlPreview({ message, onOpenAttachment, linksOff, downloadsOff = ? turnOffLinks(sanitizeEmailBodyForIframe(message.html, blockRemote).html, { title: tPhishing("link_off"), note: tPhishing("link_off_note") }) : sanitizeEmailBodyForIframe(message.html, blockRemote).html}` : message.text - ? `
${escapeHtml(message.text)}
` + ? `
${escapeHtml(message.text)}
` : ""; + // The reader's font (.email-content), for a plain-text body and as the + // default under an HTML one. + const bodyStyle = ``; // The strict CSP is the network backstop for whatever the DOM walk misses. const bodyDoc = body - ? `${body}` + ? `${bodyStyle}${body}` : ""; const downloadAttachment = (att: EmlAttachment) => { @@ -102,7 +106,7 @@ export function EmlPreview({ message, onOpenAttachment, linksOff, downloadsOff =
{t("to")}: {message.to.map(formatAddress).join(", ")}
)} {message.date && ( -
{t("date")}: {new Date(message.date).toLocaleString(undefined, { timeZone: getEffectiveTimeZone() })}
+
{t("date")}: {formatDateTime(message.date, timeFormat, { weekday: "short", year: "numeric", month: "short", day: "numeric" })}
)} {bodyDoc && ( diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index d889844e2..89d1a114c 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -13,6 +13,8 @@ import { EmlPreview, type EmlAttachment, type ParsedEml } from "@/components/fil import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; import { canOpenUrlInNewWindow } from "@/lib/standalone-display"; import { saveBlobAs } from "@/lib/download-guard"; +import { emailExportFilename } from "@/lib/download-filename"; +import type { Email } from "@/lib/jmap/types"; import { noMediaSavingProps } from "@/lib/device-frame"; import { useDevicePolicy, useStrictestDevices } from "@/hooks/use-device-policy"; import { Watermark, useWatermarkText } from "@/components/device/watermark"; @@ -49,6 +51,14 @@ const PdfMobileViewer = dynamic( { ssr: false }, ); +/** + * A message attached without a file name gets one made up from its type or + * the download fallback: "Email.eml", "attachment". + */ +function isUnnamedMessagePart(name: string): boolean { + return /^(attachment|email|download)(\.eml)?$/i.test(name.trim()); +} + interface FilePreviewModalProps { name: string; onClose: () => void; @@ -185,6 +195,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi // (text/html, image/svg+xml, ...) in a new tab would execute it in-origin. const [canOpenInNewTab, setCanOpenInNewTab] = useState(false); const [emlContent, setEmlContent] = useState(null); + const [emlBlob, setEmlBlob] = useState(null); // The PDF blob itself, kept alongside its object URL: the pdf.js mobile // viewer needs the bytes, because fetching the blob: URL is blocked by CSP // connect-src (#871). The URL is still what the