From 0a6b13da05b35c0bc797dfed2331527190dbcdc7 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 6 Oct 2026 03:27:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=AD=E7=BB=A7=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E7=8B=AC=E7=AB=8B=E9=85=8D=E7=BD=AE=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=90=AF=E5=81=9C=E5=92=8C=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/mail_listeners.go | 6 --- backend/config/mail_listeners_test.go | 28 ++++++++++ .../ConfigSettings/ConfigForm/ConfigForm.js | 23 +++++---- .../ConfigSettings/ConfigSettings.js | 5 +- .../RelayConfig/RelayConfig.vue | 3 +- test/frontend/config-form.test.mjs | 51 ++++++++++++++----- test/frontend/config-settings.test.mjs | 6 +-- 7 files changed, 84 insertions(+), 38 deletions(-) diff --git a/backend/config/mail_listeners.go b/backend/config/mail_listeners.go index c234560..b2a0fc3 100644 --- a/backend/config/mail_listeners.go +++ b/backend/config/mail_listeners.go @@ -84,12 +84,6 @@ func validateMailListeners(smtp SMTPSectionConfig, email EmailSectionConfig) err if security != MailSecurityBoth && security != MailSecuritySTARTTLS && security != MailSecurityImplicitTLS { return ErrInvalidRuntimeConfig } - if smtpRelayEnabled(smtp.Relay) && 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 7b8389c..44566f1 100644 --- a/backend/config/mail_listeners_test.go +++ b/backend/config/mail_listeners_test.go @@ -201,6 +201,34 @@ func TestMailListenerValidationRejectsUnknownSecurity(t *testing.T) { } } +func TestMailListenerAndRelaySecurityAreIndependent(t *testing.T) { + for _, listenerSecurity := range []string{MailSecurityBoth, MailSecuritySTARTTLS, MailSecurityImplicitTLS} { + for _, relaySecurity := range []string{MailSecuritySTARTTLS, MailSecurityImplicitTLS} { + t.Run(listenerSecurity+"_"+relaySecurity, func(t *testing.T) { + runtime := validRuntimeConfig() + runtime.SMTP.Listener.Security = listenerSecurity + runtime.SMTP.Relay = SMTPConfig{ + RelayEnabled: true, + RelayHost: "smtp.example.com", + RelayPort: 2525, + Security: relaySecurity, + From: "sender@example.com", + FromDomain: "example.com", + } + if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); err != nil { + t.Fatalf("independent relay validation = %v", err) + } + for _, invalidSecurity := range []string{"", "plain", "unknown"} { + runtime.SMTP.Relay.Security = invalidSecurity + if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); err == nil { + t.Fatalf("insecure relay %q must be rejected", invalidSecurity) + } + } + }) + } + } +} + func TestMailListenerValidationAllowsLegacyEmptyAndExplicitBothSecurity(t *testing.T) { runtime := validRuntimeConfig() if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); err != nil { diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index b61f1b4..ac92bd4 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -11,7 +11,6 @@ const mailSecurity = config => { const value = normalizedMailSecurity(config.listener?.security) return value || 'both' } -export const relaySecurityForMail = config => mailSecurity(config) === 'implicit_tls' ? 'implicit_tls' : 'starttls' const startTLSMail = config => mailSecurity(config) === 'starttls' const implicitTLSMail = config => mailSecurity(config) === 'implicit_tls' export const relayConfigured = config => !!( @@ -46,7 +45,7 @@ export function getRecommendedPort(config, path) { case 'http.publicHttpsPort': return 443 case 'relay.relayPort': - return smtpPorts.get(relaySecurityForMail(config)) + return smtpPorts.get(normalizedMailSecurity(config.relay?.security)) case 'listener.port': return 25 case 'listener.starttlsPort': @@ -143,8 +142,12 @@ const mailGroups = [ ]), group('relay', 'SMTP 中继连接(可选)', '', [ field('relay.enabled', '启用 SMTP 中继', 'switch', { hint: '关闭后保留配置,但系统不会使用该中继发信。' }), + field('relay.security', '中继加密方式', 'select', { + options: options([['starttls', 'STARTTLS'], ['implicit_tls', '隐式 TLS']]), + hint: '独立于本机邮件监听,请按外部 SMTP 服务商要求选择。' + }), field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }), - port('relay.relayPort', 'SMTP 发信中继端口', { hint: '外部 SMTP 服务器端口,与本机监听无关;选择加密方式后自动填写。' }), + port('relay.relayPort', 'SMTP 发信中继端口', { hint: '与本机监听无关;切换中继加密方式时更新推荐端口,保留已有自定义端口。' }), field('relay.username', '认证用户名', 'text', { placeholder: '需要认证时填写' }), field('relay.password', '中继密码', 'password', { placeholder: '留空保持原值' }), field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }), @@ -305,13 +308,12 @@ export function updateFormConfig(text, section, path, value) { config.pop3 ||= {} config.pop3.port = startTLS || both ? 110 : 0 config.pop3.tlsPort = startTLS ? 0 : 995 - config.relay ||= {} - config.relay.security = relaySecurityForMail(config) - config.relay.relayPort = smtpPorts.get(config.relay.security) } - if (key.startsWith('relay.') && key !== 'relay.security' && key !== 'relay.enabled' && config.relay?.enabled === true && 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 === 'relay.security' && ['starttls', 'implicit_tls'].includes(value)) { + const previousPort = smtpPorts.get(normalizedMailSecurity(previousValue)) + if (!validPort(config.relay.relayPort) || config.relay.relayPort === previousPort) { + config.relay.relayPort = smtpPorts.get(value) + } } if (key === 'http.tlsMode' && ['direct', 'proxy'].includes(value)) { if (!validPort(config.http.port)) { @@ -375,8 +377,7 @@ export function validateFormConfig(section, config) { if (enabled && !relay.clear) { if (!relay.relayHost) errors['relay.relayHost'] = '请填写 SMTP 服务器' const relaySecurity = normalizedMailSecurity(relay.security) - if (!['starttls', 'implicit_tls'].includes(relaySecurity)) errors['listener.security'] = '请先选择统一加密方式' - if (mailSecurity(config) !== 'both' && ['starttls', 'implicit_tls'].includes(mailSecurity(config)) && relaySecurity !== mailSecurity(config)) errors['listener.security'] = 'SMTP 中继加密方式必须与邮件服务保持一致' + if (!['starttls', 'implicit_tls'].includes(relaySecurity)) errors['relay.security'] = '请选择中继加密方式' if (!validPort(relay.relayPort)) errors['relay.relayPort'] = '请填写 1~65535 的 SMTP 端口' if (!relay.from) errors['relay.from'] = '请填写发件邮箱' if (!relay.fromDomain) errors['relay.fromDomain'] = '请填写发件域名' diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index f9965ed..8e8c8f1 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -3,7 +3,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { http } from '@/utils/axios' import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication' -import { readFormConfig, updateFormConfig, validateFormConfig, relayConfigured, relaySecurityForMail } from './ConfigForm/ConfigForm.js' +import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigForm/ConfigForm.js' const sections = [ { value: 'base', label: '基础配置' }, @@ -530,9 +530,6 @@ export function usePage(pageKey = 'base') { delete payload.relay } } - if (payload.relay && payload.relay.enabled === true && relayConfigured(payload) && !payload.relay.security) { - payload.relay.security = relaySecurityForMail(payload) - } } if (apiSection() === 'plugins' && payload.plugins && typeof payload.plugins === 'object') { Object.values(payload.plugins).forEach(plugin => { diff --git a/frontend/src/views/components/ConfigSettings/RelayConfig/RelayConfig.vue b/frontend/src/views/components/ConfigSettings/RelayConfig/RelayConfig.vue index c67a14f..53afd36 100644 --- a/frontend/src/views/components/ConfigSettings/RelayConfig/RelayConfig.vue +++ b/frontend/src/views/components/ConfigSettings/RelayConfig/RelayConfig.vue @@ -33,6 +33,7 @@
SMTP 服务器
{{ relay.relayHost || '未填写' }}
端口
{{ relay.relayPort || '未填写' }}
+
中继加密方式
{{ relay.security === 'starttls' ? 'STARTTLS' : relay.security === 'implicit_tls' ? '隐式 TLS' : '未选择' }}
认证账号
{{ relay.username || '未填写' }}
发件邮箱
{{ relay.from || '未填写' }}
密码
{{ passwordConfigured ? '已配置(不显示明文)' : '未配置' }}
@@ -104,4 +105,4 @@ const clearRelay = async () => { } - \ No newline at end of file + diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index 5b8eb17..d5ec450 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -6,11 +6,11 @@ import { createRequire } from 'node:module' const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url) const source = readFileSync(new URL('ConfigForm/ConfigForm.js', root), 'utf8') -const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } = compileFunction( +const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig, getRecommendedPort } = compileFunction( source .replaceAll('export function ', 'function ') .replaceAll('export const ', 'const ') + - '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }' + '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig, getRecommendedPort }' )() test('项目 DNS 自动续期需要凭据,已设置的凭据可留空且外部托管隐藏 DNS 字段', () => { @@ -120,7 +120,7 @@ test('邮件服务页面只管理监听和中继,邮件策略使用独立表 assert.equal(listener.fields[0].wide, 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.deepEqual(relay.fields.find(field => field.path.join('.') === 'relay.security').options.map(option => option.value), ['starttls', 'implicit_tls']) assert.ok(relay.fields.some(field => field.path.join('.') === 'relay.relayPort')) const edited = readFormConfig(updateFormConfig(text, 'mail', ['listener', 'tlsPort'], 2465)) assert.equal(edited.listener.tlsPort, 2465) @@ -424,7 +424,7 @@ test('普通文本输入使用本地编辑值,父级配置刷新不会抢占 assert.doesNotMatch(script, /!textEditing\.value \|\| nextValue !== textValue\.value/) }) -test('统一加密方式同步切换 SMTP、IMAP、POP3 与已配置中继', () => { +test('本机加密方式只切换本机监听,不改写已配置中继', () => { for (const [security, expected] of [ ['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 }], @@ -439,31 +439,31 @@ 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: expected.relaySecurity, relayPort: expected.relay }) + assert.deepEqual(updated.relay, config.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.at(-1) === 'relayPort').recommendedPort, undefined) 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 !== '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, expected.relaySecurity) + assert.equal(readFormConfig(text).relay.security, 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: { enabled: true } }), 'mail', ['relay', 'relayHost'], 'smtp.example.com')) - assert.equal(configured.relay.security, 'starttls') - assert.equal(configured.relay.relayPort, 587) + assert.equal(configured.relay.security, undefined) + assert.equal(configured.relay.relayPort, undefined) assert.deepEqual(validateFormConfig('mail', { listener: { security: 'tls', port: 25, starttlsPort: 0, tlsPort: 465 }, relay: { enabled: true, 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(defaultsOnly.relay, { fromDomain: 'example.com' }) assert.deepEqual(validateFormConfig('mail', defaultsOnly), {}) 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']) @@ -488,7 +488,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 }, @@ -502,5 +502,32 @@ test('加密配置校验拦截监听冲突和未统一的中继加密方式', () assert.ok(errors['relay.relayPort']) assert.deepEqual(validateFormConfig('mail', { ...unified, relay: {} }), {}) assert.deepEqual(validateFormConfig('mail', { ...unified, relay: { enabled: true, security: 'starttls', relayPort: 2525, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } }), {}) - assert.ok(validateFormConfig('mail', { ...unified, relay: { enabled: true, security: 'implicit_tls', relayPort: 465, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } })['listener.security']) + assert.deepEqual(validateFormConfig('mail', { ...unified, relay: { enabled: true, security: 'implicit_tls', relayPort: 465, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } }), {}) +}) + +test('中继切换只更新推荐端口,自定义端口和启停配置均保留', () => { + let text = updateFormConfig('{}', 'mail', ['relay', 'security'], 'starttls') + assert.equal(readFormConfig(text).relay.relayPort, 587) + text = updateFormConfig(text, 'mail', ['relay', 'security'], 'implicit_tls') + assert.equal(readFormConfig(text).relay.relayPort, 465) + text = updateFormConfig(text, 'mail', ['relay', 'relayPort'], 2465) + text = updateFormConfig(text, 'mail', ['relay', 'security'], 'starttls') + assert.equal(readFormConfig(text).relay.relayPort, 2465) + const relay = { ...readFormConfig(text).relay, enabled: true, username: 'sender', password: 'test-only-value', relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } + for (const enabled of [false, true]) { + const updated = readFormConfig(updateFormConfig(JSON.stringify({ relay }), 'mail', ['relay', 'enabled'], enabled)) + assert.deepEqual(updated.relay, { ...relay, enabled }) + } + for (const security of ['both', 'starttls', 'implicit_tls']) { + for (const relaySecurity of ['starttls', 'implicit_tls']) { + const config = readFormConfig(updateFormConfig(JSON.stringify({ relay: { ...relay, security: relaySecurity } }), 'mail', ['listener', 'security'], security)) + assert.deepEqual(config.relay, { ...relay, security: relaySecurity }) + assert.deepEqual(validateFormConfig('mail', config), {}) + assert.equal(getRecommendedPort(config, ['relay', 'relayPort']), relaySecurity === 'starttls' ? 587 : 465) + config.relay.security = '' + const errors = validateFormConfig('mail', config) + assert.ok(errors['relay.security']) + assert.equal(errors['listener.security'], undefined) + } + } }) diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 5d6800e..ea282fd 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -10,7 +10,7 @@ const formHelpers = compileFunction( formSource .replaceAll('export const ', 'const ') .replaceAll('export function ', 'function ') - + '\nreturn { readFormConfig, updateFormConfig, validateFormConfig, relayConfigured, relaySecurityForMail }' + + '\nreturn { readFormConfig, updateFormConfig, validateFormConfig }' )() const recentAuthenticationSource = readFileSync(new URL('../../frontend/src/utils/recentAuthentication.js', import.meta.url), 'utf8') .replace(/^import .*$/gm, '') @@ -23,7 +23,7 @@ const compileRecentAuthentication = compileFunction( const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8') const compilePage = compileFunction( source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage(pageKey)', - ['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'relayConfigured', 'relaySecurityForMail', 'isRecentAuthenticationCancelled', 'requestWithRecentAuthentication'] + ['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'isRecentAuthenticationCancelled', 'requestWithRecentAuthentication'] ) const createPageWithKey = (...args) => { const http = args[6] @@ -34,8 +34,6 @@ const createPageWithKey = (...args) => { formHelpers.readFormConfig, formHelpers.updateFormConfig, formHelpers.validateFormConfig, - formHelpers.relayConfigured, - formHelpers.relaySecurityForMail, recentAuthentication.isRecentAuthenticationCancelled, recentAuthentication.requestWithRecentAuthentication )