This commit is contained in:
parent
98af5890e5
commit
0a6b13da05
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'] = '请填写发件域名'
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
<dl class="relay-summary">
|
||||
<div><dt>SMTP 服务器</dt><dd>{{ relay.relayHost || '未填写' }}</dd></div>
|
||||
<div><dt>端口</dt><dd>{{ relay.relayPort || '未填写' }}</dd></div>
|
||||
<div><dt>中继加密方式</dt><dd>{{ relay.security === 'starttls' ? 'STARTTLS' : relay.security === 'implicit_tls' ? '隐式 TLS' : '未选择' }}</dd></div>
|
||||
<div><dt>认证账号</dt><dd>{{ relay.username || '未填写' }}</dd></div>
|
||||
<div><dt>发件邮箱</dt><dd>{{ relay.from || '未填写' }}</dd></div>
|
||||
<div><dt>密码</dt><dd>{{ passwordConfigured ? '已配置(不显示明文)' : '未配置' }}</dd></div>
|
||||
@ -104,4 +105,4 @@ const clearRelay = async () => {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="./RelayConfig.css"></style>
|
||||
<style scoped src="./RelayConfig.css"></style>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user