Files
jiang13-bbs/backend/service/operations_mail_html.go

266 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"mime/quotedprintable"
"regexp"
"strings"
"golang.org/x/net/html"
)
// 手机 QQ / 163 等会丢弃 <style>;发送与预览前把简单 class 规则内联,并做成完整 HTML 文档。
var (
mailCSSCommentRe = regexp.MustCompile(`(?s)/\*.*?\*/`)
mailCSSRuleRe = regexp.MustCompile(`([^{}@]+)\{([^{}]+)\}`)
)
func prepareMailHTML(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
doc, err := html.Parse(strings.NewReader(raw))
if err != nil {
return wrapMailDocument(raw)
}
css := map[string]string{}
collectAndRemoveMailStyles(doc, css)
if len(css) > 0 {
applyMailClassStyles(doc, css)
}
ensureMailDocumentHead(doc)
var buf bytes.Buffer
if err := html.Render(&buf, doc); err != nil {
return wrapMailDocument(raw)
}
return buf.String()
}
func wrapMailDocument(body string) string {
lower := strings.ToLower(body)
if strings.Contains(lower, "<html") {
return body
}
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;padding:0;">` + body + `</body></html>`
}
func collectAndRemoveMailStyles(n *html.Node, css map[string]string) {
if n.Type == html.ElementNode && n.Data == "style" {
var b strings.Builder
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
b.WriteString(c.Data)
}
}
parseMailCSSRules(b.String(), css)
// 删除 style 节点
parent := n.Parent
if parent != nil {
next := n.NextSibling
parent.RemoveChild(n)
if next != nil {
collectAndRemoveMailStyles(next, css)
}
return
}
}
for c := n.FirstChild; c != nil; {
next := c.NextSibling
collectAndRemoveMailStyles(c, css)
c = next
}
}
func parseMailCSSRules(src string, css map[string]string) {
src = mailCSSCommentRe.ReplaceAllString(src, "")
src = stripMailAtRules(src)
for _, m := range mailCSSRuleRe.FindAllStringSubmatch(src, -1) {
decls := normalizeMailDecls(m[2])
if decls == "" {
continue
}
for _, sel := range strings.Split(m[1], ",") {
sel = strings.TrimSpace(sel)
// 仅支持单一 class:.email-card
if !strings.HasPrefix(sel, ".") || strings.ContainsAny(sel, " \t>+~[:#") {
continue
}
name := strings.TrimPrefix(sel, ".")
if name == "" || strings.Contains(name, ".") {
continue
}
css[name] = mergeMailDecls(css[name], decls)
}
}
}
// 去掉 @media / @supports 等块,避免大括号干扰简单解析。
func stripMailAtRules(src string) string {
var b strings.Builder
for i := 0; i < len(src); {
if src[i] == '@' {
j := i
for j < len(src) && src[j] != '{' {
j++
}
if j >= len(src) {
break
}
depth := 0
for j < len(src) {
if src[j] == '{' {
depth++
} else if src[j] == '}' {
depth--
if depth == 0 {
j++
break
}
}
j++
}
i = j
continue
}
b.WriteByte(src[i])
i++
}
return b.String()
}
func normalizeMailDecls(s string) string {
parts := strings.Split(s, ";")
out := make([]string, 0, len(parts))
seen := map[string]int{}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
key, _, ok := strings.Cut(p, ":")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
if key == "" {
continue
}
// 后写覆盖先写
if idx, ok := seen[key]; ok {
out[idx] = p
continue
}
seen[key] = len(out)
out = append(out, p)
}
return strings.Join(out, ";")
}
func mergeMailDecls(base, extra string) string {
if base == "" {
return normalizeMailDecls(extra)
}
if extra == "" {
return normalizeMailDecls(base)
}
return normalizeMailDecls(base + ";" + extra)
}
func applyMailClassStyles(n *html.Node, css map[string]string) {
if n.Type == html.ElementNode {
class := ""
styleIdx := -1
styleVal := ""
for i, a := range n.Attr {
switch strings.ToLower(a.Key) {
case "class":
class = a.Val
case "style":
styleIdx = i
styleVal = a.Val
}
}
if class != "" {
var fromClass string
for _, c := range strings.Fields(class) {
if d, ok := css[c]; ok {
fromClass = mergeMailDecls(fromClass, d)
}
}
if fromClass != "" {
// 元素已有 style 优先(等价于内联覆盖 class)
merged := mergeMailDecls(fromClass, styleVal)
if styleIdx >= 0 {
n.Attr[styleIdx].Val = merged
} else {
n.Attr = append(n.Attr, html.Attribute{Key: "style", Val: merged})
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
applyMailClassStyles(c, css)
}
}
func ensureMailDocumentHead(doc *html.Node) {
var htmlNode *html.Node
for c := doc.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == "html" {
htmlNode = c
break
}
}
if htmlNode == nil {
return
}
hasLang := false
for _, a := range htmlNode.Attr {
if strings.EqualFold(a.Key, "lang") {
hasLang = true
break
}
}
if !hasLang {
htmlNode.Attr = append(htmlNode.Attr, html.Attribute{Key: "lang", Val: "zh-CN"})
}
var head *html.Node
for c := htmlNode.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == "head" {
head = c
break
}
}
if head == nil {
head = &html.Node{Type: html.ElementNode, Data: "head"}
htmlNode.InsertBefore(head, htmlNode.FirstChild)
}
hasCharset := false
for c := head.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == "meta" {
for _, a := range c.Attr {
if strings.EqualFold(a.Key, "charset") {
hasCharset = true
}
}
}
}
if !hasCharset {
meta := &html.Node{
Type: html.ElementNode,
Data: "meta",
Attr: []html.Attribute{{Key: "charset", Val: "UTF-8"}},
}
head.InsertBefore(meta, head.FirstChild)
}
}
func writeQuotedPrintableHTML(w interface{ Write([]byte) (int, error) }, body string) error {
qp := quotedprintable.NewWriter(w)
if _, err := qp.Write([]byte(body)); err != nil {
return err
}
return qp.Close()
}