feat(mail): 优化邮件正文渲染与样式保留
- 邮件正文改为在 `iframe` 中渲染,提升排版隔离和显示稳定性。 - 扩展前端清洗配置,保留邮件常见布局样式与表格属性。 - 收紧后端 HTML 策略的样式白名单,兼顾邮件样式保留与安全过滤。 - 补充测试,验证邮件布局样式可保留且危险内容仍会被拦截。
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'")
|
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) {
|
function sanitizeComposerHtml(value: string) {
|
||||||
return DOMPurify.sanitize(value || "")
|
return DOMPurify.sanitize(value || "")
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+5
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user