完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
265
backend/service/operations_mail_html.go
Normal file
265
backend/service/operations_mail_html.go
Normal file
@@ -0,0 +1,265 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user