From 566040afc33a09f1dcdfbb9e64cbc8085d26ac91 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 00:19:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=82=E8=80=83PMail=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E5=8A=A0=E5=AF=86=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/mail_listeners.go | 5 +- backend/config/mail_listeners_test.go | 7 ++- backend/config/manager_v2_test.go | 59 +++++++++++-------- .../ConfigSettings/ConfigForm/ConfigForm.js | 40 ++++++------- test/frontend/config-form.test.mjs | 23 ++++---- 5 files changed, 74 insertions(+), 60 deletions(-) diff --git a/backend/config/mail_listeners.go b/backend/config/mail_listeners.go index 19b7b60..8525759 100644 --- a/backend/config/mail_listeners.go +++ b/backend/config/mail_listeners.go @@ -6,7 +6,7 @@ const ( MailSecurityImplicitTLS = "implicit_tls" ) -// 邮件监听与发信中继相互独立。未设置加密模式的旧配置继续同时开放两套监听。 +// 邮件监听与发信中继相互独立。全部启用模式同时开放两套安全监听。 type SMTPListenerConfig struct { Port int `json:"port"` STARTTLSPort int `json:"starttlsPort"` @@ -80,9 +80,6 @@ func ResolveMailPorts(smtp SMTPSectionConfig, email EmailSectionConfig) MailPort } 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 diff --git a/backend/config/mail_listeners_test.go b/backend/config/mail_listeners_test.go index 1f50d04..7b8389c 100644 --- a/backend/config/mail_listeners_test.go +++ b/backend/config/mail_listeners_test.go @@ -152,6 +152,7 @@ func TestUnifiedMailSecurityResolvesOnlySelectedClientListeners(t *testing.T) { security string want MailPorts }{ + {MailSecurityBoth, MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}}, {MailSecuritySTARTTLS, MailPorts{SMTP: 25, SMTPSTARTTLS: 587, IMAP: 143, POP3: 110}}, {MailSecurityImplicitTLS, MailPorts{SMTP: 25, SMTPTLS: 465, IMAP: 993, POP3TLS: 995}}, } { @@ -200,13 +201,13 @@ func TestMailListenerValidationRejectsUnknownSecurity(t *testing.T) { } } -func TestMailListenerValidationAllowsLegacyEmptySecurityButRejectsExplicitBoth(t *testing.T) { +func TestMailListenerValidationAllowsLegacyEmptyAndExplicitBothSecurity(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) + if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); err != nil { + t.Fatalf("explicit both validation = %v", err) } } diff --git a/backend/config/manager_v2_test.go b/backend/config/manager_v2_test.go index 39ee2a2..b1b153c 100644 --- a/backend/config/manager_v2_test.go +++ b/backend/config/manager_v2_test.go @@ -525,31 +525,44 @@ func TestConfigManagerMailSectionAppliesBothPartitionsWithoutMailFile(t *testing func TestConfigManagerMailSectionAcceptsUnifiedSecurityModes(t *testing.T) { tests := []struct { - name string - security string - listener SMTPListenerConfig - imap IMAPListenerConfig - pop3 POP3ListenerConfig - relayPort int - want MailPorts + name string + security string + relaySecurity string + listener SMTPListenerConfig + imap IMAPListenerConfig + pop3 POP3ListenerConfig + relayPort int + want MailPorts }{ { - name: "starttls", - security: MailSecuritySTARTTLS, - listener: SMTPListenerConfig{Port: 25, STARTTLSPort: 587, Security: MailSecuritySTARTTLS}, - imap: IMAPListenerConfig{TLSPort: 143}, - pop3: POP3ListenerConfig{Port: 110}, - relayPort: 587, - want: MailPorts{SMTP: 25, SMTPSTARTTLS: 587, IMAP: 143, POP3: 110}, + name: "both", + security: MailSecurityBoth, + relaySecurity: MailSecuritySTARTTLS, + listener: SMTPListenerConfig{Port: 25, STARTTLSPort: 587, TLSPort: 465, Security: MailSecurityBoth}, + imap: IMAPListenerConfig{TLSPort: 993}, + pop3: POP3ListenerConfig{Port: 110, TLSPort: 995}, + relayPort: 587, + want: MailPorts{SMTP: 25, SMTPSTARTTLS: 587, SMTPTLS: 465, IMAP: 993, POP3: 110, POP3TLS: 995}, }, { - name: "implicit_tls", - security: MailSecurityImplicitTLS, - listener: SMTPListenerConfig{Port: 25, TLSPort: 465, Security: MailSecurityImplicitTLS}, - imap: IMAPListenerConfig{TLSPort: 993}, - pop3: POP3ListenerConfig{TLSPort: 995}, - relayPort: 465, - want: MailPorts{SMTP: 25, SMTPTLS: 465, IMAP: 993, POP3TLS: 995}, + name: "starttls", + security: MailSecuritySTARTTLS, + relaySecurity: MailSecuritySTARTTLS, + listener: SMTPListenerConfig{Port: 25, STARTTLSPort: 587, Security: MailSecuritySTARTTLS}, + imap: IMAPListenerConfig{TLSPort: 143}, + pop3: POP3ListenerConfig{Port: 110}, + relayPort: 587, + want: MailPorts{SMTP: 25, SMTPSTARTTLS: 587, IMAP: 143, POP3: 110}, + }, + { + name: "implicit_tls", + security: MailSecurityImplicitTLS, + relaySecurity: MailSecurityImplicitTLS, + listener: SMTPListenerConfig{Port: 25, TLSPort: 465, Security: MailSecurityImplicitTLS}, + imap: IMAPListenerConfig{TLSPort: 993}, + pop3: POP3ListenerConfig{TLSPort: 995}, + relayPort: 465, + want: MailPorts{SMTP: 25, SMTPTLS: 465, IMAP: 993, POP3TLS: 995}, }, } @@ -568,7 +581,7 @@ func TestConfigManagerMailSectionAcceptsUnifiedSecurityModes(t *testing.T) { mail.IMAP = test.imap mail.POP3 = test.pop3 mail.Relay.RelayPort = test.relayPort - mail.Relay.Security = test.security + mail.Relay.Security = test.relaySecurity mail.Relay.FromDomain = "example.com" mail.Relay.VerificationEnabled = true payload, err := json.Marshal(mail) @@ -599,7 +612,7 @@ func TestConfigManagerMailSectionAcceptsUnifiedSecurityModes(t *testing.T) { if got := ResolveMailPorts(reloaded.SMTP, reloaded.Email); got != test.want { t.Fatalf("reloaded ports = %+v, want %+v", got, test.want) } - if ResolveMailSecurity(reloaded.SMTP.Listener) != test.security || NormalizeMailSecurity(reloaded.SMTP.Relay.Security) != test.security { + if ResolveMailSecurity(reloaded.SMTP.Listener) != test.security || NormalizeMailSecurity(reloaded.SMTP.Relay.Security) != test.relaySecurity { t.Fatalf("reloaded security modes are not unified: listener=%q relay=%q", reloaded.SMTP.Listener.Security, reloaded.SMTP.Relay.Security) } }) diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index ad01f90..777c623 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -9,8 +9,9 @@ const smtpPorts = new Map([['starttls', 587], ['implicit_tls', 465], ['tls', 465 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' + return value || 'both' } +const relaySecurityForMail = config => mailSecurity(config) === 'implicit_tls' ? 'implicit_tls' : 'starttls' const startTLSMail = config => mailSecurity(config) === 'starttls' const implicitTLSMail = config => mailSecurity(config) === 'implicit_tls' const relayConfigured = config => !!( @@ -43,7 +44,7 @@ export function getRecommendedPort(config, path) { case 'http.httpsPort': return 443 case 'relay.relayPort': - return smtpPorts.get(mailSecurity(config)) + return smtpPorts.get(relaySecurityForMail(config)) case 'listener.port': return 25 case 'listener.starttlsPort': @@ -117,14 +118,12 @@ const mailGroups = [ field('listener.security', '统一加密方式', 'select', { wide: true, fallback: 'both', - options: [ - { value: 'both', label: '旧配置兼容状态(请选择统一加密方式)', disabled: true }, - ...options([ - ['starttls', 'STARTTLS / STLS'], - ['implicit_tls', '隐式 TLS'] - ]) - ], - hint: '切换后 SMTP 提交、IMAP、POP3 和已配置的 SMTP 中继会统一使用同一种加密方式,并自动匹配推荐端口。SMTP 25 收信端口始终保留。' + options: options([ + ['both', '全部启用(推荐)'], + ['starttls', '仅 STARTTLS / STLS'], + ['implicit_tls', '仅隐式 TLS'] + ]), + hint: '推荐同时开放 SMTP 587/465、IMAP 993 和 POP3 110/995,与 PMail 的默认监听方式一致。选择单一方式时会关闭另一组客户端端口;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 后进行认证。' }), @@ -257,22 +256,23 @@ export function updateFormConfig(text, section, path, value) { } parent[path.at(-1)] = value ?? (input.type === 'number' ? null : '') if (previousValue !== value) { - if (key === 'listener.security' && ['starttls', 'implicit_tls'].includes(value)) { + if (key === 'listener.security' && ['both', 'starttls', 'implicit_tls'].includes(value)) { const startTLS = value === 'starttls' + const both = value === 'both' config.listener.port = 25 - config.listener.starttlsPort = startTLS ? 587 : 0 + config.listener.starttlsPort = startTLS || both ? 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.port = startTLS || both ? 110 : 0 config.pop3.tlsPort = startTLS ? 0 : 995 config.relay ||= {} - config.relay.security = value - config.relay.relayPort = smtpPorts.get(value) + config.relay.security = relaySecurityForMail(config) + config.relay.relayPort = smtpPorts.get(config.relay.security) } - if (key.startsWith('relay.') && key !== 'relay.security' && relayConfigured(config) && ['starttls', 'implicit_tls'].includes(mailSecurity(config))) { - config.relay.security = mailSecurity(config) + if (key.startsWith('relay.') && key !== 'relay.security' && relayConfigured(config) && ['both', 'starttls', 'implicit_tls'].includes(mailSecurity(config))) { + config.relay.security = relaySecurityForMail(config) if (!validPort(config.relay.relayPort)) config.relay.relayPort = smtpPorts.get(config.relay.security) } if (key === 'http.tlsMode' && ['direct', 'proxy'].includes(value)) { @@ -331,14 +331,14 @@ export function validateFormConfig(section, config) { if (section === 'mail') { const relay = config.relay || {} const configured = relayConfigured(config) - if (!['starttls', 'implicit_tls'].includes(mailSecurity(config))) { - errors['listener.security'] = '请选择 STARTTLS / STLS 或隐式 TLS' + if (!['both', 'starttls', 'implicit_tls'].includes(mailSecurity(config))) { + errors['listener.security'] = '请选择全部启用、STARTTLS / STLS 或隐式 TLS' } if (configured) { if (!relay.relayHost) errors['relay.relayHost'] = '请填写 SMTP 服务器' 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 (mailSecurity(config) !== 'both' && ['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'] = '请填写发件域名' diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index 11fffd7..db46e90 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -79,7 +79,8 @@ test('邮件服务页面只管理监听和中继,邮件策略使用独立表 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.equal(listener.fields[0].options.find(option => option.value === 'both').label, '全部启用(推荐)') + assert.equal(listener.fields[0].options.find(option => option.value === 'both').disabled, undefined) 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)) @@ -318,8 +319,9 @@ test('证书管理方式只展示三种业务模式并同步底层托管配置', 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 }] + ['both', { relay: 587, relaySecurity: 'starttls', smtpStartTLS: 587, smtpTLS: 465, imap: 993, pop3: 110, pop3TLS: 995 }], + ['starttls', { relay: 587, relaySecurity: 'starttls', smtpStartTLS: 587, smtpTLS: 0, imap: 143, pop3: 110, pop3TLS: 0 }], + ['implicit_tls', { relay: 465, relaySecurity: 'implicit_tls', 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) @@ -330,16 +332,16 @@ test('统一加密方式同步切换 SMTP、IMAP、POP3 与已配置中继', () 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 }) + assert.deepEqual(updated.relay, { ...config.relay, security: expected.relaySecurity, 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') + assert.equal(fields.some(field => field.path.join('.') === 'listener.starttlsPort'), security !== 'implicit_tls') + assert.equal(fields.some(field => field.path.join('.') === 'listener.tlsPort'), security !== 'starttls') + assert.equal(fields.some(field => field.path.join('.') === 'pop3.port'), security !== 'implicit_tls') + assert.equal(fields.some(field => field.path.join('.') === 'pop3.tlsPort'), security !== 'starttls') text = updateFormConfig(text, 'mail', ['relay', 'relayPort'], 2526) - assert.equal(readFormConfig(text).relay.security, security) + assert.equal(readFormConfig(text).relay.security, expected.relaySecurity) } const untouched = { listener: {}, relay: {} } const text = JSON.stringify(untouched) @@ -356,7 +358,8 @@ test('统一加密方式同步切换 SMTP、IMAP、POP3 与已配置中继', () 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']) + assert.deepEqual(validateFormConfig('mail', { listener: {}, relay: {}, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 } }), {}) + assert.ok(validateFormConfig('mail', { listener: { security: 'unknown' }, relay: {}, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 } })['listener.security']) }) test('内置 HTTPS 自动补全端口,反代禁用内置监听,已有自定义 HTTP 端口保留', () => {