diff --git a/components/files/__tests__/file-preview-modal-history.test.tsx b/components/files/__tests__/file-preview-modal-history.test.tsx index b29dcbbfb..fd7c0d356 100644 --- a/components/files/__tests__/file-preview-modal-history.test.tsx +++ b/components/files/__tests__/file-preview-modal-history.test.tsx @@ -204,3 +204,82 @@ describe('FilePreviewModal history lifecycle (modeled queued traversal)', () => expect(screen.queryByRole('dialog')).toBeNull(); }); }); + +// A forwarded certified-mail (PEC) message: postacert.eml wraps the original +// as msg.eml, which used to be download-only inside the .eml preview. +const INNER_EML = [ + 'From: Sender ', + 'To: me@example.com', + 'Subject: Inner original', + 'Date: Tue, 27 Oct 2026 20:36:28 +0100', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'the original message', +].join('\r\n'); +const OUTER_EML = [ + 'From: Forwarder ', + 'To: me@example.com', + 'Subject: FWD: wrapper', + 'Date: Wed, 28 Oct 2026 05:50:39 +0100', + 'MIME-Version: 1.0', + 'Content-Type: multipart/mixed; boundary="b1"', + '', + '--b1', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'see attached', + '--b1', + 'Content-Type: message/rfc822; name="msg.eml"', + 'Content-Disposition: attachment; filename="msg.eml"', + '', + INNER_EML, + '--b1--', + '', +].join('\r\n'); + +describe('FilePreviewModal attachment of a previewed .eml', () => { + async function openOuter() { + const getContent = vi.fn().mockResolvedValue({ + blob: new Blob([OUTER_EML], { type: 'message/rfc822' }), contentType: 'message/rfc822', + }); + const view = openPreview(false, getContent, 'postacert.eml'); + await advance(0); + await vi.waitFor(() => expect(screen.getByText('FWD: wrapper')).toBeInTheDocument()); + fireEvent.click(screen.getByText('msg.eml')); + await advance(0); + await vi.waitFor(() => expect(screen.getByText('Inner original')).toBeInTheDocument()); + return view; + } + + it('opens in a preview of its own in place of the outer one, with a Back entry', async () => { + await openOuter(); + await advance(0); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(screen.getByRole('dialog')).toHaveAccessibleName('msg.eml'); + expect(push).toHaveBeenCalledTimes(2); + }); + + it('Escape closes the inner preview only', async () => { + const { onClose } = await openOuter(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(screen.getByRole('dialog')).toHaveAccessibleName('postacert.eml'); + expect(screen.getByText('FWD: wrapper')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('Browser Back closes the inner preview, the next Back the outer one', async () => { + const { onClose } = await openOuter(); + await advance(0); + window.history.back(); + await advance(10); + expect(screen.getByRole('dialog')).toHaveAccessibleName('postacert.eml'); + expect(onClose).not.toHaveBeenCalled(); + expect(window.history.state.__closeOnBack).toBeTruthy(); + window.history.back(); + await advance(10); + expect(onClose).toHaveBeenCalledOnce(); + expect(window.history.state).toEqual(originalState); + }); +}); diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx index 27e73ddcf..0ff73b06e 100644 --- a/components/files/eml-preview.tsx +++ b/components/files/eml-preview.tsx @@ -5,6 +5,7 @@ import { Paperclip, Download } from "@/components/icons"; import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization"; import { getEffectiveTimeZone } from "@/lib/timezone"; import { stripBidiControls } from "@/lib/bidi"; +import { isFilePreviewable } from "@/lib/file-preview"; import { useSettingsStore } from "@/stores/settings-store"; import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; @@ -17,9 +18,11 @@ export type ParsedEml = { date?: string; html?: string; text?: string; - attachments?: Array<{ filename?: string; mimeType?: string; content?: ArrayBuffer | Uint8Array }>; + attachments?: Array; }; +export type EmlAttachment = { filename?: string; mimeType?: string; content?: ArrayBuffer | Uint8Array }; + function formatAddress(a?: { name?: string; address?: string }): string { if (!a) return ""; if (a.name && a.address) return `${a.name} <${a.address}>`; @@ -41,12 +44,19 @@ function escapeHtml(s: string): string { // scripts, no same-origin), so a script-bearing .eml can never execute in our // origin. Remote content is blocked unless the user allows it everywhere: // there is no per-message "load" here. Parsing happens in the caller -// (FilePreviewModal); this is pure presentation. -export function EmlPreview({ message }: { message: ParsedEml }) { +// (FilePreviewModal); this is pure presentation. An attachment the preview +// can show - often another message: a certified-mail (PEC) envelope wraps the +// original, and a forwarded PEC wraps that envelope - opens through +// onOpenAttachment, with download beside it. +export function EmlPreview({ message, onOpenAttachment }: { + message: ParsedEml; + onOpenAttachment?: (attachment: EmlAttachment) => void; +}) { const t = useTranslations("email_viewer"); // Always blocked with no third-party traffic (#903). const remoteBlockedByOrg = useNoThirdPartyTraffic(); const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; + const tFiles = useTranslations("files"); // dir="auto" on a wrapper we control lets the first strong character pick // the direction, matching the main viewer. @@ -60,7 +70,7 @@ export function EmlPreview({ message }: { message: ParsedEml }) { ? `${body}` : ""; - const downloadAttachment = (att: NonNullable[number]) => { + const downloadAttachment = (att: EmlAttachment) => { if (!att.content) return; // content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict // BlobPart lib type (Uint8Array vs ArrayBuffer). @@ -103,19 +113,47 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
{t("attachments")}
- {message.attachments.map((att, i) => ( - - ))} + {message.attachments.map((att, i) => { + const name = att.filename ? stripBidiControls(att.filename) : "attachment"; + const opens = !!onOpenAttachment && !!att.content && isFilePreviewable(att.filename, att.mimeType); + if (!opens) { + return ( + + ); + } + return ( +
+ + +
+ ); + })}
)} diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index a1136b0d6..b1bbc10c1 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ExternalLink } from "@/components/icons"; import { Spinner } from "@/components/ui/spinner"; @@ -9,9 +9,10 @@ import { IconButton } from "@/components/ui/icon-button"; import { ZoomableImage } from "@/components/files/zoomable-image"; import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; import dynamic from "next/dynamic"; -import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview"; +import { EmlPreview, type EmlAttachment, type ParsedEml } from "@/components/files/eml-preview"; import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; import { canOpenUrlInNewWindow } from "@/lib/standalone-display"; +import { stripBidiControls } from "@/lib/bidi"; import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization"; import { useSettingsStore } from "@/stores/settings-store"; import { useCloseOnBack } from "@/hooks/use-close-on-back"; @@ -165,6 +166,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi // Always blocked with no third-party traffic (#903). const remoteBlockedByOrg = useNoThirdPartyTraffic(); const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; + // An attachment of the previewed .eml, shown in a preview of its own in + // place of this one. It holds a Back entry of its own, so Escape and Back + // close it first and come back here. + const [nestedPreview, setNestedPreview] = useState<{ name: string; blob: Blob } | null>(null); // Decide whether to render the PDF in a plain