Feature: preview .eml (message/rfc822) attachments like an email
Clicking an embedded email attachment (bounce/DSN, forward-as-attachment, ...) opened only a download. Add an 'eml' preview kind: FilePreviewModal parses the blob with postal-mime (dynamic-imported, off the bundle) and renders it via a new EmlPreview component - header (from/to/subject/date) + body + the message's own attachments. The body is sanitized with DOMPurify (sanitizeEmailHtmlForIframe) AND rendered in a fully-locked sandbox iframe (sandbox="" - no scripts, no same-origin), so a script-bearing .eml can never execute in-origin. Reuses the email_viewer locale namespace (no new keys).
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Paperclip, Download } from "lucide-react";
|
||||
import { sanitizeEmailHtmlForIframe } from "@/lib/email-sanitization";
|
||||
|
||||
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
|
||||
// this preview renders are typed.
|
||||
export type ParsedEml = {
|
||||
subject?: string;
|
||||
from?: { name?: string; address?: string };
|
||||
to?: Array<{ name?: string; address?: string }>;
|
||||
date?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
attachments?: Array<{ 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}>`;
|
||||
return a.address || a.name || "";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Renders a .eml attachment like an email: header (from/to/subject/date) + the
|
||||
// body, plus the message's own attachments. The body is sanitized with
|
||||
// DOMPurify AND rendered in a fully-locked sandbox iframe (sandbox="" - no
|
||||
// scripts, no same-origin), so a script-bearing .eml can never execute in our
|
||||
// origin. Parsing happens in the caller (FilePreviewModal); this is pure
|
||||
// presentation.
|
||||
export function EmlPreview({ message }: { message: ParsedEml }) {
|
||||
const t = useTranslations("email_viewer");
|
||||
|
||||
const bodyDoc = message.html
|
||||
? sanitizeEmailHtmlForIframe(message.html)
|
||||
: message.text
|
||||
? `<pre style="white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;margin:0;padding:8px">${escapeHtml(message.text)}</pre>`
|
||||
: "";
|
||||
|
||||
const downloadAttachment = (att: NonNullable<ParsedEml["attachments"]>[number]) => {
|
||||
if (!att.content) return;
|
||||
// content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict
|
||||
// BlobPart lib type (Uint8Array<ArrayBufferLike> vs ArrayBuffer).
|
||||
const url = URL.createObjectURL(new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = att.filename || "attachment";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-full self-start overflow-auto rounded-lg border border-border bg-background shadow-2xl p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
|
||||
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
|
||||
{message.from && (
|
||||
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
|
||||
)}
|
||||
{message.to && message.to.length > 0 && (
|
||||
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
|
||||
)}
|
||||
{message.date && (
|
||||
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
{bodyDoc && (
|
||||
<iframe
|
||||
title={message.subject || "email"}
|
||||
sandbox=""
|
||||
srcDoc={bodyDoc}
|
||||
className="w-full min-h-[55vh] mt-3 rounded bg-white"
|
||||
/>
|
||||
)}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<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 || "attachment"}</span>
|
||||
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { X, Download, Loader2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getFilePreviewKind, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
|
||||
import dynamic from "next/dynamic";
|
||||
import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview";
|
||||
|
||||
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
|
||||
// only on the mobile PDF path so pdfjs-dist + its worker never reach the
|
||||
@@ -153,6 +154,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
// navigation. Blob URLs inherit our origin, so opening a script-bearing type
|
||||
// (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);
|
||||
|
||||
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
|
||||
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
|
||||
@@ -205,6 +207,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
setContent(null);
|
||||
setObjectUrl(null);
|
||||
setCanOpenInNewTab(false);
|
||||
setEmlContent(null);
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setResolvedFileType(getFilePreviewKind(name));
|
||||
@@ -221,6 +224,12 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
if (previewType === "text" || previewType === "markdown") {
|
||||
const text = await blob.text();
|
||||
if (!cancelled) setContent(text);
|
||||
} else if (previewType === "eml") {
|
||||
// Parse the embedded message (postal-mime, dynamic-imported so it
|
||||
// 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);
|
||||
} else {
|
||||
// Stalwart's download endpoint can return generic
|
||||
// application/octet-stream for attachments even when the email's
|
||||
@@ -329,6 +338,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "eml" && emlContent && (
|
||||
<EmlPreview message={emlContent} />
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "image" && objectUrl && (
|
||||
<img
|
||||
src={objectUrl}
|
||||
|
||||
Reference in New Issue
Block a user