统一邮件协议加密方式与端口联动

This commit is contained in:
smail 2026-09-07 22:39:34 +08:00
parent 0975e26762
commit b7a4614e5b
13 changed files with 369 additions and 118 deletions

View File

@ -1,10 +1,17 @@
package config
// 邮件监听与发信中继相互独立。零值表示沿用兼容默认值,不表示关闭服务。
const (
MailSecurityBoth = "both"
MailSecuritySTARTTLS = "starttls"
MailSecurityImplicitTLS = "implicit_tls"
)
// 邮件监听与发信中继相互独立。未设置加密模式的旧配置继续同时开放两套监听。
type SMTPListenerConfig struct {
Port int `json:"port"`
STARTTLSPort int `json:"starttlsPort"`
TLSPort int `json:"tlsPort"`
Port int `json:"port"`
STARTTLSPort int `json:"starttlsPort"`
TLSPort int `json:"tlsPort"`
Security string `json:"security,omitempty"`
}
type IMAPListenerConfig struct {
@ -16,6 +23,32 @@ type POP3ListenerConfig struct {
TLSPort int `json:"tlsPort"`
}
func ResolveMailSecurity(listener SMTPListenerConfig) string {
switch NormalizeMailSecurity(listener.Security) {
case MailSecuritySTARTTLS:
return MailSecuritySTARTTLS
case MailSecurityImplicitTLS:
return MailSecurityImplicitTLS
case "", MailSecurityBoth:
return MailSecurityBoth
default:
return listener.Security
}
}
// NormalizeMailSecurity 保留空值及未知值,只将旧版 TLS 名称规范为当前名称。
func NormalizeMailSecurity(value string) string {
if value == "tls" {
return MailSecurityImplicitTLS
}
return value
}
// PublicMailSecurity 保留旧配置的空值,只规范公开视图中的旧版名称。
func PublicMailSecurity(listener SMTPListenerConfig) string {
return NormalizeMailSecurity(listener.Security)
}
// ResolveMailPorts 是公开配置视图、冲突校验及真实监听的共同端口来源。
func ResolveMailPorts(smtp SMTPSectionConfig, email EmailSectionConfig) MailPorts {
portOr := func(port, fallback int) int {
@ -24,7 +57,7 @@ func ResolveMailPorts(smtp SMTPSectionConfig, email EmailSectionConfig) MailPort
}
return fallback
}
return MailPorts{
ports := MailPorts{
SMTP: portOr(smtp.Listener.Port, portOr(email.Policy.SMTPPort, 25)),
SMTPSTARTTLS: portOr(smtp.Listener.STARTTLSPort, 587),
SMTPTLS: portOr(smtp.Listener.TLSPort, 465),
@ -33,9 +66,34 @@ func ResolveMailPorts(smtp SMTPSectionConfig, email EmailSectionConfig) MailPort
POP3: portOr(email.POP3.Port, portOr(email.Policy.POP3Port, 110)),
POP3TLS: portOr(email.POP3.TLSPort, 995),
}
switch ResolveMailSecurity(smtp.Listener) {
case MailSecuritySTARTTLS:
ports.SMTPTLS = 0
ports.IMAP = portOr(email.IMAP.TLSPort, 143)
ports.POP3TLS = 0
case MailSecurityImplicitTLS:
ports.SMTPSTARTTLS = 0
ports.IMAP = portOr(email.IMAP.TLSPort, 993)
ports.POP3 = 0
}
return ports
}
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
}
relayConfigured := smtpRelayConfigured(smtp.Relay)
if relayConfigured && 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,

View File

@ -146,3 +146,67 @@ func TestOldPartitionPayloadsKeepExistingListenerDefaults(t *testing.T) {
t.Fatalf("legacy listeners changed: %+v", got)
}
}
func TestUnifiedMailSecurityResolvesOnlySelectedClientListeners(t *testing.T) {
for _, test := range []struct {
security string
want MailPorts
}{
{MailSecuritySTARTTLS, MailPorts{SMTP: 25, SMTPSTARTTLS: 587, IMAP: 143, POP3: 110}},
{MailSecurityImplicitTLS, MailPorts{SMTP: 25, SMTPTLS: 465, IMAP: 993, POP3TLS: 995}},
} {
smtp := SMTPSectionConfig{Listener: SMTPListenerConfig{Security: test.security}}
if got := ResolveMailPorts(smtp, EmailSectionConfig{}); got != test.want {
t.Fatalf("%s ports = %+v, want %+v", test.security, got, test.want)
}
}
}
func TestLegacyMailSecurityKeepsExistingListeners(t *testing.T) {
smtp := SMTPSectionConfig{Listener: SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465}}
email := EmailSectionConfig{IMAP: IMAPListenerConfig{TLSPort: 2993}, POP3: POP3ListenerConfig{Port: 2110, TLSPort: 2995}}
want := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 2993, POP3: 2110, POP3TLS: 2995}
if got := ResolveMailPorts(smtp, email); got != want {
t.Fatalf("legacy ports = %+v, want %+v", got, want)
}
if got := ResolveMailSecurity(smtp.Listener); got != MailSecurityBoth {
t.Fatalf("legacy security = %q, want %q", got, MailSecurityBoth)
}
if got := PublicMailSecurity(smtp.Listener); got != "" {
t.Fatalf("legacy public security = %q, want empty", got)
}
if got := PublicMailSecurity(SMTPListenerConfig{Security: "tls"}); got != MailSecurityImplicitTLS {
t.Fatalf("legacy TLS public security = %q, want %q", got, MailSecurityImplicitTLS)
}
if got := NormalizeMailSecurity("tls"); got != MailSecurityImplicitTLS {
t.Fatalf("legacy relay security = %q, want %q", got, MailSecurityImplicitTLS)
}
}
func TestMailListenerValidationAcceptsMatchingLegacyTLSNames(t *testing.T) {
runtime := validRuntimeConfig()
runtime.SMTP.Listener.Security = "tls"
runtime.SMTP.Relay.Security = "tls"
if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); err != nil {
t.Fatalf("matching legacy TLS validation = %v", err)
}
}
func TestMailListenerValidationRejectsUnknownSecurity(t *testing.T) {
runtime := validRuntimeConfig()
runtime.SMTP.Listener.Security = "unknown"
if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true}); !errors.Is(err, ErrInvalidRuntimeConfig) {
t.Fatalf("validation = %v, want invalid runtime configuration", err)
}
}
func TestMailListenerValidationAllowsLegacyEmptySecurityButRejectsExplicitBoth(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)
}
}

View File

@ -289,6 +289,15 @@ func CurrentMailPorts() MailPorts {
// 旧版 SMTPPort 属于发信中继,不能用作本机监听端口。
return ResolveMailPorts(SMTPSectionConfig{}, EmailSectionConfig{})
}
func CurrentMailSecurity() string {
if manager := CurrentManager(); manager != nil {
if runtime := manager.Snapshot(); runtime.Generation != 0 {
return ResolveMailSecurity(runtime.SMTP.Listener)
}
}
return MailSecurityBoth
}
func cloneHTTPConfig(source HTTPConfig) HTTPConfig {
clone := source
clone.TrustedProxyCIDRs = append([]string(nil), source.TrustedProxyCIDRs...)

View File

@ -149,8 +149,7 @@ func validateSMTP(smtp SMTPConfig) error {
return ErrInvalidSMTP
}
}
relayConfigured := smtp.RelayHost != "" || smtp.RelayPort != 0 || smtp.Username != "" ||
smtp.Password.Configured() || smtp.Security != "" || smtp.From != ""
relayConfigured := smtpRelayConfigured(smtp)
if !relayConfigured {
return nil
}
@ -167,6 +166,12 @@ func validateSMTP(smtp SMTPConfig) error {
return nil
}
// smtpRelayConfigured 只把实际连接或发件身份字段视为启用中继。
// 推荐端口、统一加密方式和默认发件域名单独存在时,中继仍保持可选状态。
func smtpRelayConfigured(smtp SMTPConfig) bool {
return smtp.RelayHost != "" || smtp.Username != "" || smtp.Password.Configured() || smtp.From != ""
}
func validateSSL(ssl SSLConfig) error {
if !isControlledRelativePath(ssl.DefaultDirectory) {
return ErrInvalidSSL

View File

@ -183,6 +183,15 @@ func TestValidateSMTPKeepsLoginVerificationIndependentFromRelay(t *testing.T) {
},
valid: true,
},
{
name: "unified defaults without relay host",
smtp: SMTPConfig{
RelayPort: 587,
Security: "starttls",
FromDomain: "example.com",
},
valid: true,
},
{
name: "partially configured relay",
smtp: SMTPConfig{

View File

@ -449,12 +449,12 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
return map[string]any{
"meta": runtime.SMTP.Meta,
"listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS},
"listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS, Security: config.PublicMailSecurity(runtime.SMTP.Listener)},
"relay": map[string]any{
"relayHost": runtime.SMTP.Relay.RelayHost,
"relayPort": runtime.SMTP.Relay.RelayPort,
"username": runtime.SMTP.Relay.Username,
"security": runtime.SMTP.Relay.Security,
"security": config.NormalizeMailSecurity(runtime.SMTP.Relay.Security),
"from": runtime.SMTP.Relay.From,
"fromDomain": runtime.SMTP.Relay.FromDomain,
"verificationEnabled": runtime.SMTP.Relay.VerificationEnabled,
@ -467,12 +467,12 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
return map[string]any{
"meta": runtime.SMTP.Meta,
"listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS},
"listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS, Security: config.PublicMailSecurity(runtime.SMTP.Listener)},
"relay": map[string]any{
"relayHost": runtime.SMTP.Relay.RelayHost,
"relayPort": runtime.SMTP.Relay.RelayPort,
"username": runtime.SMTP.Relay.Username,
"security": runtime.SMTP.Relay.Security,
"security": config.NormalizeMailSecurity(runtime.SMTP.Relay.Security),
"from": runtime.SMTP.Relay.From,
"fromDomain": runtime.SMTP.Relay.FromDomain,
"verificationEnabled": runtime.SMTP.Relay.VerificationEnabled,

View File

@ -44,6 +44,9 @@ func TestPublicMailPortsMatchListenersWithoutChangingSnapshot(t *testing.T) {
if mail.Listener.Port != want.SMTP || mail.Listener.STARTTLSPort != want.SMTPSTARTTLS || mail.Listener.TLSPort != want.SMTPTLS || mail.IMAP.TLSPort != want.IMAP || mail.POP3.Port != want.POP3 || mail.POP3.TLSPort != want.POP3TLS {
t.Fatal("combined public mail ports differ from listener configuration")
}
if mail.Listener.Security != runtime.SMTP.Listener.Security {
t.Fatal("public mail view changed legacy security state")
}
smtpPublic, err := publicConfigSection(runtime, config.SectionSMTP)
if err != nil {
t.Fatal(err)
@ -71,3 +74,36 @@ func TestPublicMailPortsMatchListenersWithoutChangingSnapshot(t *testing.T) {
}
}
}
func TestPublicMailSecurityNormalizesLegacyTLSNames(t *testing.T) {
runtime := config.RuntimeConfig{
SMTP: config.SMTPSectionConfig{
Listener: config.SMTPListenerConfig{Security: "tls"},
Relay: config.SMTPConfig{Security: "tls"},
},
}
for _, section := range []config.Section{config.SectionSMTP, config.SectionMail} {
public, err := publicConfigSection(runtime, section)
if err != nil {
t.Fatal(err)
}
data, err := json.Marshal(public)
if err != nil {
t.Fatal(err)
}
var value struct {
Listener config.SMTPListenerConfig `json:"listener"`
Relay config.SMTPConfig `json:"relay"`
}
if err := json.Unmarshal(data, &value); err != nil {
t.Fatal(err)
}
if value.Listener.Security != config.MailSecurityImplicitTLS {
t.Fatalf("%s listener security = %q, want %q", section, value.Listener.Security, config.MailSecurityImplicitTLS)
}
if value.Relay.Security != config.MailSecurityImplicitTLS {
t.Fatalf("%s relay security = %q, want %q", section, value.Relay.Security, config.MailSecurityImplicitTLS)
}
}
}

View File

@ -9,9 +9,16 @@ import (
log "github.com/sirupsen/logrus"
)
var instance *imapserver.Server
var instanceTLS *imapserver.Server
func Stop() {
if instance != nil {
if err := instance.Close(); err != nil {
log.WithError(err).Warn("close IMAP STARTTLS server failed")
}
instance = nil
}
if instanceTLS != nil {
if err := instanceTLS.Close(); err != nil {
log.WithError(err).Warn("close IMAP server failed")
@ -20,6 +27,21 @@ func Stop() {
}
}
// Start 启动普通 IMAP 监听,认证前必须通过 STARTTLS 升级连接。
func Start() error {
server, err := newIMAPServer()
if err != nil {
return err
}
instance = server
imapPort := config.CurrentMailPorts().IMAP
log.Infof("IMAP With STARTTLS Server Start On Port :%d", imapPort)
if err := instance.ListenAndServe(fmt.Sprintf(":%d", imapPort)); err != nil {
return fmt.Errorf("serve IMAP STARTTLS: %w", err)
}
return nil
}
// StarTLS 启动隐式 TLS 监听,不提供明文认证入口。
func StarTLS() error {
server, err := newIMAPServer()

View File

@ -194,10 +194,15 @@ func Init(serverVersion string) error {
}()
}
hooks.Init(serverVersion)
// smtp server start
mailSecurity := config.CurrentMailSecurity()
// SMTP 25 用于服务器间投递,始终保留并支持机会性 STARTTLS。
startService(serviceErrors, "SMTP", smtp_server.Start)
startService(serviceErrors, "SMTP TLS", smtp_server.StartWithTLS)
startService(serviceErrors, "SMTP STARTTLS", smtp_server.StartWithTLSNew)
if mailSecurity == config.MailSecurityBoth || mailSecurity == config.MailSecurityImplicitTLS {
startService(serviceErrors, "SMTP TLS", smtp_server.StartWithTLS)
}
if mailSecurity == config.MailSecurityBoth || mailSecurity == config.MailSecuritySTARTTLS {
startService(serviceErrors, "SMTP STARTTLS", smtp_server.StartWithTLSNew)
}
// http server start
startService(serviceErrors, "HTTPS", http_server.HttpsStart)
startService(serviceErrors, "HTTP", http_server.HttpStart)
@ -211,11 +216,17 @@ func Init(serverVersion string) error {
// 更新 SSL 配置。
ssl.Update(false)
// pop3 server start
startService(serviceErrors, "POP3", pop3_server.Start)
startService(serviceErrors, "POP3 TLS", pop3_server.StartWithTls)
// imap server start
startService(serviceErrors, "IMAP TLS", imap_server.StarTLS)
if mailSecurity == config.MailSecurityBoth || mailSecurity == config.MailSecuritySTARTTLS {
startService(serviceErrors, "POP3 STLS", pop3_server.Start)
}
if mailSecurity == config.MailSecurityBoth || mailSecurity == config.MailSecurityImplicitTLS {
startService(serviceErrors, "POP3 TLS", pop3_server.StartWithTls)
}
if mailSecurity == config.MailSecuritySTARTTLS {
startService(serviceErrors, "IMAP STARTTLS", imap_server.Start)
} else {
startService(serviceErrors, "IMAP TLS", imap_server.StarTLS)
}
log.Info("邮箱服务已启动")
restart.SetCurrent(restart.NewSignalTrigger(signal.RestartChan))

View File

@ -25,7 +25,13 @@
placeholder="请选择"
@update:model-value="update"
>
<el-option v-for="option in selectOptions" :key="String(option.value)" :label="option.label" :value="option.value" />
<el-option
v-for="option in selectOptions"
:key="String(option.value)"
:label="option.label"
:value="option.value"
:disabled="option.disabled === true"
/>
</el-select>
<el-switch
v-else-if="field.type === 'switch'"

View File

@ -6,6 +6,17 @@ const loginOptions = options([
const port = (path, label, extra = {}) => field(path, label, 'number', { min: 0, max: 65535, integer: true, ...extra })
const group = (id, title, description, fields) => ({ id, title, description, fields })
const smtpPorts = new Map([['starttls', 587], ['implicit_tls', 465], ['tls', 465], ['plain', 25]])
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'
}
const startTLSMail = config => mailSecurity(config) === 'starttls'
const implicitTLSMail = config => mailSecurity(config) === 'implicit_tls'
const relayConfigured = config => !!(
config.relay?.relayHost || config.relay?.username || config.relay?.password ||
config.relay?.from
)
export function readFormConfig(text) {
try {
@ -32,7 +43,7 @@ export function getRecommendedPort(config, path) {
case 'http.httpsPort':
return 443
case 'relay.relayPort':
return smtpPorts.get(config.relay?.security)
return smtpPorts.get(mailSecurity(config))
case 'listener.port':
return 25
case 'listener.starttlsPort':
@ -40,7 +51,7 @@ export function getRecommendedPort(config, path) {
case 'listener.tlsPort':
return 465
case 'imap.tlsPort':
return 993
return startTLSMail(config) ? 143 : 993
case 'pop3.port':
return 110
case 'pop3.tlsPort':
@ -51,20 +62,6 @@ export function getRecommendedPort(config, path) {
}
const validPort = value => Number.isInteger(value) && value > 0 && value <= 65535
const localSMTPHost = value => {
if (typeof value !== 'string') return false
const host = value.trim().toLowerCase().replace(/\.$/, '')
if (host === 'localhost') return true
if (/^127(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(host)) {
return host.split('.').every(part => Number(part) <= 255)
}
try {
return new URL(`http://[${host}]`).hostname === '[::1]'
} catch {
return false
}
}
const networkDatabase = config => config.database?.type !== 'sqlite'
const uploadedCertificate = config => ['managed_upload', 'custom'].includes(config.tls?.mode)
const certificateManagement = config => {
@ -117,23 +114,24 @@ const baseGroups = [
const mailGroups = [
group('listener', '邮件服务监听', '', [
port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }),
port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, hint: '默认 587,客户端升级 TLS 后进行认证。' }),
port('listener.tlsPort', 'SMTP 隐式 TLS 端口', { min: 1, hint: '默认 465,客户端建立连接时直接使用 TLS。' }),
port('imap.tlsPort', 'IMAP 隐式 TLS 端口', { min: 1, required: true, hint: '默认 993。客户端建立连接时直接使用 TLS。' }),
port('pop3.port', 'POP3 端口(STLS)', { min: 1, required: true, hint: '默认 110,客户端使用 STLS 升级加密后认证。' }),
port('pop3.tlsPort', 'POP3 隐式 TLS 端口', { min: 1, required: true, hint: '默认 995,客户端建立连接时直接使用 TLS。' }),
field('relay.security', 'SMTP 中继加密方式', 'select', {
field('listener.security', '统一加密方式', 'select', {
wide: true,
options: options([
['', '尚未配置'],
['starttls', 'STARTTLS(推荐端口 587)'],
['implicit_tls', '隐式 TLS(推荐端口 465)'],
['tls', 'TLS 兼容模式(推荐端口 465)'],
['plain', '不加密(仅限本机,端口 25)']
]),
hint: '仅控制下方外部 SMTP 中继连接,不改变本机 SMTP、IMAP 或 POP3 监听方式。切换后会自动匹配推荐中继端口,仍可手动填写服务商指定的端口。'
})
fallback: 'both',
options: [
{ value: 'both', label: '旧配置兼容状态(请选择统一加密方式)', disabled: true },
...options([
['starttls', 'STARTTLS / STLS'],
['implicit_tls', '隐式 TLS']
])
],
hint: '切换后 SMTP 提交、IMAP、POP3 和已配置的 SMTP 中继会统一使用同一种加密方式,并自动匹配推荐端口。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 后进行认证。' }),
port('listener.tlsPort', 'SMTP 提交端口(隐式 TLS)', { min: 1, show: config => !startTLSMail(config), hint: '默认 465,客户端建立连接时直接使用 TLS。' }),
port('imap.tlsPort', 'IMAP 端口', { min: 1, required: true, dynamicLabel: config => startTLSMail(config) ? 'IMAP 端口(STARTTLS)' : 'IMAP 端口(隐式 TLS)', hint: 'STARTTLS 默认使用 143,隐式 TLS 默认使用 993。' }),
port('pop3.port', 'POP3 端口(STLS)', { min: 1, required: true, show: config => !implicitTLSMail(config), hint: '默认 110,客户端使用 STLS 升级加密后认证。' }),
port('pop3.tlsPort', 'POP3 端口(隐式 TLS)', { min: 1, required: true, show: config => !startTLSMail(config), hint: '默认 995,客户端建立连接时直接使用 TLS。' })
]),
group('relay', 'SMTP 中继连接(可选)', '', [
field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }),
@ -212,7 +210,11 @@ export function getFieldGroups(section, config) {
...item,
fields: item.fields
.filter(input => !input.show || input.show(config))
.map(input => ({ ...input, recommendedPort: getRecommendedPort(config, input.path) }))
.map(input => ({
...input,
label: input.dynamicLabel ? input.dynamicLabel(config) : input.label,
recommendedPort: getRecommendedPort(config, input.path)
}))
}))
.filter(item => item.fields.length > 0)
}
@ -252,9 +254,23 @@ export function updateFormConfig(text, section, path, value) {
}
parent[path.at(-1)] = value ?? (input.type === 'number' ? null : '')
if (previousValue !== value) {
if (key === 'relay.security') {
const recommended = getRecommendedPort(config, ['relay', 'relayPort'])
if (recommended !== undefined) config.relay.relayPort = recommended
if (key === 'listener.security' && ['starttls', 'implicit_tls'].includes(value)) {
const startTLS = value === 'starttls'
config.listener.port = 25
config.listener.starttlsPort = startTLS ? 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.tlsPort = startTLS ? 0 : 995
config.relay ||= {}
config.relay.security = value
config.relay.relayPort = smtpPorts.get(value)
}
if (key.startsWith('relay.') && key !== 'relay.security' && relayConfigured(config) && ['starttls', 'implicit_tls'].includes(mailSecurity(config))) {
config.relay.security = mailSecurity(config)
if (!validPort(config.relay.relayPort)) config.relay.relayPort = smtpPorts.get(config.relay.security)
}
if (key === 'http.tlsMode' && ['direct', 'proxy'].includes(value)) {
if (!validPort(config.http.port)) {
@ -311,22 +327,24 @@ export function validateFormConfig(section, config) {
}
if (section === 'mail') {
const relay = config.relay || {}
const relayConfigured = !!(
relay.relayHost || relay.relayPort || relay.username || relay.password || relay.security || relay.from
)
if (relayConfigured) {
const configured = relayConfigured(config)
if (!['starttls', 'implicit_tls'].includes(mailSecurity(config))) {
errors['listener.security'] = '请选择 STARTTLS / STLS 或隐式 TLS'
}
if (configured) {
if (!relay.relayHost) errors['relay.relayHost'] = '请填写 SMTP 服务器'
if (!['starttls', 'implicit_tls', 'tls', 'plain'].includes(relay.security)) errors['relay.security'] = '请选择连接加密方式'
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 (!validPort(relay.relayPort)) errors['relay.relayPort'] = '请填写 1~65535 的 SMTP 端口'
if (!relay.from) errors['relay.from'] = '请填写发件邮箱'
if (!relay.fromDomain) errors['relay.fromDomain'] = '请填写发件域名'
}
if (relay.security === 'plain' && !localSMTPHost(relay.relayHost)) {
errors['relay.relayHost'] = '不加密连接仅限本机地址;远程服务器请选择 STARTTLS 或隐式 TLS'
}
}
if (section === 'mail') {
const paths = ['listener.port', 'listener.starttlsPort', 'listener.tlsPort', 'imap.tlsPort', 'pop3.port', 'pop3.tlsPort']
const paths = getFieldGroups('mail', config).flatMap(group => group.fields)
.map(field => fieldKey(field))
.filter(path => /^(listener\.(port|starttlsPort|tlsPort)|imap\.tlsPort|pop3\.(port|tlsPort))$/.test(path))
for (const path of paths) {
const value = getValue(config, path.split('.'))
if (value > 0 && paths.some(other => other !== path && getValue(config, other.split('.')) === value)) errors[path] = '邮件服务监听端口不能重复'

View File

@ -49,7 +49,7 @@ test('配置与安全策略页面使用分组表单,控件和样式能编译
}
})
test('邮件服务页面统一编辑六个监听端口,中继和策略字段保持在同一配置分区', () => {
test('邮件服务页面使用统一加密方式管理监听端口,中继和策略字段保持在同一配置分区', () => {
const config = { listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, relay: { relayPort: 465 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } }
const text = JSON.stringify(config)
const groups = getFieldGroups('mail', config)
@ -57,7 +57,7 @@ test('邮件服务页面统一编辑六个监听端口,中继和策略字段
const relay = groups.find(group => group.id === 'relay')
const keys = groups.flatMap(group => group.fields.map(input => input.path.join('.')))
assert.deepEqual(keys.filter(key => /^(listener|imap|pop3)\./.test(key)), [
'listener.port', 'listener.starttlsPort', 'listener.tlsPort',
'listener.security', 'listener.port', 'listener.starttlsPort', 'listener.tlsPort',
'imap.tlsPort', 'pop3.port', 'pop3.tlsPort'
])
assert.ok(keys.includes('relay.relayHost'))
@ -67,9 +67,10 @@ test('邮件服务页面统一编辑六个监听端口,中继和策略字段
assert.ok(!keys.includes('policy.smtpPort'))
assert.ok(!keys.includes('policy.imapPort'))
assert.ok(!keys.includes('policy.pop3Port'))
assert.equal(listener.fields.at(-1).path.join('.'), 'relay.security')
assert.equal(listener.fields.at(-1).label, 'SMTP 中继加密方式')
assert.equal(listener.fields.at(-1).wide, true)
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.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))
@ -240,8 +241,8 @@ test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正
}
assert.ok(validateFormConfig('mail', { imap: { tlsPort: 993 }, pop3: { port: 993, tlsPort: 995 } })['pop3.port'])
assert.deepEqual(validateFormConfig('mail', {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { maxMessageBytes: 0 }
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: { maxMessageBytes: 0 }
}), {})
})
@ -306,31 +307,47 @@ test('证书管理方式只展示三种业务模式并同步底层托管配置',
assert.ok(getFieldGroups('ssl', updated).flatMap(group => group.fields).some(field => field.path.join('.') === 'tls.certificateRef'))
})
test('SMTP 加密方式自动匹配端口,手动端口和读取过程不改变原配置', () => {
for (const [security, expectedPort] of [['starttls', 587], ['implicit_tls', 465], ['tls', 465], ['plain', 25]]) {
const config = { generation: 9, relay: { relayHost: 'localhost', relayPort: 2525, future: true } }
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 }]
]) {
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)
getFieldGroups('mail', config)
assert.equal(JSON.stringify(config), text)
text = updateFormConfig(text, 'mail', ['relay', 'security'], security)
assert.deepEqual(readFormConfig(text), { ...config, relay: { ...config.relay, security, relayPort: expectedPort } })
const fields = getFieldGroups('mail', readFormConfig(text)).flatMap(group => group.fields)
assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, expectedPort)
text = updateFormConfig(text, 'mail', ['listener', 'security'], security)
const updated = readFormConfig(text)
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 })
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')
text = updateFormConfig(text, 'mail', ['relay', 'relayPort'], 2526)
assert.equal(readFormConfig(text).relay.security, security)
text = updateFormConfig(text, 'mail', ['relay', 'security'], security)
assert.equal(readFormConfig(text).relay.relayPort, 2526)
}
const fields = getFieldGroups('mail', {}).flatMap(group => group.fields)
assert.ok(fields.findIndex(field => field.path.at(-1) === 'security') < fields.findIndex(field => field.path.at(-1) === 'relayPort'))
const unused = JSON.stringify({ relay: { relayPort: 2525, security: 'starttls' } })
assert.equal(readFormConfig(updateFormConfig(unused, 'mail', ['relay', 'security'], '')).relay.relayPort, 2525)
for (const security of ['constructor', '__proto__', 'future']) {
const updated = readFormConfig(updateFormConfig(unused, 'mail', ['relay', 'security'], security))
assert.equal(updated.relay.relayPort, 2525)
const fields = getFieldGroups('mail', updated).flatMap(group => group.fields)
assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, 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: {} }), 'mail', ['relay', 'relayHost'], 'smtp.example.com'))
assert.equal(configured.relay.security, 'starttls')
assert.equal(configured.relay.relayPort, 587)
assert.deepEqual(validateFormConfig('mail', {
listener: { security: 'tls', port: 25, starttlsPort: 0, tlsPort: 465 },
relay: { 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(validateFormConfig('mail', defaultsOnly), {})
assert.ok(validateFormConfig('mail', { listener: {}, relay: {}, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 } })['listener.security'])
})
test('内置 HTTPS 自动补全端口,反代禁用内置监听,已有自定义 HTTP 端口保留', () => {
@ -352,7 +369,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 },
@ -361,14 +378,10 @@ test('加密配置校验拦截监听冲突、远程明文和已启用但未选
for (const port of [80, 0]) {
assert.ok(validateFormConfig('base', { http: { port, httpsEnabled: true, httpsPort: 80 } })['http.httpsPort'])
}
for (const relayHost of ['localhost', 'LOCALHOST', '127.0.0.1', '127.1.2.3', '::1', '0:0:0:0:0:0:0:1']) {
assert.deepEqual(validateFormConfig('mail', { ...listeners, relay: { relayHost, security: 'plain', relayPort: 25, from: 'local@example.com', fromDomain: 'example.com' } }), {})
}
for (const relayHost of ['smtp.example.com', '127.0.0.999', '127.1', '127.0.0.1.evil.com', '192.168.1.1']) {
assert.ok(validateFormConfig('mail', { ...listeners, relay: { relayHost, security: 'plain', relayPort: 25, from: 'sender@example.com', fromDomain: 'example.com' } })['relay.relayHost'])
}
const errors = validateFormConfig('mail', { ...listeners, relay: { relayHost: 'smtp.example.com', security: 'starttls', relayPort: 0, from: 'sender@example.com', fromDomain: 'example.com' } })
const unified = { ...listeners, listener: { ...listeners.listener, security: 'starttls', tlsPort: 0 }, imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 } }
const errors = validateFormConfig('mail', { ...unified, relay: { relayHost: 'smtp.example.com', security: 'starttls', relayPort: 0, from: 'sender@example.com', fromDomain: 'example.com' } })
assert.ok(errors['relay.relayPort'])
assert.deepEqual(validateFormConfig('mail', { ...listeners, relay: {} }), {})
assert.deepEqual(validateFormConfig('mail', { ...listeners, relay: { security: 'starttls', relayPort: 2525, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } }), {})
assert.deepEqual(validateFormConfig('mail', { ...unified, relay: {} }), {})
assert.deepEqual(validateFormConfig('mail', { ...unified, relay: { security: 'starttls', relayPort: 2525, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } }), {})
assert.ok(validateFormConfig('mail', { ...unified, relay: { security: 'implicit_tls', relayPort: 465, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } })['listener.security'])
})

View File

@ -77,12 +77,12 @@ for (const failedRequest of ['/status', '/pending']) {
let fail = false
let saves = 0
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: {
relayHost: 'old.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {}
}
const { page } = fixture(() => {}, 'mail', { http: {
get: async path => {
@ -271,9 +271,9 @@ for (const key of ['mail', 'security_policy']) {
let pending = null
let active = {
meta: { schema: 1, generation: 1 },
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: { verificationEnabled: true, loginPolicy: 'new_device' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 },
policy: { maxMessageBytes: 1234, spamFilterLevel: 1, externalRecoveryEnabled: true }
}
const { page } = fixture(() => {}, key, { http: {
@ -451,10 +451,10 @@ test('保存并应用一次点击按候选协议连续完成保存和应用', as
const requests = []
let pending = null
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' },
imap: { tlsPort: 993 },
pop3: { port: 110, tlsPort: 995 },
imap: { tlsPort: 143 },
pop3: { port: 110, tlsPort: 0 },
policy: { maxMessageBytes: 10 << 20, spamFilterLevel: 2 }
}
const { page } = fixture(async path => ({
@ -494,9 +494,9 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应
let saveAttempts = 0
let prompts = 0
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {}
}
const recentAuthError = Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
const { page } = fixture(async () => ({ errorNo: 0, data: active }), 'mail', {
@ -539,8 +539,8 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应
test('取消最近身份确认时保留配置输入且不重试请求', async () => {
let saves = 0
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {}, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: {}, imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {}
}
const { page, messages } = fixture(async path => ({
errorNo: 0,
@ -654,12 +654,12 @@ test('邮件页保持验证、保存、应用端点和代次协议,中继密
const requests = []
let pending = null
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {}
}
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 8 } : active }), 'mail', {
http: {
@ -729,12 +729,12 @@ test('可视化编辑按权限和工作状态保护,编辑会清除过期确
test('前端无效字段阻止网络请求,修正字段后同步清除错误', async () => {
const requests = []
const config = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {}
}
const { page } = fixture(async () => ({ errorNo: 0, data: config }), 'mail', {
http: { post: async path => { requests.push(path); return { errorNo: 0, data: { valid: true } } } }
@ -789,12 +789,12 @@ test('SQLite 不显示或发送密码,网络数据库和中继密码留空保
}
let request
const mail = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {}
}
const { page } = fixture(async () => ({ errorNo: 0, data: mail }), 'mail', {
http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } }