feat(mail): 优化邮件正文渲染与样式保留

- 邮件正文改为在 `iframe` 中渲染,提升排版隔离和显示稳定性。
- 扩展前端清洗配置,保留邮件常见布局样式与表格属性。
- 收紧后端 HTML 策略的样式白名单,兼顾邮件样式保留与安全过滤。
- 补充测试,验证邮件布局样式可保留且危险内容仍会被拦截。
This commit is contained in:
LanQin_
2026-06-24 13:55:09 +08:00
parent 295a34881d
commit 18db36d937
4 changed files with 142 additions and 4 deletions
+19
View File
@@ -840,6 +840,25 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
} }
} }
func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) {
policy := NewHTMLPolicy()
out := policy.Sanitize(`<div class="card" style="max-width:600px;margin:0 auto;background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 8px 24px rgba(0,0,0,.12);color:#fff" onclick="alert(1)">
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse"><tr><td align="center" style="padding:24px;text-align:center;background-color:#f8fafc">
<a href="javascript:alert(1)">bad</a><img src="x" onerror="alert(1)"><script>alert(1)</script>hello
</td></tr></table>
</div>`)
for _, want := range []string{"class=\"card\"", "max-width: 600px", "margin: 0 auto", "background: linear-gradient", "box-shadow:", "cellpadding=\"0\"", "cellspacing=\"0\"", "align=\"center\"", "text-align: center"} {
if !strings.Contains(out, want) {
t.Fatalf("sanitized html missing %q: %s", want, out)
}
}
for _, blocked := range []string{"onclick", "onerror", "javascript:", "<script"} {
if strings.Contains(strings.ToLower(out), blocked) {
t.Fatalf("sanitized html kept unsafe %q: %s", blocked, out)
}
}
}
func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) { func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1" a.cfg.SMTPHost = "127.0.0.1"
+28 -1
View File
@@ -22,7 +22,20 @@ type HTMLPolicy struct{ policy *bluemonday.Policy }
func NewHTMLPolicy() *HTMLPolicy { func NewHTMLPolicy() *HTMLPolicy {
p := bluemonday.UGCPolicy() p := bluemonday.UGCPolicy()
p.AllowAttrs("style").OnElements("p", "span", "div", "table", "td", "th") p.AllowAttrs("style").Globally()
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
p.AllowAttrs("align", "valign").Matching(bluemonday.Paragraph).Globally()
p.AllowAttrs("width", "height").Matching(bluemonday.NumberOrPercent).Globally()
p.AllowAttrs("bgcolor", "color").Matching(regexp.MustCompile(`(?i)^#[0-9a-f]{3,8}$|^[a-z][a-z0-9 -]{0,31}$`)).Globally()
p.AllowAttrs("border", "cellpadding", "cellspacing").Matching(bluemonday.Number).OnElements("table")
p.AllowStyles(
"background", "background-color", "background-image", "border", "border-collapse", "border-color",
"border-radius", "border-spacing", "border-style", "border-width", "box-shadow", "color", "display",
"font", "font-family", "font-size", "font-style", "font-weight", "height", "letter-spacing",
"line-height", "margin", "margin-bottom", "margin-left", "margin-right", "margin-top", "max-width",
"min-width", "opacity", "padding", "padding-bottom", "padding-left", "padding-right", "padding-top",
"text-align", "text-decoration", "text-transform", "vertical-align", "white-space", "width",
).MatchingHandler(safeEmailCSSValue).Globally()
return &HTMLPolicy{policy: p} return &HTMLPolicy{policy: p}
} }
@@ -33,6 +46,20 @@ func (p *HTMLPolicy) Sanitize(s string) string {
return p.policy.Sanitize(s) return p.policy.Sanitize(s)
} }
func safeEmailCSSValue(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || len(value) > 512 {
return false
}
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
for _, token := range unsafe {
if strings.Contains(value, token) {
return false
}
}
return true
}
func newID(prefix string) string { func newID(prefix string) string {
buf := make([]byte, 16) buf := make([]byte, 16)
_, _ = rand.Read(buf) _, _ = rand.Read(buf)
+90 -2
View File
@@ -867,7 +867,7 @@ export function MailPage() {
</div> </div>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="p-6"> <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>} {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> </div>
</ScrollArea> </ScrollArea>
@@ -1402,7 +1402,7 @@ function CompactMessageDetail({
/> />
</div> </div>
<div className="py-6 sm:py-8"> <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>} {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>
</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 }) { 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 visibleLabels = (message.labels || []).slice(0, 2)
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0) 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) { function escapeHtml(value: string) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;") return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;")
} }
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) { function sanitizeComposerHtml(value: string) {
return DOMPurify.sanitize(value || "") return DOMPurify.sanitize(value || "")
} }
+5 -1
View File
@@ -1,4 +1,8 @@
declare module "dompurify" { declare module "dompurify" {
const DOMPurify: { sanitize: (source: string) => string } type SanitizeConfig = {
ADD_ATTR?: string[]
ADD_TAGS?: string[]
}
const DOMPurify: { sanitize: (source: string, config?: SanitizeConfig) => string }
export default DOMPurify export default DOMPurify
} }