参考PMail优化邮件加密模式
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 00:19:04 +08:00
parent c469513c1f
commit 566040afc3
5 changed files with 74 additions and 60 deletions

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
})

View File

@ -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'] = '请填写发件域名'

View File

@ -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 端口保留', () => {