diff --git a/backend/config/mail_listeners.go b/backend/config/mail_listeners.go new file mode 100644 index 0000000..44ce787 --- /dev/null +++ b/backend/config/mail_listeners.go @@ -0,0 +1,48 @@ +package config + +// 邮件监听与发信中继相互独立。零值表示沿用兼容默认值,不表示关闭服务。 +type SMTPListenerConfig struct { + Port int `json:"port"` + STARTTLSPort int `json:"starttlsPort"` + TLSPort int `json:"tlsPort"` +} + +type IMAPListenerConfig struct { + TLSPort int `json:"tlsPort"` +} + +type POP3ListenerConfig struct { + Port int `json:"port"` + TLSPort int `json:"tlsPort"` +} + +// ResolveMailPorts 是公开配置视图、冲突校验及真实监听的共同端口来源。 +func ResolveMailPorts(smtp SMTPSectionConfig, email EmailSectionConfig) MailPorts { + portOr := func(port, fallback int) int { + if validPort(port) { + return port + } + return fallback + } + return MailPorts{ + SMTP: portOr(smtp.Listener.Port, portOr(email.Policy.SMTPPort, 25)), + SMTPSTARTTLS: portOr(smtp.Listener.STARTTLSPort, 587), + SMTPTLS: portOr(smtp.Listener.TLSPort, 465), + // 旧版 imapPort 从未用于监听;保留原有的 993,避免升级后擅自切换客户端端口。 + IMAP: portOr(email.IMAP.TLSPort, 993), + POP3: portOr(email.POP3.Port, portOr(email.Policy.POP3Port, 110)), + POP3TLS: portOr(email.POP3.TLSPort, 995), + } +} + +func validateMailListeners(smtp SMTPSectionConfig, email EmailSectionConfig) error { + for _, port := range []int{ + smtp.Listener.Port, smtp.Listener.STARTTLSPort, smtp.Listener.TLSPort, + email.IMAP.TLSPort, email.POP3.Port, email.POP3.TLSPort, + } { + if port != 0 && !validPort(port) { + return ErrPortConflict + } + } + return nil +} diff --git a/backend/config/mail_listeners_test.go b/backend/config/mail_listeners_test.go new file mode 100644 index 0000000..2ca14af --- /dev/null +++ b/backend/config/mail_listeners_test.go @@ -0,0 +1,148 @@ +package config + +import ( + "context" + "encoding/json" + "errors" + "testing" +) + +func TestMailListenerConfigurationOverridesLegacyPolicyAndIgnoresRelay(t *testing.T) { + previous := CurrentManager() + defer SetRuntimeManager(previous) + runtime := validRuntimeConfig() + runtime.SMTP.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465} + runtime.SMTP.Relay.RelayPort = 1993 + runtime.Email.IMAP = IMAPListenerConfig{TLSPort: 1993} + runtime.Email.POP3 = POP3ListenerConfig{Port: 1110, TLSPort: 1995} + SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}}) + want := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 1993, POP3: 1110, POP3TLS: 1995} + if got := CurrentMailPorts(); got != want { + t.Fatalf("listeners = %+v, want %+v", got, want) + } +} + +func TestMailListenerValidationCoversEveryActualListener(t *testing.T) { + for name, mutate := range map[string]func(*RuntimeConfig){ + "smtp_http": func(r *RuntimeConfig) { r.SMTP.Listener.Port = 80 }, + "smtp_starttls_https": func(r *RuntimeConfig) { r.SMTP.Listener.STARTTLSPort = 443 }, + "smtp_tls_imap": func(r *RuntimeConfig) { r.SMTP.Listener.TLSPort = 993 }, + "imap_pop3_tls": func(r *RuntimeConfig) { r.Email.IMAP.TLSPort = 995 }, + "pop3_smtp": func(r *RuntimeConfig) { r.Email.POP3.Port = 25 }, + "pop3_tls_starttls": func(r *RuntimeConfig) { r.Email.POP3.TLSPort = 587 }, + "http_default": func(r *RuntimeConfig) { r.Root.HTTP.Port = 0; r.Email.IMAP.TLSPort = 80 }, + "smtp_negative": func(r *RuntimeConfig) { r.SMTP.Listener.Port = -1 }, + "starttls_overflow": func(r *RuntimeConfig) { r.SMTP.Listener.STARTTLSPort = 65536 }, + "tls_negative": func(r *RuntimeConfig) { r.SMTP.Listener.TLSPort = -1 }, + "imap_overflow": func(r *RuntimeConfig) { r.Email.IMAP.TLSPort = 65536 }, + "pop3_negative": func(r *RuntimeConfig) { r.Email.POP3.Port = -1 }, + "pop3_tls_overflow": func(r *RuntimeConfig) { r.Email.POP3.TLSPort = 65536 }, + } { + t.Run(name, func(t *testing.T) { + runtime := validRuntimeConfig() + mutate(&runtime) + if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); !errors.Is(err, ErrPortConflict) { + t.Fatalf("validation = %v, want port conflict", err) + } + }) + } +} + +func TestMailListenerCandidateApplyAndReloadPreservesAllSections(t *testing.T) { + root := t.TempDir() + initial := validRuntimeConfig() + writeRuntimeFixture(t, root, initial) + options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}} + manager, err := NewConfigManager(root, options) + if err != nil { + t.Fatal(err) + } + for _, section := range []Section{SectionSMTP, SectionEmail} { + current := manager.Snapshot() + var value any + if section == SectionSMTP { + current.SMTP.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465} + value = current.SMTP + } else { + current.Email.IMAP.TLSPort = 1993 + current.Email.POP3 = POP3ListenerConfig{Port: 1110, TLSPort: 1995} + value = current.Email + } + payload, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + updated, err := manager.Update(context.Background(), UpdateRequest{ + Section: section, ExpectedGeneration: current.Generation, Payload: payload, Actor: authorizedConfigActor(), + }) + if err != nil { + t.Fatal(err) + } + if manager.Snapshot().Generation != current.Generation { + t.Fatal("saving candidate published it prematurely") + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, section, authorizedConfigActor()); err != nil { + t.Fatal(err) + } + } + reloaded, err := NewConfigLoader(root, options).Load() + if err != nil { + t.Fatal(err) + } + want := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 1993, POP3: 1110, POP3TLS: 1995} + if got := ResolveMailPorts(reloaded.SMTP, reloaded.Email); got != want { + t.Fatalf("reloaded listeners = %+v, want %+v", got, want) + } + if reloaded.Email.Policy != initial.Email.Policy || reloaded.SMTP.Relay != initial.SMTP.Relay { + t.Fatal("listener change overwrote policy or relay") + } +} + +func TestMailListenerConflictsUseEffectiveWebPorts(t *testing.T) { + previous := RuntimeSetupPort() + t.Cleanup(func() { SetRuntimeSetupPort(previous) }) + for _, tc := range []struct { + name string + http HTTPConfig + envHTTP string + envHTTPS string + setupPort int + mailPort int + conflict bool + }{ + {"environment_http", HTTPConfig{}, "9080", "", 8080, 9080, true}, + {"setup_http", HTTPConfig{}, "", "", 8080, 8080, true}, + {"configured_http", HTTPConfig{Port: 7080}, "9080", "", 8080, 7080, true}, + {"unused_environment_http", HTTPConfig{Port: 7080}, "9080", "", 8080, 9080, false}, + {"environment_https", HTTPConfig{HTTPSEnabled: true}, "", "9443", 8080, 9443, true}, + {"default_https", HTTPConfig{HTTPSEnabled: true}, "", "", 8080, 443, true}, + {"proxy_no_internal_https", HTTPConfig{TLSMode: HTTPSTLSModeProxy}, "", "9443", 8080, 9443, false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("SMAIL_HTTP_PORT", tc.envHTTP) + t.Setenv("SMAIL_HTTPS_PORT", tc.envHTTPS) + SetRuntimeSetupPort(tc.setupPort) + ports := ResolveMailPorts(SMTPSectionConfig{}, EmailSectionConfig{}) + ports.IMAP = tc.mailPort + err := validateRuntimePorts(tc.http, ports) + if errors.Is(err, ErrPortConflict) != tc.conflict { + t.Fatalf("validation = %v, want conflict %v", err, tc.conflict) + } + }) + } +} + +func TestOldPartitionPayloadsKeepExistingListenerDefaults(t *testing.T) { + var smtp SMTPSectionConfig + var email EmailSectionConfig + if err := ParseStrict([]byte(`{"meta":{"schema":1,"generation":1},"relay":{"relayPort":2465}}`), &smtp); err != nil { + t.Fatal(err) + } + if err := ParseStrict([]byte(`{"meta":{"schema":1,"generation":1},"policy":{"smtpPort":2525,"imapPort":1143,"pop3Port":1110}}`), &email); err != nil { + t.Fatal(err) + } + want := MailPorts{SMTP: 2525, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 1110, POP3TLS: 995} + if got := ResolveMailPorts(smtp, email); got != want { + t.Fatalf("legacy listeners changed: %+v", got) + } +} diff --git a/backend/config/runtime_access.go b/backend/config/runtime_access.go index 78c362b..34847f7 100644 --- a/backend/config/runtime_access.go +++ b/backend/config/runtime_access.go @@ -162,9 +162,12 @@ func cloneConfig(source *Config) *Config { } type MailPorts struct { - SMTP int - IMAP int - POP3 int + SMTP int + SMTPSTARTTLS int + SMTPTLS int + IMAP int + POP3 int + POP3TLS int } type MailDomains struct { @@ -273,18 +276,13 @@ func CurrentSMTPRuntimeSettings() (SMTPRuntimeSettings, error) { } func CurrentMailPorts() MailPorts { - ports := MailPorts{SMTP: 25, IMAP: 143, POP3: 110} - policy := CurrentEmailPolicy() - if policy.SMTPPort > 0 && policy.SMTPPort <= 65535 { - ports.SMTP = policy.SMTPPort + if manager := CurrentManager(); manager != nil { + if runtime := manager.Snapshot(); runtime.Generation != 0 { + return ResolveMailPorts(runtime.SMTP, runtime.Email) + } } - if policy.IMAPPort > 0 && policy.IMAPPort <= 65535 { - ports.IMAP = policy.IMAPPort - } - if policy.POP3Port > 0 && policy.POP3Port <= 65535 { - ports.POP3 = policy.POP3Port - } - return ports + // 旧版 SMTPPort 属于发信中继,不能用作本机监听端口。 + return ResolveMailPorts(SMTPSectionConfig{}, EmailSectionConfig{}) } func cloneHTTPConfig(source HTTPConfig) HTTPConfig { clone := source diff --git a/backend/config/runtime_access_test.go b/backend/config/runtime_access_test.go index 0d6308b..2ab41c8 100644 --- a/backend/config/runtime_access_test.go +++ b/backend/config/runtime_access_test.go @@ -70,7 +70,7 @@ func TestCurrentMailPortsUsesSafeDefaultsWithoutRuntimeManager(t *testing.T) { defer SetRuntimeManager(previousManager) SetRuntimeManager(nil) - if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, IMAP: 143, POP3: 110}) { + if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}) { t.Fatalf("mail ports = %+v, want safe defaults", got) } } @@ -131,7 +131,7 @@ func TestCurrentMailPortsReadsEmailPolicySnapshot(t *testing.T) { runtime.Email.Policy.POP3Port = 1110 SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}}) - if got := CurrentMailPorts(); got != (MailPorts{SMTP: 2525, IMAP: 1143, POP3: 1110}) { + if got := CurrentMailPorts(); got != (MailPorts{SMTP: 2525, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 1110, POP3TLS: 995}) { t.Fatalf("mail ports = %+v, want policy ports", got) } } @@ -146,7 +146,7 @@ func TestCurrentMailPortsFallsBackForInvalidPolicyValues(t *testing.T) { runtime.Email.Policy.POP3Port = -1 SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}}) - if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, IMAP: 143, POP3: 110}) { + if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}) { t.Fatalf("mail ports = %+v, want defaults for invalid policy values", got) } } @@ -160,7 +160,7 @@ func TestCurrentMailPortsDoesNotReadLegacyRelayPort(t *testing.T) { runtime.Email.Policy = EmailConfig{} SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}}) - if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, IMAP: 143, POP3: 110}) { + if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}) { t.Fatalf("mail ports = %+v, legacy relay port leaked into listener ports", got) } } @@ -170,7 +170,7 @@ func TestCurrentMailPortsUsesDefaultsForUnavailableSnapshot(t *testing.T) { defer SetRuntimeManager(previousManager) SetRuntimeManager(&ConfigManager{}) - if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, IMAP: 143, POP3: 110}) { + if got := CurrentMailPorts(); got != (MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}) { t.Fatalf("mail ports = %+v, want defaults for unavailable snapshot", got) } } diff --git a/backend/config/runtime_validator.go b/backend/config/runtime_validator.go index b982991..5fef330 100644 --- a/backend/config/runtime_validator.go +++ b/backend/config/runtime_validator.go @@ -55,7 +55,10 @@ func ValidateRuntimeConfig(runtime RuntimeConfig, options RuntimeValidationOptio if err := validateDomains(runtime.Root.Domains); err != nil { return err } - if err := validateRuntimePorts(runtime.Root.HTTP, runtime.Email.Policy); err != nil { + if err := validateMailListeners(runtime.SMTP, runtime.Email); err != nil { + return err + } + if err := validateRuntimePorts(runtime.Root.HTTP, ResolveMailPorts(runtime.SMTP, runtime.Email)); err != nil { return err } if err := validatePluginSection(runtime.Plugins); err != nil { @@ -65,9 +68,6 @@ func ValidateRuntimeConfig(runtime RuntimeConfig, options RuntimeValidationOptio if !options.HasSuperAdmin || runtime.Root.Domains.Primary == "" || len(runtime.Root.Domains.Accepted) == 0 || runtime.Root.Domains.Web == "" || !runtime.Root.Database.IsStructured() { return ErrInvalidRuntimeConfig } - if runtime.Email.Policy.SMTPPort == 0 || runtime.Email.Policy.IMAPPort == 0 || runtime.Email.Policy.POP3Port == 0 { - return ErrInvalidRuntimeConfig - } if runtime.Root.HTTP.HTTPSEnabled && runtime.Root.HTTP.HTTPSPort == 0 { return ErrInvalidRuntimeConfig } @@ -126,8 +126,10 @@ func normalizeConfigAccessHost(value string) (string, error) { return normalizeConfigDomain(value) } -func validateRuntimePorts(http HTTPConfig, email EmailConfig) error { - ports := []int{http.Port, email.SMTPPort, email.IMAPPort, email.POP3Port} +func validateRuntimePorts(http HTTPConfig, mail MailPorts) error { + // 与实际 Web 监听使用相同的环境变量、启动端口及反代默认值。 + http = ResolveHTTPPortDefaults(http, RuntimeSetupPort()) + ports := []int{http.Port, mail.SMTP, mail.SMTPSTARTTLS, mail.SMTPTLS, mail.IMAP, mail.POP3, mail.POP3TLS} if http.HTTPSEnabled || http.HTTPSPort != 0 { ports = append(ports, http.HTTPSPort) } diff --git a/backend/config/runtime_validator_test.go b/backend/config/runtime_validator_test.go index 48715b0..1438e57 100644 --- a/backend/config/runtime_validator_test.go +++ b/backend/config/runtime_validator_test.go @@ -61,7 +61,7 @@ func TestValidateRuntimeConfigRejectsCrossSectionViolations(t *testing.T) { mutate func(*RuntimeConfig, *RuntimeValidationOptions) want error }{ - {name: "port_conflict", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) { runtime.Email.Policy.IMAPPort = 443 }, want: ErrPortConflict}, + {name: "port_conflict", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) { runtime.Email.IMAP.TLSPort = 443 }, want: ErrPortConflict}, {name: "primary_domain_missing", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) { runtime.Root.Domains.Accepted = []string{"example.com"} }, want: ErrInvalidDomainConfig}, diff --git a/backend/config/sections.go b/backend/config/sections.go index 9ca4e9a..8721866 100644 --- a/backend/config/sections.go +++ b/backend/config/sections.go @@ -54,13 +54,16 @@ const ( ) type SMTPSectionConfig struct { - Meta SectionMeta `json:"meta"` - Relay SMTPConfig `json:"relay"` + Meta SectionMeta `json:"meta"` + Relay SMTPConfig `json:"relay"` + Listener SMTPListenerConfig `json:"listener,omitempty"` } type EmailSectionConfig struct { - Meta SectionMeta `json:"meta"` - Policy EmailConfig `json:"policy"` + Meta SectionMeta `json:"meta"` + Policy EmailConfig `json:"policy"` + IMAP IMAPListenerConfig `json:"imap,omitempty"` + POP3 POP3ListenerConfig `json:"pop3,omitempty"` } type SSLSectionConfig struct { diff --git a/backend/config/snapshot_test.go b/backend/config/snapshot_test.go index d52a0ec..a836eb1 100644 --- a/backend/config/snapshot_test.go +++ b/backend/config/snapshot_test.go @@ -15,7 +15,7 @@ func TestSnapshotStoreRejectsInvalidCandidate(t *testing.T) { t.Fatal(err) } invalid := validRuntimeConfig() - invalid.Email.Policy.IMAPPort = invalid.Root.HTTP.HTTPSPort + invalid.Email.IMAP.TLSPort = invalid.Root.HTTP.HTTPSPort if err := store.Publish(invalid); err == nil { t.Fatal("invalid runtime configuration was published") } diff --git a/backend/config/validator_security_test.go b/backend/config/validator_security_test.go index 0edcb7b..79a06c4 100644 --- a/backend/config/validator_security_test.go +++ b/backend/config/validator_security_test.go @@ -180,7 +180,7 @@ func TestSnapshotStoreRejectsInvalidCandidateWithoutChangingSnapshot(t *testing. } invalid := cloneRuntimeConfig(initial) - invalid.Email.Policy.IMAPPort = invalid.Root.HTTP.HTTPSPort + invalid.Email.IMAP.TLSPort = invalid.Root.HTTP.HTTPSPort if err := store.Publish(invalid); !errors.Is(err, ErrPortConflict) { t.Fatalf("publish error = %v, want %v", err, ErrPortConflict) } @@ -191,8 +191,8 @@ func TestSnapshotStoreRejectsInvalidCandidateWithoutChangingSnapshot(t *testing. if err != nil { t.Fatal(err) } - if after.Email.Policy.IMAPPort != before.Email.Policy.IMAPPort { - t.Fatalf("snapshot changed after rejection: before=%d after=%d", before.Email.Policy.IMAPPort, after.Email.Policy.IMAPPort) + if after.Email.IMAP.TLSPort != before.Email.IMAP.TLSPort { + t.Fatalf("snapshot changed after rejection: before=%d after=%d", before.Email.IMAP.TLSPort, after.Email.IMAP.TLSPort) } } @@ -216,7 +216,7 @@ func TestConfigManagerRejectsInvalidUpdateWithoutDiskOrStatusMutation(t *testing beforeStatus := manager.Status() current := manager.Snapshot() invalidEmail := current.Email - invalidEmail.Policy.IMAPPort = current.Root.HTTP.HTTPSPort + invalidEmail.IMAP.TLSPort = current.Root.HTTP.HTTPSPort payload, err := json.Marshal(invalidEmail) if err != nil { t.Fatal(err) diff --git a/backend/controllers/config.go b/backend/controllers/config.go index db910c8..275ebac 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -444,8 +444,10 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) ( "security": runtime.Root.Security, }, nil case config.SectionSMTP: + ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email) return map[string]any{ - "meta": runtime.SMTP.Meta, + "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, @@ -460,7 +462,11 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) ( "relayPasswordStatus": publicSecret(runtime.SMTP.Relay.Password), }, nil case config.SectionEmail: - return runtime.Email, nil + ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email) + value := runtime.Email + value.IMAP = config.IMAPListenerConfig{TLSPort: ports.IMAP} + value.POP3 = config.POP3ListenerConfig{Port: ports.POP3, TLSPort: ports.POP3TLS} + return value, nil case config.SectionSSL: return runtime.SSL, nil case config.SectionPlugins: diff --git a/backend/controllers/config_mail_ports_test.go b/backend/controllers/config_mail_ports_test.go new file mode 100644 index 0000000..090c135 --- /dev/null +++ b/backend/controllers/config_mail_ports_test.go @@ -0,0 +1,49 @@ +package controllers + +import ( + "encoding/json" + "testing" + + "git.pcux.cn/xuans/Smail/config" +) + +func TestPublicMailPortsMatchListenersWithoutChangingSnapshot(t *testing.T) { + for _, custom := range []bool{false, true} { + runtime := config.RuntimeConfig{ + SMTP: config.SMTPSectionConfig{Relay: config.SMTPConfig{RelayPort: 9999}}, + Email: config.EmailSectionConfig{Policy: config.EmailConfig{SMTPPort: 2525, IMAPPort: 1143, POP3Port: 1110}}, + } + if custom { + runtime.SMTP.Listener = config.SMTPListenerConfig{Port: 3025, STARTTLSPort: 3587, TLSPort: 3465} + runtime.Email.IMAP.TLSPort = 3993 + runtime.Email.POP3 = config.POP3ListenerConfig{Port: 3110, TLSPort: 3995} + } + before := runtime.Email + want := config.ResolveMailPorts(runtime.SMTP, runtime.Email) + smtpPublic, err := publicConfigSection(runtime, config.SectionSMTP) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(smtpPublic) + if err != nil { + t.Fatal(err) + } + var smtp struct { + Listener config.SMTPListenerConfig `json:"listener"` + } + if err := json.Unmarshal(data, &smtp); err != nil { + t.Fatal(err) + } + emailPublic, err := publicConfigSection(runtime, config.SectionEmail) + if err != nil { + t.Fatal(err) + } + email := emailPublic.(config.EmailSectionConfig) + if smtp.Listener.Port != want.SMTP || smtp.Listener.STARTTLSPort != want.SMTPSTARTTLS || smtp.Listener.TLSPort != want.SMTPTLS || email.IMAP.TLSPort != want.IMAP || email.POP3.Port != want.POP3 || email.POP3.TLSPort != want.POP3TLS { + t.Fatal("public ports differ from listener configuration") + } + if before != runtime.Email { + t.Fatal("public view changed runtime configuration") + } + } +} diff --git a/backend/listen/imap_server/imap_server.go b/backend/listen/imap_server/imap_server.go index e44680f..19620e6 100644 --- a/backend/listen/imap_server/imap_server.go +++ b/backend/listen/imap_server/imap_server.go @@ -20,14 +20,14 @@ func Stop() { } } -// StarTLS 启动TLS端口监听,不加密的代码就懒得写了 +// StarTLS 启动隐式 TLS 监听,不提供明文认证入口。 func StarTLS() error { server, err := newIMAPServer() if err != nil { return err } instanceTLS = server - const imapTLSPort = 993 + imapTLSPort := config.CurrentMailPorts().IMAP log.Infof("IMAP With TLS Server Start On Port :%d", imapTLSPort) if err := instanceTLS.ListenAndServeTLS(fmt.Sprintf(":%d", imapTLSPort)); err != nil { return fmt.Errorf("serve IMAP TLS: %w", err) diff --git a/backend/listen/pop3_server/pop3server.go b/backend/listen/pop3_server/pop3server.go index 849777d..cc5c0d4 100644 --- a/backend/listen/pop3_server/pop3server.go +++ b/backend/listen/pop3_server/pop3server.go @@ -30,10 +30,11 @@ func StartWithTls() error { tlsConfig.Certificates = []tls.Certificate{crt} tlsConfig.Time = time.Now tlsConfig.Rand = rand.Reader - instanceTls = gopop.NewPop3Server(995, "pop."+domains.Primary, true, tlsConfig, action{}) + port := config.CurrentMailPorts().POP3TLS + instanceTls = gopop.NewPop3Server(port, "pop."+domains.Primary, true, tlsConfig, action{}) instanceTls.ConnectAliveTime = 5 * time.Minute - log.Infof("POP3 With TLS Server Start On Port :995") + log.Infof("POP3 With TLS Server Start On Port :%d", port) err = instanceTls.Start() if err != nil { diff --git a/backend/listen/smtp_server/smtp.go b/backend/listen/smtp_server/smtp.go index 04be260..9e6f116 100644 --- a/backend/listen/smtp_server/smtp.go +++ b/backend/listen/smtp_server/smtp.go @@ -34,7 +34,7 @@ func StartWithTLSNew() error { instanceTlsNew = smtp.NewServer(be) - instanceTlsNew.Addr = ":587" + instanceTlsNew.Addr = fmt.Sprintf(":%d", config.CurrentMailPorts().SMTPSTARTTLS) instanceTlsNew.Domain = domains.Primary instanceTlsNew.ReadTimeout = 10 * time.Second instanceTlsNew.WriteTimeout = 10 * time.Second @@ -51,7 +51,7 @@ func StartWithTLSNew() error { instanceTlsNew.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}, MinVersion: tls.VersionTLS12} log.Println("Starting Smtp With STARTTLS Server Port:", instanceTlsNew.Addr) - // 587端口使用STARTTLS(先明文连接,再升级TLS),而非隐式TLS + // 提交端口使用 STARTTLS(先连接,再升级 TLS),而非隐式 TLS。 if err := instanceTlsNew.ListenAndServe(); err != nil && !errors.Is(err, smtp.ErrServerClosed) { return fmt.Errorf("serve SMTP STARTTLS: %w", err) } @@ -71,7 +71,7 @@ func StartWithTLS() error { instanceTls = smtp.NewServer(be) - instanceTls.Addr = ":465" + instanceTls.Addr = fmt.Sprintf(":%d", config.CurrentMailPorts().SMTPTLS) instanceTls.Domain = domains.Primary instanceTls.ReadTimeout = 10 * time.Second instanceTls.WriteTimeout = 10 * time.Second diff --git a/docs/邮件协议监听配置说明.md b/docs/邮件协议监听配置说明.md new file mode 100644 index 0000000..35366c7 --- /dev/null +++ b/docs/邮件协议监听配置说明.md @@ -0,0 +1,34 @@ +# 邮件协议监听配置说明 + +## 配置入口 + +进入“设置 → 配置管理”,分别打开 SMTP 配置、IMAP 配置、POP3 配置。 + +| 配置页面 | 监听用途 | 默认端口 | +| --- | --- | --- | +| SMTP 配置 | 接收邮件,支持 STARTTLS | 25 | +| SMTP 配置 | 客户端提交,使用 STARTTLS | 587 | +| SMTP 配置 | 隐式 TLS | 465 | +| IMAP 配置 | 隐式 TLS | 993 | +| POP3 配置 | 使用 STLS 升级加密 | 110 | +| POP3 配置 | 隐式 TLS | 995 | + +SMTP 页面中的发信中继是外部服务器连接配置,与本机监听端口无关。IMAP 当前仅提供隐式 TLS,不提供明文或 STARTTLS 监听。 + +“邮箱策略”自动读取这三个页面的已应用端口,不能再单独修改端口。该汇总不是在线检测,也不展示尚未应用的候选端口。 + +## 保存与生效 + +1. 修改对应协议端口,完成近期登录认证并验证配置。 +2. 保存候选后核对内容,再应用配置。 +3. 根据待重启提示重启后端,使新监听端口生效,并同步调整客户端、反代或防火墙。 + +IMAP、POP3、邮箱策略是独立页面,但共用邮箱配置分区。候选会提示涉及哪些页面,需要逐页核对;应用时该分区一起生效。配置端口不能与其他邮件监听或 Web 监听冲突。 + +## 存储与旧配置兼容 + +- SMTP 监听保存在 SMTP 分区的 `listener`,不覆盖 `relay`。 +- IMAP、POP3 分别保存在邮箱分区的 `imap`、`pop3`,保留 `policy`。 +- 未配置新 SMTP 收信端口、POP3 STLS 端口时,兼容旧策略端口;未配置其他端口时采用表中默认值。零值表示兼容默认,不表示禁用服务。 +- 旧 `policy.imapPort` 从未控制实际监听,保留该字段但不把原有 TLS 端口从 993 自动迁移到 143。 +- 保持原有配置分区文件结构,无需新增独立 IMAP、POP3 配置文件。 diff --git a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue index 7004618..4db3e37 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue @@ -88,6 +88,7 @@ class="config-alert" /> + + 我已确认这是受控变更,并允许使用最近认证完成高风险配置操作 @@ -164,8 +172,10 @@ diff --git a/frontend/src/views/components/ConfigSettings/MailPortSummary/MailPortSummary.css b/frontend/src/views/components/ConfigSettings/MailPortSummary/MailPortSummary.css new file mode 100644 index 0000000..0e58f70 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/MailPortSummary/MailPortSummary.css @@ -0,0 +1,52 @@ +.mail-port-summary { + margin-bottom: 24px; +} + +.summary-note { + margin: 0 0 14px; + color: var(--pm-text-secondary); + font-size: 12px; + line-height: 1.7; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.protocol-card { + padding: 16px; + border: 1px solid var(--pm-border-color); + border-radius: 10px; + min-width: 0; +} + +.protocol-card dl { + margin: 12px 0 0; +} + +.port-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 6px 12px; + margin-top: 8px; + font-size: 12px; +} + +.port-row dt { + color: var(--pm-text-secondary); +} + +.port-row dd { + margin: 0; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +@container config-main (max-width: 680px) { + .summary-grid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/frontend/src/views/components/ConfigSettings/MailPortSummary/MailPortSummary.vue b/frontend/src/views/components/ConfigSettings/MailPortSummary/MailPortSummary.vue new file mode 100644 index 0000000..45505d1 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/MailPortSummary/MailPortSummary.vue @@ -0,0 +1,22 @@ + + + 端口自动读取 SMTP、IMAP、POP3 配置,不能在邮箱策略中修改。这里显示已应用的配置值,不是端口在线检测;如有待重启提示,请重启后端后再连接。 + + + {{ service.name }} + + + {{ port.label }} + {{ port.value ?? '未读取' }} + + + + + + + + + + diff --git a/frontend/src/views/components/ConfigSettings/Pop3Config/Pop3Config.vue b/frontend/src/views/components/ConfigSettings/Pop3Config/Pop3Config.vue new file mode 100644 index 0000000..6bc7c6a --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/Pop3Config/Pop3Config.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/test/backend/mail_listeners_test.go b/test/backend/mail_listeners_test.go new file mode 100644 index 0000000..0ae3175 --- /dev/null +++ b/test/backend/mail_listeners_test.go @@ -0,0 +1,201 @@ +package integration_test + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/listen/imap_server" + "git.pcux.cn/xuans/Smail/listen/pop3_server" + "git.pcux.cn/xuans/Smail/listen/smtp_server" +) + +// 使用空白临时配置和临时证书,在非默认端口调用生产监听入口;不连接真实账号库。 +func TestConfiguredMailListenerPortsAcceptProtocolConnections(t *testing.T) { + root := t.TempDir() + ports := reservePorts(t, 6) + meta := config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 1} + smtp := config.SMTPSectionConfig{Meta: meta, Listener: config.SMTPListenerConfig{ + Port: ports[0], STARTTLSPort: ports[1], TLSPort: ports[2], + }} + 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{ + ConfigVersion: config.CurrentConfigVersion, SchemaVersion: config.CurrentSchemaVersion, Generation: 1, SetupState: config.SetupStateBootstrap, + Database: config.DatabaseConfig{Type: config.DatabaseTypeSQLite, LocalRef: "unused.db"}, + Domains: config.DomainConfig{Primary: "mail.example.com", Web: "mail.example.com", Accepted: []string{"mail.example.com"}}, + Security: config.SecurityConfig{PasswordVersion: "argon2id"}, + Sections: map[config.Section]config.SectionRef{}, + } + directory := filepath.Join(root, "config") + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + for section, value := range map[config.Section]any{ + config.SectionSMTP: smtp, config.SectionEmail: email, config.SectionSSL: ssl, + config.SectionPlugins: config.PluginsSectionConfig{Meta: meta}, + } { + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + name, _ := config.FixedSectionFile(section) + if err := os.WriteFile(filepath.Join(directory, name), data, 0600); err != nil { + t.Fatal(err) + } + rootConfig.Sections[section] = config.SectionRef{File: name, Schema: 1, Generation: 1, SHA256: config.SectionDigest(data)} + } + data, err := json.Marshal(rootConfig) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "config.json"), data, 0600); err != nil { + t.Fatal(err) + } + manager, err := config.NewConfigManager(root, config.ConfigLoaderOptions{}) + if err != nil { + t.Fatal(err) + } + previousManager, previousConfig, previousRoot := config.CurrentManager(), config.CurrentConfig(), config.ROOT_PATH + t.Cleanup(func() { + config.SetRuntimeManager(previousManager) + config.PublishConfigSnapshot(previousConfig) + config.ROOT_PATH = previousRoot + }) + config.ROOT_PATH = root + config.SetRuntimeManager(manager) + certificate, key := writeCertificate(t, root) + config.PublishConfigSnapshot(&config.Config{SSLPublicKeyPath: certificate, SSLPrivateKeyPath: key}) + cases := []struct { + name string + port int + tls bool + prefix string + start func() error + }{ + {"smtp", ports[0], false, "220", smtp_server.Start}, + {"smtp_starttls", ports[1], false, "220", smtp_server.StartWithTLSNew}, + {"smtp_tls", ports[2], true, "220", smtp_server.StartWithTLS}, + {"imap_tls", ports[3], true, "* OK", imap_server.StarTLS}, + {"pop3_stls", ports[4], false, "+OK", pop3_server.Start}, + {"pop3_tls", ports[5], true, "+OK", pop3_server.StartWithTls}, + } + var results []<-chan error + t.Cleanup(func() { + smtp_server.Stop() + imap_server.Stop() + pop3_server.Stop() + for _, result := range results { + select { + case <-result: + case <-time.After(3 * time.Second): + t.Error("listener did not stop before configuration cleanup") + } + } + }) + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + result := make(chan error, 1) + results = append(results, result) + go func() { + defer close(result) + result <- item.start() + }() + address := fmt.Sprintf("127.0.0.1:%d", item.port) + var conn net.Conn + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + select { + case err := <-result: + t.Fatalf("listener exited before connection: %v", err) + default: + } + dialer := &net.Dialer{Timeout: 200 * time.Millisecond} + if item.tls { + conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}) + } else { + conn, err = dialer.Dial("tcp", address) + } + if err == nil { + break + } + time.Sleep(25 * time.Millisecond) + } + if err != nil { + t.Fatalf("connect custom port: %v", err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + greeting, err := bufio.NewReader(conn).ReadString('\n') + if err != nil || !strings.HasPrefix(greeting, item.prefix) { + t.Fatalf("protocol greeting = %q, error = %v", greeting, err) + } + t.Logf("%s accepted protocol connection on custom port %d", item.name, item.port) + }) + } +} + +func reservePorts(t *testing.T, count int) []int { + t.Helper() + var ports []int + var listeners []net.Listener + defer func() { + for _, listener := range listeners { + _ = listener.Close() + } + }() + for range count { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + listeners = append(listeners, listener) + ports = append(ports, listener.Addr().(*net.TCPAddr).Port) + } + return ports +} + +func writeCertificate(t *testing.T, root string) (string, string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + certificatePath, keyPath := filepath.Join(root, "certificate.pem"), filepath.Join(root, "key.pem") + for path, block := range map[string]*pem.Block{ + certificatePath: {Type: "CERTIFICATE", Bytes: der}, keyPath: {Type: "PRIVATE KEY", Bytes: keyDER}, + } { + if err := os.WriteFile(path, pem.EncodeToMemory(block), 0600); err != nil { + t.Fatal(err) + } + } + return certificatePath, keyPath +} diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index cc21556..4ec7a35 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -10,7 +10,7 @@ const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } = source.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }' )() -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']) { @@ -22,7 +22,7 @@ test('五个分区使用分组表单,控件和样式能编译且不再提供 J assert.deepEqual(compileStyle({ source: css, id: `data-v-${name}`, scoped: true }).errors, []) assert.doesNotMatch(template, /[0-9a-f]+;|type="textarea"|配置 JSON 编辑器/i) } - for (const section of ['base', 'smtp', 'email', 'ssl', 'plugins']) { + for (const section of ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']) { const groups = getFieldGroups(section, { plugins: { spam_block: { settings: null } } }) assert.ok(groups.length) const keys = groups.flatMap(group => group.fields.map(field => field.path.join('.'))) @@ -32,6 +32,19 @@ test('五个分区使用分组表单,控件和样式能编译且不再提供 J } }) +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)) + 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']) +}) + test('文本输入的左右留白属于原生输入框,不影响数字、选择和开关控件', () => { const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) const { parse, compileStyle } = require('@vue/compiler-sfc') @@ -151,9 +164,9 @@ 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('email', { policy: { smtpPort: value, imapPort: 143, pop3Port: 110 } })['policy.smtpPort']) + assert.ok(validateFormConfig('imap', { imap: { tlsPort: value } })['imap.tlsPort']) } - assert.ok(validateFormConfig('email', { policy: { smtpPort: 143, imapPort: 143, pop3Port: 110 } })['policy.imapPort']) + 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 } }), {}) }) diff --git a/test/frontend/config-navigation.test.mjs b/test/frontend/config-navigation.test.mjs index 17be133..2eb4c1c 100644 --- a/test/frontend/config-navigation.test.mjs +++ b/test/frontend/config-navigation.test.mjs @@ -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', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance'] +const components = ['BaseConfig', 'SmtpConfig', 'ImapConfig', 'Pop3Config', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance'] const createNavigation = compileFunction( source.replace(/^import .*$/gm, '').replace('export function useNavigation', 'function useNavigation') + '\nreturn useNavigation()', ['computed', 'nextTick', 'ref', 'useGlobalStatusStore', ...components] @@ -18,9 +18,10 @@ function fixture(capabilities = ['config.read']) { return { page, allowed } } -test('五个菜单对应五个不同页面组件,无权限的维护入口不可打开', async () => { +test('七个菜单对应七个不同页面组件,无权限的维护入口不可打开', async () => { const { page, allowed } = fixture() - assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 5) + 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(page.maintenanceOptions.value.length, 0) await page.selectSection('audit') await page.selectSection('invalid') diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 24985a6..86c885c 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -219,9 +219,9 @@ test('同一页重复加载时,过期失败不覆盖最新请求', async () => assert.deepEqual(JSON.parse(page.editorText.value), { enabled: true }) }) -test('五个配置页固定分区并按需加载,所有分区均提供中文说明', async () => { +test('七个配置页按需加载,协议页复用正确分区且提供中文说明', async () => { const requests = [] - for (const key of ['base', 'smtp', 'email', 'ssl', 'plugins']) { + for (const key of ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']) { const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }, key) await page.loadAll() assert.equal(page.selectedSection.value, key) @@ -230,8 +230,78 @@ test('五个配置页固定分区并按需加载,所有分区均提供中文 assert.equal(page.confirmed.value, false) assert.equal(page.savedGeneration.value, 0) } - assert.deepEqual(requests, ['base', 'smtp', 'email', 'ssl', 'plugins'] - .flatMap(key => ['/api/v1/config/status', `/api/v1/config/${key}`])) + 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'] : [])])) +}) + +for (const key of ['imap', 'pop3']) { + test(`${key} 页面验证、保存、候选恢复及应用均使用邮箱分区且保留其他字段`, async () => { + const requests = [] + let pending = null + let active = { meta: { schema: 1, generation: 1 }, policy: { maxMessageBytes: 1234 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 } } + 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 } + return { errorNo: 0, data: pending } + }, + post: async (path, body) => { + requests.push(path) + if (path.endsWith('/apply')) { + assert.equal(body.candidateId, 'mail-candidate') + active = { ...pending.payload, meta: { schema: 1, generation: 2 } } + pending = null + } + return { errorNo: 0, data: { valid: true } } + } + } }) + await page.loadAll() + page.updateField([key, 'tlsPort'], key === 'imap' ? 1993 : 1995) + 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) + 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']) + }) +} + +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') + 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) }) test('迁移预览票据返回模板,迁移执行取消时不发送写入请求', async () => { @@ -312,7 +382,7 @@ test('独立页模板可编译,编辑器不再连接维护面板且权限绑 assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`)) } assert.match(maintenance, /v-if="canExportAudit"/) - for (const name of ['BaseConfig', 'SmtpConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']) { + for (const name of ['BaseConfig', 'SmtpConfig', 'ImapConfig', 'Pop3Config', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance', 'MailPortSummary']) { const page = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8') const { descriptor, errors } = parse(page) assert.deepEqual(errors, []) @@ -496,16 +566,16 @@ test('可视化编辑按权限和工作状态保护,编辑会清除过期确 test('前端无效字段阻止网络请求,修正字段后同步清除错误', async () => { const requests = [] - const { page } = fixture(async () => ({ errorNo: 0, data: { policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } } }), 'email', { + const { page } = fixture(async () => ({ errorNo: 0, data: { imap: { tlsPort: 993 } } }), 'imap', { http: { post: async path => { requests.push(path); return { errorNo: 0, data: { valid: true } } } } }) await page.loadAll() - page.updateField(['policy', 'smtpPort'], null) + page.updateField(['imap', 'tlsPort'], null) await page.validateConfig() await page.saveConfig() assert.deepEqual(requests, []) - assert.ok(page.formErrors.value['policy.smtpPort']) - page.updateField(['policy', 'smtpPort'], 2525) + assert.ok(page.formErrors.value['imap.tlsPort']) + page.updateField(['imap', 'tlsPort'], 1993) assert.deepEqual(page.formErrors.value, {}) await page.validateConfig() assert.deepEqual(requests, ['/api/v1/config/email/validate']) @@ -595,3 +665,22 @@ test('保存后更改密码或填写内容不能应用旧候选', async () => { assert.equal(page.savedGeneration.value, 0) 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) + 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.payload = { ...active, pop3: { port: 1110, tlsPort: 1995 }, policy: { maxMessageBytes: 5678 } } + 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' } + assert.match(page.pendingScopeHint.value, /候选内容尚未完整读取/) + } + const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8') + assert.match(template, /:title="pendingScopeHint"/) +})
端口自动读取 SMTP、IMAP、POP3 配置,不能在邮箱策略中修改。这里显示已应用的配置值,不是端口在线检测;如有待重启提示,请重启后端后再连接。