diff --git a/server/config/migrate.go b/server/config/migrate.go index e5e29be..d728af3 100644 --- a/server/config/migrate.go +++ b/server/config/migrate.go @@ -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), diff --git a/server/config/model.go b/server/config/model.go index 719674e..b69f563 100644 --- a/server/config/model.go +++ b/server/config/model.go @@ -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"` diff --git a/server/config/runtime_access.go b/server/config/runtime_access.go index f0eee5e..921c70a 100644 --- a/server/config/runtime_access.go +++ b/server/config/runtime_access.go @@ -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() diff --git a/server/config/runtime_access_test.go b/server/config/runtime_access_test.go index 3c77d6d..3c53313 100644 --- a/server/config/runtime_access_test.go +++ b/server/config/runtime_access_test.go @@ -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) + } +} diff --git a/server/config/validator.go b/server/config/validator.go index 7e7f0fe..fa62605 100644 --- a/server/config/validator.go +++ b/server/config/validator.go @@ -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 { diff --git a/server/controllers/config.go b/server/controllers/config.go index 160492b..51a6665 100644 --- a/server/controllers/config.go +++ b/server/controllers/config.go @@ -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, diff --git a/server/res_init/init.go b/server/res_init/init.go index d41d88f..56b05e0 100644 --- a/server/res_init/init.go +++ b/server/res_init/init.go @@ -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()