mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: title an attached message by its subject and show it in the reader's font and date format
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user