优化邮件通知模板与发送样式

This commit is contained in:
xuans 2026-10-06 00:51:43 +08:00
parent 2f53e96306
commit 63c2643c72
5 changed files with 458 additions and 43 deletions

View File

@ -11,7 +11,6 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/mail"
@ -414,50 +413,19 @@ func (s *Service) buildPayload(event Event) (mailPayload, error) {
} else if event.Type != "" {
title = eventTypeName(event.Type)
}
text := buildText(event, title, status, domainName)
text, htmlBody := buildMailContent(event, title, status, domainName, s.config)
return mailPayload{
From: address{Name: fromName, Email: fromEmail},
To: recipients,
Subject: fmt.Sprintf("[Scert] %s:%s(%s)", title, domainName, status),
Subject: buildMailSubject(title, status, domainName),
Text: text,
HTML: "<pre>" + html.EscapeString(text) + "</pre>",
HTML: htmlBody,
CC: []address{},
BCC: []address{},
Attrs: []any{},
}, nil
}
func buildText(event Event, title, status, domainName string) string {
lines := []string{
"Scert 证书管理服务通知",
"任务类型:" + title,
"任务状态:" + status,
"域名:" + domainName,
}
if len(event.Domains) > 0 {
lines = append(lines, "证书域名:"+strings.Join(event.Domains, "、"))
}
if event.ExpiresAt != nil && !event.ExpiresAt.IsZero() {
lines = append(lines, "证书到期时间:"+event.ExpiresAt.Local().Format("2006-01-02 15:04:05"))
}
if event.CertificateSerial != "" {
lines = append(lines, "证书序列号:"+event.CertificateSerial)
}
if event.ManagementURL != "" {
lines = append(lines, "管理后台:"+event.ManagementURL)
}
if event.Deployment.Total > 0 {
lines = append(lines, fmt.Sprintf("部署结果:总计 %d 台,成功 %d 台,失败 %d 台,状态 %s", event.Deployment.Total, event.Deployment.Success, event.Deployment.Failed, event.Deployment.Status))
for _, result := range event.Deployment.Results {
lines = append(lines, fmt.Sprintf("- %s:%s(%s)", result.Server, result.Status, result.Message))
}
}
if event.Error != "" {
lines = append(lines, "错误信息:"+event.Error)
}
return strings.Join(lines, "\n")
}
func eventStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "success":

View File

@ -12,6 +12,7 @@ import (
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
type credentialReaderStub struct {
@ -348,3 +349,103 @@ func TestSessionLoginReturnsBusinessMessageWithoutCredentials(t *testing.T) {
t.Fatalf("登录错误不得包含密码:%v", err)
}
}
func TestBuildMailContentSimplifiesDeploymentFailure(t *testing.T) {
cfg := newTestConfig("http://mail.example.test")
cfg.IncludeDeploymentDetails = true
cfg.IncludeCertificateSerial = true
cfg.IncludeManagementURL = true
service := New(cfg, nil)
payload, err := service.buildPayload(Event{
Type: "certificate_renewal",
Status: "failed",
Domain: "scert-multi-20260930.apmmk.cn",
Domains: []string{
"scert-multi-20260930.apmmk.cn",
},
CertificateSerial: "serial-123",
ManagementURL: "https://scert.example.test/tasks/1",
Deployment: deployment.Report{
Total: 2,
Failed: 2,
Status: "failed",
Results: []deployment.Result{
{Server: "第一台生产测试服务器", Status: "failed", Message: "本次部署未完成,已恢复旧文件,但服务或站点恢复状态未确认,已保留部署锁"},
{Server: "第二台 SSL 推送测试服务器", Status: "failed", Message: "remote HTTPS connection failed: tls: failed to verify certificate: x509: certificate is valid for *.vcer.cn, vcer.cn, not scert-multi-20260930.apmmk.cn"},
},
},
Error: "部署后 HTTPS 校验失败:服务器 第二台 SSL 推送测试服务器 的 HTTPS 校验失败:重试 6 次后仍未通过:remote HTTPS connection failed: tls: failed to verify certificate: x509: certificate is valid for *.vcer.cn, vcer.cn, not scert-multi-20260930.apmmk.cn",
})
if err != nil {
t.Fatalf("构造失败通知失败:%v", err)
}
if !strings.Contains(payload.Subject, "证书续期失败") {
t.Fatalf("邮件主题未突出失败状态:%q", payload.Subject)
}
for _, value := range []string{
"摘要:证书续期未完成,共 2 台服务器失败",
"问题概述:",
"第二台 SSL 推送测试服务器:HTTPS 证书域名不匹配",
"处理结果:",
"第一台生产测试服务器:失败,旧文件已恢复,但服务或站点状态尚未确认",
"第二台 SSL 推送测试服务器:失败,HTTPS 证书域名不匹配",
} {
if !strings.Contains(payload.Text, value) {
t.Fatalf("纯文本通知缺少关键信息 %q:%s", value, payload.Text)
}
}
if strings.Contains(payload.Text, "certificate is valid for") || strings.Contains(payload.HTML, "certificate is valid for") {
t.Fatal("通知不应暴露冗长的底层 TLS 错误")
}
if !strings.Contains(payload.HTML, "border-radius") || !strings.Contains(payload.HTML, "问题概述") || !strings.Contains(payload.HTML, "部署概况") {
t.Fatalf("HTML 通知未生成统一样式或重点区域:%s", payload.HTML)
}
}
func TestBuildMailContentHonorsOptionalFields(t *testing.T) {
cfg := newTestConfig("http://mail.example.test")
cfg.IncludeDeploymentDetails = false
cfg.IncludeCertificateSerial = false
cfg.IncludeManagementURL = false
service := New(cfg, nil)
payload, err := service.buildPayload(Event{
Type: "certificate_issue",
Status: "success",
Domain: "example.com",
CertificateSerial: "serial-hidden",
ManagementURL: "https://scert.example.test/hidden",
Deployment: deployment.Report{
Total: 1,
Success: 1,
Status: "success",
Results: []deployment.Result{{Server: "测试服务器", Status: "success", Message: "部署完成"}},
},
})
if err != nil {
t.Fatalf("构造成功通知失败:%v", err)
}
if !strings.Contains(payload.Text, "部署概况:") || strings.Contains(payload.Text, "测试服务器") {
t.Fatalf("部署概况与明细开关处理错误:%s", payload.Text)
}
for _, hidden := range []string{"serial-hidden", "https://scert.example.test/hidden", "测试服务器"} {
if strings.Contains(payload.Text, hidden) || strings.Contains(payload.HTML, hidden) {
t.Fatalf("关闭可选字段后仍包含 %q", hidden)
}
}
}
func TestBuildHTMLMailEscapesDynamicValues(t *testing.T) {
cfg := newTestConfig("http://mail.example.test")
service := New(cfg, nil)
payload, err := service.buildPayload(Event{
Status: "failed",
Domain: "<script>alert(1)</script>",
Error: "错误 <script>alert(2)</script>",
})
if err != nil {
t.Fatalf("构造转义通知失败:%v", err)
}
if strings.Contains(payload.HTML, "<script>") || !strings.Contains(payload.HTML, "&lt;script&gt;") {
t.Fatalf("HTML 动态内容未正确转义:%s", payload.HTML)
}
}

View File

@ -205,23 +205,30 @@ func buildSMTPMessage(payload mailPayload) []byte {
to = append(to, formatSMTPAddress(item))
}
subject := mime.QEncoding.Encode("UTF-8", payload.Subject)
content := payload.Text
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
content = strings.ReplaceAll(content, "\n", "\r\n")
if !strings.HasSuffix(content, "\r\n") {
content += "\r\n"
}
boundary := "=_ScertNotificationBoundary_2026"
text := normalizeMailBody(payload.Text)
htmlBody := normalizeMailBody(payload.HTML)
message := "From: " + from + "\r\n" +
"To: " + strings.Join(to, ", ") + "\r\n" +
"Subject: " + subject + "\r\n" +
"Date: " + time.Now().Format(time.RFC1123Z) + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n" +
"--" + boundary + "\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"Content-Transfer-Encoding: 8bit\r\n\r\n" + content
"Content-Transfer-Encoding: 8bit\r\n\r\n" + text + "\r\n" +
"--" + boundary + "\r\n" +
"Content-Type: text/html; charset=UTF-8\r\n" +
"Content-Transfer-Encoding: 8bit\r\n\r\n" + htmlBody + "\r\n" +
"--" + boundary + "--\r\n"
return []byte(message)
}
func normalizeMailBody(value string) string {
value = strings.ReplaceAll(value, "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
return strings.ReplaceAll(value, "\n", "\r\n")
}
func formatSMTPAddress(value address) string {
parsed, err := mail.ParseAddress(value.Email)
if err != nil || parsed.Address == "" {

View File

@ -432,3 +432,24 @@ func TestSMTPAddressFormatting(t *testing.T) {
t.Fatalf("SMTP address encoding is incorrect: %s", value)
}
}
func TestBuildSMTPMessageIncludesStyledHTMLAlternative(t *testing.T) {
message := string(buildSMTPMessage(mailPayload{
From: address{Name: "Scert", Email: "admin@example.com"},
To: []address{{Email: "receiver@example.com"}},
Subject: "证书任务失败",
Text: "纯文本通知",
HTML: "<p style=\"color:red\">HTML 通知</p>",
}))
for _, value := range []string{
"Content-Type: multipart/alternative;",
"Content-Type: text/plain; charset=UTF-8",
"Content-Type: text/html; charset=UTF-8",
"纯文本通知",
"<p style=\"color:red\">HTML 通知</p>",
} {
if !strings.Contains(message, value) {
t.Fatalf("SMTP multipart 邮件缺少 %q:%s", value, message)
}
}
}

View File

@ -0,0 +1,318 @@
package notification
import (
"fmt"
"html"
"net/url"
"strings"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
type mailContent struct {
title string
status string
statusColor string
statusSummary string
domain string
domains []string
expiresAt string
certificateSerial string
managementURL string
problem string
deployment deployment.Report
includeDeployment bool
hasDeployment bool
}
func buildMailSubject(title, status, domainName string) string {
if status == "成功" {
return fmt.Sprintf("[Scert] %s完成:%s", title, domainName)
}
return fmt.Sprintf("[Scert] %s%s:%s", title, status, domainName)
}
func buildMailContent(event Event, title, status, domainName string, cfg config.NotificationConfig) (string, string) {
content := mailContent{
title: title,
status: status,
statusColor: mailStatusColor(status),
statusSummary: mailStatusSummary(event, title, status),
domain: domainName,
domains: append([]string(nil), event.Domains...),
deployment: event.Deployment,
includeDeployment: cfg.IncludeDeploymentDetails,
hasDeployment: hasDeployment(event.Deployment),
}
if event.ExpiresAt != nil && !event.ExpiresAt.IsZero() {
content.expiresAt = event.ExpiresAt.Local().Format("2006-01-02 15:04:05")
}
if cfg.IncludeCertificateSerial {
content.certificateSerial = strings.TrimSpace(event.CertificateSerial)
}
if cfg.IncludeManagementURL {
content.managementURL = safeHTTPURL(event.ManagementURL)
}
if status != "成功" {
content.problem = problemOverview(event)
}
return buildTextMail(content), buildHTMLMail(content)
}
func buildTextMail(content mailContent) string {
lines := []string{
"Scert 证书管理通知",
"",
"状态:" + content.status,
"摘要:" + content.statusSummary,
"",
"任务信息:",
"- 任务类型:" + content.title,
"- 域名:" + content.domain,
}
if len(content.domains) > 0 {
lines = append(lines, "- 证书域名:"+strings.Join(content.domains, "、"))
}
if content.expiresAt != "" {
lines = append(lines, "- 证书到期时间:"+content.expiresAt)
}
if content.certificateSerial != "" {
lines = append(lines, "- 证书序列号:"+content.certificateSerial)
}
if content.hasDeployment {
lines = append(lines, "", "部署概况:", "- "+deploymentSummary(content.deployment))
}
if content.problem != "" {
lines = append(lines, "", "问题概述:", content.problem)
}
if content.includeDeployment && len(content.deployment.Results) > 0 {
lines = append(lines, "", "处理结果:")
for _, result := range content.deployment.Results {
lines = append(lines, fmt.Sprintf("- %s:%s,%s", result.Server, resultStatus(result.Status), resultSummary(result.Message)))
}
}
if content.managementURL != "" {
lines = append(lines, "", "管理后台:"+content.managementURL)
}
if content.status != "成功" {
lines = append(lines, "", "请登录管理后台查看完整执行记录,再决定是否重试。")
}
return strings.Join(lines, "\n")
}
func buildHTMLMail(content mailContent) string {
var builder strings.Builder
builder.WriteString(`<!doctype html><html lang="zh-CN"><body style="margin:0;padding:0;background:#f4f7fb;color:#172033;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;line-height:1.6;">`)
builder.WriteString(`<div style="padding:32px 16px;"><div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e4e9f2;border-radius:16px;overflow:hidden;">`)
builder.WriteString(`<div style="padding:24px 28px;background:#14213d;color:#ffffff;"><div style="font-size:13px;letter-spacing:.08em;opacity:.8;">SCERT 证书管理服务</div><div style="margin-top:6px;font-size:24px;font-weight:700;">证书管理通知</div></div>`)
builder.WriteString(`<div style="padding:28px;">`)
builder.WriteString(`<div style="display:inline-block;padding:4px 12px;border-radius:999px;background:` + content.statusColor + `;color:#ffffff;font-size:13px;font-weight:700;">` + escapeHTML(content.status) + `</div>`)
builder.WriteString(`<h1 style="margin:14px 0 6px;font-size:24px;line-height:1.35;color:#172033;">` + escapeHTML(content.statusSummary) + `</h1>`)
builder.WriteString(`<p style="margin:0 0 24px;color:#667085;font-size:14px;">` + escapeHTML(content.domain) + `</p>`)
builder.WriteString(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e4e9f2;border-radius:10px;overflow:hidden;font-size:14px;">`)
writeHTMLField(&builder, "任务类型", content.title)
writeHTMLField(&builder, "域名", content.domain)
if len(content.domains) > 0 {
writeHTMLField(&builder, "证书域名", strings.Join(content.domains, "、"))
}
if content.expiresAt != "" {
writeHTMLField(&builder, "证书到期时间", content.expiresAt)
}
if content.certificateSerial != "" {
writeHTMLField(&builder, "证书序列号", content.certificateSerial)
}
builder.WriteString(`</table>`)
if content.problem != "" {
builder.WriteString(`<div style="margin-top:24px;padding:16px 18px;border-left:4px solid #d92d20;background:#fff3f1;border-radius:8px;"><div style="font-size:14px;font-weight:700;color:#b42318;">问题概述</div><div style="margin-top:6px;color:#7a271a;font-size:14px;">` + escapeHTML(content.problem) + `</div></div>`)
}
if content.hasDeployment {
builder.WriteString(`<div style="margin-top:24px;"><div style="font-size:16px;font-weight:700;color:#172033;">部署概况</div><div style="margin-top:8px;padding:12px 14px;background:#f7f9fc;border-radius:8px;color:#344054;font-size:14px;">` + escapeHTML(deploymentSummary(content.deployment)) + `</div></div>`)
}
if content.includeDeployment && len(content.deployment.Results) > 0 {
builder.WriteString(`<div style="margin-top:24px;"><div style="font-size:16px;font-weight:700;color:#172033;">处理结果</div>`)
for _, result := range content.deployment.Results {
color := resultColor(result.Status)
builder.WriteString(`<div style="margin-top:8px;padding:12px 14px;border:1px solid #e4e9f2;border-radius:8px;font-size:14px;"><span style="font-weight:700;color:` + color + `;">` + escapeHTML(result.Server) + ` · ` + escapeHTML(resultStatus(result.Status)) + `</span><div style="margin-top:4px;color:#667085;">` + escapeHTML(resultSummary(result.Message)) + `</div></div>`)
}
builder.WriteString(`</div>`)
}
if content.managementURL != "" {
builder.WriteString(`<div style="margin-top:28px;"><a href="` + escapeHTML(content.managementURL) + `" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#2563eb;color:#ffffff;text-decoration:none;font-size:14px;font-weight:700;">打开管理后台</a></div>`)
}
builder.WriteString(`</div><div style="padding:16px 28px;background:#f8fafc;color:#98a2b3;font-size:12px;">此邮件由 Scert 自动发送,请勿直接回复。</div></div></div></body></html>`)
return builder.String()
}
func writeHTMLField(builder *strings.Builder, label, value string) {
builder.WriteString(`<tr><td style="width:34%;padding:10px 12px;border-bottom:1px solid #e4e9f2;background:#f8fafc;color:#667085;">` + escapeHTML(label) + `</td><td style="padding:10px 12px;border-bottom:1px solid #e4e9f2;color:#172033;word-break:break-word;">` + escapeHTML(value) + `</td></tr>`)
}
func mailStatusColor(status string) string {
switch status {
case "成功":
return "#16803c"
case "失败", "错误":
return "#c4322b"
default:
return "#667085"
}
}
func mailStatusSummary(event Event, title, status string) string {
if event.Deployment.Total > 0 && event.Deployment.Failed > 0 {
return fmt.Sprintf("%s未完成,共 %d 台服务器失败", title, event.Deployment.Failed)
}
if event.Deployment.Total > 0 && event.Deployment.Success == event.Deployment.Total {
return fmt.Sprintf("%s已完成,共 %d 台服务器成功", title, event.Deployment.Success)
}
if status == "成功" {
return title + "已完成"
}
return title + "未完成,请查看问题概述"
}
func hasDeployment(report deployment.Report) bool {
return report.Total > 0 || len(report.Results) > 0
}
func deploymentSummary(report deployment.Report) string {
status := deploymentStatus(report.Status)
return fmt.Sprintf("总计 %d 台,成功 %d 台,失败 %d 台,状态:%s", report.Total, report.Success, report.Failed, status)
}
func deploymentStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "success":
return "成功"
case "partial":
return "部分成功"
case "failed", "failure":
return "失败"
default:
return "未知"
}
}
func resultStatus(value string) string {
status := eventStatus(value)
if status == "未知" && strings.EqualFold(strings.TrimSpace(value), "partial") {
return "部分成功"
}
return status
}
func resultColor(value string) string {
switch resultStatus(value) {
case "成功":
return "#16803c"
case "失败", "错误":
return "#c4322b"
default:
return "#667085"
}
}
func problemOverview(event Event) string {
items := make([]string, 0, 3)
if detail := failureDetail(event.Error); detail != "" {
if source := failureSource(event.Error); source != "" {
detail = source + ":" + detail
}
items = appendUnique(items, detail)
}
for _, result := range event.Deployment.Results {
if result.Status == "success" {
continue
}
detail := failureDetail(result.Message)
if detail == "" {
continue
}
if result.Server != "" {
detail = result.Server + ":" + detail
}
items = appendUnique(items, detail)
}
if len(items) == 0 {
return "任务未完成,请登录管理后台查看详细执行记录。"
}
if len(items) > 2 {
items = items[:2]
}
return strings.Join(items, ";")
}
func failureSource(value string) string {
const prefix = "服务器 "
start := strings.Index(value, prefix)
if start < 0 {
return ""
}
value = value[start+len(prefix):]
end := strings.Index(value, " 的")
if end < 0 {
return ""
}
return strings.TrimSpace(value[:end])
}
func failureDetail(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
lower := strings.ToLower(value)
switch {
case strings.Contains(lower, "x509:") && strings.Contains(lower, "certificate is valid for"):
return "HTTPS 证书域名不匹配,服务器返回的证书不包含目标域名"
case strings.Contains(value, "HTTPS 校验失败") || strings.Contains(lower, "https verification"):
return "HTTPS 校验失败,请检查服务器证书、域名和 SNI 配置"
case strings.Contains(value, "恢复旧文件") && (strings.Contains(value, "未确认") || strings.Contains(value, "待核查")):
return "旧文件已恢复,但服务或站点状态尚未确认"
case strings.Contains(value, "部署锁"):
return "部署锁仍被保留,请先核查服务器和站点状态,再决定是否重试"
case strings.Contains(lower, "connection failed") || strings.Contains(value, "连接失败"):
return "无法连接服务器,请检查网络、端口和服务状态"
default:
return compactText(value, 160)
}
}
func resultSummary(value string) string {
if detail := failureDetail(value); detail != "" {
return detail
}
return "处理完成"
}
func compactText(value string, limit int) string {
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
if len([]rune(value)) <= limit {
return value
}
return string([]rune(value)[:limit]) + "…"
}
func appendUnique(items []string, value string) []string {
for _, item := range items {
if item == value {
return items
}
}
return append(items, value)
}
func safeHTTPURL(value string) string {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return ""
}
return parsed.String()
}
func escapeHTML(value string) string {
return html.EscapeString(value)
}