const field = (path, label, type = 'text', extra = {}) => ({ path: path.split('.'), label, type, ...extra }) const options = entries => entries.map(([value, label]) => ({ value, label })) const loginOptions = options([ ['', '使用默认策略'], ['off', '关闭'], ['new_device', '新设备验证'], ['always', '每次登录验证'] ]) 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 { const value = JSON.parse(text) return value && typeof value === 'object' && !Array.isArray(value) ? value : {} } catch { return {} } } export function getValue(config, path) { return path.reduce((value, key) => value?.[key], config) } export function fieldKey(item) { return item.path.join('.') } // 推荐值只用于用户主动操作,不在读取配置时覆盖已有的自定义端口。 export function getRecommendedPort(config, path) { switch (path.join('.')) { case 'http.port': return config.http?.tlsMode === 'proxy' ? 8080 : 80 case 'http.httpsPort': return 443 case 'relay.relayPort': return smtpPorts.get(mailSecurity(config)) case 'listener.port': return 25 case 'listener.starttlsPort': return 587 case 'listener.tlsPort': return 465 case 'imap.tlsPort': return startTLSMail(config) ? 143 : 993 case 'pop3.port': return 110 case 'pop3.tlsPort': return 995 default: return undefined } } const validPort = value => Number.isInteger(value) && value > 0 && value <= 65535 const networkDatabase = config => config.database?.type !== 'sqlite' const uploadedCertificate = config => ['managed_upload', 'custom'].includes(config.tls?.mode) const certificateManagement = config => { const management = config.tls?.management if (['web_server', 'project', 'custom'].includes(management)) return management return ['managed_default', 'default'].includes(config.tls?.mode) ? 'project' : 'custom' } const projectManagedCertificate = config => certificateManagement(config) === 'project' const externalManagedCertificate = config => !projectManagedCertificate(config) const normalizedDomain = value => value.toLowerCase().replace(/\.$/, '') const validDomain = value => { if (typeof value !== 'string' || value !== value.trim()) return false const domain = normalizedDomain(value) return domain.length > 0 && domain.length <= 253 && !/^\d+(\.\d+){3}$/.test(domain) && domain.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) } const baseGroups = [ group('site', '站点与域名', '', [ field('domains.primary', '主邮件域名', 'text', { required: true, placeholder: 'example.com' }), field('domains.web', '网页访问域名', 'text', { required: true, placeholder: 'mail.example.com' }), field('domains.accepted', '接收邮件的域名', 'list', { required: true, wide: false, hint: '需包含主邮件域名,每个域名单独填写。', placeholder: 'example.com' }), field('logLevel', '日志级别', 'select', { options: options([['', '默认级别'], ['debug', '调试'], ['info', '信息'], ['warn', '警告'], ['error', '错误']]) }) ]), group('database', '数据库连接', '', [ field('database.name', '数据库名称', 'text', { locked: true, show: networkDatabase }), field('database.host', '数据库主机', 'text', { required: true, show: networkDatabase, placeholder: '127.0.0.1' }), port('database.port', '数据库端口', { min: 1, show: networkDatabase }), field('database.username', '数据库用户名', 'text', { required: true, show: networkDatabase }) ]), group('http', '网页访问服务', '', [ field('http.tlsMode', '加密连接方式', 'select', { options: options([['', '沿用当前设置'], ['direct', '由 Smail 提供 HTTPS'], ['proxy', '由反向代理提供 HTTPS']]), hint: '选择内置 HTTPS 自动启用并补全端口;选择反向代理后,Smail 不自动申请或续期 SSL 证书,由反代服务器负责;邮件协议仍需有效的本地证书。' }), port('http.port', 'HTTP 监听端口', { hint: '保留已有自定义端口;未设置时自动补全。反代请转发到此端口,外部 HTTPS 端口在反代服务器配置。' }), field('http.httpsEnabled', '启用内置 HTTPS', 'switch', { show: config => config.http?.tlsMode !== 'proxy', hint: '开启时自动补全 HTTPS 端口,关闭时清空端口;正式站点须由内置服务或反代提供 HTTPS。' }), port('http.httpsPort', 'HTTPS 监听端口', { min: 1, show: config => config.http?.tlsMode !== 'proxy' && !!config.http?.httpsEnabled, hint: '默认使用 443;需要避开端口占用时可手动修改。' }) ]) ] const mailGroups = [ group('listener', '邮件服务监听', '', [ 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 收信端口始终保留。' }), 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' }), port('relay.relayPort', 'SMTP 发信中继端口', { hint: '外部 SMTP 服务器端口,与本机监听无关;选择加密方式后自动填写。' }), field('relay.username', '认证用户名', 'text', { placeholder: '需要认证时填写' }), field('relay.password', '中继密码', 'password', { placeholder: '留空保持原值' }), field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }), field('relay.fromDomain', '发件域名', 'text', { placeholder: 'example.com' }) ]) ] const mailPolicyGroups = [ group('policy', '邮件策略', '', [ field('policy.maxMessageBytes', '最大邮件大小(字节)', 'number', { min: 0, max: Number.MAX_SAFE_INTEGER, integer: true, hint: '0 使用后端默认限制;1 MiB = 1048576 字节。' }), field('policy.spamFilterLevel', '垃圾邮件过滤级别', 'select', { fallback: 0, options: options([[0, '不过滤'], [1, 'SPF 与 DKIM 均失败时过滤'], [2, 'SPF 失败时过滤'], [3, 'DKIM 失败时过滤']]) }) ]) ] const securityPolicyGroups = [ group('verification', '登录与账号恢复', '', [ field('relay.verificationEnabled', '启用账号登录验证', 'switch'), field('relay.loginPolicy', '账号登录验证策略', 'select', { options: loginOptions }), field('policy.externalRecoveryEnabled', '允许外部邮箱恢复账号', 'switch') ]) ] const sslGroups = [ group('certificate', '证书与存储', '', [ field('tls.management', '证书管理方式', 'select', { fallback: 'custom', options: options([ ['web_server', 'Web 服务器管理'], ['project', '项目自身管理'], ['custom', '自定义'] ]) }), field('tls.challenge', '证书验证方式', 'select', { show: projectManagedCertificate, fallback: 'http', options: options([['http', 'HTTP 验证'], ['dns', 'DNS 验证']]) }), field('tls.certificateRef', '证书文件', 'text', { show: externalManagedCertificate, placeholder: 'config/ssl/example.com/fullchain.pem' }), field('tls.privateKeyRef', '私钥文件', 'text', { show: externalManagedCertificate, placeholder: 'config/ssl/example.com/privkey.pem' }) ]) ] const pluginDefinitions = { spam_block: { label: '垃圾邮件拦截', fields: [ field('url', '检测服务地址', 'text', { placeholder: 'https://filter.example.com' }), field('timeout', '请求超时(毫秒)', 'number', { min: 0, max: 2147483647, integer: true, step: 100, hint: '1000 毫秒 = 1 秒;0 使用插件默认值 3000 毫秒。' }), field('threshold', '拦截阈值(百分比)', 'number', { min: 0, max: Number.MAX_SAFE_INTEGER, step: 0.1, hint: '例如 20 表示 20%;0 使用插件默认值 20%。' }) ] }, telegram_push: { label: 'Telegram 推送', fields: [ field('proxy', '代理地址', 'text', { hint: '可选,不使用代理时留空。' }), field('timeout', '请求超时(秒)', 'number', { min: 0, max: 120, integer: true, hint: '0 使用插件默认值,最大 120 秒。' }), field('webDomain', '网页域名', 'text'), field('debug', '启用调试日志', 'switch'), field('httpsEnabled', '链接使用 HTTPS', 'switch') ] }, wechat_push: { label: '微信推送', fields: [ field('appId', '应用编号(App ID)'), field('templateId', '消息模板编号'), field('userId', '接收用户编号'), field('webDomain', '网页域名') ] } } export function getFieldGroups(section, config) { const pluginId = Object.hasOwn(pluginDefinitions, section) ? section : '' if (section !== 'plugins' && !pluginId) { return ({ base: baseGroups, mail: mailGroups, mail_policy: mailPolicyGroups, security_policy: securityPolicyGroups, ssl: sslGroups }[section] || []) .map(item => ({ ...item, fields: item.fields .filter(input => !input.show || input.show(config)) .map(input => ({ ...input, label: input.dynamicLabel ? input.dynamicLabel(config) : input.label, recommendedPort: getRecommendedPort(config, input.path) })) })) .filter(item => item.fields.length > 0) } return Object.entries(config.plugins || {}).filter(([id]) => !pluginId || id === pluginId).map(([id, plugin]) => { const definition = pluginDefinitions[id] const prefix = ['plugins', id] const input = (name, label, type, extra = {}) => ({ path: [...prefix, name], label, type, ...extra }) const settingsEditable = plugin.settings == null || (typeof plugin.settings === 'object' && !Array.isArray(plugin.settings)) return { id, title: definition?.label || `插件:${id}`, description: '', fields: [ input('enabled', '启用插件', 'switch', { locked: !definition }), input('executable', '运行程序', 'text', { locked: true }), { path: [...prefix, 'permissions', 'network'], label: '允许访问的网络目标', type: 'network', hint: '仅允许 HTTPS 或 WSS;填写域名和端口,不接受 IP 地址。', locked: !definition }, ...(definition && settingsEditable ? definition.fields.map(item => ({ ...item, path: [...prefix, 'settings', ...item.path] })) : []) ], note: '', secretConfigured: !!plugin.secretStatus?.configured } }) } // 只更新明确可编辑的字段,完整保留代次、内部引用以及未来版本的未展示字段。 export function updateFormConfig(text, section, path, value) { const config = readFormConfig(text) 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 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 : '') if (previousValue !== value) { 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)) { config.http.port = getRecommendedPort(config, ['http', 'port']) } config.http.httpsEnabled = value === 'direct' config.http.httpsPort = value === 'proxy' ? 0 : ( validPort(config.http.httpsPort) ? config.http.httpsPort : 443 ) } if (key === 'http.httpsEnabled') { config.http.httpsPort = value ? (validPort(config.http.httpsPort) ? config.http.httpsPort : 443) : 0 } if (key === 'tls.management' && value === 'project') config.tls.challenge ||= 'http' } if (key === 'tls.mode' && ['managed_default', 'default'].includes(value)) config.tls.customDirectory = '' return JSON.stringify(config, null, 2) } export function validateFormConfig(section, config) { const errors = {} for (const item of getFieldGroups(section, config).flatMap(item => item.fields)) { if (item.locked) continue const value = getValue(config, item.path) const key = fieldKey(item) if (item.required && (value == null || (typeof value === 'string' && !value.trim()) || (Array.isArray(value) && !value.length))) errors[key] = `请填写${item.label}` if (item.type === 'number' && value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || (item.integer && !Number.isSafeInteger(value)) || value < item.min || value > item.max)) { errors[key] = `请填写 ${item.min}~${item.max} 范围内的${item.integer ? '整数' : '数值'}` } if (item.type === 'list' && value != null && (!Array.isArray(value) || value.some(entry => typeof entry !== 'string' || !entry.trim()) || new Set(value).size !== value.length)) { errors[key] = '每项都需填写,且不能重复;不需要的项目请移除' } if (item.type === 'network' && value != null) { const valid = Array.isArray(value) && value.every(target => target && ['https', 'wss'].includes(target.scheme) && validDomain(target.host) && Number.isInteger(target.port) && target.port > 0 && target.port <= 65535) if (!valid || new Set(value.map(target => `${target.scheme}://${normalizedDomain(target.host)}:${target.port}`)).size !== value.length) errors[key] = '请填写不重复的有效协议、域名和端口' } } if (section === 'base') { const domains = config.domains || {} for (const name of ['primary', 'web']) { if (!validDomain(domains[name])) errors[`domains.${name}`] = '请填写有效域名,不包含协议、路径或空格' } if (Array.isArray(domains.accepted) && domains.accepted.every(validDomain)) { const accepted = domains.accepted.map(normalizedDomain) if (new Set(accepted).size !== accepted.length) errors['domains.accepted'] = '接收域名不能重复,大小写和末尾点视为同一域名' if (validDomain(domains.primary) && !accepted.includes(normalizedDomain(domains.primary))) errors['domains.accepted'] = '接收域名列表必须包含主邮件域名' } else { errors['domains.accepted'] = '请逐项填写有效的接收邮件域名' } if (config.http?.httpsEnabled && !config.http.httpsPort) errors['http.httpsPort'] = '启用内置 HTTPS 时请填写 1~65535 的端口' if (config.http?.httpsEnabled && (config.http.port || 80) === config.http.httpsPort) { errors['http.httpsPort'] = 'HTTP 与 HTTPS 监听端口不能相同' } } 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 (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 (!validPort(relay.relayPort)) errors['relay.relayPort'] = '请填写 1~65535 的 SMTP 端口' if (!relay.from) errors['relay.from'] = '请填写发件邮箱' if (!relay.fromDomain) errors['relay.fromDomain'] = '请填写发件域名' } } if (section === 'mail') { 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] = '邮件服务监听端口不能重复' } } if (section === 'ssl') { const tls = config.tls || {} const management = certificateManagement(config) if (!['web_server', 'project', 'custom'].includes(management)) errors['tls.management'] = '请选择证书管理方式' if (management === 'project' && !['http', 'dns'].includes(tls.challenge || 'http')) errors['tls.challenge'] = '请选择证书验证方式' const directory = uploadedCertificate(config) ? tls.customDirectory : tls.defaultDirectory const controlled = value => typeof value === 'string' && !!value && !/^(\/|[a-z]:)/i.test(value) && !/[\\:]/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..') for (const name of ['defaultDirectory', ...(uploadedCertificate(config) ? ['customDirectory'] : [])]) { if (!controlled(tls[name])) errors[`tls.${name}`] = '请填写受管存储中的相对目录,不允许绝对路径或上级目录' } for (const name of ['certificateRef', 'privateKeyRef']) { if (tls[name] && (!controlled(tls[name]) || !tls[name].startsWith(`${directory}/`))) errors[`tls.${name}`] = '文件引用必须位于当前证书目录内' } if (!!tls.certificateRef !== !!tls.privateKeyRef) errors['tls.privateKeyRef'] = '证书与私钥引用必须成对填写' } return errors }