Files
dsh_ai1net_server/src/web/mail.ts
T
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +08:00

182 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 注册验证码的外发邮件层。
*
* 为什么做成"驱动 + 通用 HTTP 兜底"而不是直接写死某家 SDK:
* ① 平台目前**没有**任何邮件基础设施(全库 grep `smtp|nodemailer|mail` = 0 命中),
* 而注册是**唯一**必须先发信才能完成的功能 ⇒ 它是外部依赖最重的一环,必须能换;
* ② 换供应商时**只改 env、不动代码**(`http` 驱动连 body 结构都能由配置给出),
* 这对"以后可能要接别的邮件服务"是硬需求 —— 不必为了换家再走一次发布;
* ③ 不引第三方依赖:`fetch` + JSON 就够,少一个供应链面。
*
* 驱动:
* · `brevo` —— Brevo(原 Sendinblue)事务邮件 API:`POST /v3/smtp/email`,头 `api-key`。
* 本机已有可用凭据(见档案),因此作为默认驱动。
* · `http` —— **任意** JSON HTTP 接口:URL / 鉴权头 / body 模板全部由 env 给,
* body 里可用 `{{to}} {{code}} {{subject}} {{text}} {{from}} {{fromName}}` 占位。
* · `log` —— 不真发信,只把验证码交给调用方(由路由写 journald)。**仅供开发/断网排障**,
* 显式选它才会生效(不选=不发日志,避免验证码进日志)。
*
* 纪律:**失败即失败**(返回 `{ok:false}`),不重试 —— 重试会造成"用户点一次收两封",
* 且与 `email_codes` 的事件计数(= 防爆破的判据)对不上。
* @module dshs/web/mail
*/
/** 邮件通道的运行时配置(由 `config.ts` 从 env 组装后传入,本模块不读 env)。 */
export interface MailSettings {
driver: MailDriver
/** 服务端点(`brevo` 驱动留空则用官方默认)。 */
apiUrl: string
apiKey: string
/** `http` 驱动的鉴权头名(留空 + 有 apiKey ⇒ 用 `Authorization: Bearer`)。 */
authHeader: string
/** 发件地址(**必须**是该服务里已验证过的发件人,否则上游直接拒收)。 */
from: string
fromName: string
/** `http` 驱动的 JSON body 模板(支持占位符)。 */
bodyTemplate: string
timeoutMs: number
}
export type MailDriver = 'brevo' | 'http' | 'log'
export interface VerificationMail {
to: string
code: string
ttlMinutes: number
/** 展示给收件人的站点名(如 `EXAMPLE`)。**为空则整句退化成"你的验证码"**,绝不回落到平台内部名。 */
brand?: string
}
export interface MailResult {
ok: boolean
/** 失败原因(**不含**验证码本身),供审计与界面提示。 */
error: string | null
/** 上游返回的状态码(有则记),便于区分"配错了"与"上游抽风"。 */
status?: number
}
/** 该驱动是否具备发信条件(缺关键项 ⇒ 视为未配置,路由据此回退/报错)。 */
export function mailConfigured(settings: MailSettings): boolean {
if (settings.driver === 'log') return true
if (settings.from === '') return false
if (settings.driver === 'brevo') return settings.apiKey !== '' || settings.apiUrl !== ''
return settings.apiUrl !== ''
}
/**
* 渲染主题与正文(中英双语:平台默认语言是英语,运营方是中文,两者都照顾到)。
* ⚠️ `brand` 为空时**不能**回落到任何内部名(邮件是给终端用户的,平台内部名不该出现在里面)——
* 此时整句退化为"你的验证码是"。
*/
export function renderVerificationMail(mail: VerificationMail): { subject: string; text: string } {
const brand = (mail.brand ?? '').trim()
const name = brand === '' ? '' : ` ${brand}`
const subject = brand === '' ? `${mail.code} is your verification code` : `${mail.code} is your ${brand} verification code`
const text = [
`Your${name} verification code is: ${mail.code}`,
`It expires in ${mail.ttlMinutes} minutes. If you did not request this, just ignore this e-mail.`,
'',
`你的${name}验证码是:${mail.code}`,
`有效期 ${mail.ttlMinutes} 分钟。若非本人操作,请忽略本邮件。`,
...(brand === '' ? [] : ['', `— ${brand}`]),
].join('\n')
return { subject, text }
}
/** 占位符替换:值按 JSON 字符串转义后**只保留内容**,以便安全地嵌进 body 模板的引号内。 */
function applyTemplate(template: string, values: Record<string, string>): string {
return template.replace(/\{\{\s*([a-zA-Z]+)\s*\}\}/g, (match, key: string) => {
const value = values[key]
if (value === undefined) return match
// slice(1,-1) 去掉 JSON.stringify 加的两端引号:调用方的模板里自己带引号。
return JSON.stringify(value).slice(1, -1)
})
}
/** 发一封验证码邮件。永不抛异常 —— 失败以 `{ok:false}` 返回,由调用方决定如何记事件。 */
export async function sendVerificationCodeMail(
settings: MailSettings,
mail: VerificationMail,
): Promise<MailResult> {
const { subject, text } = renderVerificationMail(mail)
if (settings.driver === 'log') return { ok: true, error: null }
if (!mailConfigured(settings)) {
return { ok: false, error: 'mail_not_configured' }
}
try {
if (settings.driver === 'brevo') {
return await sendViaBrevo(settings, { ...mail, subject, text })
}
return await sendViaHttp(settings, { ...mail, subject, text })
} catch (e) {
// AbortSignal.timeout 抛的是 TimeoutError;其它是网络/解析错误。
const message = e instanceof Error ? e.message : String(e)
return { ok: false, error: message.slice(0, 200) }
}
}
async function sendViaBrevo(
settings: MailSettings,
mail: VerificationMail & { subject: string; text: string },
): Promise<MailResult> {
const url = settings.apiUrl === '' ? 'https://api.brevo.com/v3/smtp/email' : settings.apiUrl
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json' }
if (settings.apiKey !== '') headers['api-key'] = settings.apiKey
const body = {
sender: { email: settings.from, name: settings.fromName === '' ? undefined : settings.fromName },
to: [{ email: mail.to }],
subject: mail.subject,
textContent: mail.text,
}
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(settings.timeoutMs),
})
if (!res.ok) {
// 上游会回一段 JSON({code,message})—— 它不含验证码,可以安全地截断留证。
const detail = (await res.text().catch(() => '')).slice(0, 200)
return { ok: false, error: `brevo_http_${res.status}${detail === '' ? '' : `: ${detail}`}`, status: res.status }
}
return { ok: true, error: null, status: res.status }
}
async function sendViaHttp(
settings: MailSettings,
mail: VerificationMail & { subject: string; text: string },
): Promise<MailResult> {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (settings.apiKey !== '') {
if (settings.authHeader !== '') headers[settings.authHeader] = settings.apiKey
else headers.authorization = `Bearer ${settings.apiKey}`
}
const values: Record<string, string> = {
to: mail.to,
code: mail.code,
subject: mail.subject,
text: mail.text,
from: settings.from,
fromName: settings.fromName,
}
const body =
settings.bodyTemplate === ''
? JSON.stringify({ from: settings.from, to: mail.to, subject: mail.subject, text: mail.text })
: applyTemplate(settings.bodyTemplate, values)
const res = await fetch(settings.apiUrl, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(settings.timeoutMs),
})
if (!res.ok) {
const detail = (await res.text().catch(() => '')).slice(0, 200)
return { ok: false, error: `mail_http_${res.status}${detail === '' ? '' : `: ${detail}`}`, status: res.status }
}
return { ok: true, error: null, status: res.status }
}