Merge pull request #1395 from lucamzanon/fix/eml-preview-nested-attachments

This commit is contained in:
Linus Rath
2026-10-28 17:04:55 +01:00
3 changed files with 183 additions and 20 deletions
@@ -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 <sender@example.com>',
'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 <fwd@example.com>',
'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);
});
});
+55 -17
View File
@@ -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<EmlAttachment>;
};
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 }) {
? `<!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>`
: "";
const downloadAttachment = (att: NonNullable<ParsedEml["attachments"]>[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<ArrayBufferLike> vs ArrayBuffer).
@@ -103,19 +113,47 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
<div className="mt-4 border-t border-border pt-3">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("attachments")}</div>
<div className="flex flex-wrap gap-2">
{message.attachments.map((att, i) => (
<button
key={i}
type="button"
onClick={() => downloadAttachment(att)}
title={t("download")}
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-foreground hover:bg-muted/70"
>
<Paperclip className="w-3 h-3 flex-shrink-0" />
<span className="max-w-[200px] truncate">{att.filename ? stripBidiControls(att.filename) : "attachment"}</span>
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
</button>
))}
{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 (
<button
key={i}
type="button"
onClick={() => downloadAttachment(att)}
title={t("download")}
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-foreground hover:bg-muted/70"
>
<Paperclip className="w-3 h-3 flex-shrink-0" />
<span className="max-w-[200px] truncate">{name}</span>
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
</button>
);
}
return (
<div key={i} className="flex items-center rounded-md text-sm bg-muted text-foreground">
<button
type="button"
onClick={() => onOpenAttachment(att)}
title={tFiles("preview")}
className="flex items-center gap-2 ps-3 pe-2 py-1.5 rounded-s-md hover:bg-muted/70"
>
<Paperclip className="w-3 h-3 flex-shrink-0" />
<span className="max-w-[200px] truncate">{name}</span>
</button>
<button
type="button"
onClick={() => downloadAttachment(att)}
title={t("download")}
aria-label={`${t("download")} ${name}`}
className="ps-1 pe-3 py-1.5 rounded-e-md hover:bg-muted/70"
>
<Download className="w-3 h-3 text-muted-foreground" />
</button>
</div>
);
})}
</div>
</div>
)}
+49 -3
View File
@@ -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 <iframe> (desktop) or with the
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
@@ -266,6 +271,47 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
};
}, [getFileContent, name, blockRemote]);
const openEmlAttachment = (att: EmlAttachment) => {
if (!att.content) return;
setNestedPreview({
name: att.filename ? stripBidiControls(att.filename) : "attachment",
// content is a real ArrayBuffer/Uint8Array at runtime; cast for the
// strict BlobPart lib type, as EmlPreview's download does.
blob: new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }),
});
};
// Stable per attachment: the loader effect re-runs when it changes.
const nestedContent = useMemo(
() => nestedPreview
? async () => ({ blob: nestedPreview.blob, contentType: nestedPreview.blob.type })
: null,
[nestedPreview],
);
const downloadNested = () => {
if (!nestedPreview) return;
const url = URL.createObjectURL(nestedPreview.blob);
const a = document.createElement("a");
a.href = url;
a.download = nestedPreview.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// In place of this frame, not on top of it: one frame at a time takes
// Escape and the arrows. This one keeps its state and its Back entry.
if (nestedPreview && nestedContent) {
return (
<FilePreviewModal
name={nestedPreview.name}
onClose={() => setNestedPreview(null)}
onDownload={downloadNested}
getFileContent={nestedContent}
/>
);
}
return (
<PreviewShell
name={name}
@@ -306,7 +352,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
)}
{!loading && !error && fileType === "eml" && emlContent && (
<EmlPreview message={emlContent} />
<EmlPreview message={emlContent} onOpenAttachment={openEmlAttachment} />
)}
{!loading && !error && fileType === "image" && objectUrl && (