diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx new file mode 100644 index 00000000..aff7eb8e --- /dev/null +++ b/components/files/eml-preview.tsx @@ -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, "'"); +} + +// 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 + ? `
${escapeHtml(message.text)}
` + : ""; + + const downloadAttachment = (att: NonNullable[number]) => { + if (!att.content) return; + // content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict + // BlobPart lib type (Uint8Array 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 ( +
e.stopPropagation()} + > +

{message.subject || ""}

+
+ {message.from && ( +
{t("from")}: {formatAddress(message.from)}
+ )} + {message.to && message.to.length > 0 && ( +
{t("to")}: {message.to.map(formatAddress).join(", ")}
+ )} + {message.date && ( +
{t("date")}: {new Date(message.date).toLocaleString()}
+ )} +
+ {bodyDoc && ( +