保存外部邮箱验证码投递修复进度
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 02:03:18 +08:00
parent 2a3d3b420e
commit c79f1035af
7 changed files with 227 additions and 26 deletions

View File

@ -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 "验证码发送失败,请检查发信服务后重试"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -108,8 +108,8 @@ export function useExternalEmailBindingDialog(props, emit) {
const close = () => {
if (busy.value) return
clearMessages()
visible.value = false
resetAll()
}
const sendVerificationCode = async () => {