完成邮件服务策略整合与安全配置调整
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-07 15:24:41 +08:00
parent d2f14ede7a
commit cfe4401b70
32 changed files with 893 additions and 318 deletions

View File

@ -30,7 +30,7 @@ func ValidateConfigAuditQuery(query ConfigAuditQuery) error {
if query.Operation != "" && query.Operation != ConfigOperationUpdate && query.Operation != ConfigOperationRollback && query.Operation != ConfigOperationMigration {
return ErrInvalidAuditQuery
}
if query.Section != "" && query.Section != SectionRoot && query.Section != SectionSMTP && query.Section != SectionEmail && query.Section != SectionSSL && query.Section != SectionPlugins {
if query.Section != "" && query.Section != SectionRoot && query.Section != SectionMail && query.Section != SectionSMTP && query.Section != SectionEmail && query.Section != SectionSSL && query.Section != SectionPlugins {
return ErrInvalidAuditQuery
}
return nil

View File

@ -1,6 +1,7 @@
package config
import (
"context"
"errors"
"strings"
"testing"
@ -13,7 +14,7 @@ func TestValidateConfigAuditQueryAcceptsSupportedFilters(t *testing.T) {
To: time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC),
ActorID: "42",
Operation: ConfigOperationUpdate,
Section: SectionSMTP,
Section: SectionMail,
Limit: 1000,
}
if err := ValidateConfigAuditQuery(query); err != nil {
@ -21,6 +22,25 @@ func TestValidateConfigAuditQueryAcceptsSupportedFilters(t *testing.T) {
}
}
func TestConfigAuditQueryFiltersCombinedMailSection(t *testing.T) {
writer := &MemoryAuditWriter{}
for _, event := range []ConfigAuditEvent{
{Operation: ConfigOperationUpdate, Section: SectionMail, ActorID: "42", ActorRole: "super_admin", FromGeneration: 1, ToGeneration: 2, Result: "success", CreatedAt: time.Now().UTC()},
{Operation: ConfigOperationUpdate, Section: SectionSMTP, ActorID: "42", ActorRole: "super_admin", FromGeneration: 2, ToGeneration: 3, Result: "success", CreatedAt: time.Now().UTC()},
} {
if err := writer.WriteConfigAudit(context.Background(), event); err != nil {
t.Fatal(err)
}
}
items, err := writer.Query(context.Background(), ConfigAuditQuery{Section: SectionMail, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Section != SectionMail {
t.Fatalf("mail audit filter returned %+v", items)
}
}
func TestValidateConfigAuditQueryRejectsAmbiguousFilters(t *testing.T) {
tests := []ConfigAuditQuery{
{Limit: -1},

View File

@ -199,7 +199,7 @@ func Init() error {
if err = json.Unmarshal(cfgData, loaded); err != nil {
return errors.Wrap(err)
}
applyLegacyLoginPolicy(cfgData, loaded)
applyLegacySecurityDefaults(cfgData, loaded)
migrated, err := unprotectCredentials(loaded)
if err != nil {
return errors.Wrap(err)
@ -331,9 +331,12 @@ func ReadConfig() (*Config, error) {
func readConfig() (*Config, error) {
configData := Config{
DkimPrivateKeyPath: ROOT_PATH + "config/dkim/dkim.priv",
SSLPrivateKeyPath: ROOT_PATH + "config/ssl/private.key",
SSLPublicKeyPath: ROOT_PATH + "config/ssl/public.crt",
DkimPrivateKeyPath: ROOT_PATH + "config/dkim/dkim.priv",
SSLPrivateKeyPath: ROOT_PATH + "config/ssl/private.key",
SSLPublicKeyPath: ROOT_PATH + "config/ssl/public.crt",
VerificationEnabled: true,
ExternalRecoveryEnabled: true,
LoginPolicy: "new_device",
}
cfgData, err := readConfigFile("config.json")
if os.IsNotExist(err) {
@ -362,7 +365,7 @@ func readConfig() (*Config, error) {
log.Error("Read Config Unmarshal Error")
return nil, errors.Wrap(err)
}
applyLegacyLoginPolicy(cfgData, &configData)
applyLegacySecurityDefaults(cfgData, &configData)
migrated, err := unprotectCredentials(&configData)
if err != nil {
log.Error("Read Config Credential Error")
@ -378,16 +381,34 @@ func readConfig() (*Config, error) {
return &configData, nil
}
func applyLegacyLoginPolicy(data []byte, cfg *Config) {
if cfg == nil || strings.TrimSpace(cfg.LoginPolicy) != "" {
func applyLegacySecurityDefaults(data []byte, cfg *Config) {
if cfg == nil {
return
}
var legacy struct {
VerificationLoginPolicy string `json:"verificationLoginPolicy"`
VerificationEnabled *bool `json:"verificationEnabled"`
ExternalRecoveryEnabled *bool `json:"externalRecoveryEnabled"`
LoginPolicy *string `json:"loginPolicy"`
VerificationLoginPolicy string `json:"verificationLoginPolicy"`
}
if json.Unmarshal(data, &legacy) == nil {
cfg.LoginPolicy = strings.TrimSpace(legacy.VerificationLoginPolicy)
if json.Unmarshal(data, &legacy) != nil {
return
}
if legacy.VerificationEnabled == nil {
cfg.VerificationEnabled = true
}
if legacy.ExternalRecoveryEnabled == nil {
cfg.ExternalRecoveryEnabled = true
}
if legacy.LoginPolicy != nil && strings.TrimSpace(*legacy.LoginPolicy) != "" {
cfg.LoginPolicy = strings.TrimSpace(*legacy.LoginPolicy)
return
}
if policy := strings.TrimSpace(legacy.VerificationLoginPolicy); policy != "" {
cfg.LoginPolicy = policy
return
}
cfg.LoginPolicy = "new_device"
}
func (c *Config) fixPath() {

View File

@ -744,6 +744,8 @@ func servicesForSection(section Section) []ServiceName {
return []ServiceName{ServiceSMTP}
case SectionEmail:
return []ServiceName{ServiceEmail}
case SectionMail:
return []ServiceName{ServiceSMTP, ServiceEmail}
case SectionSSL:
return []ServiceName{ServiceHTTP, ServiceSMTP, ServiceSSL}
case SectionPlugins:
@ -813,12 +815,38 @@ func applySectionPayload(current RuntimeConfig, section Section, payload json.Ra
}
value.SMTPSectionConfig.Relay.Password = current.SMTP.Relay.Password
candidate.SMTP = value.SMTPSectionConfig
candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy
case SectionEmail:
var value EmailSectionConfig
if err := ParseStrict(payload, &value); err != nil {
return RuntimeConfig{}, err
}
candidate.Email = value
candidate.SMTP.Relay.LoginPolicy = candidate.Email.Policy.LoginVerificationPolicy
case SectionMail:
var value struct {
MailSectionConfig
RelayPasswordStatus json.RawMessage `json:"relayPasswordStatus"`
}
if err := ParseStrict(payload, &value); err != nil {
return RuntimeConfig{}, err
}
if value.MailSectionConfig.Relay.Password.Configured() {
return RuntimeConfig{}, ErrInvalidSecretChange
}
value.MailSectionConfig.Relay.Password = current.SMTP.Relay.Password
candidate.SMTP = SMTPSectionConfig{
Meta: value.Meta,
Listener: value.Listener,
Relay: value.Relay,
}
candidate.Email = EmailSectionConfig{
Meta: value.Meta,
IMAP: value.IMAP,
POP3: value.POP3,
Policy: value.Policy,
}
candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy
case SectionSSL:
var value SSLSectionConfig
if err := ParseStrict(payload, &value); err != nil {
@ -901,7 +929,7 @@ func applySecretChanges(candidate *RuntimeConfig, section Section, changes map[s
return err
}
candidate.Root.Database.Password = stored
case section == SectionSMTP && name == "relay.password":
case (section == SectionSMTP || section == SectionMail) && name == "relay.password":
stored, err := ApplySecretChange(vault, candidate.SMTP.Relay.Password, change, SecretPolicy{Required: candidate.SMTP.Relay.Username != "", AllowClear: candidate.SMTP.Relay.Username == "", MaxLength: 4096}, now)
if err != nil {
return err

View File

@ -96,6 +96,17 @@ func authorizedConfigActor() AuditActor {
return AuditActor{UserID: "1", Role: "super_admin", SessionID: "test-session", RecentAuth: true, RecoveryCodeConfigured: true}
}
func mailSectionFromRuntime(runtime RuntimeConfig) MailSectionConfig {
return MailSectionConfig{
Meta: runtime.SMTP.Meta,
Listener: runtime.SMTP.Listener,
Relay: runtime.SMTP.Relay,
IMAP: runtime.Email.IMAP,
POP3: runtime.Email.POP3,
Policy: runtime.Email.Policy,
}
}
type rejectingRuntimeSnapshotStore struct {
current RuntimeConfig
err error
@ -372,6 +383,249 @@ func TestConfigManagerUpdateApplyUsesPendingGeneration(t *testing.T) {
}
}
func TestConfigManagerMailSectionRejectsNonStrictPayloadsAndDirectSecrets(t *testing.T) {
root := t.TempDir()
writeRuntimeFixture(t, root, validRuntimeConfig())
manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
if err != nil {
t.Fatal(err)
}
mail := mailSectionFromRuntime(manager.Snapshot())
payload, err := json.Marshal(mail)
if err != nil {
t.Fatal(err)
}
unknown := append([]byte(nil), payload[:len(payload)-1]...)
unknown = append(unknown, []byte(`,"unknown":true}`)...)
if _, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionMail, Payload: unknown}); err == nil {
t.Fatal("mail section accepted an unknown JSON field")
}
meta, err := json.Marshal(mail.Meta)
if err != nil {
t.Fatal(err)
}
duplicate := append([]byte(`{"meta":`), meta...)
duplicate = append(duplicate, ',')
duplicate = append(duplicate, payload[1:]...)
if _, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionMail, Payload: duplicate}); err == nil {
t.Fatal("mail section accepted a duplicate JSON field")
}
mail.Relay.Password = StoredSecret{Ciphertext: "enc:v1:direct-secret", Version: credential.CurrentVersion, UpdatedAt: time.Now().UTC()}
directSecret, err := json.Marshal(mail)
if err != nil {
t.Fatal(err)
}
if _, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionMail, Payload: directSecret}); !errors.Is(err, ErrInvalidSecretChange) {
t.Fatalf("direct relay secret error = %v, want invalid secret change", err)
}
}
func TestConfigManagerMailSectionAppliesBothPartitionsWithoutMailFile(t *testing.T) {
root := t.TempDir()
runtime := validRuntimeConfig()
vault, err := credential.NewVault(root)
if err != nil {
t.Fatal(err)
}
stored, err := ApplySecretChange(vault, StoredSecret{}, SecretChange{
Operation: SecretReplace,
Value: &SecretInput{Plaintext: "existing-relay-secret"},
}, SecretPolicy{Required: true, MaxLength: 4096}, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
runtime.SMTP.Relay = SMTPConfig{
RelayHost: "smtp.example.com",
RelayPort: 587,
Username: "relay-user",
Password: stored,
Security: "starttls",
From: "no-reply@example.com",
FromDomain: "example.com",
VerificationEnabled: true,
LoginPolicy: "new_device",
}
runtime.Email.Policy.LoginVerificationPolicy = "new_device"
runtime.Email.Policy.ExternalRecoveryEnabled = true
writeRuntimeFixture(t, root, runtime)
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
manager, err := NewConfigManager(root, options)
if err != nil {
t.Fatal(err)
}
mail := mailSectionFromRuntime(manager.Snapshot())
mail.Relay.Password = StoredSecret{}
mail.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465}
mail.IMAP.TLSPort = 2993
mail.POP3 = POP3ListenerConfig{Port: 2110, TLSPort: 2995}
mail.Policy.MaxMessageBytes = 8 << 20
mail.Policy.SpamFilterLevel = 2
payload, err := json.Marshal(mail)
if err != nil {
t.Fatal(err)
}
updated, err := manager.Update(context.Background(), UpdateRequest{
Section: SectionMail, ExpectedGeneration: 1, Payload: payload, Actor: authorizedConfigActor(),
})
if err != nil {
t.Fatal(err)
}
if updated.Candidate.SMTP.Relay.Password != stored {
t.Fatal("mail candidate did not preserve the existing relay secret")
}
status := manager.Status()
if len(status.PendingRestart) != 2 || status.PendingRestart[0] != ServiceSMTP || status.PendingRestart[1] != ServiceEmail {
t.Fatalf("mail pending restart services = %+v, want smtp and email", status.PendingRestart)
}
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, "wrong-candidate", SectionMail, authorizedConfigActor()); !errors.Is(err, ErrGenerationConflict) {
t.Fatalf("wrong candidate ID error = %v, want generation conflict", err)
}
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionSMTP, authorizedConfigActor()); !errors.Is(err, ErrGenerationConflict) {
t.Fatalf("wrong section error = %v, want generation conflict", err)
}
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation+1, updated.CandidateID, SectionMail, authorizedConfigActor()); !errors.Is(err, ErrGenerationConflict) {
t.Fatalf("wrong generation error = %v, want generation conflict", err)
}
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionMail, authorizedConfigActor()); err != nil {
t.Fatal(err)
}
configDirectory := filepath.Join(root, "config")
if _, err := os.Stat(filepath.Join(configDirectory, "mail.json")); !os.IsNotExist(err) {
t.Fatalf("combined mail view was persisted as mail.json: %v", err)
}
for _, name := range []string{"smtp.json", "email.json"} {
data, err := os.ReadFile(filepath.Join(configDirectory, name))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"generation":2`) {
t.Fatalf("%s was not updated to generation 2: %s", name, data)
}
}
reloaded, err := NewConfigLoader(root, options).Load()
if err != nil {
t.Fatal(err)
}
wantPorts := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 2993, POP3: 2110, POP3TLS: 2995}
if got := ResolveMailPorts(reloaded.SMTP, reloaded.Email); got != wantPorts {
t.Fatalf("reloaded mail ports = %+v, want %+v", got, wantPorts)
}
if reloaded.Email.Policy.MaxMessageBytes != 8<<20 || reloaded.Email.Policy.SpamFilterLevel != 2 {
t.Fatalf("reloaded mail policy is incomplete: %+v", reloaded.Email.Policy)
}
if reloaded.SMTP.Relay.Password != stored {
t.Fatal("reloaded relay secret changed without an explicit secret operation")
}
}
func TestConfigManagerMailSectionSecretReplacementUsesVault(t *testing.T) {
root := t.TempDir()
writeRuntimeFixture(t, root, validRuntimeConfig())
manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
if err != nil {
t.Fatal(err)
}
mail := mailSectionFromRuntime(manager.Snapshot())
mail.Relay = SMTPConfig{
RelayHost: "smtp.example.com",
RelayPort: 587,
Username: "relay-user",
Security: "starttls",
From: "no-reply@example.com",
FromDomain: "example.com",
}
payload, err := json.Marshal(mail)
if err != nil {
t.Fatal(err)
}
request := UpdateRequest{
Section: SectionMail, ExpectedGeneration: 1, Payload: payload, Actor: authorizedConfigActor(),
SecretChanges: map[string]SecretChange{
"relay.password": {Operation: SecretReplace, Value: &SecretInput{Plaintext: "new-relay-secret"}},
},
}
if _, err := manager.Update(context.Background(), request); !errors.Is(err, ErrStepUpRequired) {
t.Fatalf("mail secret replacement without step-up error = %v, want step-up required", err)
}
request.Confirmation = StepUpConfirmation{Confirmed: true, Method: "recent_auth"}
updated, err := manager.Update(context.Background(), request)
if err != nil {
t.Fatal(err)
}
stored := updated.Candidate.SMTP.Relay.Password
if !stored.Configured() || stored.Ciphertext == "new-relay-secret" || !credential.IsEncrypted(stored.Ciphertext) {
t.Fatalf("relay secret was not stored as managed ciphertext: %+v", stored)
}
plaintext, err := manager.vault.Decrypt(stored.Version, stored.Ciphertext)
if err != nil || plaintext != "new-relay-secret" {
t.Fatalf("relay secret cannot be recovered from the managed vault: value=%q error=%v", plaintext, err)
}
}
func TestConfigManagerMailSectionPreservesFieldsAcrossPageSpecificEdits(t *testing.T) {
root := t.TempDir()
runtime := validRuntimeConfig()
runtime.SMTP.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465}
runtime.Email.IMAP.TLSPort = 2993
runtime.Email.POP3 = POP3ListenerConfig{Port: 2110, TLSPort: 2995}
runtime.Email.Policy.MaxMessageBytes = 4 << 20
writeRuntimeFixture(t, root, runtime)
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
manager, err := NewConfigManager(root, options)
if err != nil {
t.Fatal(err)
}
securityEdit := mailSectionFromRuntime(manager.Snapshot())
securityEdit.Relay.VerificationEnabled = true
securityEdit.Relay.LoginPolicy = "new_device"
securityEdit.Policy.ExternalRecoveryEnabled = true
securityPayload, err := json.Marshal(securityEdit)
if err != nil {
t.Fatal(err)
}
updated, err := manager.Update(context.Background(), UpdateRequest{
Section: SectionMail, ExpectedGeneration: 1, Payload: securityPayload, Actor: authorizedConfigActor(),
})
if err != nil {
t.Fatal(err)
}
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionMail, authorizedConfigActor()); err != nil {
t.Fatal(err)
}
afterSecurity := manager.Snapshot()
wantPorts := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 2993, POP3: 2110, POP3TLS: 2995}
if got := ResolveMailPorts(afterSecurity.SMTP, afterSecurity.Email); got != wantPorts || afterSecurity.Email.Policy.MaxMessageBytes != 4<<20 {
t.Fatal("security policy edit overwrote mail listener or policy fields")
}
mailEdit := mailSectionFromRuntime(afterSecurity)
mailEdit.Policy.MaxMessageBytes = 12 << 20
mailPayload, err := json.Marshal(mailEdit)
if err != nil {
t.Fatal(err)
}
updated, err = manager.Update(context.Background(), UpdateRequest{
Section: SectionMail, ExpectedGeneration: 2, Payload: mailPayload, Actor: authorizedConfigActor(),
})
if err != nil {
t.Fatal(err)
}
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionMail, authorizedConfigActor()); err != nil {
t.Fatal(err)
}
afterMail := manager.Snapshot()
if !afterMail.SMTP.Relay.VerificationEnabled || afterMail.SMTP.Relay.LoginPolicy != "new_device" ||
afterMail.Email.Policy.LoginVerificationPolicy != "new_device" || !afterMail.Email.Policy.ExternalRecoveryEnabled {
t.Fatal("mail page edit overwrote security policy fields")
}
}
func TestTransactionRecoveryRestoresOldGenerationAfterSectionFailure(t *testing.T) {
root := t.TempDir()
current := validRuntimeConfig()
@ -688,7 +942,13 @@ func TestConfigManagerSecretChangesRequireStepUpAndPreserveStoredSecret(t *testi
func TestConfigManagerSecretClearRequiresExplicitConfirmation(t *testing.T) {
root := t.TempDir()
runtime := validRuntimeConfig()
runtime.SMTP.Relay = SMTPConfig{}
runtime.SMTP.Relay = SMTPConfig{
RelayHost: "smtp.example.com",
RelayPort: 587,
Security: "starttls",
From: "no-reply@example.com",
FromDomain: "example.com",
}
writeRuntimeFixture(t, root, runtime)
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
manager, err := NewConfigManager(root, options)
@ -696,6 +956,7 @@ func TestConfigManagerSecretClearRequiresExplicitConfirmation(t *testing.T) {
t.Fatal(err)
}
smtp := manager.Snapshot().SMTP
smtp.Relay.Username = "relay-user"
payload, err := json.Marshal(smtp)
if err != nil {
t.Fatal(err)
@ -713,6 +974,7 @@ func TestConfigManagerSecretClearRequiresExplicitConfirmation(t *testing.T) {
t.Fatal(err)
}
smtp = manager.Snapshot().SMTP
smtp.Relay.Username = ""
smtp.Relay.Password = StoredSecret{}
payload, err = json.Marshal(smtp)
if err != nil {
@ -1255,7 +1517,7 @@ func TestConfigManagerStatusReportsPendingRestartServices(t *testing.T) {
}
current := manager.Snapshot()
smtp := current.SMTP
smtp.Relay.RelayPort++
smtp.Listener.Port++
payload, err := json.Marshal(smtp)
if err != nil {
t.Fatal(err)

View File

@ -78,8 +78,8 @@ type legacyMigrationConfig struct {
SMTPSecurity string `json:"smtpSecurity"`
SMTPFrom string `json:"smtpFrom"`
SMTPFromDomain string `json:"smtpFromDomain"`
VerificationEnabled bool `json:"verificationEnabled"`
ExternalRecoveryEnabled bool `json:"externalRecoveryEnabled"`
VerificationEnabled *bool `json:"verificationEnabled"`
ExternalRecoveryEnabled *bool `json:"externalRecoveryEnabled"`
LoginPolicy string `json:"loginPolicy"`
VerificationLoginPolicy string `json:"verificationLoginPolicy"`
PluginAllowList []string `json:"pluginAllowList"`
@ -514,7 +514,7 @@ func legacyRuntimeConfig(root string, legacy legacyMigrationConfig) (RuntimeConf
POP3Port: 110,
SpamFilterLevel: legacy.SpamFilterLevel,
LoginVerificationPolicy: legacyLoginPolicy(legacy),
ExternalRecoveryEnabled: legacy.ExternalRecoveryEnabled,
ExternalRecoveryEnabled: legacyBoolDefault(legacy.ExternalRecoveryEnabled, true),
}},
SSL: SSLSectionConfig{Meta: meta, TLS: ssl},
Plugins: PluginsSectionConfig{Meta: meta, Plugins: plugins},
@ -705,13 +705,13 @@ func legacySMTPConfig(root string, vault *credential.Vault, legacy legacyMigrati
Security: legacy.SMTPSecurity,
From: legacy.SMTPFrom,
FromDomain: legacy.SMTPFromDomain,
VerificationEnabled: legacy.VerificationEnabled,
VerificationEnabled: legacyBoolDefault(legacy.VerificationEnabled, true),
LoginPolicy: legacyLoginPolicy(legacy),
}
if relay.FromDomain == "" {
relay.FromDomain = legacy.Domain
}
if !relay.VerificationEnabled && relay.RelayHost == "" && relay.RelayPort == 0 && relay.Username == "" && legacy.SMTPPassword == "" {
if relay.RelayHost == "" && relay.RelayPort == 0 && relay.Username == "" && legacy.SMTPPassword == "" {
return relay, nil, nil
}
if relay.RelayPort == 0 {
@ -871,7 +871,17 @@ func legacyLoginPolicy(legacy legacyMigrationConfig) string {
if legacy.LoginPolicy != "" {
return legacy.LoginPolicy
}
return legacy.VerificationLoginPolicy
if legacy.VerificationLoginPolicy != "" {
return legacy.VerificationLoginPolicy
}
return "new_device"
}
func legacyBoolDefault(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func legacyControlledReference(value string) (string, bool) {

View File

@ -100,6 +100,65 @@ func TestMigrateLegacyConfigPreservesBackupAndLoadsPartitions(t *testing.T) {
}
}
func TestLegacySecurityDefaultsPreserveExplicitValues(t *testing.T) {
falseValue := false
tests := []struct {
name string
legacy legacyMigrationConfig
verification bool
recovery bool
policy string
}{
{
name: "missing values use secure defaults",
verification: true,
recovery: true,
policy: "new_device",
},
{
name: "explicit false values are preserved",
legacy: legacyMigrationConfig{
VerificationEnabled: &falseValue,
ExternalRecoveryEnabled: &falseValue,
LoginPolicy: "off",
},
policy: "off",
},
{
name: "legacy policy is preserved",
legacy: legacyMigrationConfig{
VerificationLoginPolicy: "always",
},
verification: true,
recovery: true,
policy: "always",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime, manualFields, err := legacyRuntimeConfig(t.TempDir(), test.legacy)
if err != nil {
t.Fatal(err)
}
if len(manualFields) != 0 {
t.Fatalf("unexpected manual fields: %+v", manualFields)
}
if runtime.SMTP.Relay.VerificationEnabled != test.verification ||
runtime.Email.Policy.ExternalRecoveryEnabled != test.recovery ||
runtime.SMTP.Relay.LoginPolicy != test.policy ||
runtime.Email.Policy.LoginVerificationPolicy != test.policy {
t.Fatalf("security migration = verification %t, recovery %t, smtp policy %q, email policy %q",
runtime.SMTP.Relay.VerificationEnabled,
runtime.Email.Policy.ExternalRecoveryEnabled,
runtime.SMTP.Relay.LoginPolicy,
runtime.Email.Policy.LoginVerificationPolicy,
)
}
})
}
}
func TestMigrateLegacyConfigResumesAfterNonSecretPartitionWrite(t *testing.T) {
root := t.TempDir()
original := writeLegacyMigrationFixture(t, root, map[string]any{

View File

@ -61,7 +61,7 @@ func runtimeCompatibility(runtime RuntimeConfig) (*Config, error) {
DkimPrivateKeyPath: filepath.Join(ROOT_PATH, "config", "dkim", "dkim.priv"),
SSLType: SSLTypeUser,
SpamFilterLevel: runtime.Email.Policy.SpamFilterLevel,
LoginPolicy: runtime.Email.Policy.LoginVerificationPolicy,
LoginPolicy: runtime.SMTP.Relay.LoginPolicy,
ExternalRecoveryEnabled: runtime.Email.Policy.ExternalRecoveryEnabled,
SMTPHost: runtime.SMTP.Relay.RelayHost, SMTPPort: runtime.SMTP.Relay.RelayPort,
SMTPUsername: runtime.SMTP.Relay.Username, SMTPSecurity: runtime.SMTP.Relay.Security,

View File

@ -13,6 +13,7 @@ type Section string
const (
SectionRoot Section = "root"
SectionMail Section = "mail"
SectionSMTP Section = "smtp"
SectionEmail Section = "email"
SectionSSL Section = "ssl"
@ -66,6 +67,16 @@ type EmailSectionConfig struct {
POP3 POP3ListenerConfig `json:"pop3,omitempty"`
}
// MailSectionConfig 是 API 与候选配置使用的组合视图,磁盘仍分别保存 smtp.json 和 email.json。
type MailSectionConfig struct {
Meta SectionMeta `json:"meta"`
Listener SMTPListenerConfig `json:"listener"`
Relay SMTPConfig `json:"relay"`
IMAP IMAPListenerConfig `json:"imap"`
POP3 POP3ListenerConfig `json:"pop3"`
Policy EmailConfig `json:"policy"`
}
type SSLSectionConfig struct {
Meta SectionMeta `json:"meta"`
TLS SSLConfig `json:"tls"`

View File

@ -117,68 +117,53 @@ func validateSMTP(smtp SMTPConfig) error {
if !validLoginVerificationPolicy(smtp.LoginPolicy) {
return ErrInvalidSMTP
}
if smtp.RelayHost != "" && !validSMTPHost(smtp.RelayHost) {
return ErrInvalidSMTP
}
if smtp.RelayPort != 0 && !validPort(smtp.RelayPort) {
return ErrInvalidSMTP
}
if smtp.FromDomain != "" && !validCanonicalDomain(smtp.FromDomain) {
return ErrInvalidSMTP
}
if strings.TrimSpace(smtp.Username) != smtp.Username {
return ErrInvalidSMTP
}
if err := ValidateStoredSecret(smtp.Password, false); err != nil {
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
}
if smtp.RelayPort != 0 && !validPort(smtp.RelayPort) {
return ErrInvalidSMTP
}
if smtp.FromDomain != "" && !validCanonicalDomain(smtp.FromDomain) {
return ErrInvalidSMTP
}
if strings.TrimSpace(smtp.Username) != smtp.Username {
return ErrInvalidSMTP
}
if err := ValidateStoredSecret(smtp.Password, false); err != nil {
return ErrInvalidSMTP
}
if smtp.Username != "" {
if err := ValidateStoredSecret(smtp.Password, true); err != nil {
return ErrInvalidSMTP
}
}
if smtp.Security != "" {
switch smtp.Security {
case "starttls", "implicit_tls", "tls":
case "plain":
if !isLocalSMTPHost(smtp.RelayHost) {
return ErrInvalidSMTP
}
default:
if smtp.Security != "" {
switch smtp.Security {
case "starttls", "implicit_tls", "tls":
case "plain":
if !isLocalSMTPHost(smtp.RelayHost) {
return ErrInvalidSMTP
}
default:
return ErrInvalidSMTP
}
}
relayConfigured := smtp.RelayHost != "" || smtp.RelayPort != 0 || smtp.Username != "" ||
smtp.Password.Configured() || smtp.Security != "" || smtp.From != ""
if !relayConfigured {
return nil
}
if !validSMTPHost(smtp.RelayHost) || !validPort(smtp.RelayPort) || !validCanonicalDomain(smtp.FromDomain) || smtp.From == "" {
return ErrInvalidSMTP
}
if strings.TrimSpace(smtp.Username) != smtp.Username {
if smtp.RelayHost == "" || smtp.RelayPort == 0 || smtp.Security == "" || smtp.From == "" || smtp.FromDomain == "" {
return ErrInvalidSMTP
}
if smtp.Username == "" {
if err := ValidateStoredSecret(smtp.Password, false); err != nil || smtp.Password.Configured() {
if smtp.Password.Configured() {
return ErrInvalidSMTP
}
} else if err := ValidateStoredSecret(smtp.Password, true); err != nil {
return ErrInvalidSMTP
}
switch smtp.Security {
case "starttls", "implicit_tls", "tls":
case "plain":
if !isLocalSMTPHost(smtp.RelayHost) {
return ErrInvalidSMTP
}
default:
return ErrInvalidSMTP
}
return nil
}

View File

@ -168,6 +168,57 @@ func TestValidateRuntimeConfigRejectsUnsafeCrossSectionValues(t *testing.T) {
}
}
func TestValidateSMTPKeepsLoginVerificationIndependentFromRelay(t *testing.T) {
tests := []struct {
name string
smtp SMTPConfig
valid bool
}{
{
name: "verification enabled without relay",
smtp: SMTPConfig{
VerificationEnabled: true,
LoginPolicy: "new_device",
FromDomain: "example.com",
},
valid: true,
},
{
name: "partially configured relay",
smtp: SMTPConfig{
RelayHost: "smtp.example.com",
RelayPort: 587,
Security: "starttls",
LoginPolicy: "new_device",
},
},
{
name: "complete relay",
smtp: SMTPConfig{
RelayHost: "smtp.example.com",
RelayPort: 587,
Security: "starttls",
From: "no-reply@example.com",
FromDomain: "example.com",
LoginPolicy: "new_device",
},
valid: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateSMTP(test.smtp)
if test.valid && err != nil {
t.Fatalf("validateSMTP() error = %v", err)
}
if !test.valid && !errors.Is(err, ErrInvalidSMTP) {
t.Fatalf("validateSMTP() error = %v, want %v", err, ErrInvalidSMTP)
}
})
}
}
func TestSnapshotStoreRejectsInvalidCandidateWithoutChangingSnapshot(t *testing.T) {
initial := validRuntimeConfig()
store, err := NewSnapshotStore(initial, RuntimeValidationOptions{HasSuperAdmin: true, Production: true})

View File

@ -352,6 +352,8 @@ func configSectionFromPath(path string) (config.Section, bool) {
switch parts[3] {
case "base":
return config.SectionRoot, true
case "mail":
return config.SectionMail, true
case "smtp":
return config.SectionSMTP, true
case "email":
@ -461,6 +463,27 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (
},
"relayPasswordStatus": publicSecret(runtime.SMTP.Relay.Password),
}, nil
case config.SectionMail:
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
return map[string]any{
"meta": runtime.SMTP.Meta,
"listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS},
"relay": map[string]any{
"relayHost": runtime.SMTP.Relay.RelayHost,
"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,
"password": nil,
},
"imap": config.IMAPListenerConfig{TLSPort: ports.IMAP},
"pop3": config.POP3ListenerConfig{Port: ports.POP3, TLSPort: ports.POP3TLS},
"policy": runtime.Email.Policy,
"relayPasswordStatus": publicSecret(runtime.SMTP.Relay.Password),
}, nil
case config.SectionEmail:
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
value := runtime.Email

View File

@ -20,6 +20,30 @@ func TestPublicMailPortsMatchListenersWithoutChangingSnapshot(t *testing.T) {
}
before := runtime.Email
want := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
mailPublic, err := publicConfigSection(runtime, config.SectionMail)
if err != nil {
t.Fatal(err)
}
mailData, err := json.Marshal(mailPublic)
if err != nil {
t.Fatal(err)
}
var writableMail map[string]json.RawMessage
if err := json.Unmarshal(mailData, &writableMail); err != nil {
t.Fatal(err)
}
delete(writableMail, "relayPasswordStatus")
mailData, err = json.Marshal(writableMail)
if err != nil {
t.Fatal(err)
}
var mail config.MailSectionConfig
if err := config.ParseStrict(mailData, &mail); err != nil {
t.Fatal(err)
}
if mail.Listener.Port != want.SMTP || mail.Listener.STARTTLSPort != want.SMTPSTARTTLS || mail.Listener.TLSPort != want.SMTPTLS || mail.IMAP.TLSPort != want.IMAP || mail.POP3.Port != want.POP3 || mail.POP3.TLSPort != want.POP3TLS {
t.Fatal("combined public mail ports differ from listener configuration")
}
smtpPublic, err := publicConfigSection(runtime, config.SectionSMTP)
if err != nil {
t.Fatal(err)

View File

@ -63,6 +63,10 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes
var value config.SMTPSectionConfig
return config.ParseStrict(data, &value)
}},
{config.SectionMail, func(data []byte) error {
var value config.MailSectionConfig
return config.ParseStrict(data, &value)
}},
{config.SectionEmail, func(data []byte) error {
var value config.EmailSectionConfig
return config.ParseStrict(data, &value)
@ -90,12 +94,24 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes
if strings.Contains(string(data), "database-secret") || strings.Contains(string(data), "relay-secret") {
t.Fatalf("public configuration contains secret data: %s", data)
}
if strings.Contains(string(data), "enc:v1:") {
t.Fatalf("public configuration contains encrypted secret material: %s", data)
}
writableData := make(map[string]json.RawMessage)
if err := json.Unmarshal(data, &writableData); err != nil {
t.Fatalf("public configuration is not a JSON object: %v", err)
}
delete(writableData, "databasePasswordStatus")
delete(writableData, "relayPasswordStatus")
if test.section == config.SectionMail {
var relay map[string]json.RawMessage
if err := json.Unmarshal(writableData["relay"], &relay); err != nil {
t.Fatalf("mail relay public configuration is not an object: %v", err)
}
if password, ok := relay["password"]; !ok || string(password) != "null" {
t.Fatalf("mail relay password placeholder is invalid: %s", password)
}
}
if test.section == config.SectionPlugins {
var plugins map[string]json.RawMessage
if err := json.Unmarshal(writableData["plugins"], &plugins); err != nil {

View File

@ -17,6 +17,7 @@ func Finish() error {
if err != nil {
return errors.Wrap(err)
}
initializing := !cfg.IsInit && cfg.SetupState != config.SetupStateReady
portsChanged := applyServicePorts(cfg)
if cfg.IsSetupReady() {
if portsChanged {
@ -29,6 +30,11 @@ func Finish() error {
}
cfg.SetupState = config.SetupStateReady
cfg.IsInit = true
if initializing {
cfg.VerificationEnabled = true
cfg.ExternalRecoveryEnabled = true
cfg.LoginPolicy = "new_device"
}
err = config.WriteConfig(cfg)
if err != nil {

View File

@ -33,6 +33,33 @@ func TestFinishPersistsServicePorts(t *testing.T) {
if loaded.SetupState != config.SetupStateReady || !loaded.IsInit {
t.Fatalf("setup state = %q, isInit = %t", loaded.SetupState, loaded.IsInit)
}
if !loaded.VerificationEnabled || !loaded.ExternalRecoveryEnabled || loaded.LoginPolicy != "new_device" {
t.Fatalf("security defaults = verification %t, recovery %t, policy %q", loaded.VerificationEnabled, loaded.ExternalRecoveryEnabled, loaded.LoginPolicy)
}
}
func TestFinishPreservesExplicitlyDisabledSecurityOptions(t *testing.T) {
prepareFinishConfigTest(t)
if err := config.WriteConfig(&config.Config{
SetupState: config.SetupStateReady,
IsInit: true,
VerificationEnabled: false,
ExternalRecoveryEnabled: false,
LoginPolicy: "off",
}); err != nil {
t.Fatal(err)
}
if err := Finish(); err != nil {
t.Fatal(err)
}
loaded, err := config.ReadConfig()
if err != nil {
t.Fatal(err)
}
if loaded.VerificationEnabled || loaded.ExternalRecoveryEnabled || loaded.LoginPolicy != "off" {
t.Fatalf("explicit security options changed: verification %t, recovery %t, policy %q", loaded.VerificationEnabled, loaded.ExternalRecoveryEnabled, loaded.LoginPolicy)
}
}
func TestFinishRepairsReadyConfigWithMissingPorts(t *testing.T) {

View File

@ -5,9 +5,6 @@
<div class="overview-copy">
<div class="overview-title">
<h3 id="config-page-title">{{ currentSection.label }}</h3>
<el-tag v-if="!loading && !availabilityError" :type="stateTagType" effect="light" size="small">
<span class="sr-only">运行状态:</span>{{ stateLabel }}
</el-tag>
</div>
</div>
<div class="overview-actions">
@ -36,15 +33,6 @@
</template>
</header>
<el-alert
v-if="!canWrite"
title="当前账号仅能查看配置,保存、应用和回滚操作已隐藏。"
type="info"
:closable="false"
show-icon
class="config-alert"
/>
<div v-if="availabilityError" class="config-availability" role="status">
<el-alert :title="availabilityError" type="warning" :closable="false" show-icon />
<el-button :loading="loading" @click="refreshPage">重新检查</el-button>
@ -52,10 +40,6 @@
<div v-else-if="!loading" class="config-page-body">
<section class="editor-panel" :aria-busy="sectionLoading">
<p v-if="selectedSection === 'ssl' && status.tlsMode === 'proxy'" class="heading-note" role="note">
当前由反向代理提供 HTTPS,Smail 不自动申请或续期 SSL 证书。下方配置仅用于本地邮件服务证书;外部续期后请重新导入并应用,不能仅更新反代服务器上的证书。
</p>
<el-alert
v-if="sectionError"
:title="sectionError"
@ -65,7 +49,6 @@
class="config-alert"
/>
<MailPortSummary v-if="mailPortSummary && !sectionLoading && !sectionError" :services="mailPortSummary" />
<ConfigForm
v-if="!sectionLoading && !sectionError"
:section="selectedSection"
@ -74,8 +57,6 @@
:disabled="!canWrite || !!working || !!pendingCandidate"
@change="updateField"
/>
<p v-if="sensitiveHint" id="config-sensitive-hint" class="editor-hint">{{ sensitiveHint }}。</p>
<div v-if="secretField" class="secret-editor">
<div>
<label for="config-secret-input">{{ secretField.label }}</label>
@ -129,7 +110,6 @@
{{ savedGeneration ? `应用代次 ${savedGeneration}` : '应用配置' }}
</el-button>
</div>
<p class="action-hint">保存不会立即生效;需要继续修改时先取消候选。跨会话恢复或取消需要近期认证并勾选确认。</p>
</div>
<div v-if="validationResult" class="result-box" :class="validationResult.valid ? 'result-success' : 'result-error'" role="status">
<strong>{{ validationResult.valid ? '验证通过' : '验证未通过' }}</strong>
@ -149,16 +129,12 @@
<script setup>
import { inject } from 'vue'
import ConfigForm from '../ConfigForm/ConfigForm.vue'
import MailPortSummary from '../MailPortSummary/MailPortSummary.vue'
const {
mailPortSummary,
availabilityError,
loading,
working,
status,
stateLabel,
stateTagType,
selectedSection,
currentSection,
sectionLoading,
@ -167,7 +143,6 @@ const {
updateField,
updateSecret,
sectionError,
sensitiveHint,
secretField,
secretValue,
confirmed,

View File

@ -33,16 +33,6 @@
line-height: 1.5;
}
.form-group-description {
flex: 1 1 100%;
margin: 0;
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.credential-status,
.form-group-note,
.form-empty {
margin: 0 0 8px;
color: var(--pm-text-secondary);
@ -85,7 +75,7 @@
}
.config-form[data-form-section="base"] .form-grid,
.config-form[data-form-section="email"] .form-grid,
.config-form[data-form-section="mail"] .form-grid,
.form-group[data-group="verification"] .form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

View File

@ -108,11 +108,14 @@ const baseGroups = [
])
]
const smtpGroups = [
group('listener', 'SMTP 服务监听', '', [
const mailGroups = [
group('listener', '邮件服务监听', '', [
port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }),
port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, hint: '默认 587,客户端升级 TLS 后进行认证。' }),
port('listener.tlsPort', 'SMTP 隐式 TLS 端口', { min: 1, hint: '默认 465,客户端建立连接时直接使用 TLS。' })
port('listener.tlsPort', 'SMTP 隐式 TLS 端口', { min: 1, hint: '默认 465,客户端建立连接时直接使用 TLS。' }),
port('imap.tlsPort', 'IMAP 隐式 TLS 端口', { min: 1, required: true, hint: '默认 993。客户端建立连接时直接使用 TLS。' }),
port('pop3.port', 'POP3 端口(STLS)', { min: 1, required: true, hint: '默认 110,客户端使用 STLS 升级加密后认证。' }),
port('pop3.tlsPort', 'POP3 隐式 TLS 端口', { min: 1, required: true, hint: '默认 995,客户端建立连接时直接使用 TLS。' })
]),
group('relay', 'SMTP 中继连接(可选)', '', [
field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }),
@ -131,27 +134,17 @@ const smtpGroups = [
field('relay.password', '中继密码', 'password', { placeholder: '留空保持原值' }),
field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }),
field('relay.fromDomain', '发件域名', 'text', { placeholder: 'example.com' })
])
]
const imapGroups = [
group('imap', 'IMAP 服务监听', '', [
port('imap.tlsPort', 'IMAP 隐式 TLS 端口', { min: 1, required: true, hint: '默认 993。当前仅支持隐式 TLS,不提供明文或 STARTTLS 监听。' })
])
]
const pop3Groups = [
group('pop3', 'POP3 服务监听', '', [
port('pop3.port', 'POP3 端口(STLS)', { min: 1, required: true, hint: '默认 110,客户端使用 STLS 升级加密后认证。' }),
port('pop3.tlsPort', 'POP3 隐式 TLS 端口', { min: 1, required: true, hint: '默认 995,客户端建立连接时直接使用 TLS。' })
])
]
const emailGroups = [
group('policy', '邮件与账号策略', '', [
]),
group('policy', '邮件策略', '', [
field('policy.maxMessageBytes', '最大邮件大小(字节)', 'number', { min: 0, max: Number.MAX_SAFE_INTEGER, integer: true, hint: '0 使用后端默认限制;1 MiB = 1048576 字节。' }),
field('policy.spamFilterLevel', '垃圾邮件过滤级别', 'select', { fallback: 0, options: options([[0, '不过滤'], [1, 'SPF 与 DKIM 均失败时过滤'], [2, 'SPF 失败时过滤'], [3, 'DKIM 失败时过滤']]) }),
field('policy.loginVerificationPolicy', '账号登录验证策略', 'select', { options: loginOptions }),
field('policy.spamFilterLevel', '垃圾邮件过滤级别', 'select', { fallback: 0, options: options([[0, '不过滤'], [1, 'SPF 与 DKIM 均失败时过滤'], [2, 'SPF 失败时过滤'], [3, 'DKIM 失败时过滤']]) })
])
]
const securityPolicyGroups = [
group('verification', '登录与账号恢复', '', [
field('relay.verificationEnabled', '启用账号登录验证', 'switch'),
field('relay.loginPolicy', '账号登录验证策略', 'select', { options: loginOptions }),
field('policy.externalRecoveryEnabled', '允许外部邮箱恢复账号', 'switch')
])
]
@ -190,7 +183,7 @@ const pluginDefinitions = {
export function getFieldGroups(section, config) {
const pluginId = Object.hasOwn(pluginDefinitions, section) ? section : ''
if (section !== 'plugins' && !pluginId) {
return ({ base: baseGroups, smtp: smtpGroups, imap: imapGroups, pop3: pop3Groups, email: emailGroups, ssl: sslGroups }[section] || [])
return ({ base: baseGroups, mail: mailGroups, security_policy: securityPolicyGroups, ssl: sslGroups }[section] || [])
.map(item => ({
...item,
fields: item.fields
@ -207,14 +200,14 @@ export function getFieldGroups(section, config) {
return {
id,
title: definition?.label || `插件:${id}`,
description: `插件标识:${id}。程序与权限由安装目录维护;凭据请在专用插件设置中管理。`,
description: '',
fields: [
input('enabled', '启用插件', 'switch', { locked: !definition }),
input('executable', '运行程序', 'text', { locked: true }),
{ path: [...prefix, 'permissions', 'network'], label: '允许访问的网络目标', type: 'network', hint: '仅允许 HTTPS 或 WSS;填写域名和端口,不接受 IP 地址。', locked: !definition },
...(definition && settingsEditable ? definition.fields.map(item => ({ ...item, path: [...prefix, 'settings', ...item.path] })) : [])
],
note: !settingsEditable ? '当前插件使用非标准设置格式,已保留原值;请在专用插件页面调整。' : '',
note: '',
secretConfigured: !!plugin.secretStatus?.configured
}
})
@ -291,22 +284,24 @@ export function validateFormConfig(section, config) {
errors['http.httpsPort'] = 'HTTP 与 HTTPS 监听端口不能相同'
}
}
if (section === 'smtp') {
if (section === 'mail') {
const relay = config.relay || {}
if (relay.relayHost && relay.security) {
if (!['starttls', 'implicit_tls', 'tls', 'plain'].includes(relay.security)) {
errors['relay.security'] = '请选择明确的连接加密方式,系统会自动匹配端口'
}
const relayConfigured = !!(
relay.relayHost || relay.relayPort || relay.username || relay.password || relay.security || relay.from
)
if (relayConfigured) {
if (!relay.relayHost) errors['relay.relayHost'] = '请填写 SMTP 服务器'
if (!['starttls', 'implicit_tls', 'tls', 'plain'].includes(relay.security)) errors['relay.security'] = '请选择连接加密方式'
if (!validPort(relay.relayPort)) errors['relay.relayPort'] = '请填写 1~65535 的 SMTP 端口'
if (!relay.from) errors['relay.from'] = '请填写发件邮箱'
if (!relay.fromDomain) errors['relay.fromDomain'] = '请填写发件域名'
}
if (relay.security === 'plain' && !localSMTPHost(relay.relayHost)) {
errors['relay.relayHost'] = '不加密连接仅限本机地址;远程服务器请选择 STARTTLS 或隐式 TLS'
}
}
if (['smtp', 'imap', 'pop3'].includes(section)) {
const paths = section === 'smtp'
? ['listener.port', 'listener.starttlsPort', 'listener.tlsPort']
: ['imap.tlsPort', 'pop3.port', 'pop3.tlsPort']
if (section === 'mail') {
const paths = ['listener.port', 'listener.starttlsPort', 'listener.tlsPort', 'imap.tlsPort', 'pop3.port', 'pop3.tlsPort']
for (const path of paths) {
const value = getValue(config, path.split('.'))
if (value > 0 && paths.some(other => other !== path && getValue(config, other.split('.')) === value)) errors[path] = '邮件服务监听端口不能重复'

View File

@ -10,11 +10,8 @@
>
<div class="form-group-heading">
<h4>{{ group.title }}</h4>
<p v-if="group.description" class="form-group-description">{{ group.description }}</p>
<span v-if="group.fields.some(field => errors[fieldKey(field)])" class="section-error">待修正</span>
</div>
<p v-if="group.secretConfigured !== undefined" class="credential-status">{{ group.secretConfigured ? '凭据已配置' : '未配置凭据' }}</p>
<p v-if="group.note" class="form-group-note" role="note">{{ group.note }}</p>
<div class="form-grid" :class="{ 'site-grid': section === 'base' && group.id === 'site' }">
<ConfigField
v-for="field in group.fields"

View File

@ -1,9 +1,6 @@
import { computed, nextTick, ref } from 'vue'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import BaseConfig from './BaseConfig/BaseConfig.vue'
import SmtpConfig from './SmtpConfig/SmtpConfig.vue'
import ImapConfig from './ImapConfig/ImapConfig.vue'
import Pop3Config from './Pop3Config/Pop3Config.vue'
import EmailConfig from './EmailConfig/EmailConfig.vue'
import SslConfig from './SslConfig/SslConfig.vue'
import PluginsConfig from './PluginsConfig/PluginsConfig.vue'
@ -17,10 +14,7 @@ export function useNavigation() {
const switching = ref(false)
const pages = [
{ value: 'base', label: '基础配置', component: BaseConfig },
{ value: 'smtp', label: 'SMTP 配置', component: SmtpConfig },
{ value: 'imap', label: 'IMAP 配置', component: ImapConfig },
{ value: 'pop3', label: 'POP3 配置', component: Pop3Config },
{ value: 'email', label: '邮箱策略', component: EmailConfig },
{ value: 'mail', label: '邮件服务与策略', component: EmailConfig },
{ value: 'ssl', label: 'SSL 配置', component: SslConfig },
{ value: 'plugins', label: '插件配置', children: [
{ value: 'spam_block', label: '垃圾邮件拦截', component: PluginsConfig },

View File

@ -5,16 +5,14 @@ import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigForm/ConfigForm.js'
const sections = [
{ value: 'base', label: '基础配置', description: '管理站点与数据库连接,敏感凭据单独填写。' },
{ value: 'smtp', label: 'SMTP 配置', description: '管理本机监听与发信中继;监听端口变更需应用配置并重启后端。' },
{ value: 'imap', label: 'IMAP 配置', description: '管理 IMAP 隐式 TLS 监听;端口变更需应用配置并重启后端。' },
{ value: 'pop3', label: 'POP3 配置', description: '管理 POP3 的 STLS 与隐式 TLS 监听;端口变更需应用配置并重启后端。' },
{ value: 'email', label: '邮箱策略', description: '自动汇总三个协议的配置端口,管理邮件与账号恢复策略。' },
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' },
{ value: 'spam_block', label: '垃圾邮件拦截', description: '配置垃圾邮件检测服务、超时和拦截阈值,仅编辑当前插件。' },
{ value: 'telegram_push', label: 'Telegram 推送', description: '配置 Telegram 推送的运行参数与网络权限,个人绑定在扩展功能中管理。' },
{ value: 'wechat_push', label: '微信推送', description: '配置微信推送的应用、模板与接收用户,敏感凭据不回填。' }
{ value: 'base', label: '基础配置' },
{ value: 'mail', label: '邮件服务与策略' },
{ value: 'security_policy', label: '安全策略' },
{ value: 'ssl', label: 'SSL 配置' },
{ value: 'plugins', label: '插件配置' },
{ value: 'spam_block', label: '垃圾邮件拦截' },
{ value: 'telegram_push', label: 'Telegram 推送' },
{ value: 'wechat_push', label: '微信推送' }
]
const serviceLabels = {
@ -57,7 +55,7 @@ export function usePage(pageKey = 'base') {
// 独立页面复用原配置分区的原子保存与候选保护,不丢弃其他页面的字段。
const pluginPages = ['spam_block', 'telegram_push', 'wechat_push']
const apiSection = () => pluginPages.includes(selectedSection.value) ? 'plugins'
: ['imap', 'pop3'].includes(selectedSection.value) ? 'email' : selectedSection.value
: selectedSection.value === 'security_policy' ? 'mail' : selectedSection.value
const currentSection = computed(() => sections.find(item => item.value === selectedSection.value) || sections[0])
const sectionLoading = ref(false)
let sectionRequestId = 0
@ -66,23 +64,6 @@ export function usePage(pageKey = 'base') {
const formConfig = computed(() => readFormConfig(editorText.value))
const formErrors = ref({})
const loadedText = ref('')
const smtpListener = ref(null)
const mailPortSummary = computed(() => {
if (selectedSection.value !== 'email' || !loadedText.value || !smtpListener.value) return null
const email = readFormConfig(loadedText.value)
return [
{ name: 'SMTP', ports: [
{ label: '收信 / STARTTLS', value: smtpListener.value.port },
{ label: '提交 / STARTTLS', value: smtpListener.value.starttlsPort },
{ label: '隐式 TLS', value: smtpListener.value.tlsPort }
] },
{ name: 'IMAP', ports: [{ label: '隐式 TLS', value: email.imap?.tlsPort }] },
{ name: 'POP3', ports: [
{ label: 'STLS', value: email.pop3?.port },
{ label: '隐式 TLS', value: email.pop3?.tlsPort }
] }
]
})
const sectionError = ref('')
const secretValue = ref('')
const confirmed = ref(false)
@ -102,22 +83,24 @@ export function usePage(pageKey = 'base') {
: '候选内容尚未完整读取,请刷新后核对。'
return `${scope}三个插件共用一个配置分区,请在对应三级菜单核对;应用时整个候选一起生效,其他插件的原有配置会保留。`
}
if (pending?.section !== 'email' || apiSection() !== 'email') return ''
if (pending?.section !== 'mail' || apiSection() !== 'mail') return ''
const active = readFormConfig(loadedText.value)
const candidate = pending.payload || {}
const changed = [
['imap', 'IMAP 配置'],
['pop3', 'POP3 配置'],
['policy', '邮箱策略']
['listener', 'SMTP 监听'],
['imap', 'IMAP 监听'],
['pop3', 'POP3 监听'],
['relay', 'SMTP 中继与登录验证'],
['policy', '邮件与账号策略']
].filter(([key]) => {
const before = active[key] || {}
const after = candidate[key] || {}
return Object.keys({ ...before, ...after }).some(field => JSON.stringify(before[field]) !== JSON.stringify(after[field]))
}).map(([, label]) => label)
const scope = pending.payload && loadedText.value
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的协议端口和邮箱策略未变化。')
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的邮件服务与安全策略未变化。')
: '候选内容尚未完整读取,请刷新后核对。'
return `${scope}IMAP、POP3 和邮箱策略共用一个配置分区,请逐页核对;应用时会一起生效,监听端口仍需重启后端。`
return `${scope}邮件服务页和安全策略页共用一个候选配置;应用时全部变更一起生效,监听端口变更仍需重启后端。`
})
let savedInput = null
const validationResult = ref(null)
@ -294,7 +277,6 @@ export function usePage(pageKey = 'base') {
formErrors.value = {}
editorText.value = ''
loadedText.value = ''
smtpListener.value = null
secretValue.value = ''
confirmed.value = false
validationResult.value = null
@ -305,16 +287,6 @@ export function usePage(pageKey = 'base') {
if (requestId !== sectionRequestId) return
notifyResponseError(response, '配置分区读取失败')
const publicConfig = response.data || {}
if (selectedSection.value === 'email') {
const smtpResponse = await http.get('/api/v1/config/smtp')
if (requestId !== sectionRequestId) return
notifyResponseError(smtpResponse, 'SMTP 监听配置读取失败')
// 不拼接不同代次的配置,也不在读取失败时显示旧端口。
if (smtpResponse.data?.meta?.generation !== publicConfig.meta?.generation) {
throw new Error('配置代次已变化,请刷新后查看最新端口')
}
smtpListener.value = smtpResponse.data?.listener || null
}
editorText.value = JSON.stringify(publicConfig, null, 2)
loadedText.value = editorText.value
if (section === 'base') {
@ -519,7 +491,7 @@ export function usePage(pageKey = 'base') {
if (selectedSection.value === 'base') {
delete payload.databasePasswordStatus
}
if (selectedSection.value === 'smtp') {
if (apiSection() === 'mail') {
delete payload.relayPasswordStatus
if (payload.relay && 'password' in payload.relay) {
delete payload.relay.password
@ -549,7 +521,7 @@ export function usePage(pageKey = 'base') {
secretChanges['database.password'] = { operation: 'replace', value: { plaintext: value } }
}
const relayPassword = formConfig.value?.relay?.password
if (relayPassword && selectedSection.value === 'smtp') {
if (relayPassword && selectedSection.value === 'mail') {
secretChanges['relay.password'] = { operation: 'replace', value: { plaintext: relayPassword } }
}
return {
@ -737,7 +709,6 @@ export function usePage(pageKey = 'base') {
})
return {
mailPortSummary,
pendingScopeHint,
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret,

View File

@ -7,7 +7,7 @@ import { provide } from 'vue'
import { usePage } from '../ConfigSettings.js'
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
const page = usePage('email')
const page = usePage('mail')
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>

View File

@ -0,0 +1,3 @@
.global-security-policy {
min-width: 0;
}

View File

@ -0,0 +1 @@
export const globalSecurityPolicySection = 'security_policy'

View File

@ -0,0 +1,17 @@
<template>
<div class="global-security-policy">
<ConfigEditor />
</div>
</template>
<script setup>
import { provide } from 'vue'
import { usePage } from '../../ConfigSettings/ConfigSettings.js'
import ConfigEditor from '../../ConfigSettings/ConfigEditor/ConfigEditor.vue'
const page = usePage('security_policy')
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>
<style scoped src="./GlobalSecurityPolicy.css"></style>

View File

@ -12,8 +12,12 @@ export function usePage() {
const isSuperAdmin = computed(() => globalStatus.role === 'super_admin')
const supported = webAuthnSupported()
const webAuthnEnabled = computed(() => globalStatus.userInfos.webAuthnEnabled === true)
const canReadGlobalPolicy = computed(() => globalStatus.hasCapability('config.read'))
const sectionOptions = computed(() => {
const options = []
if (canReadGlobalPolicy.value) {
options.push({ value: 'security-policy', label: '安全策略' })
}
if (!isSuperAdmin.value) {
options.push({ value: 'password', label: lang.modify_pwd })
}
@ -31,10 +35,11 @@ export function usePage() {
set: value => { globalStatus.securityActiveSection = value }
})
const selectSection = value => {
if (sectionOptions.value.some(item => item.value === value)) {
selectedSection.value = value
}
const globalSecurityPolicyPage = ref(null)
const selectSection = async value => {
if (!sectionOptions.value.some(item => item.value === value) || value === selectedSection.value) return
if (selectedSection.value === 'security-policy' && !await (globalSecurityPolicyPage.value?.beforeLeave?.() ?? true)) return
selectedSection.value = value
}
watch(sectionOptions, options => {
@ -290,6 +295,7 @@ export function usePage() {
return {
lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection,
globalSecurityPolicyPage,
registering, credentialsLoading, credentials, securityLoading,
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,

View File

@ -9,6 +9,11 @@
/>
<div class="security-main">
<GlobalSecurityPolicy
v-if="selectedSection === 'security-policy'"
ref="globalSecurityPolicyPage"
/>
<section v-if="selectedSection === 'password' && !isSuperAdmin" class="security-section">
<div class="settings-header">
<h3>{{ lang.modify_pwd }}</h3>
@ -95,9 +100,11 @@
<script setup>
import { usePage } from './SecuritySettings.js'
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
import GlobalSecurityPolicy from './GlobalSecurityPolicy/GlobalSecurityPolicy.vue'
const {
lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection,
globalSecurityPolicyPage,
registering, credentialsLoading, credentials, securityLoading,
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,

View File

@ -33,8 +33,14 @@ func TestConfiguredMailListenerPortsAcceptProtocolConnections(t *testing.T) {
smtp := config.SMTPSectionConfig{Meta: meta, Listener: config.SMTPListenerConfig{
Port: ports[0], STARTTLSPort: ports[1], TLSPort: ports[2],
}}
// 中继与 IMAP 共用数值端口也不冲突;不配置中继主机仍可启动全部本地监听。
smtp.Relay.RelayPort = ports[3]
// 中继与 IMAP 共用数值端口也不冲突;中继是外部连接,不参与本地监听端口占用。
smtp.Relay = config.SMTPConfig{
RelayHost: "127.0.0.1",
RelayPort: ports[3],
Security: "plain",
From: "no-reply@example.com",
FromDomain: "example.com",
}
email := config.EmailSectionConfig{Meta: meta, IMAP: config.IMAPListenerConfig{TLSPort: ports[3]}, POP3: config.POP3ListenerConfig{Port: ports[4], TLSPort: ports[5]}}
ssl := config.SSLSectionConfig{Meta: meta, TLS: config.SSLConfig{Mode: "default", DefaultDirectory: "config/ssl"}}
rootConfig := config.RootConfig{

View File

@ -22,7 +22,7 @@ test('基础配置不再展示冗余安全选项与开关,通行密钥由系
assert.equal(updated.security.webAuthnRpId, 'mail.old.com')
})
test('七个页面使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => {
test('配置与安全策略页面使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
for (const name of ['ConfigForm', 'ConfigField', 'ConfigEditor']) {
@ -34,8 +34,13 @@ test('七个页面使用分组表单,控件和样式能编译且不再提供 J
assert.deepEqual(compileStyle({ source: css, id: `data-v-${name}`, scoped: true }).errors, [])
assert.doesNotMatch(template, /&#x[0-9a-f]+;|type="textarea"|配置 JSON 编辑器/i)
}
for (const section of ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']) {
const groups = getFieldGroups(section, { plugins: { spam_block: { settings: null } } })
const config = { plugins: {
spam_block: { settings: null },
telegram_push: { settings: null },
wechat_push: { settings: null }
} }
for (const section of ['base', 'mail', 'security_policy', 'ssl', 'plugins', 'spam_block', 'telegram_push', 'wechat_push']) {
const groups = getFieldGroups(section, config)
assert.ok(groups.length)
const keys = groups.flatMap(group => group.fields.map(field => field.path.join('.')))
assert.equal(keys.length, new Set(keys).size)
@ -44,17 +49,26 @@ test('七个页面使用分组表单,控件和样式能编译且不再提供 J
}
})
test('各协议仅编辑自己的监听端口,邮箱策略没有重复端口输入项', () => {
test('邮件服务页面统一编辑六个监听端口,中继和策略字段保持在同一配置分区', () => {
const config = { listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, relay: { relayPort: 465 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } }
const text = JSON.stringify(config)
assert.equal(updateFormConfig(text, 'email', ['policy', 'smtpPort'], 2525), text)
assert.equal(updateFormConfig(text, 'imap', ['pop3', 'port'], 1110), text)
assert.equal(updateFormConfig(text, 'pop3', ['imap', 'tlsPort'], 1993), text)
const edited = readFormConfig(updateFormConfig(text, 'smtp', ['listener', 'tlsPort'], 2465))
const groups = getFieldGroups('mail', config)
const keys = groups.flatMap(group => group.fields.map(input => input.path.join('.')))
assert.deepEqual(keys.filter(key => /^(listener|imap|pop3)\./.test(key)), [
'listener.port', 'listener.starttlsPort', 'listener.tlsPort',
'imap.tlsPort', 'pop3.port', 'pop3.tlsPort'
])
assert.ok(keys.includes('relay.relayHost'))
assert.ok(keys.includes('relay.password'))
assert.ok(keys.includes('policy.maxMessageBytes'))
assert.ok(keys.includes('policy.spamFilterLevel'))
assert.ok(!keys.includes('policy.smtpPort'))
assert.ok(!keys.includes('policy.imapPort'))
assert.ok(!keys.includes('policy.pop3Port'))
const edited = readFormConfig(updateFormConfig(text, 'mail', ['listener', 'tlsPort'], 2465))
assert.equal(edited.listener.tlsPort, 2465)
assert.equal(edited.relay.relayPort, 465)
assert.deepEqual(getFieldGroups('email', config).flatMap(group => group.fields).filter(field => /port/i.test(field.path.join('.'))), [])
assert.ok(validateFormConfig('smtp', { ...config, listener: { ...config.listener, tlsPort: 587 } })['listener.tlsPort'])
assert.ok(validateFormConfig('mail', { ...config, listener: { ...config.listener, tlsPort: 587 } })['listener.tlsPort'])
})
test('三个插件页面只展示和编辑自身字段,完整保留其他插件和未知字段', () => {
@ -119,22 +133,22 @@ test('文本输入的左右留白属于原生输入框,不影响数字、选
assert.deepEqual(rules.get('.field-text-input[data-v-caret-test] .el-input__inner'), { 'padding-inline': '11px' })
})
test('分组不显示重复标题和介绍,保留无障碍名称、凭据状态及异常提示', () => {
test('分组只显示必要标题和字段错误,删除普通介绍、备注和凭据状态', () => {
const template = readFileSync(new URL('ConfigForm/ConfigForm.vue', root), 'utf8')
const css = readFileSync(new URL('ConfigForm/ConfigForm.css', root), 'utf8')
assert.match(template, /<h4>{{ group.title }}<\/h4>/)
assert.match(template, /form-group-heading|form-group-description|group\.description/)
assert.match(template, /form-group-heading/)
assert.doesNotMatch(template, /form-group-description|group\.description/)
assert.doesNotMatch(template, /aria-labelledby/)
assert.match(template, /<section\b[^>]*:aria-label="group.title"/)
assert.match(template, /v-if="group.secretConfigured !== undefined" class="credential-status"/)
assert.match(template, /group.secretConfigured \? '凭据已配置' : '未配置凭据'/)
assert.match(template, /v-if="group.note" class="form-group-note" role="note"/)
assert.doesNotMatch(template, /credential-status|secretConfigured|form-group-note|group\.note/)
assert.match(template, /v-if="!groups.length" class="form-empty"/)
assert.match(template, /<ConfigField\b/)
for (const binding of ['v-for="field in group.fields"', ':value="getValue(config, field.path)"', ':error="errors[fieldKey(field)]"', ':disabled="disabled"', "emit('change', path, value)"]) {
assert.ok(template.includes(binding))
}
assert.match(css, /form-group-heading|form-group-description/)
assert.match(css, /form-group-heading/)
assert.doesNotMatch(css, /form-group-description|credential-status|form-group-note/)
const formStyle = css.match(/\.config-form\s*\{([^}]+)\}/)?.[1] || ''
assert.doesNotMatch(formStyle, /margin-top|padding-top/)
assert.match(css, /\.config-form:not\(:first-child\)\s*\{\s*margin-top: 10px;/)
@ -211,10 +225,13 @@ test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正
assert.equal(readFormConfig(text).http.port, null)
assert.ok(validateFormConfig('base', readFormConfig(text))['http.port'])
for (const value of ['25', 0, -1, 65536, 1.5, null]) {
assert.ok(validateFormConfig('imap', { imap: { tlsPort: value } })['imap.tlsPort'])
assert.ok(validateFormConfig('mail', { imap: { tlsPort: value } })['imap.tlsPort'])
}
assert.ok(validateFormConfig('pop3', { imap: { tlsPort: 993 }, pop3: { port: 993, tlsPort: 995 } })['pop3.port'])
assert.deepEqual(validateFormConfig('email', { policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } }), {})
assert.ok(validateFormConfig('mail', { imap: { tlsPort: 993 }, pop3: { port: 993, tlsPort: 995 } })['pop3.port'])
assert.deepEqual(validateFormConfig('mail', {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { maxMessageBytes: 0 }
}), {})
})
test('域名校验兼容大小写和末尾点,拒绝重复项、空白和路径', () => {
@ -268,25 +285,25 @@ test('SMTP 加密方式自动匹配端口,手动端口和读取过程不改变
for (const [security, expectedPort] of [['starttls', 587], ['implicit_tls', 465], ['tls', 465], ['plain', 25]]) {
const config = { generation: 9, relay: { relayHost: 'localhost', relayPort: 2525, future: true } }
let text = JSON.stringify(config)
getFieldGroups('smtp', config)
getFieldGroups('mail', config)
assert.equal(JSON.stringify(config), text)
text = updateFormConfig(text, 'smtp', ['relay', 'security'], security)
text = updateFormConfig(text, 'mail', ['relay', 'security'], security)
assert.deepEqual(readFormConfig(text), { ...config, relay: { ...config.relay, security, relayPort: expectedPort } })
const fields = getFieldGroups('smtp', readFormConfig(text)).flatMap(group => group.fields)
const fields = getFieldGroups('mail', readFormConfig(text)).flatMap(group => group.fields)
assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, expectedPort)
text = updateFormConfig(text, 'smtp', ['relay', 'relayPort'], 2526)
text = updateFormConfig(text, 'mail', ['relay', 'relayPort'], 2526)
assert.equal(readFormConfig(text).relay.security, security)
text = updateFormConfig(text, 'smtp', ['relay', 'security'], security)
text = updateFormConfig(text, 'mail', ['relay', 'security'], security)
assert.equal(readFormConfig(text).relay.relayPort, 2526)
}
const fields = getFieldGroups('smtp', {}).flatMap(group => group.fields)
const fields = getFieldGroups('mail', {}).flatMap(group => group.fields)
assert.ok(fields.findIndex(field => field.path.at(-1) === 'security') < fields.findIndex(field => field.path.at(-1) === 'relayPort'))
const unused = JSON.stringify({ relay: { relayPort: 2525, security: 'starttls' } })
assert.equal(readFormConfig(updateFormConfig(unused, 'smtp', ['relay', 'security'], '')).relay.relayPort, 2525)
assert.equal(readFormConfig(updateFormConfig(unused, 'mail', ['relay', 'security'], '')).relay.relayPort, 2525)
for (const security of ['constructor', '__proto__', 'future']) {
const updated = readFormConfig(updateFormConfig(unused, 'smtp', ['relay', 'security'], security))
const updated = readFormConfig(updateFormConfig(unused, 'mail', ['relay', 'security'], security))
assert.equal(updated.relay.relayPort, 2525)
const fields = getFieldGroups('smtp', updated).flatMap(group => group.fields)
const fields = getFieldGroups('mail', updated).flatMap(group => group.fields)
assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, undefined)
}
})
@ -311,17 +328,22 @@ test('内置 HTTPS 自动补全端口,反代禁用内置监听,已有自定
})
test('加密配置校验拦截监听冲突、远程明文和已启用但未选择加密的中继', () => {
const listeners = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
imap: { tlsPort: 993 },
pop3: { port: 110, tlsPort: 995 }
}
for (const port of [80, 0]) {
assert.ok(validateFormConfig('base', { http: { port, httpsEnabled: true, httpsPort: 80 } })['http.httpsPort'])
}
for (const relayHost of ['localhost', 'LOCALHOST', '127.0.0.1', '127.1.2.3', '::1', '0:0:0:0:0:0:0:1']) {
assert.deepEqual(validateFormConfig('smtp', { relay: { relayHost, security: 'plain', relayPort: 25 } }), {})
assert.deepEqual(validateFormConfig('mail', { ...listeners, relay: { relayHost, security: 'plain', relayPort: 25, from: 'local@example.com', fromDomain: 'example.com' } }), {})
}
for (const relayHost of ['smtp.example.com', '127.0.0.999', '127.1', '127.0.0.1.evil.com', '192.168.1.1']) {
assert.ok(validateFormConfig('smtp', { relay: { relayHost, security: 'plain', relayPort: 25 } })['relay.relayHost'])
assert.ok(validateFormConfig('mail', { ...listeners, relay: { relayHost, security: 'plain', relayPort: 25, from: 'sender@example.com', fromDomain: 'example.com' } })['relay.relayHost'])
}
const errors = validateFormConfig('smtp', { relay: { relayHost: 'smtp.example.com', security: 'starttls', relayPort: 0 } })
const errors = validateFormConfig('mail', { ...listeners, relay: { relayHost: 'smtp.example.com', security: 'starttls', relayPort: 0, from: 'sender@example.com', fromDomain: 'example.com' } })
assert.ok(errors['relay.relayPort'])
assert.deepEqual(validateFormConfig('smtp', { relay: {} }), {})
assert.deepEqual(validateFormConfig('smtp', { relay: { security: 'starttls', relayPort: 2525, relayHost: 'smtp.example.com' } }), {})
assert.deepEqual(validateFormConfig('mail', { ...listeners, relay: {} }), {})
assert.deepEqual(validateFormConfig('mail', { ...listeners, relay: { security: 'starttls', relayPort: 2525, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } }), {})
})

View File

@ -5,7 +5,7 @@ import { compileFunction } from 'node:vm'
import { computed, reactive, ref } from './vue.mjs'
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigNavigation.js', import.meta.url), 'utf8')
const components = ['BaseConfig', 'SmtpConfig', 'ImapConfig', 'Pop3Config', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']
const components = ['BaseConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']
const createNavigation = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function useNavigation', 'function useNavigation') + '\nreturn useNavigation()',
['computed', 'nextTick', 'ref', 'useGlobalStatusStore', ...components]
@ -20,8 +20,8 @@ function fixture(capabilities = ['config.read']) {
test('配置菜单包含三个独立插件三级页面,无权限的维护入口不可打开', async () => {
const { page, allowed } = fixture()
assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 7)
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins'])
assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 4)
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['base', 'mail', 'ssl', 'plugins'])
const children = page.sectionOptions.value.find(item => item.value === 'plugins').children
assert.deepEqual(children.map(item => item.value), ['spam_block', 'telegram_push', 'wechat_push'])
await page.selectSection('plugins')
@ -61,17 +61,17 @@ test('切页等待离开确认,取消和快速重复点击不会更改页面',
let resolve
let prompts = 0
page.currentPage.value = { beforeLeave: () => { prompts++; return new Promise(done => { resolve = done }) } }
const first = page.selectSection('smtp')
const first = page.selectSection('mail')
await page.selectSection('ssl')
assert.equal(prompts, 1)
assert.equal(page.selectedSection.value, 'base')
resolve(false)
await first
assert.equal(page.selectedSection.value, 'base')
const second = page.selectSection('email')
const second = page.selectSection('mail')
resolve(true)
await second
assert.equal(page.selectedSection.value, 'email')
assert.equal(page.selectedSection.value, 'mail')
assert.equal(page.activePage.value.component.name, 'EmailConfig')
})

View File

@ -45,8 +45,7 @@ test('证书页从状态接口读取生效反代模式,不额外加载基础
assert.equal(page.status.tlsMode, 'proxy')
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/ssl'])
const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8')
assert.match(template, /v-if="selectedSection === 'ssl' && status.tlsMode === 'proxy'"/)
assert.match(template, /Smail 不自动申请或续期 SSL 证书/)
assert.doesNotMatch(template, /selectedSection === 'ssl' && status\.tlsMode === 'proxy'|Smail 不自动申请或续期 SSL 证书/)
})
for (const failedRequest of ['/status', '/pending']) {
@ -54,14 +53,22 @@ for (const failedRequest of ['/status', '/pending']) {
let pending = null
let fail = false
let saves = 0
const { page } = fixture(() => {}, 'smtp', { http: {
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: 'old.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page } = fixture(() => {}, 'mail', { http: {
get: async path => {
if (fail && path.endsWith(failedRequest)) throw new Error('模拟读取失败')
return { errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 1 } : { relay: { relayHost: 'old.example.com' } } }
return { errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 1 } : active }
},
put: async (path, body) => {
saves++
pending = { generation: 2, candidateId: 'pending-recovery', section: 'smtp', sameSession: true, payload: body.payload }
pending = { generation: 2, candidateId: 'pending-recovery', section: 'mail', sameSession: true, payload: body.payload }
fail = true
return { errorNo: 0, data: pending }
},
@ -173,7 +180,7 @@ test('状态请求失败后可重试恢复,不保留不可用提示', async ()
assert.equal(page.availabilityError.value, '')
})
test('独立页互不覆盖,旧页卸载时清除密码并丢弃较慢响应', async () => {
test('独立页互不覆盖,页面卸载时清除密码并丢弃较慢响应', async () => {
const base = deferred()
const email = deferred()
const { page, dispose } = fixture(() => base.promise)
@ -188,11 +195,11 @@ test('独立页互不覆盖,旧页卸载时清除密码并丢弃较慢响应',
page.secretValue.value = 'test-only-value'
dispose()
assert.equal(page.secretValue.value, '')
const emailPage = fixture(() => email.promise, 'email').page
const emailPage = fixture(() => email.promise, 'mail').page
const emailRequest = emailPage.loadSection()
email.resolve({ errorNo: 0, data: { policy: { smtpPort: 25 } } })
await emailRequest
assert.equal(emailPage.currentSection.value.label, '邮箱策略')
assert.equal(emailPage.currentSection.value.label, '邮件服务与策略')
assert.equal(emailPage.sectionLoading.value, false)
base.resolve({ errorNo: 0, data: { database: { type: 'sqlite' } } })
await baseRequest
@ -219,34 +226,39 @@ test('同一页重复加载时,过期失败不覆盖最新请求', async () =>
assert.deepEqual(JSON.parse(page.editorText.value), { enabled: true })
})
test('七个配置页按需加载,协议页复用正确分区且提供中文说明', async () => {
test('配置页按需加载,邮件页和安全策略页共用 mail 分区', async () => {
const requests = []
for (const key of ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']) {
for (const key of ['base', 'mail', 'security_policy', 'ssl', 'plugins']) {
const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }, key)
await page.loadAll()
assert.equal(page.selectedSection.value, key)
assert.match(page.currentSection.value.description, /[\u4e00-\u9fff]/)
assert.match(page.currentSection.value.label, /[\u4e00-\u9fff]/)
assert.equal(page.secretValue.value, '')
assert.equal(page.confirmed.value, false)
assert.equal(page.savedGeneration.value, 0)
}
assert.deepEqual(requests, ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']
.flatMap(key => ['/api/v1/config/status', `/api/v1/config/${['imap', 'pop3'].includes(key) ? 'email' : key}`, ...(key === 'email' ? ['/api/v1/config/smtp'] : [])]))
assert.deepEqual(requests, ['base', 'mail', 'security_policy', 'ssl', 'plugins']
.flatMap(key => ['/api/v1/config/status', `/api/v1/config/${key === 'security_policy' ? 'mail' : key}`]))
})
for (const key of ['imap', 'pop3']) {
test(`${key} 页面验证、保存、候选恢复及应用均使用邮箱分区且保留其他字段`, async () => {
for (const key of ['mail', 'security_policy']) {
test(`${key} 页面验证、保存、候选恢复及应用均使用 mail 分区且保留未展示字段`, async () => {
const requests = []
const payloads = []
let pending = null
let active = { meta: { schema: 1, generation: 1 }, policy: { maxMessageBytes: 1234 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 } }
let active = {
meta: { schema: 1, generation: 1 },
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { verificationEnabled: true, loginPolicy: 'new_device' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 1234, spamFilterLevel: 1, externalRecoveryEnabled: true }
}
const { page } = fixture(() => {}, key, { http: {
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : structuredClone(active) }),
put: async (path, body) => {
requests.push(path)
assert.equal(body.expectedGeneration, 1)
assert.deepEqual(body.payload.policy, active.policy)
assert.deepEqual(body.payload[key === 'imap' ? 'pop3' : 'imap'], active[key === 'imap' ? 'pop3' : 'imap'])
pending = { generation: 2, candidateId: 'mail-candidate', section: 'email', sameSession: true, payload: body.payload }
payloads.push(structuredClone(body))
pending = { generation: 2, candidateId: 'mail-candidate', section: 'mail', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
@ -260,48 +272,50 @@ for (const key of ['imap', 'pop3']) {
}
} })
await page.loadAll()
page.updateField([key, 'tlsPort'], key === 'imap' ? 1993 : 1995)
const path = key === 'mail' ? ['imap', 'tlsPort'] : ['relay', 'loginPolicy']
page.updateField(path, key === 'mail' ? 1993 : 'always')
await page.validateConfig()
await page.saveConfig()
await page.refreshPage()
assert.equal(page.savedGeneration.value, 2)
page.updateField([key, 'tlsPort'], 8888)
assert.notEqual(page.formConfig.value[key].tlsPort, 8888)
assert.equal(payloads[0].expectedGeneration, 1)
assert.deepEqual(payloads[0].payload.listener, active.listener)
assert.deepEqual(payloads[0].payload.pop3, active.pop3)
if (key === 'security_policy') assert.equal(payloads[0].secretChanges['relay.password'], undefined)
if (key === 'security_policy') {
await page.refreshPage()
assert.equal(page.savedGeneration.value, 2)
}
page.updateField(path, key === 'mail' ? 8888 : 'off')
assert.notEqual(key === 'mail' ? page.formConfig.value.imap.tlsPort : page.formConfig.value.relay.loginPolicy, key === 'mail' ? 8888 : 'off')
await page.applyConfig()
assert.equal(page.status.activeGeneration, 2)
assert.equal(page.savedGeneration.value, 0)
assert.deepEqual(requests, ['/api/v1/config/email/validate', '/api/v1/config/email', '/api/v1/config/email/apply'])
assert.deepEqual(requests, ['/api/v1/config/mail/validate', '/api/v1/config/mail', '/api/v1/config/mail/apply'])
})
}
test('邮箱策略从三种协议配置汇总已应用端口,不能修改或被候选值误导', async () => {
const config = { meta: { generation: 3 }, policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 }, imap: { tlsPort: 1993 }, pop3: { port: 1110, tlsPort: 1995 } }
const { page } = fixture(() => {}, 'email', { http: { get: async path => ({ errorNo: 0, data:
path.endsWith('/status') ? { activeGeneration: 3 } : path.endsWith('/smtp') ? { meta: { generation: 3 }, listener: { port: 2525, starttlsPort: 2587, tlsPort: 2465 }, relay: { relayPort: 9999 } } :
path.endsWith('/pending') ? { section: 'email', generation: 4, sameSession: true, payload: { ...config, imap: { tlsPort: 2993 } } } : config
}) } })
await page.loadAll()
assert.deepEqual(page.mailPortSummary.value.flatMap(service => service.ports.map(port => port.value)), [2525, 2587, 2465, 1993, 1110, 1995])
assert.equal(page.formConfig.value.imap.tlsPort, 2993)
const before = page.editorText.value
for (const path of [['policy', 'smtpPort'], ['policy', 'imapPort'], ['imap', 'tlsPort']]) page.updateField(path, 8888)
assert.equal(page.editorText.value, before)
})
test('邮箱策略拒绝不同代次的端口汇总,后续加载失败清除旧汇总', async () => {
let generation = 2
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/smtp')
? { meta: { generation }, listener: { port: 2525 } }
: { meta: { generation: 1 }, imap: { tlsPort: 1993 }, pop3: { port: 1110, tlsPort: 1995 } } }), 'email')
test('安全策略页只编辑登录验证和账号恢复字段,完整保留邮件监听、中继与邮件策略', async () => {
const config = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls', verificationEnabled: true, loginPolicy: 'new_device' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 10485760, spamFilterLevel: 2, externalRecoveryEnabled: true }
}
let request
const { page } = fixture(async () => ({ errorNo: 0, data: config }), 'security_policy', {
http: { put: async (path, body) => { request = { path, body }; return { errorNo: 0, data: { generation: 2 } } } }
})
await page.loadSection()
assert.match(page.sectionError.value, /代次/)
assert.equal(page.mailPortSummary.value, null)
generation = 1
await page.loadSection()
assert.ok(page.mailPortSummary.value)
generation = 3
await page.loadSection()
assert.equal(page.mailPortSummary.value, null)
page.updateField(['relay', 'loginPolicy'], 'always')
page.updateField(['listener', 'port'], 2525)
await page.saveConfig()
assert.equal(request.path, '/api/v1/config/mail')
assert.equal(request.body.payload.listener.port, 25)
assert.equal(request.body.payload.relay.loginPolicy, 'always')
assert.deepEqual(request.body.payload.imap, config.imap)
assert.deepEqual(request.body.payload.pop3, config.pop3)
assert.equal(request.body.payload.policy.maxMessageBytes, config.policy.maxMessageBytes)
assert.deepEqual(request.body.secretChanges, {})
})
test('迁移预览票据返回模板,迁移执行取消时不发送写入请求', async () => {
@ -382,7 +396,7 @@ test('独立页模板可编译,编辑器不再连接维护面板且权限绑
assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`))
}
assert.match(maintenance, /v-if="canExportAudit"/)
for (const name of ['BaseConfig', 'SmtpConfig', 'ImapConfig', 'Pop3Config', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance', 'MailPortSummary']) {
for (const name of ['BaseConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']) {
const page = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8')
const { descriptor, errors } = parse(page)
assert.deepEqual(errors, [])
@ -416,9 +430,9 @@ test('配置编辑区移除填写标题、说明与重复标签,保留主标
assert.doesNotMatch(template, /'填写配置'|'查看配置'|按分组填写内容|当前页面的脱敏配置内容|panel-heading|selected-section-label/)
assert.doesNotMatch(css, /selected-section-label/)
assert.match(template, /<h3 id="config-page-title">{{ currentSection.label }}/)
assert.match(template, /v-if="selectedSection === 'ssl' && status.tlsMode === 'proxy'"[^>]*role="note"/)
assert.doesNotMatch(template, /selectedSection === 'ssl' && status\.tlsMode === 'proxy'|role="note"/)
assert.match(template, /v-if="sectionError"[\s\S]*?:title="sectionError"/)
assert.match(template, /id="config-sensitive-hint"/)
assert.doesNotMatch(template, /config-sensitive-hint|sensitiveHint/)
assert.match(template, /v-if="secretField" class="secret-editor"/)
assert.match(template, /v-if="validationResult"[^>]*role="status"/)
})
@ -426,7 +440,7 @@ test('配置编辑区移除填写标题、说明与重复标签,保留主标
test('离开、刷新和正在执行的变更受保护,取消时保留编辑内容', async () => {
let cancel = true
let prompts = 0
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', {
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'mail', {
confirm: async () => { prompts++; if (cancel) throw 'cancel' }
})
await page.loadAll()
@ -461,7 +475,7 @@ test('标题合并配置状态,保留刷新保护和降级提示,不再渲
const header = template.match(/<header[\s\S]*?<\/header>/)?.[0] || ''
assert.match(header, /aria-labelledby="config-page-title"/)
assert.match(header, /<h3 id="config-page-title">{{ currentSection.label }}/)
assert.match(header, /<span class="sr-only">运行状态:<\/span>{{ stateLabel }}/)
assert.doesNotMatch(header, /stateLabel|运行状态|state-tag/)
assert.match(header, /v-for="issue in status.degradedReasons \|\| \[\]"/)
assert.match(header, /v-if="!loading && !availabilityError"/)
assert.match(header, /:disabled="!!working \|\| sectionLoading \|\| loading"/)
@ -497,16 +511,24 @@ test('只读账号不得发送配置写入请求或读取无权限维护数据',
assert.deepEqual(requests, ['/api/v1/config/status'])
})
test('独立页保持验证、保存、应用端点和代次协议,敏感值不串页', async () => {
test('邮件页保持验证、保存、应用端点和代次协议,中继密码只作为 secretChanges 发送', async () => {
const requests = []
let pending = null
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 8 } : { relayPasswordStatus: 'configured' } }), 'smtp', {
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 8 } : active }), 'mail', {
http: {
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 8 } : { relayPasswordStatus: 'configured' } }),
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 8 } : active }),
post: async (path, body) => { requests.push({ path, body }); if (path.endsWith('/apply')) pending = null; return { errorNo: 0, data: { valid: true } } },
put: async (path, body) => {
requests.push({ path, body })
pending = { generation: 9, candidateId: 'candidate-9', section: 'smtp', sameSession: true, payload: {} }
pending = { generation: 9, candidateId: 'candidate-9', section: 'mail', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
}
}
@ -518,9 +540,10 @@ test('独立页保持验证、保存、应用端点和代次协议,敏感值
await page.saveConfig()
assert.equal(page.savedGeneration.value, 9)
await page.applyConfig()
assert.deepEqual(requests.map(item => item.path), ['/api/v1/config/smtp/validate', '/api/v1/config/smtp', '/api/v1/config/smtp/apply'])
assert.deepEqual(requests.map(item => item.path), ['/api/v1/config/mail/validate', '/api/v1/config/mail', '/api/v1/config/mail/apply'])
assert.equal(requests[0].body.expectedGeneration, 8)
assert.deepEqual(requests[0].body.payload, {})
assert.equal(requests[0].body.payload.relay.password, undefined)
assert.deepEqual(requests[0].body.payload.listener, active.listener)
assert.deepEqual(requests[0].body.secretChanges, { 'relay.password': { operation: 'replace', value: { plaintext: 'test-only-value' } } })
assert.deepEqual(requests[0].body.confirmation, { confirmed: true, method: 'recent_auth' })
assert.deepEqual(requests[2].body, { generation: 9, candidateId: 'candidate-9' })
@ -530,7 +553,11 @@ test('独立页保持验证、保存、应用端点和代次协议,敏感值
})
test('可视化编辑按权限和工作状态保护,编辑会清除过期确认及候选代次', async () => {
const { page, dispose } = fixture(async () => ({ errorNo: 0, data: { relay: { relayHost: 'smtp.example.com' } } }), 'smtp')
const config = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { relayHost: 'smtp.example.com' }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page, dispose } = fixture(async () => ({ errorNo: 0, data: config }), 'mail')
await page.loadAll()
const original = page.editorText.value
for (const state of ['loading', 'sectionLoading', 'working', 'sectionError']) {
@ -541,7 +568,7 @@ test('可视化编辑按权限和工作状态保护,编辑会清除过期确
assert.equal(page.secretValue.value, '')
page[state].value = false
}
const readonly = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', { hasCapability: () => false }).page
const readonly = fixture(async () => ({ errorNo: 0, data: {} }), 'mail', { hasCapability: () => false }).page
readonly.updateField(['relay', 'relayHost'], 'blocked.example.com')
assert.equal(readonly.editorText.value, '')
page.savedGeneration.value = 8
@ -562,7 +589,15 @@ test('可视化编辑按权限和工作状态保护,编辑会清除过期确
test('前端无效字段阻止网络请求,修正字段后同步清除错误', async () => {
const requests = []
const { page } = fixture(async () => ({ errorNo: 0, data: { imap: { tlsPort: 993 } } }), 'imap', {
const config = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page } = fixture(async () => ({ errorNo: 0, data: config }), 'mail', {
http: { post: async path => { requests.push(path); return { errorNo: 0, data: { valid: true } } } }
})
await page.loadAll()
@ -574,7 +609,7 @@ test('前端无效字段阻止网络请求,修正字段后同步清除错误',
page.updateField(['imap', 'tlsPort'], 1993)
assert.deepEqual(page.formErrors.value, {})
await page.validateConfig()
assert.deepEqual(requests, ['/api/v1/config/email/validate'])
assert.deepEqual(requests, ['/api/v1/config/mail/validate'])
})
test('插件读取状态字段不传回严格解析接口,未展示字段和权限仍保留', async () => {
@ -614,7 +649,15 @@ test('SQLite 不显示或发送密码,网络数据库和中继密码留空保
assert.deepEqual(request.secretChanges, type === 'sqlite' ? {} : { 'database.password': { operation: 'replace', value: { plaintext: ' test-only-value ' } } })
}
let request
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', {
const mail = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page } = fixture(async () => ({ errorNo: 0, data: mail }), 'mail', {
http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } }
})
await page.loadSection()
@ -644,7 +687,7 @@ test('基础配置保存完整保留隐藏的额外访问主机、数据库和
test('保存后更改密码或填写内容不能应用旧候选', async () => {
const requests = []
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', {
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'mail', {
http: {
put: async () => ({ errorNo: 0, data: { generation: 8 } }),
post: async path => { requests.push(path); return { errorNo: 0 } }
@ -662,19 +705,24 @@ test('保存后更改密码或填写内容不能应用旧候选', async () => {
assert.deepEqual(requests, [])
})
test('共用邮箱分区的候选明确提示其他页面的改动,忽略代次与字段顺序', async () => {
const active = { meta: { generation: 1 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { maxMessageBytes: 1234 } }
for (const key of ['imap', 'pop3', 'email']) {
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/smtp') ? { meta: { generation: 1 }, listener: {} } : active }), key)
test('邮件页与安全策略页共享候选并明确提示改动范围,忽略代次与字段顺序', async () => {
const active = {
meta: { generation: 1 }, listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { verificationEnabled: true, loginPolicy: 'new_device' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 1234, externalRecoveryEnabled: true }
}
for (const key of ['mail', 'security_policy']) {
const { page } = fixture(async () => ({ errorNo: 0, data: active }), key)
await page.loadSection()
page.pendingCandidate.value = { section: 'email', payload: { ...active, meta: { generation: 2 }, pop3: { tlsPort: 995, port: 110 } } }
assert.match(page.pendingScopeHint.value, /协议端口和邮箱策略未变化/)
page.pendingCandidate.value = { section: 'mail', payload: { ...active, meta: { generation: 2 }, pop3: { tlsPort: 995, port: 110 } } }
assert.match(page.pendingScopeHint.value, /邮件服务与安全策略未变化/)
page.pendingCandidate.value.payload = { ...active, pop3: { port: 1110, tlsPort: 1995 }, policy: { maxMessageBytes: 5678 } }
assert.match(page.pendingScopeHint.value, /本次候选涉及:POP3 配置、邮箱策略。/)
assert.match(page.pendingScopeHint.value, /请逐页核对;应用时会一起生效/)
assert.match(page.pendingScopeHint.value, /本次候选涉及:POP3 监听、邮件与账号策略。/)
assert.match(page.pendingScopeHint.value, /邮件服务页和安全策略页共用一个候选配置/)
page.pendingCandidate.value = { section: 'smtp' }
assert.equal(page.pendingScopeHint.value, '')
page.pendingCandidate.value = { section: 'email' }
page.pendingCandidate.value = { section: 'mail' }
assert.match(page.pendingScopeHint.value, /候选内容尚未完整读取/)
}
const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8')