mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge pull request #1395 from lucamzanon/fix/eml-preview-nested-attachments
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user