266 lines
5.9 KiB
Go
266 lines
5.9 KiB
Go
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()
|
||
}
|