fix: title an attached message by its subject and show it in the reader's font and date format

This commit is contained in:
Linus Rath
2026-11-04 06:38:13 +01:00
parent 279a1dd593
commit 4f00deffe9
3 changed files with 116 additions and 7 deletions
@@ -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<typeof import('@/lib/download-guard')>()),
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 <tobias.brenner@example.net>',
'To: Mara Lindqvist <mara.lindqvist@example.net>',
'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(
<FilePreviewModal
name={name}
onClose={() => {}}
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(
<EmlPreview message={{ subject: 's', text: 'Room 2.14 is booked.', date: '2026-10-15T03:40:37Z', attachments: [] }} />,
);
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/);
});
});
+8 -4
View File
@@ -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}</div>`
: message.text
? `<pre dir="auto" style="white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;margin:0;padding:8px">${escapeHtml(message.text)}</pre>`
? `<div dir="auto" style="white-space:pre-wrap;word-break:break-word;margin:0;padding:8px">${escapeHtml(message.text)}</div>`
: "";
// The reader's font (.email-content), for a plain-text body and as the
// default under an HTML one.
const bodyStyle = `<style>body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:0.9375rem;line-height:1.6;color:#1a1a1a}</style>`;
// The strict CSP is the network backstop for whatever the DOM walk misses.
const bodyDoc = body
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(blockRemote)}"><meta name="referrer" content="no-referrer"></head><body>${body}</body></html>`
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(blockRemote)}"><meta name="referrer" content="no-referrer">${bodyStyle}</head><body>${body}</body></html>`
: "";
const downloadAttachment = (att: EmlAttachment) => {
@@ -102,7 +106,7 @@ export function EmlPreview({ message, onOpenAttachment, linksOff, downloadsOff =
<div><span className="font-medium text-foreground">{t("to")}: </span><bdi>{message.to.map(formatAddress).join(", ")}</bdi></div>
)}
{message.date && (
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString(undefined, { timeZone: getEffectiveTimeZone() })}</div>
<div><span className="font-medium text-foreground">{t("date")}: </span>{formatDateTime(message.date, timeFormat, { weekday: "short", year: "numeric", month: "short", day: "numeric" })}</div>
)}
</div>
{bodyDoc && (
+30 -3
View File
@@ -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<ParsedEml | null>(null);
const [emlBlob, setEmlBlob] = useState<Blob | null>(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 <iframe> and the
@@ -235,6 +246,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
setObjectUrl(null);
setCanOpenInNewTab(false);
setEmlContent(null);
setEmlBlob(null);
setPdfBlob(null);
setHtmlDoc(null);
setLoading(true);
@@ -263,7 +275,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
// stays off the bundle) and render it like an email via EmlPreview.
const { default: PostalMime } = await import("postal-mime");
const parsed = await new PostalMime().parse(await blob.arrayBuffer());
if (!cancelled) setEmlContent(parsed as ParsedEml);
if (!cancelled) {
setEmlContent(parsed as ParsedEml);
setEmlBlob(blob);
}
} else {
// Re-type the blob to one its viewer can show; a blob: URL with a
// type the browser cannot display is silently downloaded instead.
@@ -339,11 +354,23 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
);
}
// An attached message that came without a file name of its own is called
// by its subject, not "attachment", and saved under it.
const emlSubject = fileType === "eml" && isUnnamedMessagePart(name) ? emlContent?.subject?.trim() || null : null;
const download = () => {
if (emlSubject && emlBlob) {
const filename = emailExportFilename({ subject: emlSubject } as Email, { template: "{subject}" });
saveBlobAs(emlBlob, filename, { kind: "attachment", subject: policyAccount });
return;
}
void onDownload();
};
return (
<PreviewShell
name={name}
name={emlSubject ?? name}
onClose={onClose}
onDownload={downloadsOff ? undefined : () => void onDownload()}
onDownload={downloadsOff ? undefined : download}
neighbours={neighbours}
// An installed app hands the new window to the system browser, which
// cannot load our blob: URL (#865): no dead button there.