From c79f1035af6cfdeda65ecbe4d3468ef1b0f5edc3 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 02:03:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=A4=96=E9=83=A8=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E9=AA=8C=E8=AF=81=E7=A0=81=E6=8A=95=E9=80=92=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/security.go | 7 +- backend/res_init/init.go | 22 +--- backend/res_init/verification_delivery.go | 119 ++++++++++++++++++ .../res_init/verification_delivery_test.go | 76 +++++++++++ backend/services/verification/delivery.go | 17 ++- .../services/verification/delivery_test.go | 10 ++ .../ExternalEmailBindingDialog.js | 2 +- 7 files changed, 227 insertions(+), 26 deletions(-) create mode 100644 backend/res_init/verification_delivery.go create mode 100644 backend/res_init/verification_delivery_test.go diff --git a/backend/controllers/security.go b/backend/controllers/security.go index a45c3eb..9bd65eb 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -718,6 +718,7 @@ func BindExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Re } challenge, err := runtime.verification.Issue(verification.IssueRequest{Purpose: verification.PurposeExternalEmailBind, UserID: ctx.UserID, Account: ctx.UserAccount, Email: input.Email, ResourceID: item.ID}, remoteIP(req)) if err != nil { + log.WithError(err).Warn("issue external email verification challenge failed") if created { _ = runtime.external.AbandonPending(ctx.UserID, item.ID) } @@ -837,7 +838,11 @@ func externalEmailVerificationIssueMessage(err error) string { case errors.Is(err, verification.ErrSMTPRelayUnavailable), errors.Is(err, verification.ErrInvalidSMTPConfig), errors.Is(err, verification.ErrUnsupportedSMTP): - return "验证码发信服务尚未配置,请先完成 SMTP 发信配置" + return "SMTP 中继配置不完整,请完善配置或清空中继后使用项目自身投递" + case errors.Is(err, verification.ErrProjectMailUnavailable): + return "项目邮件投递尚未就绪,请检查主域名和 DKIM 配置" + case errors.Is(err, verification.ErrMailDeliveryFailed): + return "验证码邮件投递失败,请确认收件地址和邮件服务状态" default: return "验证码发送失败,请检查发信服务后重试" } diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 78c7a2a..6c39d66 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -308,25 +308,7 @@ func startVerificationRuntime() error { runtime.Stop() return fmt.Errorf("start external email repository: %w", err) } - delivery := verification.NewLazySMTPDelivery(func() (verification.SMTPSettings, error) { - smtpSettings, err := config.CurrentSMTPRuntimeSettings() - if err != nil { - return verification.SMTPSettings{}, err - } - security, err := smtpSecurityMode(smtpSettings.Relay.Security) - if err != nil { - return verification.SMTPSettings{}, err - } - return verification.SMTPSettings{ - Host: smtpSettings.Relay.RelayHost, - Port: smtpSettings.Relay.RelayPort, - Username: smtpSettings.Relay.Username, - Password: smtpSettings.Password, - Security: security, - From: smtpSettings.Relay.From, - FromDomain: smtpSettings.Relay.FromDomain, - }, nil - }) + delivery := newVerificationDelivery() service, err := verification.NewService(runtime.Store, delivery, runtime.Events) if err != nil { runtime.Stop() @@ -342,7 +324,7 @@ func startVerificationRuntime() error { controllers.ConfigureSecurityRuntime(service, external, recover, credential) credentialService = credential verificationRuntime = runtime - log.Info("安全验证码服务已启动,外发邮件将在实际使用时检查 SMTP 中继配置") + log.Info("安全验证码服务已启动,已配置中继时优先使用中继,否则使用项目自身邮件投递") return nil } diff --git a/backend/res_init/verification_delivery.go b/backend/res_init/verification_delivery.go new file mode 100644 index 0000000..e7feb90 --- /dev/null +++ b/backend/res_init/verification_delivery.go @@ -0,0 +1,119 @@ +package res_init + +import ( + "context" + "fmt" + "strings" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/dto/parsemail" + "git.pcux.cn/xuans/Smail/services/verification" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + "git.pcux.cn/xuans/Smail/utils/send" +) + +func newVerificationDelivery() verification.Delivery { + return newVerificationDeliveryWithDependencies( + config.CurrentSMTPRuntimeSettings, + deliverVerificationWithSMTPRelay, + deliverVerificationWithProjectMail, + ) +} + +func newVerificationDeliveryWithDependencies( + loadSettings func() (config.SMTPRuntimeSettings, error), + deliverRelay func(config.SMTPRuntimeSettings, verification.Challenge, string) error, + deliverProject func(verification.Challenge, string) error, +) verification.Delivery { + return func(challenge verification.Challenge, code string) error { + if strings.TrimSpace(challenge.Email) == "" || strings.TrimSpace(code) == "" { + return verification.ErrInvalidChallenge + } + if loadSettings == nil || deliverRelay == nil || deliverProject == nil { + return verification.ErrProjectMailUnavailable + } + + runtimeSettings, err := loadSettings() + if err != nil { + return fmt.Errorf("load verification mail settings: %w", err) + } + if smtpRelayRequested(runtimeSettings) { + return deliverRelay(runtimeSettings, challenge, code) + } + + return deliverProject(challenge, code) + } +} + +func smtpRelayRequested(settings config.SMTPRuntimeSettings) bool { + relay := settings.Relay + return strings.TrimSpace(relay.RelayHost) != "" || + strings.TrimSpace(relay.Username) != "" || + strings.TrimSpace(settings.Password) != "" || + strings.TrimSpace(relay.From) != "" +} + +func verificationSMTPSettings(settings config.SMTPRuntimeSettings) (verification.SMTPSettings, error) { + security, err := smtpSecurityMode(settings.Relay.Security) + if err != nil { + return verification.SMTPSettings{}, err + } + return verification.SMTPSettings{ + Host: settings.Relay.RelayHost, + Port: settings.Relay.RelayPort, + Username: settings.Relay.Username, + Password: settings.Password, + Security: security, + From: settings.Relay.From, + FromDomain: settings.Relay.FromDomain, + }, nil +} + +func deliverVerificationWithSMTPRelay(runtimeSettings config.SMTPRuntimeSettings, challenge verification.Challenge, code string) error { + settings, err := verificationSMTPSettings(runtimeSettings) + if err != nil { + return err + } + delivery, err := verification.NewSMTPDelivery(settings) + if err != nil { + return fmt.Errorf("SMTP relay is unavailable: %w", err) + } + if err = delivery.Delivery()(challenge, code); err != nil { + return fmt.Errorf("%w: SMTP relay rejected delivery", verification.ErrMailDeliveryFailed) + } + return nil +} + +func deliverVerificationWithProjectMail(challenge verification.Challenge, code string) error { + domain := strings.TrimSpace(config.CurrentMailDomains().Primary) + if domain == "" { + return fmt.Errorf("%w: primary mail domain is not configured", verification.ErrProjectMailUnavailable) + } + messageID, err := parsemail.GenerateMsgID(domain) + if err != nil { + return fmt.Errorf("%w: generate message id", verification.ErrProjectMailUnavailable) + } + subject, text := verification.CodeMessage(challenge.Purpose, code) + sender := &parsemail.User{ + Name: "Smail 安全中心", + EmailAddress: "no-reply@" + domain, + } + message := &parsemail.Email{ + From: sender, + Sender: sender, + To: []*parsemail.User{ + {EmailAddress: challenge.Email}, + }, + Subject: subject, + Text: []byte(text), + MsgID: messageID, + } + ctx := &appcontext.Context{ + Context: context.Background(), + Values: make(map[string]any), + } + if deliveryErr, _ := send.Send(ctx, message); deliveryErr != nil { + return fmt.Errorf("%w: project mail delivery failed", verification.ErrMailDeliveryFailed) + } + return nil +} diff --git a/backend/res_init/verification_delivery_test.go b/backend/res_init/verification_delivery_test.go new file mode 100644 index 0000000..97d74f0 --- /dev/null +++ b/backend/res_init/verification_delivery_test.go @@ -0,0 +1,76 @@ +package res_init + +import ( + "errors" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/services/verification" +) + +func TestVerificationDeliveryUsesProjectMailWithoutRelay(t *testing.T) { + settings := config.SMTPRuntimeSettings{ + Relay: config.SMTPConfig{ + RelayPort: 587, + Security: "starttls", + FromDomain: "example.com", + }, + } + relayCalled := false + projectCalled := false + delivery := newVerificationDeliveryWithDependencies( + func() (config.SMTPRuntimeSettings, error) { return settings, nil }, + func(config.SMTPRuntimeSettings, verification.Challenge, string) error { + relayCalled = true + return nil + }, + func(challenge verification.Challenge, code string) error { + projectCalled = challenge.Email == "user@example.net" && code == "12345678" + return nil + }, + ) + + if err := delivery(verification.Challenge{Email: "user@example.net"}, "12345678"); err != nil { + t.Fatal(err) + } + if relayCalled || !projectCalled { + t.Fatalf("relayCalled=%v projectCalled=%v", relayCalled, projectCalled) + } +} + +func TestVerificationDeliveryDoesNotHidePartialRelayConfiguration(t *testing.T) { + expected := verification.ErrInvalidSMTPConfig + projectCalled := false + delivery := newVerificationDeliveryWithDependencies( + func() (config.SMTPRuntimeSettings, error) { + return config.SMTPRuntimeSettings{Relay: config.SMTPConfig{RelayHost: "smtp.example.com"}}, nil + }, + func(config.SMTPRuntimeSettings, verification.Challenge, string) error { return expected }, + func(verification.Challenge, string) error { + projectCalled = true + return nil + }, + ) + + err := delivery(verification.Challenge{Email: "user@example.net"}, "12345678") + if !errors.Is(err, expected) { + t.Fatalf("error = %v, want %v", err, expected) + } + if projectCalled { + t.Fatal("partial relay configuration unexpectedly fell back to project mail") + } +} + +func TestVerificationDeliveryPreservesSettingsError(t *testing.T) { + expected := errors.New("settings unavailable") + delivery := newVerificationDeliveryWithDependencies( + func() (config.SMTPRuntimeSettings, error) { return config.SMTPRuntimeSettings{}, expected }, + func(config.SMTPRuntimeSettings, verification.Challenge, string) error { return nil }, + func(verification.Challenge, string) error { return nil }, + ) + + err := delivery(verification.Challenge{Email: "user@example.net"}, "12345678") + if !errors.Is(err, expected) { + t.Fatalf("error = %v, want wrapped settings error", err) + } +} diff --git a/backend/services/verification/delivery.go b/backend/services/verification/delivery.go index a9623fd..d08161a 100644 --- a/backend/services/verification/delivery.go +++ b/backend/services/verification/delivery.go @@ -22,9 +22,11 @@ const ( ) var ( - ErrInvalidSMTPConfig = errors.New("invalid SMTP configuration") - ErrUnsupportedSMTP = errors.New("unsupported SMTP security mode") - ErrSMTPRelayUnavailable = errors.New("SMTP relay is unavailable") + ErrInvalidSMTPConfig = errors.New("invalid SMTP configuration") + ErrUnsupportedSMTP = errors.New("unsupported SMTP security mode") + ErrSMTPRelayUnavailable = errors.New("SMTP relay is unavailable") + ErrProjectMailUnavailable = errors.New("project mail delivery is unavailable") + ErrMailDeliveryFailed = errors.New("verification mail delivery failed") ) type SMTPSettings struct { @@ -178,6 +180,12 @@ func sendSMTPMessage(settings SMTPSettings, recipient string, message []byte) er } func buildCodeMessage(from, recipient string, code string, purpose Purpose) []byte { + title, text := CodeMessage(purpose, code) + return []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", from, recipient, mime.QEncoding.Encode("UTF-8", title), text)) +} + +// CodeMessage 返回验证码邮件的统一标题和正文,供中继与项目自身投递复用。 +func CodeMessage(purpose Purpose, code string) (string, string) { title := "Smail 安全验证码" purposeText := "完成本次安全验证" switch purpose { @@ -190,7 +198,8 @@ func buildCodeMessage(from, recipient string, code string, purpose Purpose) []by case PurposeExternalEmailChange: purposeText = "完成外部邮箱变更验证" } - return []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n您好,您正在%s。验证码:%s\r\n验证码有效期为十分钟,请勿向他人泄露。\r\n", from, recipient, mime.QEncoding.Encode("UTF-8", title), purposeText, code)) + text := fmt.Sprintf("您好,您正在%s。验证码:%s\r\n验证码有效期为十分钟,请勿向他人泄露。", purposeText, code) + return title, text } func clearBytes(data []byte) { diff --git a/backend/services/verification/delivery_test.go b/backend/services/verification/delivery_test.go index 35d29bc..a331ead 100644 --- a/backend/services/verification/delivery_test.go +++ b/backend/services/verification/delivery_test.go @@ -40,6 +40,16 @@ func TestSMTPDeliveryKeepsCodeOutOfChallenge(t *testing.T) { clearBytes(message) } +func TestCodeMessageDescribesExternalEmailBinding(t *testing.T) { + title, text := CodeMessage(PurposeExternalEmailBind, "12345678") + if title != "Smail 安全验证码" { + t.Fatalf("title = %q", title) + } + if !strings.Contains(text, "外部邮箱绑定验证") || !strings.Contains(text, "12345678") { + t.Fatalf("unexpected verification message: %q", text) + } +} + func TestSMTPModesAreValidated(t *testing.T) { base := SMTPSettings{Host: "localhost", Port: 25, Security: SecurityPlain, From: "no-reply@example.com"} if _, err := NewSMTPDelivery(base); err != nil { diff --git a/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.js b/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.js index 58b0735..a33c12d 100644 --- a/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.js +++ b/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.js @@ -108,8 +108,8 @@ export function useExternalEmailBindingDialog(props, emit) { const close = () => { if (busy.value) return - clearMessages() visible.value = false + resetAll() } const sendVerificationCode = async () => {