三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。 ⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。 【档案 134 · 注册页人机验证 + 邮箱验证码】 - DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引) - 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts - routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code; 注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为) - 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF) - 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯 (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定 - Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的, 只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的 - 新增 test/register-guard.test.mjs(19 用例) 【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】 - login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形 → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name) - portal 顶栏换图标(页面名「管理门户」保留) - 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它 - 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果) - scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG 解析失败、图标静默不显示 —— 实际踩到过) - 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束) 【档案 135/136 · 域名迁移线(另一会话产出)】 - 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置 - src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新; src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs - 档案 136 = 控制面按两台中继取并集(**已立项、未落地**) 验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped| verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、 发码 delivered、四页 deepseek 命中 0、favicon 200。
21 KiB
134-注册页人机验证与邮箱验证码(2026-09-19 落地)
一句话:注册页从「用户名 + 密码」变成「用户名 + 密码 + 邮箱验证码(+ 人机验证)」,并配一套 可解释、可审计、重启不清零的防爆破策略。 触发:用户 2026-09-19 原话 —— 「注册账号页面增加,cloudfare人机验证和 amber发送邮件验证码的验证(增加重复获取验证码的爆破设计)」
背景与动机
- 注册口是唯一无鉴权写入口。原实现只有「用户名 + 密码」+
@fastify/rate-limit(每 IP 每分钟 5 次), 机器人可以低成本批量灌pending用户(每个用户都会占一条 DB 行 + 一个 home 目录)。 - 平台本来就没有邮件基础设施(全库
grep -iE "smtp|nodemailer|mail"= 0 命中)⇒ 邮箱验证码 不是"加个字段",而是从零建一条外发通道。 - 用户明确要了防爆破设计 ⇒ "能发验证码"只算一半,另一半是"不能被当成免费邮件炮台 + 不能被当口令猜"。
用户决策
| 时间 | 用户口径 | 落地 |
|---|---|---|
| 2026-09-19 | 「增加 cloudflare 人机验证」 | 接 Cloudflare Turnstile(服务端 siteverify)。⚠️ 密钥待用户提供(见 §待办) |
| 2026-09-19 | 「amber 发送邮件验证码」 | 用户当日澄清:「amber 是日志服务我说错了」 ⇒ 与邮件无关(即档案 129 的 yaop-labs/amber)|落地 = Brevo + 可插拔驱动(brevo / 通用 http / log,换供应商只改 env、不改代码) |
| 2026-09-19 | 「用 B 方案:[email protected]」 |
已落地:Brevo 建域名 ai1net.com → 4 条 DNS + SPF → authenticated: true / verified: true → 发件人 id=2 [email protected](AI1NET) → DSHS_MAIL_FROM 切换并重启|实测 delivered from= [email protected] |
| 2026-09-19 | 「1、说一下在哪里操作」(Turnstile 密钥) | 见 §待办 1 —— 给了控制台逐步路径 |
| 2026-09-19 | 「使用代理访问」(抓官方 prompt.md) |
经本机代理抓到 Cloudflare 官方 Turnstile Spin skill(developers.cloudflare.com/turnstile/spin/prompt.md,31.6 KB)⇒ 对照出我实现里的两处校验缺口并已补齐(见 §实现 D):官方 canonical siteverify = success ∧ action ∧ hostname 三项,我原先只校 success |
| 2026-09-19 | 「4、调整完成后在处理」 | 调整已完成;提交/推送留待你一句话(本轮未 commit) |
| 2026-09-19 | 提供 Turnstile 两把密钥(「站点密钥:0x4AAAAAAE8aE7sRfp10el5C / 站点 secret:…」) |
人机验证正式启用:写入 600 的 drop-in + restart dshs;密钥自证 + 配置端点 + 403 拦截三项实测(见 §实现 E)。⛔ 私钥不入任何文档与记忆 |
| 2026-09-19 | 「登录和注册页面上的 deepseek的图标去掉,改为中文:能力枢纽…CapabilityNet」+「一并替换」+「会话页面左上角的 deepseek 先不替换」 | 另立档案 137(品牌标识改造:四页 + favicon;会话页面那处不动)—— 与本节同一会话的相邻改动,见 137-品牌标识改造-去DeepSeek图形.md |
| 2026-09-19 | 「增加重复获取验证码的爆破设计」 | 见 §实现 B:三层配额 + 递增冷却 + 试错上限 + 单次使用,全部落库 |
实现
A. 代码
| 文件 | 改动 |
|---|---|
src/db/schema.ts |
迁移 v10:users.email + 唯一索引 LOWER(email) + email_codes 事件表(2 个索引) |
src/db/types.ts |
User.email、EmailCodeRow、RecordEmailCodeInput、EmailCodeCounts、toEmailCode() |
src/db/adapter.ts |
DbAdapter 新增 7 个方法(findUserByEmail + 6 个事件表方法) |
src/db/repo.ts / sqlite.ts / pg.ts |
两方言各自实现(计数一律现算,不维护冗余计数器) |
src/web/register-guard.ts |
新增:纯函数策略(发码判定 / 校验前判定),零 IO,可逐边界断言 |
src/web/mail.ts |
新增:邮件驱动(brevo / http / log)+ 双语正文渲染 |
src/web/turnstile.ts |
新增:Cloudflare Turnstile 服务端校验(失败关闭) |
src/web/email-code.ts |
新增:编排(人机验证 → 配额 → 发信 → 落事件 / 校验 → 消费) |
src/web/routes/auth.ts |
新增 GET /api/auth/register/config、POST /api/auth/register/email-code;POST /api/auth/register 加人机验证与验证码校验 |
src/config.ts |
新增 12 个配置项 + 3 个解析工具(全部默认空 ⇒ 不配即老行为) |
web/register.html |
邮箱 + 「获取验证码」(带倒计时)+ 验证码 + Turnstile 容器;配置由服务端下发 |
web/i18n.js |
新增 25 条 register.* 词条(中英) |
test/register-guard.test.mjs |
新增 19 条用例(策略边界 / 哈希 / 邮件驱动 / Turnstile 三项判定逐组合 / 主机名归一 / 编排 / 迁移 v10) |
package.json |
测试清单纳入 test/register-guard.test.mjs |
两个刻意的默认值: ① 所有新配置默认为空 ⇒ 「不配任何 env = 与改造前完全一致」; ② 迁移 v10 是纯加列/加表,回滚只需不读新列(列可留着不删)。
E. Turnstile 正式启用(2026-09-19 08:3x · 用户提供密钥)
用户按「路 A」在 CF 控制台建好 widget 并把两把密钥发来,落地:
| 项 | 值 |
|---|---|
env(drop-in /etc/systemd/system/dshs.service.d/register-verify.conf,600) |
DSHS_TURNSTILE_SITE_KEY=0x4AAAAAAE8aE7sRfp10el5C(公钥,公开)/DSHS_TURNSTILE_SECRET(私钥,只落 600 文件,⛔ 不入任何文档与记忆)/DSHS_TURNSTILE_ACTION=signup/DSHS_TURNSTILE_HOSTNAMES=ai1net.com,www.ai1net.com,alotbuy.com,www.alotbuy.com |
| 密钥自证 | 用假 token 打 siteverify ⇒ 回 invalid-input-response(而非 invalid-input-secret)⇒ 私钥被 CF 认可 |
| 部署后验收 | GET /api/auth/register/config ⇒ {"captcha":{"enabled":true,"siteKey":"0x4AAAAAAE8aE7sRfp10el5C","action":"signup"},…}|无 token 提交 ⇒ 403 captcha_failed|假 token 提交 ⇒ 403 captcha_failed |
| 备份 | register-verify.conf.bak-turnstile-20260919-083853(= 只有邮件通道、无 Turnstile 的上一版) |
为什么 hostnames 写 4 条(超集):这是安全的 —— CF 的 widget 域名列表是第一道闸门 (只有注册过的域名才会被签发 token),我方白名单列全只是保证"旧域仍在线期间"从旧域注册的访客不被误拒。 若将来旧域退役(档案 135),可收窄为两条。
D. Turnstile 校验补强(2026-09-19 · 对照官方 prompt.md 后补齐)
抓官方文档后发现原先只校 success,漏了另外两项 —— 这是真实的安全缺口,已补齐:
| 校验项 | 不校它的后果 | 现状 |
|---|---|---|
success === true |
无 | ✅ 原先就有 |
action 匹配 |
同一 sitekey 的所有入口共享 token ⇒ 人机验证退化成"过任意一处即可用到处" | ✅ 新增(默认 signup,前后端取值由服务端下发,防漂移) |
🔴 hostname 在白名单 |
sitekey 是公开的(就在页面 HTML 里)⇒ 攻击者可在自己站点嵌入我们的 sitekey、为真人访客拿到合法 token,再拿去打我们的注册接口。这是三项里最关键的一项 | ✅ 新增(默认从 baseDomain 派生 <domain> + www.<domain>) |
附带加固:
- token 长度上限 2048(CF 规定)+ 空 token ⇒ 本地就拒,不浪费上游往返;
hostnames为空数组 ⇒turnstileEnabled=false⇒ 视为未配置完成(宁可功能不启用,也不放开);- 「密钥给了但主机名没给」= 人机验证静默不生效 ⇒
captchaPartiallyConfigured()检测 + 进程内首条 WARN(这种"看起来配了、实际没防住"的形态必须能在日志里被看见); - 主机名归一化(去协议 / 端口 / 路径、小写、去重):运维照 URL 习惯写
https://ai1net.com/也能匹配;⛔ 绝不自动加localhost/127.0.0.1(生产白名单里放它们 = 放开本地伪造); verifyUrl可覆盖 ⇒action/hostname的每个组合都能用本地假 siteverify 断言(否则只能靠真 token 打线上才验得到)。- 新增 2 个配置:
DSHS_TURNSTILE_ACTION(默认signup)、DSHS_TURNSTILE_HOSTNAMES(逗号分隔)。 ⚠️ 旧域alotbuy.com门户仍在线 ⇒ 部署时须显式写全(ai1net.com,www.ai1net.com,alotbuy.com,www.alotbuy.com), 否则从旧域注册的访客会被人机验证拒掉(实测配置解析已确认四种形态均按预期)。
B. 防爆破(用户点名的部分)
四道闸门,层层独立:
| 维度 | 阈值(默认) | 作用 |
|---|---|---|
| 邮箱 · 冷却阶梯 | 60s → 60s → 180s → 300s → 900s → 1800s(按最近 1 小时已发起次数取档) | 让脚本化重试收益递减;真人重发一次仍只等 60 秒 |
| 邮箱 · 小时/天配额 | 每小时 ≤ 6 次发起;每 24 小时 ≤ 8 封真正发出 | 卡住"盯着一个邮箱猛发" |
| IP · 小时配额 | ≤ 20 次(IP 可能 NAT,故意比邮箱宽) | 卡住"一台机器换邮箱猛发" |
| 全局 · 小时配额 | ≤ 200 次 | 保护邮件服务商日配额(Brevo 免费档 300 封/天) |
校验侧(防猜码):
| 机制 | 值 |
|---|---|
| 验证码 | 6 位数字,crypto.randomInt(非 Math.random) |
| 存储 | 只存哈希:sha256(email|purpose|code|encryptionSecret),明文只出现在邮件里 |
| 有效期 | 10 分钟 |
| 试错上限 | 5 次;达上限立即作废(连正确码也不再认,必须重新获取) |
| 单次使用 | 校验通过即 consumed_at(CAS 写入,并发重放只有一次成功) |
| 绑定 | 码与「申请时填的用户名」绑定,换用户名重放不作数 |
| 比较 | timingSafeEqual |
三条不变式(顺序即语义):
① 先人机验证再花配额 —— 否则机器人只要猛点就能把真用户的配额吃光;
② 被拒绝也落库(status='throttled')—— 它既是配额的一部分,也是"有人在撞"的唯一证据;
③ 发信成功才记 sent —— 记早了会让"上游全挂"看起来像"发出去过"。
⚠️ 计数器落 DB 而不是进程内 Map:进程内计数一次 restart 就清零,而本部署天天重启 ⇒ 等于把限流关掉。
C. 部署
- 产物:
lib/**(tsc 产物)+web/register.html+web/i18n.js→/opt/dshs/(只传本次改的文件,非整包覆盖) - 备份:
/opt/dsh/backups/register-verify-20260919_070418/{pre.tar,pre2.tar}(600) - env:新 drop-in
/etc/systemd/system/dshs.service.d/register-verify.conf(600)DSHS_MAIL_DRIVER=brevo DSHS_MAIL_API_KEY=<Brevo 密钥> [email protected] # 2026-09-19 08:07 由 [email protected] 切换 DSHS_MAIL_FROM_NAME=aisharenet - 发件域名认证(2026-09-19 已在 Cloudflare 加 5 条记录):
类型 主机 值 CNAME brevo1._domainkeyb1.ai1net-com.dkim.brevo.com(DNS-only)CNAME brevo2._domainkeyb2.ai1net-com.dkim.brevo.com(DNS-only)TXT @brevo-code:2d01b491ca8de2c083b49bcbc606c95bTXT @v=spf1 include:spf.brevo.com ~allTXT _dmarc已有( p=quarantine,平台原有),未动⇒ Brevo 侧 GET /senders/domains=authenticated: true / verified: true;发件人id=2 [email protected]。⚠️ 触发验证的正确端点 = PUT /v3/senders/domains/{domain}/authenticate(…/validate不存在,会 404resource_not_found)。 - ⚠️ 未配
DSHS_TURNSTILE_SITE_KEY/DSHS_TURNSTILE_SECRET⇒ 人机验证当前为停用状态 (未配置 = 跳过,不是"配了一半")
验证记录
本机
| 项 | 结果 |
|---|---|
tsc -p tsconfig.json |
0 报错 |
node --test test/register-guard.test.mjs |
19/19 通过(含 Turnstile action/hostname 五个组合 + 本地拒空/超长 token 不打上游) |
| 全量单测(15 个文件) | 219 通过 / 1 skipped(pg,缺 DSHS_TEST_DB_URL)/ 0 失败(# tests 220) |
| 配置解析实测(5 种形态) | 派生 / 显式含旧域 / URL 形态误填 / 显式空 / action 非法 ⇒ 全部按预期 |
check-layering.mjs |
无新增违规 |
verify-static.mjs |
全部合格(含 register.html 内联脚本语法、去痕迹、i18n 无裸中文) |
其余 10 个 verify-*.mjs/cjs |
全 OK |
线上(https://ai1net.com,2026-09-19 07:0x)
| 步骤 | 实测输出 | 结论 |
|---|---|---|
GET /api/auth/register/config |
{"captcha":{"enabled":false,"siteKey":""},"emailCode":{"enabled":true,"ttlSeconds":600,"maxAttempts":5},…} |
配置下发通路可用;人机验证按预期停用 |
POST …/email-code(首发) |
200 {"ok":true,"retryAfterSeconds":60,"expiresInSeconds":600} |
发码成功 |
| 立刻重发 | 429 + retry-after: 58 |
冷却生效 |
| 事件表 | sent(07:05:23,hash 前 8 位 7f4d15a3)+ throttled(07:05:26,reason=email_cooldown) |
落库口径正确 |
| 审计 | register_code_sent / register_code_rejected,带真实客户端 IPv6 |
取证可用 |
| Brevo 投递事件 | delivered ×2(07:05:25 959046 is your DSH verification code;07:09:16 261788 is your AI1NET verification code) |
端到端投递成功 |
| 错码提交 | 400 {"error":"code_invalid","attemptsLeft":4} |
试错计数生效 |
| 正确码提交 | 201 + 建号成功,users.email 落库 |
全链路通 |
| 同邮箱重放 | 409 {"error":"email_taken"} |
重放被挡 |
| md5 对账 | lib/web/mail.js / email-code.js 两端一致 |
部署无漂移 |
| Turnstile 补强部署后复验 | 配置端点新增 "action":"signup";register.html 含 cfg.captcha.action;门户 200;发码仍 200 |
补强零回归(未配密钥 ⇒ 行为与补强前一致) |
清理:测试账号 rcverify1(DB 行 + 47/106 上的目录)、tzcheck1/nrverify1 的测试事件行全部删除;
email_codes 现存 0 行;47 users/ 目录数 7(= 2 真实用户 + 5 个历史孤儿,本轮未新增)。
事故/踩坑记录
- 🔴 人机验证只校
success是不够的(本档案最重要的教训 · 2026-09-19 对照官方文档才发现): sitekey 本身就是公开的(写在页面 HTML 里)⇒ 攻击者可以在自己的站点用我们的 sitekey 渲染 widget、 为真人访客拿到合法 token,再拿到我们的注册接口上用 —— 只校success时这条路完全通畅。 必须同时校hostname(Cloudflare 服务端判定并回显,访客篡改不了)与action。 官方 canonical siteverify 就是三项齐备。⇒ 凡"公开 key + 服务端校验"的模型,都要问一句 "这个 token 凭什么只是给我用的",答案往往就是 hostname / audience / origin 这类服务端回显的绑定字段。 DSH曾写进用户可见邮件主题。首版renderVerificationMail把brand默认为'DSH', 实测投递的主题是959046 is your DSH verification code⇒ 平台内部名泄露给终端用户。 修法:brand缺省为空,由baseDomain主标签大写(ai1net.com→AI1NET)推导; 空则整句退化为"你的验证码"。已加断言!/DSH|dshs/i.test(subject+text)。- 本机默认
node是 24.x,而better-sqlite3是按 22 编译的(NODE_MODULE_VERSION 127 vs 137) ⇒ERR_DLOPEN_FAILED。跑测试必须用 Node 22(E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe)。 - 冷却阶梯与小时配额曾互相打架:
emailPerHour=5时,阶梯第 6 级(1800s)永远走不到 ⇒ 白写一级。改为emailPerHour=6,并加断言ladder.length === emailPerHour。 - 新用户的实例目录落在
w-106而不是 47(集群按容量分配)⇒ 清理测试账号时容易漏。 本次已按w-106:/var/lib/dshs/users/<id>单点删除。 - Brevo 事件接口有分钟级延迟:发信后立刻查
/smtp/statistics/events查不到, ≈25 秒后才出现。别据此误判"没发出去",应以业务侧的sent落库 + 稍后复查事件为准。 - Brevo 触发域名验证的端点是
PUT /v3/senders/domains/{domain}/authenticate;…/validate会回 404resource_not_found(官方文档未列,实测而来)。 ai1net.com原先没有 SPF 记录 ⇒ 本次新建时一并补上v=spf1 include:spf.brevo.com ~all(没有 SPF 的域用第三方发信,被判伪造 / 进垃圾箱的概率显著上升)。既有的_dmarc(p=quarantine)未动。- 主机名白名单必须含旧域:
alotbuy.com门户仍在线(软回滚路径)⇒ 只写ai1net.com会让人机验证把从旧域注册的访客拒掉。配置解析已实测四种形态(派生 / 显式含旧域 / URL 形态误填 / 显式空)均按预期。
回滚 / 注意
回滚(三选一,代价递增)
- 只停用功能:
mv /etc/systemd/system/dshs.service.d/register-verify.conf{,.off} && systemctl daemon-reload && systemctl restart dshs⇒emailCodeActive=false,注册页恢复成"用户名 + 密码"(迁移 v10 的列/表留着不碍事)。 - 回滚代码:从
/opt/dsh/backups/register-verify-20260919_070418/pre.tar(+pre2.tar)覆盖回/opt/dshs/后重启。 - 回滚 schema:需要
DROP TABLE email_codes/DROP INDEX idx_users_email/ALTER TABLE users DROP COLUMN email- 删
schema_migrations里 version=10 的行。⚠️ 不必做 —— 纯加列加表对旧代码零影响。
- 删
注意
- 注册页改的是静态文件(
web/*.html),scp即生效、不需要重启;本次重启只为lib/**。 - ⚠️ Turnstile 脚本来自
challenges.cloudflare.com:中国大陆网络下可能加载慢/被拦截。 前端已做失败关闭 + 明确提示(提示关掉广告拦截或换网络),不会静默变成"点不了"。 - ⚠️ 发件人是
[email protected](Brevo 里唯一已验证的发件人)。发给 QQ/163 等国内邮箱 大概率进垃圾箱,建议在 Brevo 验证自有域名(ai1net.com)再改DSHS_MAIL_FROM。 email_codes表自带自维护清理(每小时最多一次,清 30 天前的行),不需要额外 cron。
待办 / 未闭环
- ✅ 人机验证已启用(2026-09-19 08:3x,用户按「路 A」建好 widget 并提供密钥)—— 见 §实现 E。
密钥自证(假 token ⇒
invalid-input-response)、配置端点enabled:true、无 token / 假 token 提交均 403 三项已实测。 ⚠️ 唯一未做的是真实人机通过后的端到端(需要一个真人/真浏览器取得真 token 再提交)—— 该项留给用户首次真实注册时自然验证:若届时收到"人机验证未通过",最常见的两类原因是 ① widget 的 Hostnames 没覆盖当前访问域名;② 访客侧 CF 脚本被广告拦截插件挡住(页面已做明确提示而非静默失败)。 (历史备查 · 两条原始路径:路 A = CF 控制台 → 左侧 Turnstile → Add widget(Hostnames 填域名、Mode = Managed)→ 取两把密钥; 路 B = 给一个含Account → Turnstile → Edit的 account 级令牌,我用官方 API 自建。本轮走的是路 A。) - ✅ 「amber」已对号 —— 用户当日澄清 = 日志服务(即档案 129 的
yaop-labs/amber),与邮件无关; 邮件按 Brevo 落地,且驱动可插拔(换供应商零代码)。 - ✅ 发件人已换
[email protected]—— Brevo 域名ai1net.com已认证 + 4 条 DNS + SPF; 实测delivered from= [email protected]。替代了原先的个人 Gmail。 - 🟡 未提交 / 未推送(用户级规矩:未明确要求不 commit/push;且用户 2026-09-19 回「调整完成后在处理」 ⇒ 调整已完成,待你一句话即提交)。改动面较大(5 个 DB 文件 + 4 个新模块), 建议尽快提交以免被并行会话的 checkout 冲掉。
- 🟡
users.email目前只在 DB,未进PublicUser/ admin 用户列表 ⇒ admin 看不到用户邮箱。 - 🟡 47 上另有 5 个孤儿用户目录(
3ec95f69…/4eaeb26b…/74e8804a…/7ba268be…/ca3f36e0…, 09-15 产生,DB 无对应行)—— 本次未动,属历史遗留,建议单独清理。 - 🟡 CF 侧仍有 1 处遗留:本次为 Brevo 加的 5 条记录已生效,但未动任何既有记录;
若日后停用 Brevo,需回收这 4 条(
brevo-code/ 两条 DKIM CNAME / SPF)。