From 18db36d937d5a1d43d0c82cd8c384a911a63b4d8 Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Wed, 24 Jun 2026 13:55:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E4=BC=98=E5=8C=96=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E6=AD=A3=E6=96=87=E6=B8=B2=E6=9F=93=E4=B8=8E=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 邮件正文改为在 `iframe` 中渲染,提升排版隔离和显示稳定性。 - 扩展前端清洗配置,保留邮件常见布局样式与表格属性。 - 收紧后端 HTML 策略的样式白名单,兼顾邮件样式保留与安全过滤。 - 补充测试,验证邮件布局样式可保留且危险内容仍会被拦截。 --- apps/api/internal/app/app_test.go | 19 +++++++ apps/api/internal/app/util.go | 29 +++++++++- apps/web/src/pages/mail.tsx | 92 ++++++++++++++++++++++++++++++- apps/web/src/types.d.ts | 6 +- 4 files changed, 142 insertions(+), 4 deletions(-) diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index df9eba1..d097385 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -840,6 +840,25 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) { } } +func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) { + policy := NewHTMLPolicy() + out := policy.Sanitize(`
+
+ badhello +
+
`) + 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:", " 512 { + return false + } + unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "
-
${escapeHtml(selected.bodyText || "")}`) }} /> + {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
@@ -1402,7 +1402,7 @@ function CompactMessageDetail({ />
-
${escapeHtml(selected.bodyText || "")}`) }} /> + {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
@@ -1412,6 +1412,62 @@ function CompactMessageDetail({ ) } +function MailHtmlFrame({ message }: { message: MailMessage }) { + const iframeRef = React.useRef(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 ( +