diff --git a/backend/internal/config/notification_sender.go b/backend/internal/config/notification_sender.go index 6a81ada..aa053f8 100644 --- a/backend/internal/config/notification_sender.go +++ b/backend/internal/config/notification_sender.go @@ -6,6 +6,11 @@ import ( "strings" ) +// UsesSmailSystemAccount 判断通知账号是否为 Smail 系统通知账号。 +func (c NotificationConfig) UsesSmailSystemAccount() bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.SMTPUsername)), "notify:") +} + // SenderIdentity 根据系统通知账号确定发件人,不能将管理员邮箱当作 SMTP 发件人。 func (c NotificationConfig) SenderIdentity() (string, string, error) { name := strings.TrimSpace(c.SenderName) @@ -14,7 +19,7 @@ func (c NotificationConfig) SenderIdentity() (string, string, error) { return name, email, nil } username := strings.ToLower(strings.TrimSpace(c.SMTPUsername)) - if strings.HasPrefix(username, "notify:") { + if c.UsesSmailSystemAccount() { account := strings.TrimPrefix(username, "notify:") parsed, err := mail.ParseAddress(account) parts := strings.Split(account, "@") diff --git a/backend/internal/notification/security.go b/backend/internal/notification/security.go index b3db7c6..7c2b99f 100644 --- a/backend/internal/notification/security.go +++ b/backend/internal/notification/security.go @@ -3,6 +3,7 @@ package notification import ( "context" "errors" + "fmt" "html" "net/mail" ) @@ -42,6 +43,15 @@ func (s *Service) SendSecurityCode(ctx context.Context, recipient, code string) Text: text, HTML: "
" + html.EscapeString(text) + "", CC: []address{}, BCC: []address{}, Attrs: []any{}, } + if s.config.UsesSmailSystemAccount() { + if !s.config.SMTPEnabled { + return errors.New("Smail 系统通知账号必须启用 SMTP 通道") + } + if err := s.sendSMTPWithRetry(ctx, payload); err != nil { + return fmt.Errorf("SMTP 验证码邮件发送失败:%w", err) + } + return nil + } if s.config.HTTPEnabled { if err := s.sendHTTPPayload(ctx, payload); err == nil { return nil diff --git a/backend/internal/notification/security_test.go b/backend/internal/notification/security_test.go index 12e5099..b4b2d91 100644 --- a/backend/internal/notification/security_test.go +++ b/backend/internal/notification/security_test.go @@ -144,3 +144,22 @@ func TestSecurityCodeSMTPAndHTTPFallback(t *testing.T) { }) } } + +func TestSecurityCodeSmailSystemAccountRequiresSMTP(t *testing.T) { + var calls atomic.Int32 + endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer endpoint.Close() + cfg := newTestConfig(endpoint.URL) + cfg.SMTPUsername = "notify:scert@example.test" + cfg.SMTPEnabled = false + service := New(cfg, nil) + if err := service.SendSecurityCode(t.Context(), "bound@example.test", "12345678"); err == nil || !strings.Contains(err.Error(), "必须启用 SMTP") { + t.Fatalf("Smail 系统通知未拒绝 HTTP-only 配置:%v", err) + } + if calls.Load() != 0 { + t.Fatal("Smail 系统通知账号禁止调用 HTTP 通道") + } +} diff --git a/backend/internal/notification/service.go b/backend/internal/notification/service.go index 28745d8..7ed23f9 100644 --- a/backend/internal/notification/service.go +++ b/backend/internal/notification/service.go @@ -122,6 +122,9 @@ func (s *Service) sendHTTP(ctx context.Context, event Event) error { if !s.config.HTTPEnabled { return nil } + if s.config.UsesSmailSystemAccount() { + return errors.New("Smail 系统通知账号只能通过 SMTP 通道发送") + } if err := ctx.Err(); err != nil { return err } diff --git a/backend/internal/notification/smtp.go b/backend/internal/notification/smtp.go index d93408a..cf131d5 100644 --- a/backend/internal/notification/smtp.go +++ b/backend/internal/notification/smtp.go @@ -25,6 +25,16 @@ func (s *Service) Send(ctx context.Context, event Event) error { if err := ctx.Err(); err != nil { return err } + if s.config.UsesSmailSystemAccount() { + if !s.config.SMTPEnabled { + return errors.New("Smail 系统通知账号必须启用 SMTP 通道") + } + payload, err := s.buildPayload(event) + if err != nil { + return err + } + return s.sendSMTPWithRetry(ctx, payload) + } var errs []error if s.config.HTTPEnabled { if err := s.sendHTTP(ctx, event); err != nil { diff --git a/backend/internal/notification/smtp_test.go b/backend/internal/notification/smtp_test.go index b6b1b3f..a90a30a 100644 --- a/backend/internal/notification/smtp_test.go +++ b/backend/internal/notification/smtp_test.go @@ -12,6 +12,8 @@ import ( "io" "math/big" "net" + "net/http" + "net/http/httptest" "strings" "sync" "testing" @@ -313,6 +315,35 @@ func TestSmailSystemSenderUsesSameEnvelopeAndHeader(t *testing.T) { } } +func TestSmailSystemNotificationUsesSMTPOnly(t *testing.T) { + server := newSMTPTestServer(t, true, false) + host, port := server.address() + calls := 0 + endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusBadGateway) + })) + defer endpoint.Close() + cfg := smtpTestConfig(host, port, "implicit_tls") + cfg.SMTPUsername = "notify:scert@example.test" + cfg.HTTPEnabled = true + cfg.Endpoint = endpoint.URL + cfg.HTTPPath = "/api/email/send" + service := New(cfg, credentialReaderStub{values: map[string]string{"password": "test-only-notification-credential"}}) + service.smtpTLSConfig = server.clientTLSConfig + if err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.test"}); err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatalf("Smail 系统通知不应调用 HTTP 通道,调用次数:%d", calls) + } + server.mu.Lock() + defer server.mu.Unlock() + if len(server.messages) != 1 { + t.Fatalf("SMTP 系统通知数量错误:%d", len(server.messages)) + } +} + func TestSMTPStartTLS(t *testing.T) { server := newSMTPTestServer(t, false, true) host, port := server.address() diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 32876cd..9ad2704 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,4 +1,12 @@ # Linux 生产流程测试记录 +## Smail 系统通知通道收敛专项(2026-09-30) + +- 代码层识别 Smail `notify:` 系统通知账号,并固定使用 `notification+system@邮件域名` 作为 SMTP 发件身份;不使用管理员邮箱替代。 +- 证书任务通知和超级管理员验证码在该账号模式下都只调用 SMTP,不调用普通 HTTP 发信接口;验证码收件人仍严格限定为当前绑定邮箱。 +- SMTP 未启用时返回明确配置错误;未关闭 TLS 校验,未记录 SMTP 密码、验证码或其他真实凭据。 +- 通知专项、后端全量测试、静态检查和构建均已通过;本记录仅证明代码候选通过,尚未证明远端 Smail SMTP 参数、验证码、密码重置和生产上线流程通过。 +- 下一步:提交并构建正式候选包,使用密码 SSH 部署到 `8.137.23.95` 和 `45.136.13.241`,按生产流程清理测试数据后验证发信与密码重置;总体验收继续保持 P0-03 完成、等待复审。 + ## 恢复标准 Nginx 重载命令后正式页面复测(2026-09-30 17:39 UTC) - 通过正式服务器配置页面将临时测试用的重载命令 `/usr/bin/true` 恢复为标准 Nginx 参数 `nginx`、`-s`、`reload`,保存提示“服务器配置已更新”。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 54476b7..a74dc95 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,4 +1,14 @@ # Scert 整改任务执行清单 +## 本轮任务看板(2026-09-30,Smail 系统通知通道收敛) + +- [x] T2 子项:识别 Smail `notify:` 系统通知账号,固定系统通知发件身份并禁止 HTTP 通道发送。 +- [x] T2 子项:超级管理员验证码只发送绑定邮箱;系统通知账号只走 SMTP,SMTP 未启用时返回明确错误。 +- [x] T2 子项:通知专项测试、后端全量测试、`go vet`、`go build` 和差异检查通过。 +- [~] T4:提交本轮实现,编译正式 Linux 候选包并部署到第一台、第二台服务器,进行真实 SMTP 和密码重置闭环验证。 +- [~] T5:继续验证多服务器、多域名、证书推送、失败恢复、重试幂等及任务状态持久化。 +- [ ] T7:三数据库、Windows 服务、清库首次初始化、公网 DNS/ACME、备份恢复和最终生产上线结论。 +- [!] 总体验收位置不变:**P0-03 完成,等待复审**;本轮没有新增 `[✓]`。 + ## 当前任务看板(2026-09-30 17:39 UTC,恢复标准 Nginx 重载命令后正式页面复测) - [x] 已通过正式服务器配置页面将临时 `/usr/bin/true` 重载命令恢复为 `nginx`、`-s`、`reload`,并保存成功。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index f32a7b3..ee4c757 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,4 +1,17 @@ # Scert 整改、合并与优化清单 +## 本轮整改进度(2026-09-30,Smail 系统通知通道收敛) + +需求确认:继续按明日上线目标推进,但不缩减生产门禁;系统通知使用 Smail `notify:` SMTP 账号时,只允许通过 SMTP 发信,不使用普通 HTTP 发信接口,不记录真实凭据。 + +- [x] 新增 Smail 系统通知账号识别,统一生成固定的 `notification+system@邮件域名` 发件身份。 +- [x] 证书任务通知检测到 `notify:` 系统账号时只走 SMTP,不再执行 HTTP 通道,避免重复发送或普通接口伪造系统发件地址。 +- [x] 超级管理员验证码检测到 `notify:` 系统账号时只发送到绑定邮箱并只走 SMTP;SMTP 未启用时明确返回配置错误。 +- [x] 增加系统通知只走 SMTP、HTTP-only 拒绝和系统发件身份保持不变的回归测试;后端全量测试、`go vet`、`go build` 和 `git diff --check` 通过。 +- [~] 待提交本轮代码并构建正式 Linux 候选包,随后使用密码 SSH 部署到两台真实服务器,复测 Smail SMTP 验证码和密码重置闭环。 +- [~] P0-10 后置 HTTPS 失败自动恢复、进程崩溃事务接管、多目标部分失败和重试幂等仍需负向实机证据。 +- [!] 三数据库矩阵、Windows 服务、清库后首次初始化、公网 DNS/HTTPS/SNI/ACME、备份恢复和最终生产门禁仍未完成。 +- [!] 总体验收继续保持 **P0-03:完成,等待复审**,本轮不标记 `[✓]`。 + ## 当前验收总览(2026-09-30 17:39 UTC,恢复标准 Nginx 重载命令后正式页面复测) - [x] 已通过正式服务器配置页面将临时测试用的重载命令 `/usr/bin/true` 恢复为标准 Nginx 参数 `nginx`、`-s`、`reload`,并保存成功。