增加SMTP中继开关与配置管理
Some checks are pending
Security Checks / security (push) Waiting to run

新增中继启用控制、列表展示、编辑和清除逻辑,保留安全配置边界。
This commit is contained in:
smail 2026-10-06 00:20:29 +08:00
parent a71d57e848
commit 3cca3438b8
13 changed files with 332 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'] = '请先选择统一加密方式'

View File

@ -8,11 +8,19 @@
:data-group="group.id"
:aria-label="group.title"
>
<div class="form-group-heading">
<div v-if="!(section === 'mail' && group.id === 'relay')" class="form-group-heading">
<h4>{{ group.title }}</h4>
<span v-if="group.fields.some(field => errors[fieldKey(field)])" class="section-error">待修正</span>
</div>
<div class="form-grid" :class="{ 'site-grid': section === 'base' && group.id === 'site' }">
<RelayConfig
v-if="section === 'mail' && group.id === 'relay'"
:config="config"
:fields="group.fields"
:errors="errors"
:disabled="disabled"
@change="(path, value) => emit('change', path, value)"
/>
<div v-else class="form-grid" :class="{ 'site-grid': section === 'base' && group.id === 'site' }">
<ConfigField
v-for="field in group.fields"
:key="fieldKey(field)"
@ -31,6 +39,7 @@
import { computed } from 'vue'
import { fieldKey, getFieldGroups, getValue } from './ConfigForm.js'
import ConfigField from '../ConfigField/ConfigField.vue'
import RelayConfig from '../RelayConfig/RelayConfig.vue'
const props = defineProps({
section: { type: String, required: true },

View File

@ -555,7 +555,7 @@ export function usePage(pageKey = 'base') {
secretChanges['database.password'] = { operation: 'replace', value: { plaintext: value } }
}
const relayPassword = formConfig.value?.relay?.password
if (relayPassword && selectedSection.value === 'mail') {
if (relayPassword && selectedSection.value === 'mail' && !formConfig.value?.relay?.clear) {
secretChanges['relay.password'] = { operation: 'replace', value: { plaintext: relayPassword } }
}
if (selectedSection.value === 'ssl' && formConfig.value?.tls?.dnsProvider === 'alidns') {

View File

@ -0,0 +1,103 @@
.relay-config {
display: flex;
flex-direction: column;
gap: 16px;
grid-column: 1 / -1;
}
.relay-list-heading,
.relay-item-heading,
.relay-editor-heading,
.relay-item-actions,
.relay-editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.relay-list-heading h5,
.relay-editor-heading h5 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 15px;
}
.relay-list-heading p,
.relay-editor-heading span,
.relay-subtitle {
margin: 5px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.relay-list {
display: grid;
gap: 12px;
}
.relay-item {
padding: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.relay-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 20px;
margin: 18px 0;
}
.relay-summary div {
min-width: 0;
}
.relay-summary dt {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.relay-summary dd {
margin: 4px 0 0;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.relay-editor {
padding: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.relay-fields {
margin-top: 16px;
}
.relay-alert {
margin: 0;
}
@media (max-width: 900px) {
.relay-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.relay-list-heading,
.relay-item-heading,
.relay-editor-heading {
align-items: flex-start;
flex-direction: column;
}
.relay-summary {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,107 @@
<template>
<div class="relay-config">
<div class="relay-list-heading">
<div>
<h5>已配置的 SMTP 中继</h5>
<p>中继配置独立于本机邮件服务;关闭开关只停止使用,不会删除已填写的信息。</p>
</div>
<el-button v-if="hasConfigured && !editing" type="primary" plain :disabled="disabled" @click="editing = true">
编辑中继
</el-button>
</div>
<el-alert
v-if="relay.clear"
title="已标记清除,保存后将删除服务器、端口、账号、密码、发件邮箱和发件域名。"
type="warning"
:closable="false"
show-icon
class="relay-alert"
/>
<div v-if="hasConfigured && !editing" class="relay-list" role="list" aria-label="SMTP 中继列表">
<article class="relay-item" role="listitem">
<div class="relay-item-heading">
<div>
<strong>默认 SMTP 中继</strong>
<span class="relay-subtitle">单个默认发信中继配置</span>
</div>
<el-tag :type="relay.enabled ? 'success' : 'info'" effect="plain">
{{ relay.enabled ? '已启用' : '已关闭' }}
</el-tag>
</div>
<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.username || '未填写' }}</dd></div>
<div><dt>发件邮箱</dt><dd>{{ relay.from || '未填写' }}</dd></div>
<div><dt>密码</dt><dd>{{ passwordConfigured ? '已配置(不显示明文)' : '未配置' }}</dd></div>
<div><dt>发件域名</dt><dd>{{ relay.fromDomain || '未填写' }}</dd></div>
</dl>
<div class="relay-item-actions">
<el-button type="primary" plain :disabled="disabled" @click="editing = true">编辑</el-button>
<el-button type="danger" plain :disabled="disabled" @click="clearRelay">清除中继</el-button>
</div>
</article>
</div>
<el-empty v-else-if="!editing" description="暂无已配置的 SMTP 中继" />
<div v-if="editing" class="relay-editor">
<div class="relay-editor-heading">
<h5>{{ hasConfigured ? '编辑 SMTP 中继' : '添加 SMTP 中继' }}</h5>
<span>密码留空表示保持原值,清除操作需要单独点击“清除中继”。</span>
</div>
<div class="form-grid relay-fields">
<ConfigField
v-for="field in fields"
:key="fieldKey(field)"
:field="field"
:value="getValue(config, field.path)"
:error="errors[fieldKey(field)]"
:disabled="disabled"
@change="(path, value) => emit('change', path, value)"
/>
</div>
<div class="relay-editor-actions">
<el-button v-if="hasConfigured" :disabled="disabled" @click="editing = false">取消编辑</el-button>
<el-button v-if="hasConfigured" type="danger" plain :disabled="disabled" @click="clearRelay">清除中继</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import ConfigField from '../ConfigField/ConfigField.vue'
import { fieldKey, getValue, relayConfigured } from '../ConfigForm/ConfigForm.js'
const props = defineProps({
config: { type: Object, required: true },
fields: { type: Array, required: true },
errors: { type: Object, default: () => ({}) },
disabled: Boolean
})
const emit = defineEmits(['change'])
const relay = computed(() => props.config.relay || {})
const hasConfigured = computed(() => relayConfigured(props.config))
const passwordConfigured = computed(() => !!props.config.relayPasswordStatus?.configured)
const editing = ref(!hasConfigured.value)
const clearRelay = async () => {
try {
await ElMessageBox.confirm(
'清除后将删除已保存的 SMTP 中继信息和密码,关闭中继开关不会产生此影响。确认清除吗?',
'确认清除 SMTP 中继',
{ type: 'warning', confirmButtonText: '确认清除', cancelButtonText: '取消' }
)
emit('change', ['relay', 'clear'], true)
editing.value = false
} catch (error) {
if (error !== 'cancel' && error !== 'close') throw error
}
}
</script>
<style scoped src="./RelayConfig.css"></style>