From a9937c3e6ae2270e6e081c2e866eb72264b4e3f2 Mon Sep 17 00:00:00 2001 From: xuans Date: Sat, 26 Sep 2026 22:50:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E8=AE=BE=E7=BD=AE=E5=B9=B6=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E4=BD=BF=E7=94=A8Smail=E7=B3=BB=E7=BB=9F=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E5=8F=91=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/bootstrap/notification.go | 7 ++++ .../internal/bootstrap/notification_test.go | 5 ++- backend/internal/cli/root.go | 2 +- .../internal/config/notification_sender.go | 36 +++++++++++++++++ .../config/notification_sender_test.go | 40 +++++++++++++++++++ backend/internal/httpapi/setup_test.go | 2 +- backend/internal/notification/security.go | 8 ++-- backend/internal/notification/service.go | 7 +++- backend/internal/notification/smtp_test.go | 38 ++++++++++++++++++ backend/internal/setup/setup.go | 11 ++++- doc/Smail系统通知接入说明.md | 18 +++++++++ .../src/views/SetupWizard/SetupWizard.css | 4 +- frontend/src/views/SetupWizard/SetupWizard.js | 4 -- .../src/views/SetupWizard/SetupWizard.vue | 36 +++++------------ .../SetupWizard/SetupWizardBootstrap.test.js | 8 ++-- .../SetupWizard/SetupWizardLayout.test.js | 8 ++-- 16 files changed, 186 insertions(+), 48 deletions(-) create mode 100644 backend/internal/config/notification_sender.go create mode 100644 backend/internal/config/notification_sender_test.go create mode 100644 doc/Smail系统通知接入说明.md diff --git a/backend/internal/bootstrap/notification.go b/backend/internal/bootstrap/notification.go index 0f688b6..3fb764e 100644 --- a/backend/internal/bootstrap/notification.go +++ b/backend/internal/bootstrap/notification.go @@ -97,6 +97,13 @@ func (completer *Completer) prepareNotification(ctx context.Context, current con return candidate, nil, completeError(CompleteErrorInput, "SMTP 通知主机、账号、密码或连接安全参数无效", nil) } credentials := make(map[string]map[string]string) + if candidate.SMTPEnabled { + name, email, err := candidate.SenderIdentity() + if err != nil { + return candidate, nil, completeError(CompleteErrorInput, "SMTP 发件账号无效,请使用 Smail 系统通知页提供的完整用户名", err) + } + candidate.SenderName, candidate.SenderEmail = name, email + } if candidate.HTTPEnabled && candidate.HTTPAuthMode != "none" { values, err := notificationCredentials(candidate.HTTPAuthMode, input.HTTPCredentials) if err != nil { diff --git a/backend/internal/bootstrap/notification_test.go b/backend/internal/bootstrap/notification_test.go index aa8436f..e7aa6d5 100644 --- a/backend/internal/bootstrap/notification_test.go +++ b/backend/internal/bootstrap/notification_test.go @@ -15,7 +15,7 @@ func completionNotificationInput() *NotificationInput { return &NotificationInput{ HTTPEnabled: true, Endpoint: "https://mail.example.test", HTTPAuthMode: "session", HTTPCredentials: map[string]string{"account": "mail-owner", "password": "private-http-password"}, - SMTPEnabled: true, SMTPHost: "smtp.example.test", SMTPUsername: "smtp-owner", SMTPPassword: "private-smtp-password", + SMTPEnabled: true, SMTPHost: "smtp.example.test", SMTPUsername: "notify:smtp-owner@example.test", SMTPPassword: "private-smtp-password", IncludeManagementURL: true, IncludeCertificateSerial: true, Recipients: []string{"notify@example.test"}, TimeoutSecs: 15, RetryCount: 0, RetryIntervalsSecs: []int{}, } @@ -59,6 +59,9 @@ func TestCompleterStoresNotificationCredentialsWithoutPlaintext(t *testing.T) { t.Fatalf("通知选项未完整保存:%+v", stored.Notification) } httpCredentials, err := fixture.store.Get(t.Context(), stored.Notification.HTTPCredentialRef) + if stored.Notification.SenderEmail != "notification+system@example.test" || stored.Notification.SenderName != "系统通知" { + t.Fatal("初始化没有保存 Smail 系统通知发件人") + } if err != nil || httpCredentials["password"] != "private-http-password" { t.Fatalf("HTTP 通知凭据未进入敏感存储:%v", err) } diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 160b622..6e3bb14 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -235,7 +235,7 @@ func newInitCommandWithDependencies(firewallManager firewall.Manager, issue init command.Flags().StringVar(&emailHTTPHeaders, "email-http-headers", "", "HTTP 自定义请求头 JSON") command.Flags().StringVar(&emailEventMode, "email-event-mode", "detailed", "通知事件模式:detailed、unified") command.Flags().StringVar(&emailSenderName, "email-sender-name", "Scert", "通知发件人名称") - command.Flags().StringVar(&emailSenderEmail, "email-sender-email", "", "通知发件人地址,留空使用管理员邮箱") + command.Flags().StringVar(&emailSenderEmail, "email-sender-email", "", "兼容通知发件人地址;Smail 系统通知账号自动确定,其他 SMTP 留空使用完整邮箱账号") command.Flags().BoolVar(&emailIncludeDeploymentDetails, "email-include-deployment-details", true, "是否发送服务器部署明细") command.Flags().BoolVar(&emailIncludeCertificateSerial, "email-include-certificate-serial", true, "是否发送证书序列号") command.Flags().BoolVar(&emailIncludeManagementURL, "email-include-management-url", true, "是否发送管理后台链接") diff --git a/backend/internal/config/notification_sender.go b/backend/internal/config/notification_sender.go new file mode 100644 index 0000000..6a81ada --- /dev/null +++ b/backend/internal/config/notification_sender.go @@ -0,0 +1,36 @@ +package config + +import ( + "errors" + "net/mail" + "strings" +) + +// SenderIdentity 根据系统通知账号确定发件人,不能将管理员邮箱当作 SMTP 发件人。 +func (c NotificationConfig) SenderIdentity() (string, string, error) { + name := strings.TrimSpace(c.SenderName) + email := strings.TrimSpace(c.SenderEmail) + if !c.SMTPEnabled { + return name, email, nil + } + username := strings.ToLower(strings.TrimSpace(c.SMTPUsername)) + if strings.HasPrefix(username, "notify:") { + account := strings.TrimPrefix(username, "notify:") + parsed, err := mail.ParseAddress(account) + parts := strings.Split(account, "@") + if err != nil || parsed.Address != account || parsed.Name != "" || len(parts) != 2 || + strings.ContainsAny(account, "\r\n<>()") { + return "", "", errors.New("Smail 系统通知用户名必须使用 notify:账号@邮件域名 格式") + } + // Smail 保留该发件地址;不能使用登录账号、管理员邮箱或自定义名称替代。 + return "系统通知", "notification+system@" + parts[1], nil + } + if email == "" { + parsed, err := mail.ParseAddress(username) + if err != nil || parsed.Address != username || parsed.Name != "" { + return "", "", errors.New("请填写 Smail 系统通知用户名;兼容 SMTP 账号必须是完整邮箱地址") + } + email = username + } + return name, email, nil +} diff --git a/backend/internal/config/notification_sender_test.go b/backend/internal/config/notification_sender_test.go new file mode 100644 index 0000000..26ab456 --- /dev/null +++ b/backend/internal/config/notification_sender_test.go @@ -0,0 +1,40 @@ +package config + +import "testing" + +func TestNotificationSenderIdentity(t *testing.T) { + for _, item := range []struct { + name, username, sender, wantName, wantEmail string + }{ + {"system", "notify:scert@example.test", "", "系统通知", "notification+system@example.test"}, + {"stale_admin", "notify:scert@example.test", "admin@other.test", "系统通知", "notification+system@example.test"}, + {"normalized", " NOTIFY:SCERT@EXAMPLE.TEST ", "", "系统通知", "notification+system@example.test"}, + {"smtp_compatibility", "sender@example.test", "", "Scert", "sender@example.test"}, + {"explicit_legacy", "smtp-login", "sender@example.test", "Scert", "sender@example.test"}, + } { + t.Run(item.name, func(t *testing.T) { + cfg := NotificationConfig{SMTPEnabled: true, SMTPUsername: item.username, SenderName: "Scert", SenderEmail: item.sender} + name, email, err := cfg.SenderIdentity() + if err != nil || name != item.wantName || email != item.wantEmail { + t.Fatalf("发件身份不匹配:name=%q email=%q err=%v", name, email, err) + } + }) + } +} + +func TestNotificationSenderRejectsInvalidSystemAccount(t *testing.T) { + for _, username := range []string{"notify:", "notify:user", "notify:@example.test", "notify:a@b@example.test", "notify:User ", "notify:a@example.test\r\nBcc:other@example.test", "smtp-login", ""} { + cfg := NotificationConfig{SMTPEnabled: true, SMTPUsername: username} + if _, _, err := cfg.SenderIdentity(); err == nil { + t.Fatal("无效账号不得回退到管理员邮箱") + } + } +} + +func TestNotificationSenderPreservesHTTPIdentity(t *testing.T) { + cfg := NotificationConfig{SMTPUsername: "notify:unused@example.test", SenderName: "兼容通知", SenderEmail: "sender@example.test"} + name, email, err := cfg.SenderIdentity() + if err != nil || name != cfg.SenderName || email != cfg.SenderEmail { + t.Fatal("未启用 SMTP 时不得改写原有 HTTP 发件人") + } +} diff --git a/backend/internal/httpapi/setup_test.go b/backend/internal/httpapi/setup_test.go index d749ab3..122cdc9 100644 --- a/backend/internal/httpapi/setup_test.go +++ b/backend/internal/httpapi/setup_test.go @@ -716,7 +716,7 @@ func TestSetupLateFailureDeletesNewNotificationCredentials(t *testing.T) { "email_smtp_enabled":true, "email_smtp_host":"smtp.example.test", "email_smtp_port":587, - "email_smtp_username":"smtp-user", + "email_smtp_username":"notify:smtp-user@example.test", "email_smtp_password":"smtp-secret", "email_smtp_mode":"starttls",`, 1) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload)) diff --git a/backend/internal/notification/security.go b/backend/internal/notification/security.go index 9129159..b3db7c6 100644 --- a/backend/internal/notification/security.go +++ b/backend/internal/notification/security.go @@ -5,7 +5,6 @@ import ( "errors" "html" "net/mail" - "strings" ) // SendSecurityCode 仅向绑定邮箱发送验证码,不使用证书通知的收件人列表。 @@ -24,7 +23,10 @@ func (s *Service) SendSecurityCode(ctx context.Context, recipient, code string) return errors.New("验证码格式无效") } } - from := strings.TrimSpace(s.config.SenderEmail) + fromName, from, err := s.config.SenderIdentity() + if err != nil { + return err + } for _, value := range []string{from, recipient} { parsed, err := mail.ParseAddress(value) if err != nil || parsed.Address != value { @@ -34,7 +36,7 @@ func (s *Service) SendSecurityCode(ctx context.Context, recipient, code string) text := "有人正在部署服务器上重置 Scert 超级管理员密码。\n验证码:" + code + "\n验证码仅在当前本机重置会话中有效,有效期不超过两分钟。\n如果不是您本人操作,请勿提供此验证码,并检查服务器访问权限。" payload := mailPayload{ - From: address{Name: s.config.SenderName, Email: from}, + From: address{Name: fromName, Email: from}, To: []address{{Email: recipient}}, Subject: "[Scert] 超级管理员密码重置验证", Text: text, HTML: "
" + html.EscapeString(text) + "
", diff --git a/backend/internal/notification/service.go b/backend/internal/notification/service.go index dc50985..28745d8 100644 --- a/backend/internal/notification/service.go +++ b/backend/internal/notification/service.go @@ -371,7 +371,10 @@ func (s *Service) loadCredentials(ctx context.Context) (map[string]string, error } func (s *Service) buildPayload(event Event) (mailPayload, error) { - fromEmail := strings.TrimSpace(s.config.SenderEmail) + fromName, fromEmail, err := s.config.SenderIdentity() + if err != nil { + return mailPayload{}, err + } if fromEmail == "" { return mailPayload{}, errors.New("通知发件人地址不能为空") } @@ -410,7 +413,7 @@ func (s *Service) buildPayload(event Event) (mailPayload, error) { } text := buildText(event, title, status, domainName) return mailPayload{ - From: address{Name: s.config.SenderName, Email: fromEmail}, + From: address{Name: fromName, Email: fromEmail}, To: recipients, Subject: fmt.Sprintf("[Scert] %s:%s(%s)", title, domainName, status), Text: text, diff --git a/backend/internal/notification/smtp_test.go b/backend/internal/notification/smtp_test.go index 6269a2f..b6b1b3f 100644 --- a/backend/internal/notification/smtp_test.go +++ b/backend/internal/notification/smtp_test.go @@ -28,6 +28,7 @@ type smtpTestServer struct { rejectAuth bool mu sync.Mutex messages [][]byte + mailFrom string connections int } @@ -121,6 +122,7 @@ func (s *smtpTestServer) handle(connection net.Conn, startTLS bool) { } case strings.HasPrefix(upper, "MAIL FROM:"): s.mu.Lock() + s.mailFrom = command fail := s.failMailOnce s.failMailOnce = false s.mu.Unlock() @@ -275,6 +277,42 @@ func TestSMTPSendsUTF8WithAllowedPlainAuthentication(t *testing.T) { } } +func TestSmailSystemSenderUsesSameEnvelopeAndHeader(t *testing.T) { + for _, security := range []bool{false, true} { + t.Run(map[bool]string{false: "notification", true: "security_code"}[security], func(t *testing.T) { + server := newSMTPTestServer(t, false, true) + host, port := server.address() + cfg := smtpTestConfig(host, port, "starttls") + cfg.SMTPUsername = "notify:scert@example.test" + cfg.SenderEmail = "admin@other.test" + service := New(cfg, credentialReaderStub{values: map[string]string{"password": "test-only-notification-credential"}}) + service.smtpTLSConfig = server.clientTLSConfig + var err error + if security { + err = service.SendSecurityCode(t.Context(), "bound@example.test", "01234567") + } else { + err = service.Send(t.Context(), Event{Type: "certificate_issue", Status: "success", Domain: "example.test"}) + } + if err != nil { + t.Fatal(err) + } + server.mu.Lock() + envelope := server.mailFrom + server.mu.Unlock() + if !strings.HasPrefix(envelope, "MAIL FROM:") { + t.Fatal("SMTP 信封没有使用系统通知地址") + } + message := string(server.message()) + if !strings.Contains(message, formatSMTPAddress(address{Name: "系统通知", Email: "notification+system@example.test"})) || strings.Contains(message, "admin@other.test") { + t.Fatal("邮件头没有使用固定系统通知身份,或泄漏旧管理员发件地址") + } + if security && strings.Contains(message, "receiver@example.com") { + t.Fatal("验证码不得发送给普通通知收件人") + } + }) + } +} + func TestSMTPStartTLS(t *testing.T) { server := newSMTPTestServer(t, false, true) host, port := server.address() diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 468146d..0d34848 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -170,8 +170,17 @@ func Apply(cfg *config.Config, input Input) error { } if strings.TrimSpace(input.EmailSenderEmail) != "" { cfg.Notification.SenderEmail = strings.ToLower(strings.TrimSpace(input.EmailSenderEmail)) - } else { + } else if !cfg.Notification.SMTPEnabled { cfg.Notification.SenderEmail = adminEmail + } else { + cfg.Notification.SenderEmail = "" + } + if cfg.Notification.SMTPEnabled { + name, email, err := cfg.Notification.SenderIdentity() + if err != nil { + return err + } + cfg.Notification.SenderName, cfg.Notification.SenderEmail = name, email } if input.EmailTimeoutSecs > 0 { cfg.Notification.TimeoutSecs = input.EmailTimeoutSecs diff --git a/doc/Smail系统通知接入说明.md b/doc/Smail系统通知接入说明.md new file mode 100644 index 0000000..07cfc01 --- /dev/null +++ b/doc/Smail系统通知接入说明.md @@ -0,0 +1,18 @@ +# Smail 系统通知接入说明 + +初始化向导仅配置 SMTP 连接、系统通知凭据和通知收件人,不再显示自定义发件人名称、发件人地址及通知页底部的密码重置地址说明条。 + +## 使用方式 + +1. 在 Smail 的系统通知页面启用对应账号的发信授权,并获取连接参数和独立发信凭据。 +2. 将该页面提供的 SMTP 主机、端口、连接模式和完整用户名填入 Scert。用户名格式为 `notify:账号@邮件主域名`,密码栏填写系统通知专用凭据,不是普通登录密码。 +3. Scert 自动使用 `notification+system@邮件主域名` 作为发件地址,显示名称固定为“系统通知”。不要从 SMTP 主机名或 Scert 管理域名猜测邮件主域名,应原样使用 Smail 给出的用户名。 +4. 通知收件人单独配置;管理员邮箱不再作为 SMTP 默认发件人。未填写通知收件人时仍沿用管理员邮箱作为收件人。 + +## 兼容与安全边界 + +- Smail 系统通知身份优先于历史自定义发件人配置。普通 SMTP 配置仍保留兼容能力;未设置发件地址时只能使用完整邮箱形式的 SMTP 账号,不能回退为管理员邮箱。 +- 已有 HTTP 通知配置保留兼容,不在初始化向导恢复 HTTP 入口。 +- 密码重置安全邮件仍只发往绑定邮箱;隐藏说明条不改变受保护的重置流程。 +- 凭据仍进入加密存储,不写入正式配置或文档。保留 TLS 校验,不为发信测试关闭认证或证书校验。 +- 本说明描述接入规则,不代表真实 Smail 认证、投递及收件箱验收已完成;协议单元测试不能代替生产实发。 diff --git a/frontend/src/views/SetupWizard/SetupWizard.css b/frontend/src/views/SetupWizard/SetupWizard.css index 1f6672e..48b4828 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.css +++ b/frontend/src/views/SetupWizard/SetupWizard.css @@ -1108,8 +1108,8 @@ button:disabled { .notification-service-row, .notification-api-row, -.notification-sender-row { - grid-template-columns: repeat(3, minmax(0, 1fr)); +.notification-recipient-row { + grid-template-columns: minmax(0, 1fr); } .notification-auth-row { diff --git a/frontend/src/views/SetupWizard/SetupWizard.js b/frontend/src/views/SetupWizard/SetupWizard.js index a20f377..5584911 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.js +++ b/frontend/src/views/SetupWizard/SetupWizard.js @@ -84,8 +84,6 @@ export function useSetupWizard(props, emit) { email_include_deployment_details: true, email_include_certificate_serial: true, email_include_management_url: true, - email_sender_name: "Scert", - email_sender_email: "", email_timeout_secs: 10, email_retry_count: 3, email_retry_intervals_secs: "30,120,600", @@ -687,8 +685,6 @@ export function useSetupWizard(props, emit) { email_include_certificate_serial: form.value.email_include_certificate_serial, email_include_management_url: form.value.email_include_management_url, - email_sender_name: form.value.email_sender_name, - email_sender_email: form.value.email_sender_email, email_timeout_secs: Number(form.value.email_timeout_secs), email_retry_count: Number(form.value.email_retry_count), email_retry_intervals_secs: retryIntervals(), diff --git a/frontend/src/views/SetupWizard/SetupWizard.vue b/frontend/src/views/SetupWizard/SetupWizard.vue index a126c55..7b6f6e1 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.vue +++ b/frontend/src/views/SetupWizard/SetupWizard.vue @@ -365,12 +365,17 @@ const { /> @@ -381,7 +386,7 @@ const { name="email_smtp_password" v-model="form.email_smtp_password" type="password" - placeholder="请输入 SMTP 密码或授权码" + placeholder="请输入 Smail 系统通知发信凭据" autocomplete="new-password" /> @@ -409,24 +414,7 @@ const { -
- - +
-
- 密码重置地址 - {{ resetURLPreview }} - 只有手动启动 Scert-admin 后,此地址才会临时可用。 -
-