-
+
- SMTP 服务器
- {{ relay.relayHost || '未填写' }}
- 端口
- {{ relay.relayPort || '未填写' }}
- 认证账号
- {{ relay.username || '未填写' }}
- 发件邮箱
- {{ relay.from || '未填写' }}
- 密码
- {{ passwordConfigured ? '已配置(不显示明文)' : '未配置' }}
- 发件域名
- {{ relay.fromDomain || '未填写' }}
diff --git a/backend/config/mail_listeners.go b/backend/config/mail_listeners.go index 8525759..c234560 100644 --- a/backend/config/mail_listeners.go +++ b/backend/config/mail_listeners.go @@ -84,8 +84,7 @@ func validateMailListeners(smtp SMTPSectionConfig, email EmailSectionConfig) err if security != MailSecurityBoth && security != MailSecuritySTARTTLS && security != MailSecurityImplicitTLS { return ErrInvalidRuntimeConfig } - relayConfigured := smtpRelayConfigured(smtp.Relay) - if relayConfigured && security != MailSecurityBoth { + if smtpRelayEnabled(smtp.Relay) && security != MailSecurityBoth { relaySecurity := NormalizeMailSecurity(smtp.Relay.Security) if relaySecurity != security { return ErrInvalidSMTP diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index af97a96..d4dd300 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -870,6 +870,7 @@ func applySectionPayload(current RuntimeConfig, section Section, payload json.Ra return RuntimeConfig{}, errors.New("configuration payload is empty") } candidate := cloneRuntimeConfig(current) + clearRelay := false switch section { case SectionRoot: var value struct { @@ -895,7 +896,15 @@ func applySectionPayload(current RuntimeConfig, section Section, payload json.Ra if value.SMTPSectionConfig.Relay.Password.Configured() { return RuntimeConfig{}, ErrInvalidSecretChange } - value.SMTPSectionConfig.Relay.Password = current.SMTP.Relay.Password + clearRelay = value.SMTPSectionConfig.Relay.Clear + if !value.SMTPSectionConfig.Relay.relayEnabledSet { + value.SMTPSectionConfig.Relay.RelayEnabled = current.SMTP.Relay.RelayEnabled + } + if clearRelay { + clearSMTPRelay(&value.SMTPSectionConfig.Relay) + } else { + value.SMTPSectionConfig.Relay.Password = current.SMTP.Relay.Password + } if smtpRelayConfigured(value.SMTPSectionConfig.Relay) && value.SMTPSectionConfig.Relay.Security == "" { if ResolveMailSecurity(value.SMTPSectionConfig.Listener) == MailSecurityImplicitTLS { value.SMTPSectionConfig.Relay.Security = MailSecurityImplicitTLS @@ -923,7 +932,15 @@ func applySectionPayload(current RuntimeConfig, section Section, payload json.Ra if value.MailSectionConfig.Relay.Password.Configured() { return RuntimeConfig{}, ErrInvalidSecretChange } - value.MailSectionConfig.Relay.Password = current.SMTP.Relay.Password + clearRelay = value.MailSectionConfig.Relay.Clear + if !value.MailSectionConfig.Relay.relayEnabledSet { + value.MailSectionConfig.Relay.RelayEnabled = current.SMTP.Relay.RelayEnabled + } + if clearRelay { + clearSMTPRelay(&value.MailSectionConfig.Relay) + } else { + value.MailSectionConfig.Relay.Password = current.SMTP.Relay.Password + } if smtpRelayConfigured(value.Relay) && value.Relay.Security == "" { if ResolveMailSecurity(value.Listener) == MailSecurityImplicitTLS { value.Relay.Security = MailSecurityImplicitTLS @@ -980,10 +997,29 @@ func applySectionPayload(current RuntimeConfig, section Section, payload json.Ra if err := applySecretChanges(&candidate, section, changes, vault); err != nil { return RuntimeConfig{}, err } + if clearRelay { + clearSMTPRelay(&candidate.SMTP.Relay) + } candidate.SetupState = candidate.Root.SetupState return candidate, nil } +func clearSMTPRelay(relay *SMTPConfig) { + if relay == nil { + return + } + relay.RelayEnabled = false + relay.RelayHost = "" + relay.RelayPort = 0 + relay.Username = "" + relay.Password = StoredSecret{} + relay.Security = "" + relay.From = "" + relay.FromDomain = "" + relay.Clear = false + relay.relayEnabledSet = true +} + func setCandidateGeneration(candidate *RuntimeConfig, generation uint64) { if candidate == nil { return diff --git a/backend/config/migrate.go b/backend/config/migrate.go index 1a7cddc..78052a4 100644 --- a/backend/config/migrate.go +++ b/backend/config/migrate.go @@ -763,6 +763,7 @@ func validLegacyDatabasePassword(value string) bool { func legacySMTPConfig(root string, vault *credential.Vault, legacy legacyMigrationConfig) (SMTPConfig, []string, error) { relay := SMTPConfig{ + RelayEnabled: legacy.SMTPHost != "" || legacy.SMTPPort != 0 || legacy.SMTPUsername != "" || legacy.SMTPFrom != "" || legacy.SMTPPassword != "", RelayHost: legacy.SMTPHost, RelayPort: legacy.SMTPPort, Username: legacy.SMTPUsername, diff --git a/backend/config/model.go b/backend/config/model.go index e647eb6..215221f 100644 --- a/backend/config/model.go +++ b/backend/config/model.go @@ -1,6 +1,9 @@ package config -import "time" +import ( + "encoding/json" + "time" +) type SetupState string @@ -82,6 +85,7 @@ type DatabaseConfig struct { } type SMTPConfig struct { + RelayEnabled bool `json:"enabled"` RelayHost string `json:"relayHost"` RelayPort int `json:"relayPort"` Username string `json:"username"` @@ -91,6 +95,42 @@ type SMTPConfig struct { FromDomain string `json:"fromDomain"` VerificationEnabled bool `json:"verificationEnabled"` LoginPolicy string `json:"loginPolicy"` + Clear bool `json:"clear,omitempty"` + relayEnabledSet bool `json:"-"` +} + +func (s *SMTPConfig) UnmarshalJSON(data []byte) error { + type smtpConfigAlias SMTPConfig + var value smtpConfigAlias + if err := json.Unmarshal(data, &value); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + *s = SMTPConfig(value) + _, enabledProvided := fields["enabled"] + configured := smtpRelayFieldsConfigured(*s) + s.relayEnabledSet = enabledProvided && (s.RelayEnabled || configured) + if !enabledProvided { + s.RelayEnabled = configured + } + return nil +} + +func (s SMTPConfig) MarshalJSON() ([]byte, error) { + type smtpConfigAlias SMTPConfig + data, err := json.Marshal(smtpConfigAlias(s)) + if err != nil || s.RelayEnabled || s.relayEnabledSet { + return data, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return nil, err + } + delete(fields, "enabled") + return json.Marshal(fields) } type EmailConfig struct { diff --git a/backend/config/runtime_access.go b/backend/config/runtime_access.go index 77c5219..c710284 100644 --- a/backend/config/runtime_access.go +++ b/backend/config/runtime_access.go @@ -288,6 +288,7 @@ func CurrentSMTPRuntimeSettings() (SMTPRuntimeSettings, error) { return SMTPRuntimeSettings{}, fmt.Errorf("runtime configuration is unavailable") } settings.Relay = SMTPConfig{ + RelayEnabled: cfg.SMTPHost != "" || cfg.SMTPPort != 0 || cfg.SMTPUsername != "" || cfg.SMTPFrom != "" || cfg.SMTPPassword != "", RelayHost: cfg.SMTPHost, RelayPort: cfg.SMTPPort, Username: cfg.SMTPUsername, diff --git a/backend/config/validator.go b/backend/config/validator.go index 6e4471b..a42a8e0 100644 --- a/backend/config/validator.go +++ b/backend/config/validator.go @@ -152,8 +152,7 @@ func validateSMTP(smtp SMTPConfig) error { return ErrInvalidSMTP } } - relayConfigured := smtpRelayConfigured(smtp) - if !relayConfigured { + if !smtpRelayEnabled(smtp) { return nil } if smtp.RelayHost == "" || smtp.RelayPort == 0 || smtp.Security == "" || smtp.From == "" || smtp.FromDomain == "" { @@ -169,12 +168,22 @@ func validateSMTP(smtp SMTPConfig) error { return nil } -// smtpRelayConfigured 只把实际连接或发件身份字段视为启用中继。 -// 推荐端口、统一加密方式和默认发件域名单独存在时,中继仍保持可选状态。 -func smtpRelayConfigured(smtp SMTPConfig) bool { +func smtpRelayFieldsConfigured(smtp SMTPConfig) bool { return smtp.RelayHost != "" || smtp.Username != "" || smtp.Password.Configured() || smtp.From != "" } +func smtpRelayConfigured(smtp SMTPConfig) bool { + return smtpRelayFieldsConfigured(smtp) || smtp.RelayEnabled +} + +func smtpRelayEnabled(smtp SMTPConfig) bool { + return smtp.RelayEnabled || (!smtp.relayEnabledSet && smtpRelayFieldsConfigured(smtp)) +} + +func (smtp SMTPConfig) RelayActive() bool { + return smtpRelayEnabled(smtp) +} + func validateSSL(ssl SSLConfig) error { if err := validateDNSProvider(ssl); err != nil { return err diff --git a/backend/controllers/config.go b/backend/controllers/config.go index a545fb6..43fb999 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -456,6 +456,7 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) ( "meta": runtime.SMTP.Meta, "listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS, Security: config.PublicMailSecurity(runtime.SMTP.Listener)}, "relay": map[string]any{ + "enabled": runtime.SMTP.Relay.RelayEnabled, "relayHost": runtime.SMTP.Relay.RelayHost, "relayPort": runtime.SMTP.Relay.RelayPort, "username": runtime.SMTP.Relay.Username, @@ -474,6 +475,7 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) ( "meta": runtime.SMTP.Meta, "listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS, Security: config.PublicMailSecurity(runtime.SMTP.Listener)}, "relay": map[string]any{ + "enabled": runtime.SMTP.Relay.RelayEnabled, "relayHost": runtime.SMTP.Relay.RelayHost, "relayPort": runtime.SMTP.Relay.RelayPort, "username": runtime.SMTP.Relay.Username, diff --git a/backend/res_init/verification_delivery.go b/backend/res_init/verification_delivery.go index ece3de1..941aa5e 100644 --- a/backend/res_init/verification_delivery.go +++ b/backend/res_init/verification_delivery.go @@ -81,11 +81,7 @@ func smtpRelayComplete(settings config.SMTPRuntimeSettings) bool { } func smtpRelayRequested(settings config.SMTPRuntimeSettings) bool { - relay := settings.Relay - return strings.TrimSpace(relay.RelayHost) != "" || - strings.TrimSpace(relay.Username) != "" || - strings.TrimSpace(settings.Password) != "" || - strings.TrimSpace(relay.From) != "" + return settings.Relay.RelayActive() } func verificationSMTPSettings(settings config.SMTPRuntimeSettings) (verification.SMTPSettings, error) { diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 57aa639..243f0f6 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -15,8 +15,9 @@ export const relaySecurityForMail = config => mailSecurity(config) === 'implicit const startTLSMail = config => mailSecurity(config) === 'starttls' const implicitTLSMail = config => mailSecurity(config) === 'implicit_tls' export const relayConfigured = config => !!( - config.relay?.relayHost || config.relay?.username || config.relay?.password || - config.relay?.from + config.relay?.enabled || config.relay?.relayHost || config.relay?.relayPort || + config.relay?.username || config.relay?.password || config.relay?.security || + config.relay?.from || config.relay?.fromDomain ) export function readFormConfig(text) { @@ -141,6 +142,7 @@ const mailGroups = [ port('pop3.tlsPort', 'POP3 端口(隐式 TLS)', { min: 1, required: true, show: config => !startTLSMail(config), hint: '默认 995,客户端建立连接时直接使用 TLS。' }) ]), group('relay', 'SMTP 中继连接(可选)', '', [ + field('relay.enabled', '启用 SMTP 中继', 'switch', { hint: '关闭后保留配置,但系统不会使用该中继发信。' }), field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }), port('relay.relayPort', 'SMTP 发信中继端口', { hint: '外部 SMTP 服务器端口,与本机监听无关;选择加密方式后自动填写。' }), field('relay.username', '认证用户名', 'text', { placeholder: '需要认证时填写' }), @@ -281,14 +283,16 @@ export function updateFormConfig(text, section, path, value) { if (!Array.isArray(path) || path.some(key => ['__proto__', 'prototype', 'constructor'].includes(key))) return text const key = path.join('.') const input = getFieldGroups(section, config).flatMap(item => item.fields).find(item => fieldKey(item) === key) - if (!input || input.locked) return text + const isRelayClear = section === 'mail' && key === 'relay.clear' + if ((!input && !isRelayClear) || input?.locked) return text const previousValue = getValue(config, path) let parent = config for (const part of path.slice(0, -1)) { if (!parent[part] || typeof parent[part] !== 'object') parent[part] = {} parent = parent[part] } - parent[path.at(-1)] = value ?? (input.type === 'number' ? null : '') + parent[path.at(-1)] = value ?? (input?.type === 'number' ? null : '') + if (key.startsWith('relay.') && key !== 'relay.clear' && config.relay) config.relay.clear = false if (previousValue !== value) { if (key === 'listener.security' && ['both', 'starttls', 'implicit_tls'].includes(value)) { const startTLS = value === 'starttls' @@ -305,7 +309,7 @@ export function updateFormConfig(text, section, path, value) { config.relay.security = relaySecurityForMail(config) config.relay.relayPort = smtpPorts.get(config.relay.security) } - if (key.startsWith('relay.') && key !== 'relay.security' && relayConfigured(config) && ['both', 'starttls', 'implicit_tls'].includes(mailSecurity(config))) { + if (key.startsWith('relay.') && key !== 'relay.security' && key !== 'relay.enabled' && 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) } @@ -365,10 +369,11 @@ export function validateFormConfig(section, config) { if (section === 'mail') { const relay = config.relay || {} const configured = relayConfigured(config) + const enabled = relay.enabled === true || (relay.enabled === undefined && configured) if (!['both', 'starttls', 'implicit_tls'].includes(mailSecurity(config))) { errors['listener.security'] = '请选择全部启用、STARTTLS / STLS 或隐式 TLS' } - if (configured) { + 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'] = '请先选择统一加密方式' diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue index 8c98e7c..ee0107d 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue @@ -8,11 +8,19 @@ :data-group="group.id" :aria-label="group.title" > -
中继配置独立于本机邮件服务;关闭开关只停止使用,不会删除已填写的信息。
+