feat(mail): 优化邮件正文渲染与样式保留
- 邮件正文改为在 `iframe` 中渲染,提升排版隔离和显示稳定性。 - 扩展前端清洗配置,保留邮件常见布局样式与表格属性。 - 收紧后端 HTML 策略的样式白名单,兼顾邮件样式保留与安全过滤。 - 补充测试,验证邮件布局样式可保留且危险内容仍会被拦截。
This commit is contained in:
@@ -867,7 +867,7 @@ export function MailPage() {
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-6">
|
||||
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
|
||||
<MailHtmlFrame message={selected} />
|
||||
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex items-center justify-between rounded-md border p-3 text-sm text-muted-foreground" key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -1402,7 +1402,7 @@ function CompactMessageDetail({
|
||||
/>
|
||||
</div>
|
||||
<div className="py-6 sm:py-8">
|
||||
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
|
||||
<MailHtmlFrame message={selected} />
|
||||
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex flex-col gap-1 rounded-md border p-3 text-sm hover:bg-accent sm:flex-row sm:items-center sm:justify-between" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex flex-col gap-1 rounded-md border p-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between" key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1412,6 +1412,62 @@ function CompactMessageDetail({
|
||||
)
|
||||
}
|
||||
|
||||
function MailHtmlFrame({ message }: { message: MailMessage }) {
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
const [height, setHeight] = React.useState(260)
|
||||
const srcDoc = React.useMemo(() => buildMailFrameSrcDoc(message.bodyHtml || "", message.bodyText || ""), [message.bodyHtml, message.bodyText])
|
||||
|
||||
const resize = React.useCallback(() => {
|
||||
const doc = iframeRef.current?.contentDocument
|
||||
if (!doc) return
|
||||
const body = doc.body
|
||||
const html = doc.documentElement
|
||||
const nextHeight = Math.max(180, Math.ceil(Math.max(body?.scrollHeight || 0, body?.offsetHeight || 0, html?.scrollHeight || 0, html?.offsetHeight || 0)))
|
||||
setHeight(nextHeight)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
setHeight(260)
|
||||
const frame = iframeRef.current
|
||||
if (!frame) return
|
||||
let observer: ResizeObserver | undefined
|
||||
const timers = [window.setTimeout(resize, 0), window.setTimeout(resize, 120), window.setTimeout(resize, 600)]
|
||||
const attach = () => {
|
||||
const doc = frame.contentDocument
|
||||
if (!doc) return
|
||||
doc.querySelectorAll("a[href]").forEach((link) => {
|
||||
link.setAttribute("target", "_blank")
|
||||
link.setAttribute("rel", "noopener noreferrer")
|
||||
})
|
||||
resize()
|
||||
if ("ResizeObserver" in window) {
|
||||
observer = new ResizeObserver(resize)
|
||||
observer.observe(doc.documentElement)
|
||||
if (doc.body) observer.observe(doc.body)
|
||||
}
|
||||
doc.querySelectorAll("img").forEach((img) => img.addEventListener("load", resize, { once: true }))
|
||||
}
|
||||
frame.addEventListener("load", attach)
|
||||
return () => {
|
||||
frame.removeEventListener("load", attach)
|
||||
observer?.disconnect()
|
||||
timers.forEach((timer) => window.clearTimeout(timer))
|
||||
}
|
||||
}, [resize, srcDoc])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="邮件正文"
|
||||
className="block w-full border-0 bg-white"
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
srcDoc={srcDoc}
|
||||
style={{ height }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar, canOrganize }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void; canOrganize: boolean }) {
|
||||
const visibleLabels = (message.labels || []).slice(0, 2)
|
||||
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
|
||||
@@ -3056,6 +3112,38 @@ function plainTextToHtmlFragment(value: string) { return value.split("\n").map((
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'")
|
||||
}
|
||||
function buildMailFrameSrcDoc(bodyHtml: string, bodyText: string) {
|
||||
const rawBody = bodyHtml.trim() ? bodyHtml : `<pre>${escapeHtml(bodyText || "")}</pre>`
|
||||
const sanitizedBody = DOMPurify.sanitize(rawBody, {
|
||||
ADD_ATTR: ["style", "align", "valign", "bgcolor", "border", "cellpadding", "cellspacing", "width", "height"],
|
||||
ADD_TAGS: ["center"],
|
||||
})
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<base target="_blank">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #fff; color: #111827; }
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
table { max-width: 100%; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
a { color: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${sanitizedBody}</body>
|
||||
</html>`
|
||||
}
|
||||
function sanitizeComposerHtml(value: string) {
|
||||
return DOMPurify.sanitize(value || "")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user