补齐验证码邮件样式并修正失败摘要和恢复提醒
This commit is contained in:
parent
5988ce3c04
commit
b55688c055
38
backend/internal/notification/recovery_warning.go
Normal file
38
backend/internal/notification/recovery_warning.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 恢复警告不受明细开关或摘要条数限制,不包含服务器名称及远端输出。
|
||||||
|
func recoveryWarning(event Event) string {
|
||||||
|
const warning = "需要人工核查:部署恢复尚未确认或部署锁需要处理,勿直接重试或删除锁。请管理员核查服务器文件、服务和站点状态。"
|
||||||
|
switch strings.ToLower(strings.TrimSpace(event.Deployment.RecoveryStatus)) {
|
||||||
|
case "unconfirmed", "failed", "recovery_required":
|
||||||
|
return warning
|
||||||
|
}
|
||||||
|
for _, result := range event.Deployment.Results {
|
||||||
|
switch result.FailureCode {
|
||||||
|
case deployment.FailureRollback, deployment.FailureRuntimeUnknown,
|
||||||
|
deployment.FailureRecoveryPending, deployment.FailureRecoveryUnknown,
|
||||||
|
deployment.FailureLock, deployment.FailureLockRelease:
|
||||||
|
return warning
|
||||||
|
}
|
||||||
|
if hasRecoveryWarning(result.Message) {
|
||||||
|
return warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasRecoveryWarning(event.Error) {
|
||||||
|
return warning
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容未携带结构化诊断的历史任务。
|
||||||
|
func hasRecoveryWarning(message string) bool {
|
||||||
|
return strings.Contains(message, "部署锁") ||
|
||||||
|
(strings.Contains(message, "恢复") &&
|
||||||
|
(strings.Contains(message, "未确认") || strings.Contains(message, "待核查")))
|
||||||
|
}
|
||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
|
||||||
"net/mail"
|
"net/mail"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -40,7 +39,7 @@ func (s *Service) SendSecurityCode(ctx context.Context, recipient, code string)
|
|||||||
From: address{Name: fromName, Email: from},
|
From: address{Name: fromName, Email: from},
|
||||||
To: []address{{Email: recipient}},
|
To: []address{{Email: recipient}},
|
||||||
Subject: "[Scert] 超级管理员密码重置验证",
|
Subject: "[Scert] 超级管理员密码重置验证",
|
||||||
Text: text, HTML: "<pre>" + html.EscapeString(text) + "</pre>",
|
Text: text, HTML: buildSecurityHTML(code),
|
||||||
CC: []address{}, BCC: []address{}, Attrs: []any{},
|
CC: []address{}, BCC: []address{}, Attrs: []any{},
|
||||||
}
|
}
|
||||||
if s.config.UsesSmailSystemAccount() {
|
if s.config.UsesSmailSystemAccount() {
|
||||||
|
|||||||
34
backend/internal/notification/security_template.go
Normal file
34
backend/internal/notification/security_template.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func buildSecurityHTML(code string) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString(`<!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;background:#f4f7fb;color:#172033;font-family:Arial,'Microsoft YaHei',sans-serif;line-height:1.6;">
|
||||||
|
<div style="padding:32px 16px;">
|
||||||
|
<div style="max-width:600px;margin:0 auto;background:#ffffff;border:1px solid #e4e9f2;border-radius:16px;overflow:hidden;">
|
||||||
|
<div style="padding:24px;background:#14213d;color:#ffffff;">
|
||||||
|
<div style="font-size:13px;">SCERT 证书管理服务</div>
|
||||||
|
<h1 style="margin:8px 0 0;font-size:24px;">超级管理员密码重置验证</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px;">
|
||||||
|
<p style="margin:0 0 20px;">有人正在部署服务器上重置 Scert 超级管理员密码。</p>
|
||||||
|
<div style="padding:20px 12px;border:1px solid #d0d5dd;border-radius:12px;background:#f8fafc;text-align:center;">
|
||||||
|
<div style="font-size:14px;color:#667085;">本次验证码</div>
|
||||||
|
<div style="margin-top:8px;font-size:30px;font-weight:700;letter-spacing:4px;color:#14213d;">`)
|
||||||
|
builder.WriteString(escapeHTML(code))
|
||||||
|
builder.WriteString(`</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:20px 0;">请在服务器本机重置终端输入。验证码仅在当前本机重置会话中有效,有效期不超过<strong>两分钟</strong>。</p>
|
||||||
|
<div style="padding:16px;border-left:4px solid #f79009;border-radius:8px;background:#fffaeb;color:#93370d;">如果不是您本人操作,请勿提供此验证码,并检查服务器访问权限。</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 24px;background:#f8fafc;color:#667085;font-size:12px;">此邮件由 Scert 自动发送,请勿直接回复。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
@ -402,7 +402,11 @@ func (s *Service) buildPayload(event Event) (mailPayload, error) {
|
|||||||
status := eventStatus(event.Status)
|
status := eventStatus(event.Status)
|
||||||
domainName := strings.TrimSpace(event.Domain)
|
domainName := strings.TrimSpace(event.Domain)
|
||||||
if domainName == "" {
|
if domainName == "" {
|
||||||
domainName = strings.TrimSpace(event.DomainID)
|
for _, domain := range event.Domains {
|
||||||
|
if domainName = strings.TrimSpace(domain); domainName != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if domainName == "" {
|
if domainName == "" {
|
||||||
domainName = "未指定域名"
|
domainName = "未指定域名"
|
||||||
|
|||||||
@ -21,6 +21,7 @@ type mailContent struct {
|
|||||||
certificateSerial string
|
certificateSerial string
|
||||||
managementURL string
|
managementURL string
|
||||||
problem string
|
problem string
|
||||||
|
recoveryWarning string
|
||||||
deployment deployment.Report
|
deployment deployment.Report
|
||||||
includeDeployment bool
|
includeDeployment bool
|
||||||
hasDeployment bool
|
hasDeployment bool
|
||||||
@ -39,6 +40,7 @@ func buildMailContent(event Event, title, status, domainName string, cfg config.
|
|||||||
status: status,
|
status: status,
|
||||||
statusColor: mailStatusColor(status),
|
statusColor: mailStatusColor(status),
|
||||||
statusSummary: mailStatusSummary(event, title, status),
|
statusSummary: mailStatusSummary(event, title, status),
|
||||||
|
recoveryWarning: recoveryWarning(event),
|
||||||
domain: domainName,
|
domain: domainName,
|
||||||
domains: append([]string(nil), event.Domains...),
|
domains: append([]string(nil), event.Domains...),
|
||||||
deployment: event.Deployment,
|
deployment: event.Deployment,
|
||||||
@ -86,6 +88,9 @@ func buildTextMail(content mailContent) string {
|
|||||||
if content.problem != "" {
|
if content.problem != "" {
|
||||||
lines = append(lines, "", "问题概述:", content.problem)
|
lines = append(lines, "", "问题概述:", content.problem)
|
||||||
}
|
}
|
||||||
|
if content.recoveryWarning != "" {
|
||||||
|
lines = append(lines, "", content.recoveryWarning)
|
||||||
|
}
|
||||||
if content.includeDeployment && len(content.deployment.Results) > 0 {
|
if content.includeDeployment && len(content.deployment.Results) > 0 {
|
||||||
lines = append(lines, "", "处理结果:")
|
lines = append(lines, "", "处理结果:")
|
||||||
for _, result := range content.deployment.Results {
|
for _, result := range content.deployment.Results {
|
||||||
@ -103,7 +108,7 @@ func buildTextMail(content mailContent) string {
|
|||||||
|
|
||||||
func buildHTMLMail(content mailContent) string {
|
func buildHTMLMail(content mailContent) string {
|
||||||
var builder strings.Builder
|
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(`<!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;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: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: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="padding:28px;">`)
|
||||||
@ -131,6 +136,9 @@ func buildHTMLMail(content mailContent) string {
|
|||||||
if content.hasDeployment {
|
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>`)
|
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.recoveryWarning != "" {
|
||||||
|
builder.WriteString(`<div style="margin-top:20px;padding:16px;border:1px solid #fdb022;border-radius:8px;background:#fffaeb;color:#93370d;font-weight:700;">` + escapeHTML(content.recoveryWarning) + `</div>`)
|
||||||
|
}
|
||||||
if content.includeDeployment && len(content.deployment.Results) > 0 {
|
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>`)
|
builder.WriteString(`<div style="margin-top:24px;"><div style="font-size:16px;font-weight:700;color:#172033;">处理结果</div>`)
|
||||||
for _, result := range content.deployment.Results {
|
for _, result := range content.deployment.Results {
|
||||||
@ -165,7 +173,7 @@ func mailStatusSummary(event Event, title, status string) string {
|
|||||||
if event.Deployment.Total > 0 && event.Deployment.Failed > 0 {
|
if event.Deployment.Total > 0 && event.Deployment.Failed > 0 {
|
||||||
return fmt.Sprintf("%s未完成,共 %d 台服务器失败", title, event.Deployment.Failed)
|
return fmt.Sprintf("%s未完成,共 %d 台服务器失败", title, event.Deployment.Failed)
|
||||||
}
|
}
|
||||||
if event.Deployment.Total > 0 && event.Deployment.Success == event.Deployment.Total {
|
if status == eventStatus("success") && event.Deployment.Total > 0 && event.Deployment.Success == event.Deployment.Total {
|
||||||
return fmt.Sprintf("%s已完成,共 %d 台服务器成功", title, event.Deployment.Success)
|
return fmt.Sprintf("%s已完成,共 %d 台服务器成功", title, event.Deployment.Success)
|
||||||
}
|
}
|
||||||
if status == "成功" {
|
if status == "成功" {
|
||||||
|
|||||||
183
backend/internal/notification/template_safety_test.go
Normal file
183
backend/internal/notification/template_safety_test.go
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||||
|
htmlparser "golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFailedTaskCannotHaveCompletedSummary(t *testing.T) {
|
||||||
|
for _, status := range []string{"failed", "error", "unknown"} {
|
||||||
|
t.Run(status, func(t *testing.T) {
|
||||||
|
payload, err := New(newTestConfig("http://mail.example.test"), nil).buildPayload(Event{
|
||||||
|
Status: status,
|
||||||
|
Domain: "example.test",
|
||||||
|
Deployment: deployment.Report{Total: 2, Success: 2, Status: "success"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, body := range []string{payload.Text, payload.HTML} {
|
||||||
|
if strings.Contains(body, "已完成") {
|
||||||
|
t.Fatal("失败或未知任务不能显示完成摘要")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryWarningSurvivesHiddenDetails(t *testing.T) {
|
||||||
|
for _, report := range []deployment.Report{
|
||||||
|
{RecoveryStatus: "unconfirmed"},
|
||||||
|
{RecoveryStatus: "failed"},
|
||||||
|
{RecoveryStatus: "recovery_required"},
|
||||||
|
{Results: []deployment.Result{{Status: "failed", Message: "已恢复旧文件,但服务或站点恢复状态未确认,已保留部署锁"}}},
|
||||||
|
} {
|
||||||
|
cfg := newTestConfig("http://mail.example.test")
|
||||||
|
cfg.IncludeDeploymentDetails = false
|
||||||
|
payload, err := New(cfg, nil).buildPayload(Event{Status: "failed", Domain: "example.test", Deployment: report})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, body := range []string{payload.Text, payload.HTML} {
|
||||||
|
if !strings.Contains(body, "勿直接重试或删除锁") {
|
||||||
|
t.Fatal("隐藏部署明细不能隐藏恢复安全提示")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryWarningUsesFailureCodes(t *testing.T) {
|
||||||
|
for _, code := range []deployment.FailureCode{
|
||||||
|
deployment.FailureRollback, deployment.FailureRuntimeUnknown,
|
||||||
|
deployment.FailureRecoveryPending, deployment.FailureRecoveryUnknown,
|
||||||
|
deployment.FailureLock, deployment.FailureLockRelease,
|
||||||
|
} {
|
||||||
|
t.Run(string(code), func(t *testing.T) {
|
||||||
|
cfg := newTestConfig("http://mail.example.test")
|
||||||
|
cfg.IncludeDeploymentDetails = false
|
||||||
|
payload, err := New(cfg, nil).buildPayload(Event{
|
||||||
|
Status: "failed", Domain: "example.test", Error: "primary failure",
|
||||||
|
Deployment: deployment.Report{Results: []deployment.Result{
|
||||||
|
{Status: "failed", Message: "first failure"},
|
||||||
|
{Status: "failed", Message: "second failure"},
|
||||||
|
{Server: "hidden-server", Status: "failed", FailureCode: code},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, body := range []string{payload.Text, payload.HTML} {
|
||||||
|
if !strings.Contains(body, "勿直接重试或删除锁") || strings.Contains(body, "hidden-server") {
|
||||||
|
t.Fatal("恢复警告必须独立展示且不能暴露已隐藏的服务器名称")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifiedRecoveryDoesNotRequireManualUnlock(t *testing.T) {
|
||||||
|
event := Event{Status: "failed", Deployment: deployment.Report{
|
||||||
|
RecoveryStatus: "success",
|
||||||
|
Results: []deployment.Result{{FailureCode: deployment.FailureRecoveryRestored}},
|
||||||
|
}}
|
||||||
|
if recoveryWarning(event) != "" {
|
||||||
|
t.Fatal("已确认恢复的任务不应提示恢复未确认")
|
||||||
|
}
|
||||||
|
event = Event{Status: "failed", Error: "已保留部署锁"}
|
||||||
|
if recoveryWarning(event) == "" {
|
||||||
|
t.Fatal("仅任务错误携带恢复信息时也应提示核查")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailUsesCertificateDomainInsteadOfInternalID(t *testing.T) {
|
||||||
|
for _, domains := range [][]string{{" ", "example.test"}, nil} {
|
||||||
|
payload, err := New(newTestConfig("http://mail.example.test"), nil).buildPayload(Event{
|
||||||
|
Status: "success", DomainID: "internal-domain-id", Domains: domains,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected := "未指定域名"
|
||||||
|
if len(domains) > 0 {
|
||||||
|
expected = "example.test"
|
||||||
|
}
|
||||||
|
for _, body := range []string{payload.Subject, payload.Text, payload.HTML} {
|
||||||
|
if strings.Contains(body, "internal-domain-id") || !strings.Contains(body, expected) {
|
||||||
|
t.Fatal("用户邮件应显示证书域名而非内部记录编号")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllMailTemplatesHaveValidBodyStyles(t *testing.T) {
|
||||||
|
for _, eventType := range []string{"certificate_issue", "certificate_renewal", "management_renewal", "certificate_import", "unknown"} {
|
||||||
|
for _, status := range []string{"success", "failed", "error"} {
|
||||||
|
payload, err := New(newTestConfig("http://mail.example.test"), nil).buildPayload(Event{
|
||||||
|
Type: eventType, Status: status, Domain: "example.test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertMailBodyStyle(t, payload.HTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertMailBodyStyle(t, buildSecurityHTML("01234567"))
|
||||||
|
if strings.Contains(buildSecurityHTML(`<script>alert(1)</script>`), "<script>") {
|
||||||
|
t.Fatal("模板必须转义动态内容")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMailBodyStyle(t *testing.T, body string) {
|
||||||
|
t.Helper()
|
||||||
|
tokenizer := htmlparser.NewTokenizer(strings.NewReader(body))
|
||||||
|
for {
|
||||||
|
switch tokenizer.Next() {
|
||||||
|
case htmlparser.ErrorToken:
|
||||||
|
if tokenizer.Err() != io.EOF {
|
||||||
|
t.Fatal(tokenizer.Err())
|
||||||
|
}
|
||||||
|
t.Fatal("邮件缺少有效正文样式")
|
||||||
|
case htmlparser.StartTagToken:
|
||||||
|
token := tokenizer.Token()
|
||||||
|
if token.Data != "body" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(token.Attr) != 1 || token.Attr[0].Key != "style" ||
|
||||||
|
!strings.Contains(token.Attr[0].Val, "line-height:1.6;") {
|
||||||
|
t.Fatal("正文样式属性被引号截断或缺失")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityMailUsesStyledTemplate(t *testing.T) {
|
||||||
|
var received mailPayload
|
||||||
|
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer endpoint.Close()
|
||||||
|
cfg := newTestConfig(endpoint.URL)
|
||||||
|
cfg.Recipients = []string{"unrelated@example.test"}
|
||||||
|
if err := New(cfg, nil).SendSecurityCode(t.Context(), "bound@example.test", "01234567"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"border-radius", "01234567", "两分钟", "请勿提供此验证码"} {
|
||||||
|
if !strings.Contains(received.HTML, expected) {
|
||||||
|
t.Fatalf("验证码邮件缺少样式或安全信息:%s", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(received.To) != 1 || received.To[0].Email != "bound@example.test" || len(received.CC) != 0 || len(received.BCC) != 0 {
|
||||||
|
t.Fatal("验证码只能发送到绑定邮箱")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user