diff --git a/backend/config/mail_listeners.go b/backend/config/mail_listeners.go index 44ce787..19b7b60 100644 --- a/backend/config/mail_listeners.go +++ b/backend/config/mail_listeners.go @@ -1,10 +1,17 @@ package config -// 邮件监听与发信中继相互独立。零值表示沿用兼容默认值,不表示关闭服务。 +const ( + MailSecurityBoth = "both" + MailSecuritySTARTTLS = "starttls" + MailSecurityImplicitTLS = "implicit_tls" +) + +// 邮件监听与发信中继相互独立。未设置加密模式的旧配置继续同时开放两套监听。 type SMTPListenerConfig struct { - Port int `json:"port"` - STARTTLSPort int `json:"starttlsPort"` - TLSPort int `json:"tlsPort"` + Port int `json:"port"` + STARTTLSPort int `json:"starttlsPort"` + TLSPort int `json:"tlsPort"` + Security string `json:"security,omitempty"` } type IMAPListenerConfig struct { @@ -16,6 +23,32 @@ type POP3ListenerConfig struct { TLSPort int `json:"tlsPort"` } +func ResolveMailSecurity(listener SMTPListenerConfig) string { + switch NormalizeMailSecurity(listener.Security) { + case MailSecuritySTARTTLS: + return MailSecuritySTARTTLS + case MailSecurityImplicitTLS: + return MailSecurityImplicitTLS + case "", MailSecurityBoth: + return MailSecurityBoth + default: + return listener.Security + } +} + +// NormalizeMailSecurity 保留空值及未知值,只将旧版 TLS 名称规范为当前名称。 +func NormalizeMailSecurity(value string) string { + if value == "tls" { + return MailSecurityImplicitTLS + } + return value +} + +// PublicMailSecurity 保留旧配置的空值,只规范公开视图中的旧版名称。 +func PublicMailSecurity(listener SMTPListenerConfig) string { + return NormalizeMailSecurity(listener.Security) +} + // ResolveMailPorts 是公开配置视图、冲突校验及真实监听的共同端口来源。 func ResolveMailPorts(smtp SMTPSectionConfig, email EmailSectionConfig) MailPorts { portOr := func(port, fallback int) int { @@ -24,7 +57,7 @@ func ResolveMailPorts(smtp SMTPSectionConfig, email EmailSectionConfig) MailPort } return fallback } - return MailPorts{ + ports := MailPorts{ SMTP: portOr(smtp.Listener.Port, portOr(email.Policy.SMTPPort, 25)), SMTPSTARTTLS: portOr(smtp.Listener.STARTTLSPort, 587), SMTPTLS: portOr(smtp.Listener.TLSPort, 465), @@ -33,9 +66,34 @@ func ResolveMailPorts(smtp SMTPSectionConfig, email EmailSectionConfig) MailPort POP3: portOr(email.POP3.Port, portOr(email.Policy.POP3Port, 110)), POP3TLS: portOr(email.POP3.TLSPort, 995), } + switch ResolveMailSecurity(smtp.Listener) { + case MailSecuritySTARTTLS: + ports.SMTPTLS = 0 + ports.IMAP = portOr(email.IMAP.TLSPort, 143) + ports.POP3TLS = 0 + case MailSecurityImplicitTLS: + ports.SMTPSTARTTLS = 0 + ports.IMAP = portOr(email.IMAP.TLSPort, 993) + ports.POP3 = 0 + } + return ports } func validateMailListeners(smtp SMTPSectionConfig, email EmailSectionConfig) error { + if smtp.Listener.Security == MailSecurityBoth { + return ErrInvalidRuntimeConfig + } + security := ResolveMailSecurity(smtp.Listener) + if security != MailSecurityBoth && security != MailSecuritySTARTTLS && security != MailSecurityImplicitTLS { + return ErrInvalidRuntimeConfig + } + relayConfigured := smtpRelayConfigured(smtp.Relay) + if relayConfigured && security != MailSecurityBoth { + relaySecurity := NormalizeMailSecurity(smtp.Relay.Security) + if relaySecurity != security { + return ErrInvalidSMTP + } + } for _, port := range []int{ smtp.Listener.Port, smtp.Listener.STARTTLSPort, smtp.Listener.TLSPort, email.IMAP.TLSPort, email.POP3.Port, email.POP3.TLSPort, diff --git a/backend/config/mail_listeners_test.go b/backend/config/mail_listeners_test.go index 2ca14af..1f50d04 100644 --- a/backend/config/mail_listeners_test.go +++ b/backend/config/mail_listeners_test.go @@ -146,3 +146,67 @@ func TestOldPartitionPayloadsKeepExistingListenerDefaults(t *testing.T) { t.Fatalf("legacy listeners changed: %+v", got) } } + +func TestUnifiedMailSecurityResolvesOnlySelectedClientListeners(t *testing.T) { + for _, test := range []struct { + security string + want MailPorts + }{ + {MailSecuritySTARTTLS, MailPorts{SMTP: 25, SMTPSTARTTLS: 587, IMAP: 143, POP3: 110}}, + {MailSecurityImplicitTLS, MailPorts{SMTP: 25, SMTPTLS: 465, IMAP: 993, POP3TLS: 995}}, + } { + smtp := SMTPSectionConfig{Listener: SMTPListenerConfig{Security: test.security}} + if got := ResolveMailPorts(smtp, EmailSectionConfig{}); got != test.want { + t.Fatalf("%s ports = %+v, want %+v", test.security, got, test.want) + } + } +} + +func TestLegacyMailSecurityKeepsExistingListeners(t *testing.T) { + smtp := SMTPSectionConfig{Listener: SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465}} + email := EmailSectionConfig{IMAP: IMAPListenerConfig{TLSPort: 2993}, POP3: POP3ListenerConfig{Port: 2110, TLSPort: 2995}} + want := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 2993, POP3: 2110, POP3TLS: 2995} + if got := ResolveMailPorts(smtp, email); got != want { + t.Fatalf("legacy ports = %+v, want %+v", got, want) + } + if got := ResolveMailSecurity(smtp.Listener); got != MailSecurityBoth { + t.Fatalf("legacy security = %q, want %q", got, MailSecurityBoth) + } + if got := PublicMailSecurity(smtp.Listener); got != "" { + t.Fatalf("legacy public security = %q, want empty", got) + } + if got := PublicMailSecurity(SMTPListenerConfig{Security: "tls"}); got != MailSecurityImplicitTLS { + t.Fatalf("legacy TLS public security = %q, want %q", got, MailSecurityImplicitTLS) + } + if got := NormalizeMailSecurity("tls"); got != MailSecurityImplicitTLS { + t.Fatalf("legacy relay security = %q, want %q", got, MailSecurityImplicitTLS) + } +} + +func TestMailListenerValidationAcceptsMatchingLegacyTLSNames(t *testing.T) { + runtime := validRuntimeConfig() + runtime.SMTP.Listener.Security = "tls" + runtime.SMTP.Relay.Security = "tls" + if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); err != nil { + t.Fatalf("matching legacy TLS validation = %v", err) + } +} + +func TestMailListenerValidationRejectsUnknownSecurity(t *testing.T) { + runtime := validRuntimeConfig() + runtime.SMTP.Listener.Security = "unknown" + if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); !errors.Is(err, ErrInvalidRuntimeConfig) { + t.Fatalf("validation = %v, want invalid runtime configuration", err) + } +} + +func TestMailListenerValidationAllowsLegacyEmptySecurityButRejectsExplicitBoth(t *testing.T) { + runtime := validRuntimeConfig() + if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); err != nil { + t.Fatalf("legacy empty security validation = %v", err) + } + runtime.SMTP.Listener.Security = MailSecurityBoth + if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); !errors.Is(err, ErrInvalidRuntimeConfig) { + t.Fatalf("explicit both validation = %v, want invalid runtime configuration", err) + } +} diff --git a/backend/config/runtime_access.go b/backend/config/runtime_access.go index 705ac32..f76afa3 100644 --- a/backend/config/runtime_access.go +++ b/backend/config/runtime_access.go @@ -289,6 +289,15 @@ func CurrentMailPorts() MailPorts { // 旧版 SMTPPort 属于发信中继,不能用作本机监听端口。 return ResolveMailPorts(SMTPSectionConfig{}, EmailSectionConfig{}) } + +func CurrentMailSecurity() string { + if manager := CurrentManager(); manager != nil { + if runtime := manager.Snapshot(); runtime.Generation != 0 { + return ResolveMailSecurity(runtime.SMTP.Listener) + } + } + return MailSecurityBoth +} func cloneHTTPConfig(source HTTPConfig) HTTPConfig { clone := source clone.TrustedProxyCIDRs = append([]string(nil), source.TrustedProxyCIDRs...) diff --git a/backend/config/validator.go b/backend/config/validator.go index 6a87d0b..3bb2d0e 100644 --- a/backend/config/validator.go +++ b/backend/config/validator.go @@ -149,8 +149,7 @@ func validateSMTP(smtp SMTPConfig) error { return ErrInvalidSMTP } } - relayConfigured := smtp.RelayHost != "" || smtp.RelayPort != 0 || smtp.Username != "" || - smtp.Password.Configured() || smtp.Security != "" || smtp.From != "" + relayConfigured := smtpRelayConfigured(smtp) if !relayConfigured { return nil } @@ -167,6 +166,12 @@ func validateSMTP(smtp SMTPConfig) error { return nil } +// smtpRelayConfigured 只把实际连接或发件身份字段视为启用中继。 +// 推荐端口、统一加密方式和默认发件域名单独存在时,中继仍保持可选状态。 +func smtpRelayConfigured(smtp SMTPConfig) bool { + return smtp.RelayHost != "" || smtp.Username != "" || smtp.Password.Configured() || smtp.From != "" +} + func validateSSL(ssl SSLConfig) error { if !isControlledRelativePath(ssl.DefaultDirectory) { return ErrInvalidSSL diff --git a/backend/config/validator_security_test.go b/backend/config/validator_security_test.go index dfc3704..2eb769a 100644 --- a/backend/config/validator_security_test.go +++ b/backend/config/validator_security_test.go @@ -183,6 +183,15 @@ func TestValidateSMTPKeepsLoginVerificationIndependentFromRelay(t *testing.T) { }, valid: true, }, + { + name: "unified defaults without relay host", + smtp: SMTPConfig{ + RelayPort: 587, + Security: "starttls", + FromDomain: "example.com", + }, + valid: true, + }, { name: "partially configured relay", smtp: SMTPConfig{ diff --git a/backend/controllers/config.go b/backend/controllers/config.go index cb02f80..662fada 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -449,12 +449,12 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) ( 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}, + "listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS, Security: config.PublicMailSecurity(runtime.SMTP.Listener)}, "relay": map[string]any{ "relayHost": runtime.SMTP.Relay.RelayHost, "relayPort": runtime.SMTP.Relay.RelayPort, "username": runtime.SMTP.Relay.Username, - "security": runtime.SMTP.Relay.Security, + "security": config.NormalizeMailSecurity(runtime.SMTP.Relay.Security), "from": runtime.SMTP.Relay.From, "fromDomain": runtime.SMTP.Relay.FromDomain, "verificationEnabled": runtime.SMTP.Relay.VerificationEnabled, @@ -467,12 +467,12 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) ( 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}, + "listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS, Security: config.PublicMailSecurity(runtime.SMTP.Listener)}, "relay": map[string]any{ "relayHost": runtime.SMTP.Relay.RelayHost, "relayPort": runtime.SMTP.Relay.RelayPort, "username": runtime.SMTP.Relay.Username, - "security": runtime.SMTP.Relay.Security, + "security": config.NormalizeMailSecurity(runtime.SMTP.Relay.Security), "from": runtime.SMTP.Relay.From, "fromDomain": runtime.SMTP.Relay.FromDomain, "verificationEnabled": runtime.SMTP.Relay.VerificationEnabled, diff --git a/backend/controllers/config_mail_ports_test.go b/backend/controllers/config_mail_ports_test.go index df04c82..d5a3cc0 100644 --- a/backend/controllers/config_mail_ports_test.go +++ b/backend/controllers/config_mail_ports_test.go @@ -44,6 +44,9 @@ func TestPublicMailPortsMatchListenersWithoutChangingSnapshot(t *testing.T) { 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") } + if mail.Listener.Security != runtime.SMTP.Listener.Security { + t.Fatal("public mail view changed legacy security state") + } smtpPublic, err := publicConfigSection(runtime, config.SectionSMTP) if err != nil { t.Fatal(err) @@ -71,3 +74,36 @@ func TestPublicMailPortsMatchListenersWithoutChangingSnapshot(t *testing.T) { } } } + +func TestPublicMailSecurityNormalizesLegacyTLSNames(t *testing.T) { + runtime := config.RuntimeConfig{ + SMTP: config.SMTPSectionConfig{ + Listener: config.SMTPListenerConfig{Security: "tls"}, + Relay: config.SMTPConfig{Security: "tls"}, + }, + } + + for _, section := range []config.Section{config.SectionSMTP, config.SectionMail} { + public, err := publicConfigSection(runtime, section) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(public) + if err != nil { + t.Fatal(err) + } + var value struct { + Listener config.SMTPListenerConfig `json:"listener"` + Relay config.SMTPConfig `json:"relay"` + } + if err := json.Unmarshal(data, &value); err != nil { + t.Fatal(err) + } + if value.Listener.Security != config.MailSecurityImplicitTLS { + t.Fatalf("%s listener security = %q, want %q", section, value.Listener.Security, config.MailSecurityImplicitTLS) + } + if value.Relay.Security != config.MailSecurityImplicitTLS { + t.Fatalf("%s relay security = %q, want %q", section, value.Relay.Security, config.MailSecurityImplicitTLS) + } + } +} diff --git a/backend/listen/imap_server/imap_server.go b/backend/listen/imap_server/imap_server.go index 19620e6..456735c 100644 --- a/backend/listen/imap_server/imap_server.go +++ b/backend/listen/imap_server/imap_server.go @@ -9,9 +9,16 @@ import ( log "github.com/sirupsen/logrus" ) +var instance *imapserver.Server var instanceTLS *imapserver.Server func Stop() { + if instance != nil { + if err := instance.Close(); err != nil { + log.WithError(err).Warn("close IMAP STARTTLS server failed") + } + instance = nil + } if instanceTLS != nil { if err := instanceTLS.Close(); err != nil { log.WithError(err).Warn("close IMAP server failed") @@ -20,6 +27,21 @@ func Stop() { } } +// Start 启动普通 IMAP 监听,认证前必须通过 STARTTLS 升级连接。 +func Start() error { + server, err := newIMAPServer() + if err != nil { + return err + } + instance = server + imapPort := config.CurrentMailPorts().IMAP + log.Infof("IMAP With STARTTLS Server Start On Port :%d", imapPort) + if err := instance.ListenAndServe(fmt.Sprintf(":%d", imapPort)); err != nil { + return fmt.Errorf("serve IMAP STARTTLS: %w", err) + } + return nil +} + // StarTLS 启动隐式 TLS 监听,不提供明文认证入口。 func StarTLS() error { server, err := newIMAPServer() diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 878ed37..78c7a2a 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -194,10 +194,15 @@ func Init(serverVersion string) error { }() } hooks.Init(serverVersion) - // smtp server start + mailSecurity := config.CurrentMailSecurity() + // SMTP 25 用于服务器间投递,始终保留并支持机会性 STARTTLS。 startService(serviceErrors, "SMTP", smtp_server.Start) - startService(serviceErrors, "SMTP TLS", smtp_server.StartWithTLS) - startService(serviceErrors, "SMTP STARTTLS", smtp_server.StartWithTLSNew) + if mailSecurity == config.MailSecurityBoth || mailSecurity == config.MailSecurityImplicitTLS { + startService(serviceErrors, "SMTP TLS", smtp_server.StartWithTLS) + } + if mailSecurity == config.MailSecurityBoth || mailSecurity == config.MailSecuritySTARTTLS { + startService(serviceErrors, "SMTP STARTTLS", smtp_server.StartWithTLSNew) + } // http server start startService(serviceErrors, "HTTPS", http_server.HttpsStart) startService(serviceErrors, "HTTP", http_server.HttpStart) @@ -211,11 +216,17 @@ func Init(serverVersion string) error { // 更新 SSL 配置。 ssl.Update(false) - // pop3 server start - startService(serviceErrors, "POP3", pop3_server.Start) - startService(serviceErrors, "POP3 TLS", pop3_server.StartWithTls) - // imap server start - startService(serviceErrors, "IMAP TLS", imap_server.StarTLS) + if mailSecurity == config.MailSecurityBoth || mailSecurity == config.MailSecuritySTARTTLS { + startService(serviceErrors, "POP3 STLS", pop3_server.Start) + } + if mailSecurity == config.MailSecurityBoth || mailSecurity == config.MailSecurityImplicitTLS { + startService(serviceErrors, "POP3 TLS", pop3_server.StartWithTls) + } + if mailSecurity == config.MailSecuritySTARTTLS { + startService(serviceErrors, "IMAP STARTTLS", imap_server.Start) + } else { + startService(serviceErrors, "IMAP TLS", imap_server.StarTLS) + } log.Info("邮箱服务已启动") restart.SetCurrent(restart.NewSignalTrigger(signal.RestartChan)) diff --git a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue index a602f3d..320840d 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue @@ -25,7 +25,13 @@ placeholder="请选择" @update:model-value="update" > - + field(path, label, 'number', { min: 0, max: 65535, integer: true, ...extra }) const group = (id, title, description, fields) => ({ id, title, description, fields }) const smtpPorts = new Map([['starttls', 587], ['implicit_tls', 465], ['tls', 465], ['plain', 25]]) +const normalizedMailSecurity = value => value === 'tls' ? 'implicit_tls' : value +const mailSecurity = config => { + const value = normalizedMailSecurity(config.listener?.security) + return ['starttls', 'implicit_tls', 'both'].includes(value) ? value : 'both' +} +const startTLSMail = config => mailSecurity(config) === 'starttls' +const implicitTLSMail = config => mailSecurity(config) === 'implicit_tls' +const relayConfigured = config => !!( + config.relay?.relayHost || config.relay?.username || config.relay?.password || + config.relay?.from +) export function readFormConfig(text) { try { @@ -32,7 +43,7 @@ export function getRecommendedPort(config, path) { case 'http.httpsPort': return 443 case 'relay.relayPort': - return smtpPorts.get(config.relay?.security) + return smtpPorts.get(mailSecurity(config)) case 'listener.port': return 25 case 'listener.starttlsPort': @@ -40,7 +51,7 @@ export function getRecommendedPort(config, path) { case 'listener.tlsPort': return 465 case 'imap.tlsPort': - return 993 + return startTLSMail(config) ? 143 : 993 case 'pop3.port': return 110 case 'pop3.tlsPort': @@ -51,20 +62,6 @@ export function getRecommendedPort(config, path) { } const validPort = value => Number.isInteger(value) && value > 0 && value <= 65535 -const localSMTPHost = value => { - if (typeof value !== 'string') return false - const host = value.trim().toLowerCase().replace(/\.$/, '') - if (host === 'localhost') return true - if (/^127(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(host)) { - return host.split('.').every(part => Number(part) <= 255) - } - try { - return new URL(`http://[${host}]`).hostname === '[::1]' - } catch { - return false - } -} - const networkDatabase = config => config.database?.type !== 'sqlite' const uploadedCertificate = config => ['managed_upload', 'custom'].includes(config.tls?.mode) const certificateManagement = config => { @@ -117,23 +114,24 @@ const baseGroups = [ 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('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。' }), - field('relay.security', 'SMTP 中继加密方式', 'select', { + field('listener.security', '统一加密方式', 'select', { wide: true, - options: options([ - ['', '尚未配置'], - ['starttls', 'STARTTLS(推荐端口 587)'], - ['implicit_tls', '隐式 TLS(推荐端口 465)'], - ['tls', 'TLS 兼容模式(推荐端口 465)'], - ['plain', '不加密(仅限本机,端口 25)'] - ]), - hint: '仅控制下方外部 SMTP 中继连接,不改变本机 SMTP、IMAP 或 POP3 监听方式。切换后会自动匹配推荐中继端口,仍可手动填写服务商指定的端口。' - }) + fallback: 'both', + options: [ + { value: 'both', label: '旧配置兼容状态(请选择统一加密方式)', disabled: true }, + ...options([ + ['starttls', 'STARTTLS / STLS'], + ['implicit_tls', '隐式 TLS'] + ]) + ], + hint: '切换后 SMTP 提交、IMAP、POP3 和已配置的 SMTP 中继会统一使用同一种加密方式,并自动匹配推荐端口。SMTP 25 收信端口始终保留。' + }), + port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }), + port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, show: config => !implicitTLSMail(config), hint: '默认 587,客户端升级 TLS 后进行认证。' }), + port('listener.tlsPort', 'SMTP 提交端口(隐式 TLS)', { min: 1, show: config => !startTLSMail(config), hint: '默认 465,客户端建立连接时直接使用 TLS。' }), + port('imap.tlsPort', 'IMAP 端口', { min: 1, required: true, dynamicLabel: config => startTLSMail(config) ? 'IMAP 端口(STARTTLS)' : 'IMAP 端口(隐式 TLS)', hint: 'STARTTLS 默认使用 143,隐式 TLS 默认使用 993。' }), + port('pop3.port', 'POP3 端口(STLS)', { min: 1, required: true, show: config => !implicitTLSMail(config), hint: '默认 110,客户端使用 STLS 升级加密后认证。' }), + port('pop3.tlsPort', 'POP3 端口(隐式 TLS)', { min: 1, required: true, show: config => !startTLSMail(config), hint: '默认 995,客户端建立连接时直接使用 TLS。' }) ]), group('relay', 'SMTP 中继连接(可选)', '', [ field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }), @@ -212,7 +210,11 @@ export function getFieldGroups(section, config) { ...item, fields: item.fields .filter(input => !input.show || input.show(config)) - .map(input => ({ ...input, recommendedPort: getRecommendedPort(config, input.path) })) + .map(input => ({ + ...input, + label: input.dynamicLabel ? input.dynamicLabel(config) : input.label, + recommendedPort: getRecommendedPort(config, input.path) + })) })) .filter(item => item.fields.length > 0) } @@ -252,9 +254,23 @@ export function updateFormConfig(text, section, path, value) { } parent[path.at(-1)] = value ?? (input.type === 'number' ? null : '') if (previousValue !== value) { - if (key === 'relay.security') { - const recommended = getRecommendedPort(config, ['relay', 'relayPort']) - if (recommended !== undefined) config.relay.relayPort = recommended + if (key === 'listener.security' && ['starttls', 'implicit_tls'].includes(value)) { + const startTLS = value === 'starttls' + config.listener.port = 25 + config.listener.starttlsPort = startTLS ? 587 : 0 + config.listener.tlsPort = startTLS ? 0 : 465 + config.imap ||= {} + config.imap.tlsPort = startTLS ? 143 : 993 + config.pop3 ||= {} + config.pop3.port = startTLS ? 110 : 0 + config.pop3.tlsPort = startTLS ? 0 : 995 + config.relay ||= {} + config.relay.security = value + config.relay.relayPort = smtpPorts.get(value) + } + if (key.startsWith('relay.') && key !== 'relay.security' && relayConfigured(config) && ['starttls', 'implicit_tls'].includes(mailSecurity(config))) { + config.relay.security = mailSecurity(config) + if (!validPort(config.relay.relayPort)) config.relay.relayPort = smtpPorts.get(config.relay.security) } if (key === 'http.tlsMode' && ['direct', 'proxy'].includes(value)) { if (!validPort(config.http.port)) { @@ -311,22 +327,24 @@ export function validateFormConfig(section, config) { } if (section === 'mail') { const relay = config.relay || {} - const relayConfigured = !!( - relay.relayHost || relay.relayPort || relay.username || relay.password || relay.security || relay.from - ) - if (relayConfigured) { + const configured = relayConfigured(config) + if (!['starttls', 'implicit_tls'].includes(mailSecurity(config))) { + errors['listener.security'] = '请选择 STARTTLS / STLS 或隐式 TLS' + } + if (configured) { if (!relay.relayHost) errors['relay.relayHost'] = '请填写 SMTP 服务器' - if (!['starttls', 'implicit_tls', 'tls', 'plain'].includes(relay.security)) errors['relay.security'] = '请选择连接加密方式' + const relaySecurity = normalizedMailSecurity(relay.security) + if (!['starttls', 'implicit_tls'].includes(relaySecurity)) errors['listener.security'] = '请先选择统一加密方式' + if (['starttls', 'implicit_tls'].includes(mailSecurity(config)) && relaySecurity !== mailSecurity(config)) errors['listener.security'] = 'SMTP 中继加密方式必须与邮件服务保持一致' 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 (section === 'mail') { - const paths = ['listener.port', 'listener.starttlsPort', 'listener.tlsPort', 'imap.tlsPort', 'pop3.port', 'pop3.tlsPort'] + const paths = getFieldGroups('mail', config).flatMap(group => group.fields) + .map(field => fieldKey(field)) + .filter(path => /^(listener\.(port|starttlsPort|tlsPort)|imap\.tlsPort|pop3\.(port|tlsPort))$/.test(path)) 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] = '邮件服务监听端口不能重复' diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index 7bf1369..c478599 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -49,7 +49,7 @@ test('配置与安全策略页面使用分组表单,控件和样式能编译 } }) -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) const groups = getFieldGroups('mail', config) @@ -57,7 +57,7 @@ test('邮件服务页面统一编辑六个监听端口,中继和策略字段 const relay = groups.find(group => group.id === 'relay') 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', + 'listener.security', 'listener.port', 'listener.starttlsPort', 'listener.tlsPort', 'imap.tlsPort', 'pop3.port', 'pop3.tlsPort' ]) assert.ok(keys.includes('relay.relayHost')) @@ -67,9 +67,10 @@ test('邮件服务页面统一编辑六个监听端口,中继和策略字段 assert.ok(!keys.includes('policy.smtpPort')) assert.ok(!keys.includes('policy.imapPort')) assert.ok(!keys.includes('policy.pop3Port')) - assert.equal(listener.fields.at(-1).path.join('.'), 'relay.security') - assert.equal(listener.fields.at(-1).label, 'SMTP 中继加密方式') - assert.equal(listener.fields.at(-1).wide, true) + assert.equal(listener.fields[0].path.join('.'), 'listener.security') + assert.equal(listener.fields[0].label, '统一加密方式') + assert.equal(listener.fields[0].wide, true) + assert.equal(listener.fields[0].options.find(option => option.value === 'both').disabled, true) assert.ok(!relay.fields.some(field => field.path.join('.') === 'relay.security')) assert.ok(relay.fields.some(field => field.path.join('.') === 'relay.relayPort')) const edited = readFormConfig(updateFormConfig(text, 'mail', ['listener', 'tlsPort'], 2465)) @@ -240,8 +241,8 @@ test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正 } 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 } + listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, + imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: { maxMessageBytes: 0 } }), {}) }) @@ -306,31 +307,47 @@ test('证书管理方式只展示三种业务模式并同步底层托管配置', assert.ok(getFieldGroups('ssl', updated).flatMap(group => group.fields).some(field => field.path.join('.') === 'tls.certificateRef')) }) -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 } } +test('统一加密方式同步切换 SMTP、IMAP、POP3 与已配置中继', () => { + for (const [security, expected] of [ + ['starttls', { relay: 587, smtpStartTLS: 587, smtpTLS: 0, imap: 143, pop3: 110, pop3TLS: 0 }], + ['implicit_tls', { relay: 465, smtpStartTLS: 0, smtpTLS: 465, imap: 993, pop3: 0, pop3TLS: 995 }] + ]) { + const config = { generation: 9, listener: { port: 2525, starttlsPort: 2587, tlsPort: 2465 }, imap: { tlsPort: 2993 }, pop3: { port: 2110, tlsPort: 2995 }, relay: { relayHost: 'smtp.example.com', relayPort: 2525, from: 'sender@example.com', fromDomain: 'example.com', future: true } } let text = JSON.stringify(config) getFieldGroups('mail', config) assert.equal(JSON.stringify(config), text) - text = updateFormConfig(text, 'mail', ['relay', 'security'], security) - assert.deepEqual(readFormConfig(text), { ...config, relay: { ...config.relay, security, relayPort: expectedPort } }) - 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, 'mail', ['listener', 'security'], security) + const updated = readFormConfig(text) + assert.deepEqual(updated.listener, { security, port: 25, starttlsPort: expected.smtpStartTLS, tlsPort: expected.smtpTLS }) + assert.equal(updated.imap.tlsPort, expected.imap) + assert.deepEqual(updated.pop3, { port: expected.pop3, tlsPort: expected.pop3TLS }) + assert.deepEqual(updated.relay, { ...config.relay, security, relayPort: expected.relay }) + const fields = getFieldGroups('mail', updated).flatMap(group => group.fields) + assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, expected.relay) + assert.equal(fields.find(field => field.path.join('.') === 'imap.tlsPort').label, security === 'starttls' ? 'IMAP 端口(STARTTLS)' : 'IMAP 端口(隐式 TLS)') + assert.equal(fields.some(field => field.path.join('.') === 'listener.starttlsPort'), security === 'starttls') + assert.equal(fields.some(field => field.path.join('.') === 'listener.tlsPort'), security === 'implicit_tls') + assert.equal(fields.some(field => field.path.join('.') === 'pop3.port'), security === 'starttls') + assert.equal(fields.some(field => field.path.join('.') === 'pop3.tlsPort'), security === 'implicit_tls') text = updateFormConfig(text, 'mail', ['relay', 'relayPort'], 2526) assert.equal(readFormConfig(text).relay.security, security) - text = updateFormConfig(text, 'mail', ['relay', 'security'], security) - assert.equal(readFormConfig(text).relay.relayPort, 2526) - } - 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, 'mail', ['relay', 'security'], '')).relay.relayPort, 2525) - for (const security of ['constructor', '__proto__', 'future']) { - const updated = readFormConfig(updateFormConfig(unused, 'mail', ['relay', 'security'], security)) - assert.equal(updated.relay.relayPort, 2525) - const fields = getFieldGroups('mail', updated).flatMap(group => group.fields) - assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, undefined) } + const untouched = { listener: {}, relay: {} } + const text = JSON.stringify(untouched) + getFieldGroups('mail', untouched) + assert.equal(JSON.stringify(untouched), text) + const configured = readFormConfig(updateFormConfig(JSON.stringify({ listener: { security: 'starttls' }, relay: {} }), 'mail', ['relay', 'relayHost'], 'smtp.example.com')) + assert.equal(configured.relay.security, 'starttls') + assert.equal(configured.relay.relayPort, 587) + assert.deepEqual(validateFormConfig('mail', { + listener: { security: 'tls', port: 25, starttlsPort: 0, tlsPort: 465 }, + relay: { relayHost: 'smtp.example.com', relayPort: 465, security: 'tls', from: 'sender@example.com', fromDomain: 'example.com' }, + imap: { tlsPort: 993 }, pop3: { port: 0, tlsPort: 995 } + }), {}) + const defaultsOnly = readFormConfig(updateFormConfig(JSON.stringify({ listener: {}, relay: { fromDomain: 'example.com' } }), 'mail', ['listener', 'security'], 'starttls')) + assert.deepEqual(defaultsOnly.relay, { fromDomain: 'example.com', security: 'starttls', relayPort: 587 }) + assert.deepEqual(validateFormConfig('mail', defaultsOnly), {}) + assert.ok(validateFormConfig('mail', { listener: {}, relay: {}, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 } })['listener.security']) }) test('内置 HTTPS 自动补全端口,反代禁用内置监听,已有自定义 HTTP 端口保留', () => { @@ -352,7 +369,7 @@ test('内置 HTTPS 自动补全端口,反代禁用内置监听,已有自定 assert.equal(readFormConfig(updateFormConfig(custom, 'base', ['http', 'tlsMode'], 'direct')).http.httpsPort, 8443) }) -test('加密配置校验拦截监听冲突、远程明文和已启用但未选择加密的中继', () => { +test('加密配置校验拦截监听冲突和未统一的中继加密方式', () => { const listeners = { listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, imap: { tlsPort: 993 }, @@ -361,14 +378,10 @@ test('加密配置校验拦截监听冲突、远程明文和已启用但未选 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('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('mail', { ...listeners, relay: { relayHost, security: 'plain', relayPort: 25, from: 'sender@example.com', fromDomain: 'example.com' } })['relay.relayHost']) - } - const errors = validateFormConfig('mail', { ...listeners, relay: { relayHost: 'smtp.example.com', security: 'starttls', relayPort: 0, from: 'sender@example.com', fromDomain: 'example.com' } }) + const unified = { ...listeners, listener: { ...listeners.listener, security: 'starttls', tlsPort: 0 }, imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 } } + const errors = validateFormConfig('mail', { ...unified, relay: { relayHost: 'smtp.example.com', security: 'starttls', relayPort: 0, from: 'sender@example.com', fromDomain: 'example.com' } }) assert.ok(errors['relay.relayPort']) - 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' } }), {}) + assert.deepEqual(validateFormConfig('mail', { ...unified, relay: {} }), {}) + assert.deepEqual(validateFormConfig('mail', { ...unified, relay: { security: 'starttls', relayPort: 2525, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } }), {}) + assert.ok(validateFormConfig('mail', { ...unified, relay: { security: 'implicit_tls', relayPort: 465, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } })['listener.security']) }) diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 4e6efd5..3664303 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -77,12 +77,12 @@ for (const failedRequest of ['/status', '/pending']) { let fail = false let saves = 0 const active = { - listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, 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: {} + imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {} } const { page } = fixture(() => {}, 'mail', { http: { get: async path => { @@ -271,9 +271,9 @@ for (const key of ['mail', 'security_policy']) { let pending = null let active = { meta: { schema: 1, generation: 1 }, - listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, relay: { verificationEnabled: true, loginPolicy: 'new_device' }, - imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, + imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: { maxMessageBytes: 1234, spamFilterLevel: 1, externalRecoveryEnabled: true } } const { page } = fixture(() => {}, key, { http: { @@ -451,10 +451,10 @@ test('保存并应用一次点击按候选协议连续完成保存和应用', as const requests = [] let pending = null const active = { - listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' }, - imap: { tlsPort: 993 }, - pop3: { port: 110, tlsPort: 995 }, + imap: { tlsPort: 143 }, + pop3: { port: 110, tlsPort: 0 }, policy: { maxMessageBytes: 10 << 20, spamFilterLevel: 2 } } const { page } = fixture(async path => ({ @@ -494,9 +494,9 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应 let saveAttempts = 0 let prompts = 0 const active = { - listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' }, - imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {} + imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {} } const recentAuthError = Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' }) const { page } = fixture(async () => ({ errorNo: 0, data: active }), 'mail', { @@ -539,8 +539,8 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应 test('取消最近身份确认时保留配置输入且不重试请求', async () => { let saves = 0 const active = { - listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, - relay: {}, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {} + listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, + relay: {}, imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {} } const { page, messages } = fixture(async path => ({ errorNo: 0, @@ -654,12 +654,12 @@ test('邮件页保持验证、保存、应用端点和代次协议,中继密 const requests = [] let pending = null const active = { - listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, 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: {} + imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {} } const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 8 } : active }), 'mail', { http: { @@ -729,12 +729,12 @@ test('可视化编辑按权限和工作状态保护,编辑会清除过期确 test('前端无效字段阻止网络请求,修正字段后同步清除错误', async () => { const requests = [] const config = { - listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, 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: {} + imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {} } const { page } = fixture(async () => ({ errorNo: 0, data: config }), 'mail', { http: { post: async path => { requests.push(path); return { errorNo: 0, data: { valid: true } } } } @@ -789,12 +789,12 @@ test('SQLite 不显示或发送密码,网络数据库和中继密码留空保 } let request const mail = { - listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, 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: {} + imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {} } const { page } = fixture(async () => ({ errorNo: 0, data: mail }), 'mail', { http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } }