精简初始化通知设置并默认使用Smail系统通知身份发信

This commit is contained in:
xuans 2026-09-26 22:50:04 +08:00
parent 229d150a08
commit a9937c3e6a
16 changed files with 186 additions and 48 deletions

View File

@ -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 {

View File

@ -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)
}

View File

@ -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, "是否发送管理后台链接")

View File

@ -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
}

View File

@ -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 <a@example.test>", "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 发件人")
}
}

View File

@ -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))

View File

@ -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: "<pre>" + html.EscapeString(text) + "</pre>",

View File

@ -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,

View File

@ -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:<notification+system@example.test>") {
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()

View File

@ -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

View File

@ -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 认证、投递及收件箱验收已完成;协议单元测试不能代替生产实发。

View File

@ -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 {

View File

@ -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(),

View File

@ -365,12 +365,17 @@ const {
/>
</label>
<label>
<span>SMTP 用户名</span>
<span>
SMTP 用户名
<HelpTip>
使用 Smail 系统通知页提供的 notify:账号@邮件域名和专用发信凭据,自动使用系统通知发件人,不使用管理员邮箱。
</HelpTip>
</span>
<input
id="email-smtp-username"
name="email_smtp_username"
v-model.trim="form.email_smtp_username"
placeholder="发件邮箱或账号"
placeholder="notify:账号@邮件域名"
autocomplete="username"
/>
</label>
@ -381,7 +386,7 @@ const {
name="email_smtp_password"
v-model="form.email_smtp_password"
type="password"
placeholder="请输入 SMTP 密码或授权码"
placeholder="请输入 Smail 系统通知发信凭据"
autocomplete="new-password"
/>
</label>
@ -409,24 +414,7 @@ const {
</label>
</div>
<div class="notification-row notification-sender-row">
<label>
<span>发件人名称</span>
<input
id="email-sender-name"
v-model.trim="form.email_sender_name"
placeholder="Scert"
/>
</label>
<label>
<span>发件人地址(可选)</span>
<input
id="email-sender-email"
v-model.trim="form.email_sender_email"
type="email"
placeholder="留空使用管理员邮箱"
/>
</label>
<div class="notification-row notification-recipient-row">
<label>
<span>
通知收件人
@ -444,12 +432,6 @@ const {
</div>
</div>
<div class="provider-description reset-preview">
<strong>密码重置地址</strong>
<span>{{ resetURLPreview }}</span>
<span>只有手动启动 Scert-admin 后,此地址才会临时可用。</span>
</div>
<div v-if="form.email_smtp_enabled" class="notification-advanced-panel">
<button
class="notification-advanced-toggle"

View File

@ -112,7 +112,9 @@ for (const driver of ["sqlite", "mysql", "postgres"]) {
email_http_auth_mode: "bearer",
email_smtp_enabled: true,
email_smtp_host: "smtp.example.test",
email_smtp_username: "sender@example.test",
email_smtp_username: "notify:sender@example.test",
email_sender_name: "Ignored sender",
email_sender_email: "ignored@example.test",
email_smtp_password: "smtp-test-only",
email_retry_count: 0,
recipients: "first@example.test,second@example.test",
@ -134,13 +136,13 @@ for (const driver of ["sqlite", "mysql", "postgres"]) {
assert.equal(submitted[0].notification.http_enabled, false);
assert.equal(submitted[0].notification.smtp_enabled, true);
assert.equal(submitted[0].notification.smtp_host, "smtp.example.test");
assert.equal(submitted[0].notification.smtp_username, "sender@example.test");
assert.equal(submitted[0].notification.smtp_username, "notify:sender@example.test");
assert.equal(submitted[0].notification.smtp_password, "smtp-test-only");
assert.equal(submitted[0].notification.smtp_port, 587);
assert.equal(submitted[0].notification.smtp_mode, "starttls");
assert.equal(submitted[0].notification.retry_count, 0);
assert.deepEqual(submitted[0].notification.recipients, ["first@example.test", "second@example.test"]);
for (const key of ["endpoint", "http_auth_mode", "http_credentials", "http_path", "http_headers"]) {
for (const key of ["endpoint", "http_auth_mode", "http_credentials", "http_path", "http_headers", "sender_name", "sender_email"]) {
assert.equal(Object.hasOwn(submitted[0].notification, key), false);
}
assert.equal(submitted[0].certificate_root, "/srv/scert/ssl");

View File

@ -33,23 +33,25 @@ test("certificate root remains editable during bootstrap", () => {
&& prop.exp?.content === "form.certificate_root"));
});
test("notification step exposes SMTP only and keeps sender fields in its panel", () => {
test("notification step uses the system sender and keeps only recipient settings", () => {
const root = template("./SetupWizard.vue");
const step = find(root, hasClass("setup-notification-step"));
const panel = find(step, hasClass("smtp-basic-panel"));
const id = (value) => (node) => node.props?.some((prop) =>
prop.name === "id" && prop.value?.content === value);
assert.ok(find(step, id("email-smtp-enabled")));
for (const value of ["email-http-enabled", "email-endpoint", "email-http-path", "email-http-auth-mode"]) {
for (const value of ["email-http-enabled", "email-endpoint", "email-http-path", "email-http-auth-mode", "email-sender-name", "email-sender-email"]) {
assert.equal(find(step, id(value)), undefined);
}
for (const value of ["email-smtp-host", "email-smtp-password", "email-sender-name", "email-sender-email", "recipients"]) {
for (const value of ["email-smtp-host", "email-smtp-password", "recipients"]) {
assert.ok(find(panel, id(value)), value);
}
const advanced = find(step, hasClass("notification-advanced-panel"));
assert.equal(advanced.props.find((prop) => prop.name === "if").exp.content, "form.email_smtp_enabled");
assert.ok(find(advanced, hasClass("notification-delivery-row")));
assert.ok(find(advanced, hasClass("email-option-grid")));
assert.equal(find(step, hasClass("reset-preview")), undefined);
assert.ok(find(panel, hasClass("notification-recipient-row")));
});
test("notification checkboxes stay compact and centered with their labels", () => {