接入SMTP分区快照配置并保护验证码投递凭据
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-31 00:11:16 +08:00
parent 7abe44a59d
commit dfd27a34b6
7 changed files with 184 additions and 18 deletions

View File

@ -684,6 +684,7 @@ func legacySMTPConfig(root string, vault *credential.Vault, legacy legacyMigrati
RelayPort: legacy.SMTPPort,
Username: legacy.SMTPUsername,
Security: legacy.SMTPSecurity,
From: legacy.SMTPFrom,
FromDomain: legacy.SMTPFromDomain,
VerificationEnabled: legacy.VerificationEnabled,
LoginPolicy: legacyLoginPolicy(legacy),

View File

@ -87,6 +87,7 @@ type SMTPConfig struct {
Username string `json:"username"`
Password StoredSecret `json:"password,omitempty"`
Security string `json:"security"`
From string `json:"from"`
FromDomain string `json:"fromDomain"`
VerificationEnabled bool `json:"verificationEnabled"`
LoginPolicy string `json:"loginPolicy"`

View File

@ -1,8 +1,11 @@
package config
import (
"fmt"
"strings"
"sync/atomic"
"git.pcux.cn/xuans/Smail/utils/credential"
)
// publishedConfig 是旧配置模型的只读兼容快照。
@ -124,6 +127,56 @@ type MailPorts struct {
POP3 int
}
type SMTPRuntimeSettings struct {
Relay SMTPConfig
Password string
VerificationEnabled bool
ExternalRecoveryEnabled bool
}
func CurrentSMTPRuntimeSettings() (SMTPRuntimeSettings, error) {
settings := SMTPRuntimeSettings{}
if manager := CurrentManager(); manager != nil {
runtime := manager.Snapshot()
if runtime.Generation != 0 {
settings.Relay = runtime.SMTP.Relay
settings.ExternalRecoveryEnabled = runtime.Email.Policy.ExternalRecoveryEnabled
if settings.Relay.Username != "" {
vault, err := credential.NewVault(ROOT_PATH)
if err != nil {
return SMTPRuntimeSettings{}, fmt.Errorf("create SMTP credential vault: %w", err)
}
password, err := OpenStoredSecret(vault, settings.Relay.Password)
if err != nil {
return SMTPRuntimeSettings{}, fmt.Errorf("open SMTP relay password: %w", err)
}
settings.Password = password
}
settings.VerificationEnabled = settings.Relay.VerificationEnabled
return settings, nil
}
}
cfg := CurrentConfig()
if cfg == nil {
return SMTPRuntimeSettings{}, fmt.Errorf("runtime configuration is unavailable")
}
settings.Relay = SMTPConfig{
RelayHost: cfg.SMTPHost,
RelayPort: cfg.SMTPPort,
Username: cfg.SMTPUsername,
Security: cfg.SMTPSecurity,
From: cfg.SMTPFrom,
FromDomain: cfg.SMTPFromDomain,
VerificationEnabled: cfg.VerificationEnabled,
LoginPolicy: cfg.LoginPolicy,
}
settings.Password = cfg.SMTPPassword
settings.VerificationEnabled = cfg.VerificationEnabled
settings.ExternalRecoveryEnabled = cfg.ExternalRecoveryEnabled
return settings, nil
}
func CurrentMailPorts() MailPorts {
ports := MailPorts{SMTP: 25, IMAP: 143, POP3: 110}
manager := CurrentManager()

View File

@ -1,6 +1,11 @@
package config
import "testing"
import (
"testing"
"time"
"git.pcux.cn/xuans/Smail/utils/credential"
)
type runtimeAccessSnapshotStore struct {
runtime RuntimeConfig
@ -123,3 +128,106 @@ func TestCurrentMailPortsUsesDefaultsForUnavailableSnapshot(t *testing.T) {
t.Fatalf("mail ports = %+v, want defaults for unavailable snapshot", got)
}
}
func TestCurrentSMTPRuntimeSettingsReadsPartitionSnapshot(t *testing.T) {
previousManager := CurrentManager()
defer SetRuntimeManager(previousManager)
runtime := validRuntimeConfig()
runtime.SMTP.Relay = SMTPConfig{
RelayHost: "smtp.example.com",
RelayPort: 587,
Security: "starttls",
From: "no-reply@example.com",
FromDomain: "example.com",
VerificationEnabled: true,
LoginPolicy: "all",
}
runtime.Email.Policy.ExternalRecoveryEnabled = true
SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}})
got, err := CurrentSMTPRuntimeSettings()
if err != nil {
t.Fatal(err)
}
if got.Relay.RelayHost != "smtp.example.com" || got.Relay.RelayPort != 587 || got.Relay.From != "no-reply@example.com" {
t.Fatalf("SMTP relay settings = %+v, want partition snapshot", got)
}
if !got.VerificationEnabled || !got.ExternalRecoveryEnabled {
t.Fatalf("SMTP security flags = %+v, want enabled", got)
}
}
func TestCurrentSMTPRuntimeSettingsOpensPartitionPassword(t *testing.T) {
previousManager := CurrentManager()
previousRoot := ROOT_PATH
defer func() {
SetRuntimeManager(previousManager)
ROOT_PATH = previousRoot
}()
ROOT_PATH = t.TempDir()
vault, err := credential.NewVault(ROOT_PATH)
if err != nil {
t.Fatal(err)
}
ciphertext, err := vault.Encrypt("relay-password")
if err != nil {
t.Fatal(err)
}
runtime := validRuntimeConfig()
runtime.SMTP.Relay = SMTPConfig{
RelayHost: "smtp.example.com",
RelayPort: 465,
Username: "relay-user",
Password: StoredSecret{Ciphertext: ciphertext, Version: vault.Version(), UpdatedAt: time.Now().UTC()},
Security: "tls",
From: "no-reply@example.com",
FromDomain: "example.com",
VerificationEnabled: true,
LoginPolicy: "all",
}
SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}})
got, err := CurrentSMTPRuntimeSettings()
if err != nil {
t.Fatal(err)
}
if got.Password != "relay-password" {
t.Fatalf("SMTP relay password = %q, want decrypted password", got.Password)
}
}
func TestCurrentSMTPRuntimeSettingsFallsBackToLegacyConfig(t *testing.T) {
previousManager := CurrentManager()
previousInstance := Instance
defer func() {
SetRuntimeManager(previousManager)
Instance = previousInstance
PublishConfigSnapshot(previousInstance)
}()
SetRuntimeManager(nil)
Instance = &Config{
Domain: "example.com",
SMTPHost: "smtp.example.com",
SMTPPort: 587,
SMTPUsername: "relay-user",
SMTPPassword: "relay-password",
SMTPSecurity: "starttls",
SMTPFrom: "no-reply@example.com",
SMTPFromDomain: "example.com",
VerificationEnabled: true,
ExternalRecoveryEnabled: true,
LoginPolicy: "all",
}
PublishConfigSnapshot(Instance)
got, err := CurrentSMTPRuntimeSettings()
if err != nil {
t.Fatal(err)
}
if got.Password != "relay-password" || got.Relay.Username != "relay-user" || got.Relay.From != "no-reply@example.com" {
t.Fatalf("legacy SMTP settings = %+v, password=%q", got.Relay, got.Password)
}
}

View File

@ -3,6 +3,7 @@ package config
import (
"errors"
"net"
"net/mail"
"path"
"path/filepath"
"regexp"
@ -102,6 +103,12 @@ func validateSMTP(smtp SMTPConfig) error {
if !validLoginVerificationPolicy(smtp.LoginPolicy) {
return ErrInvalidSMTP
}
if smtp.From != "" {
parsedFrom, err := mail.ParseAddress(smtp.From)
if err != nil || parsedFrom.Address != smtp.From || strings.ContainsAny(smtp.From, "\r\n") {
return ErrInvalidSMTP
}
}
if !smtp.VerificationEnabled {
if smtp.RelayHost != "" && !validSMTPHost(smtp.RelayHost) {
return ErrInvalidSMTP
@ -136,7 +143,7 @@ func validateSMTP(smtp SMTPConfig) error {
}
return nil
}
if !validSMTPHost(smtp.RelayHost) || !validPort(smtp.RelayPort) || !validCanonicalDomain(smtp.FromDomain) {
if !validSMTPHost(smtp.RelayHost) || !validPort(smtp.RelayPort) || !validCanonicalDomain(smtp.FromDomain) || smtp.From == "" {
return ErrInvalidSMTP
}
if strings.TrimSpace(smtp.Username) != smtp.Username {

View File

@ -443,6 +443,7 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (
"relayPort": runtime.SMTP.Relay.RelayPort,
"username": runtime.SMTP.Relay.Username,
"security": runtime.SMTP.Relay.Security,
"from": runtime.SMTP.Relay.From,
"fromDomain": runtime.SMTP.Relay.FromDomain,
"verificationEnabled": runtime.SMTP.Relay.VerificationEnabled,
"loginPolicy": runtime.SMTP.Relay.LoginPolicy,

View File

@ -283,17 +283,12 @@ func startVerificationRuntime() error {
runtime.Stop()
return fmt.Errorf("start external email repository: %w", err)
}
cfg := config.CurrentConfig()
if cfg == nil {
smtpSettings, err := config.CurrentSMTPRuntimeSettings()
if err != nil {
runtime.Stop()
return fmt.Errorf("runtime configuration is unavailable")
return err
}
externalRecoveryEnabled := cfg.ExternalRecoveryEnabled
if manager := config.CurrentManager(); manager != nil {
runtimeConfig := manager.Snapshot()
externalRecoveryEnabled = runtimeConfig.Email.Policy.ExternalRecoveryEnabled
}
if !cfg.VerificationEnabled && !externalRecoveryEnabled {
if !smtpSettings.VerificationEnabled && !smtpSettings.ExternalRecoveryEnabled {
recover, recoverErr := recovery.NewService(db.Instance, nil, external, credential)
if recoverErr != nil {
runtime.Stop()
@ -306,19 +301,19 @@ func startVerificationRuntime() error {
log.Info("邮箱验证码服务已禁用")
return nil
}
security, err := smtpSecurityMode(cfg.SMTPSecurity)
security, err := smtpSecurityMode(smtpSettings.Relay.Security)
if err != nil {
runtime.Stop()
return err
}
delivery, err := verification.NewSMTPDelivery(verification.SMTPSettings{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
Username: cfg.SMTPUsername,
Password: cfg.SMTPPassword,
Host: smtpSettings.Relay.RelayHost,
Port: smtpSettings.Relay.RelayPort,
Username: smtpSettings.Relay.Username,
Password: smtpSettings.Password,
Security: security,
From: cfg.SMTPFrom,
FromDomain: cfg.SMTPFromDomain,
From: smtpSettings.Relay.From,
FromDomain: smtpSettings.Relay.FromDomain,
})
if err != nil {
runtime.Stop()