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
)