From 63c2643c72b8c58442e73490e2869379cc12b560 Mon Sep 17 00:00:00 2001 From: xuans Date: Tue, 6 Oct 2026 00:51:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=82=AE=E4=BB=B6=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E6=A8=A1=E6=9D=BF=E4=B8=8E=E5=8F=91=E9=80=81=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/notification/service.go | 38 +-- backend/internal/notification/service_test.go | 101 ++++++ backend/internal/notification/smtp.go | 23 +- backend/internal/notification/smtp_test.go | 21 ++ backend/internal/notification/template.go | 318 ++++++++++++++++++ 5 files changed, 458 insertions(+), 43 deletions(-) create mode 100644 backend/internal/notification/template.go diff --git a/backend/internal/notification/service.go b/backend/internal/notification/service.go index 7ed23f9..d8e3edd 100644 --- a/backend/internal/notification/service.go +++ b/backend/internal/notification/service.go @@ -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: "
" + html.EscapeString(text) + "
", + 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": diff --git a/backend/internal/notification/service_test.go b/backend/internal/notification/service_test.go index 89add14..663bd58 100644 --- a/backend/internal/notification/service_test.go +++ b/backend/internal/notification/service_test.go @@ -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: "", + Error: "错误 ", + }) + if err != nil { + t.Fatalf("构造转义通知失败:%v", err) + } + if strings.Contains(payload.HTML, "