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