本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/mail_resend/说明.md。
邮件 Resend(mail_resend)插件说明
版本:1.0.1 | 作者:FlintHub | 状态:⏳ 未启用(待验收)
功能:用 Resend API 接管核心发信(注册邮箱验证码 / 找回密码 / 重发验证邮件),
替代配置繁琐且有频率限制的 SMTP。含 API Key 加密存储、后台配置页、发送日志页与失败留痕。
定位:邮箱验证专用。不接管通用发信、不做收信、不做 Resend 域名 / Key / Webhook 管理。
SMTP 保留,插件按自己的开关决定是否接管;关闭时核心发信行为 100% 不变。
一、功能简介
1.1 核心是怎么被接管的(唯一入口)
核心发信只有一个入口:app/Helpers/Mailer.php::send()。
插件无法用钩子接管它(原有 43 个钩子无任何 mail 相关),因此本次对核心做了最小改动(方案 A):
// app/Helpers/Mailer.php::send(),插在 filter_var 校验之后、$driver 分支之前
$GLOBALS['mail_send_handled'] = false;
$GLOBALS['mail_send_result'] = null;
\app\Helpers\Plugin::hook('mail_send_before', [
'to' => $to, 'subject' => $subject, 'body' => $body, 'isHtml' => $isHtml,
]);
if (!empty($GLOBALS['mail_send_handled'])) {
$r = $GLOBALS['mail_send_result'];
return \is_array($r) ? $r : self::err('mail driver handled without result');
}- 无插件监听 → 钩子空转 → 行为 100% 不变;
- 回滚 = 删掉这几行;
- 钩子已登记到
docs/插件开发规范.md§9 H 组 #44;核心版本号V1.1.13 → V1.1.14。
1.2 接管 5 步(hook/mail_send_before.php → Plugin::handleMailSend())
| 步 | 条件 / 动作 | 结果 |
|---|---|---|
| 1 | enabled != '1'(或 Key / 发件人缺失) | 不接管 → 核心走原 SMTP |
| 2 | 读 Key;解密失败 | 写日志(status=error)+ 不接管(降级 SMTP) |
| 3 | 调 POST https://api.resend.com/emails | — |
| 4 | 无论成败都写日志 | status=sent/failed + http_code + error |
| 5 | 置 mail_send_handled = true + mail_send_result | 失败也不回退 SMTP(站长拍板) |
⚠️ 第 5 步「失败不回退」是有意的:SMTP 与 Resend 同时失败会产生两封信 / 两份日志,
且会把「Resend 配置错」掩盖成「偶尔能收到信」。失败信息通过
message透传给调用方。
1.3 API 调用一律走 Plugin::http()
不自己写 curl。Plugin::http('mail_resend', ...) 自带:
net:fetch 权限校验 → URL 校验 → SSRF 防护(gethostbynamel + 内网/保留 IP 拦截)→
方法白名单(防 CRLF 注入)→ 禁跟随重定向 → 超时 → 浏览器 UA。
1.4 纯文本 vs HTML
Resend 的 html 与 text 二选一:isHtml=true → 发 html;isHtml=false → 发 text。
核心的 4 个调用点目前都传 false(纯文本),故走 text。
二、路由与接口
后台路由由 hook/admin_route_register.php 注册(不写 /admin 前缀,核心已 group('/admin'))。
| 方法 | 实际路径 | 动作 | 说明 |
|---|---|---|---|
| GET | /admin/mail-resend | index | 配置页 |
| POST | /admin/mail-resend/save | save | 保存配置(PRG) |
| POST | /admin/mail-resend/test | testMail | 发送测试邮件(PRG) |
| GET | /admin/mail-resend/logs | logs | 日志页(支持 ?status= / ?page=) |
| POST | /admin/mail-resend/logs/clear | clearLogs | 清空日志(PRG) |
- 全部 POST 走
Csrf::verifyOrDie();门禁由Admin\BaseController构造函数提供(管理员 + 二次验证)。 - 后台多页 → 页内自建 Tab 导航(核心插件卡片只认单个
admin_url)。
三、后台可配项(5 个设置键)
| 键 | 类型 | 默认 | 说明 |
|---|---|---|---|
enabled | bool | 0 | 总开关。关 = 插件对核心发信零影响 |
api_key | secret | '' | Resend API Key,AES-256-GCM 密文落库 |
from_addr | text | '' | 发件人地址(必须是 Resend 已验证域名下的地址) |
from_name | text | '' | 发件人显示名(可空) |
timeout | int | 10 | 单次 API 请求超时(3~60 秒) |
Key 的三条约定:
- 加密落库(
SecretBox,魔数FHMR\x01+ IV + TAG + 密文 → base64); - 不回显:输入框留空 +
placeholder显示打码(re_s••••••••klmn,头 4 尾 4); - 留空 = 不修改;要清空必须显式勾选「清除已保存的 API Key」。
表单哨兵:mr_form_submitted=1。checkbox 未勾选时浏览器不提交该字段,
缺哨兵 = 不是本表单提交 → 直接拒绝(否则部分 POST 会把 enabled 静默写成 0)。
四、数据表(插件独立库)
库文件:plugins/mail_resend/data/mail_resend.sqlite(由 Plugin::db() 自动建目录 + 写 .htaccess / web.config 防护)。
不写核心 settings —— 避开「settings_cache.php 不自动补新键 → 上线新设置项必须清缓存」这个坑。
CREATE TABLE mr_settings ( -- 插件自有配置(key/value)
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE mr_logs ( -- 发送日志(★ 不存邮件正文)
id INTEGER PRIMARY KEY,
to_email TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '', -- sent / failed / error
http_code INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_mr_logs_created ON mr_logs (created_at DESC);
CREATE INDEX idx_mr_logs_status ON mr_logs (status, created_at DESC);- 日志上限 2000 条(
Plugin::MAX_LOGS),超出的按id淘汰最旧; - 建表守卫戳
data/schema.version存SCHEMA_VERSION内容(不是判存在)——
改 ddlStatements() 必须同步 +1 SCHEMA_VERSION,否则老站覆盖上传新版时新表永远建不出来。
五、目录结构
plugins/mail_resend/
├── plugin.json # 清单(permissions: net:fetch / route:admin)
├── Plugin.php # 主类:生命周期 / 配置读写 / 日志 / 接管逻辑
├── SecretBox.php # API Key 加解密(AES-256-GCM)
├── ResendClient.php # Resend API 客户端(走 Plugin::http())
├── AdminController.php # 后台控制器(配置页 + 日志页)
├── hook/
│ ├── init_after.php # 惰性建表兜底(戳文件门控)
│ ├── admin_route_register.php # 注册后台路由(不写 /admin 前缀)
│ └── mail_send_before.php # ★ 接管核心发信
├── views/
│ ├── admin_settings.php # 配置页
│ └── admin_logs.php # 日志页
├── assets/
│ ├── admin.css # 后台样式(只用 --c-* 变量)
│ └── admin.js # 清空日志的二次确认(事件委托)
├── lang/{zh,en,zh_tw}.php # 三语(键集合零差异 + 键序一致,各 72 键)
├── data/ # 独立库 + 密钥(运行时自动生成,不进包)
└── 说明.md六、注意事项(改本插件前必读)
6.1 目录名含下划线 → 保留 class_alias
mail_resend 含下划线。本插件命名空间刻意与目录名逐字节一致(Plugin\mail_resend),
自动加载器候选路径 ① 直接命中;Plugin.php 末尾仍保留 class_alias 幂等守卫(规范 §4.1)。
自检:python .workbuddy-ai/tools/plugin_case_lint.py plugins/mail_resend。
6.2 钩子文件禁止声明顶层 function/class/interface/trait
一次请求可能发多封信(注册 + 找回密码)。声明了符号的文件会被核心降级为 include_once
→ 第二次静默不执行。故 mail_send_before.php 只读 $params 并调静态方法。
6.3 渲染/发信钩子里不能 die()/exit()/header()
发信发生在控制器流程中,中断会破坏响应。失败一律「写日志 + 不接管」。
6.4 settings() 刻意不走 budget() 闸门
读配置是正确性前置:若因预算耗尽而回落默认值,插件会「静默变回未配置」并悄悄退回 SMTP
(最难排查的一类故障)。本方法有静态缓存,每请求至多 1 次查询。
受预算约束的只有「淘汰旧日志」这条纯优化 SQL(超预算则跳过,不影响正确性)。
6.5 log() 写入不受预算约束
漏记日志 = 违反「失败必记、不静默吞掉」。写日志失败只 error_log,绝不外抛
(否则会把「发信失败」升级成 500)。
6.6 后台表格列宽/换行要提权
核心 admin.css:180-189 按列序硬编码(td:nth-child(4) 截断、1|3|5|6|7|8 不换行、
td:last-child 不换行)。本插件日志表 6 列里「主题」「错误」需要换行 →
assets/admin.css 用 .admin-content .mr-table td:nth-child(6)(0,3,1)提权重置,不依赖加载顺序。
6.7 后台 markup 必须留在 <main class="admin-content"> 内
核心 .admin-content .form-input / .btn-primary / .alert-* 全是 .admin-content 作用域,
放到 </main> 之后 = 「有 HTML 无样式」(零日志)。
6.8 API 端点写死在常量里
ResendClient::ENDPOINT 是常量,不允许从配置改写 —— 防「配置被改坏 → 密钥发到第三方」。
6.9 init_after 每请求必跑 → 必须靠戳文件短路
命中戳后只做一次 is_file() + file_get_contents(),零查询。
6.10 与 Mailer 的有意差异:拒绝降级 CBC
核心 Mailer::encryptSmtpPass() 在 GCM 不可用时降级 CBC(无认证);SecretBox 拒绝降级并抛异常。
API Key 泄露可被用来以你的名义发信(含钓鱼),宁可明确报错。
6.11 加密密钥四来源(与 bitiful_storage 同构)
① env FLINTHUB_MAIL_RESEND_SECRET_KEY → ② config.php 的 MAIL_RESEND_SECRET_KEY
→ ③ protected/mail_resend_key.php(<?php return '...';)→ ④ 自动生成 data/secret.key。
轮换密钥会使既有密文解不开(GCM 认证失败 → 拒绝而非降级),需在后台重录 API Key。
后台展示「密钥来源 + 指纹(sha256 前 8 位)」以便确认换没换。
6.12 卸载顺序不可反
uninstall():先 DROP 独立库表 → 再删自动生成的密钥 → 最后删戳文件。
同时必须 SecretBox::flush() —— 否则同请求内再 activate() 会凭内存缓存误判「密钥仍在」,
不再重建文件,结果是用一把不在磁盘上的密钥加密(静默数据损坏)。
6.13 性能
单次接管 = 2 次 SQL(1 读配置 + 1 写日志),另加 1 次受预算约束的淘汰 DELETE。
配置读取走静态缓存,同请求内多封信只读 1 次。
⚠️ 首次打开插件独立库需建立新 SQLite 连接(本机实测 0.1~0.3s),
发信路径本身是秒级网络 I/O,占比可接受;但不要在循环里高频调 Mailer::send()。
七、发布前自检
PHP="D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe"
# 1) 语法
for f in plugins/mail_resend/*.php plugins/mail_resend/hook/*.php plugins/mail_resend/views/*.php plugins/mail_resend/lang/*.php; do "$PHP" -l "$f"; done
# 2) 命名空间 ↔ 目录名(Linux 大小写语义)
python .workbuddy-ai/tools/plugin_case_lint.py plugins/mail_resend
# 3) i18n 三语键集合 + 键序
"$PHP" .workbuddy-ai/tools/fh_i18n_check.php plugins/mail_resend
# 4) 全角标点紧跟变量
"$PHP" .workbuddy-ai/tools/php_var_cjk_check.php plugins/mail_resend/
# 5) 逻辑探针 / HTTP 冒烟
"$PHP" .workbuddy-ai/tmp/mr_logic_probe.php
"$PHP" .workbuddy-ai/tmp/mr_http_smoke.php八、当前状态
| 项 | 状态 |
|---|---|
| 代码 | ✅ 完成(21 个文件:16 进包 + 5 运行时数据) |
php -l | ✅ 全绿(插件 13 个 PHP + 核心 2 个改动文件) |
| 命名空间自检 | ✅ 通过 |
| i18n 三语 | ✅ 72 键,键集合零差异 + 键序一致 |
| 逻辑探针 | ✅ 71 PASS / 0 FAIL(含真实调用 api.resend.com 的 401 失败路径) |
| HTTP 冒烟 | ✅ 55 PASS / 0 FAIL(门禁 / 配置 CRUD / 日志 / 404 / 增量守卫) |
| 后台「保存设置」 | ✅ 已修复 404(2026-10-09,详见下方 8.1) |
| 真实发信验收 | ⏳ 待站长提供 Resend API Key + 已验证发件域名 |
| 插件启用状态 | ⏳ 包内 activated: false(待站长在后台「插件」页启用) |
8.1 修复记录
2026-10-09 · 后台「保存设置」返回 404
- 现象:后台配置页点「保存」→ 404(页面不存在);
error.log零记录(404 不进 500 日志)。 - 根因:
views/admin_settings.php主表单action误用了$mrSetUrl(=$this->url('/admin/mail-resend'),
即配置页自身的路径),而路由只注册了 POST /mail-resend/save → 浏览器 POST 到一个只注册了 GET 的路径。
- 修复:新增
$mrSaveUrl = $this->url('/admin/mail-resend/save')专供表单action;
Tab 导航仍用 $mrSetUrl(正确)。
- 验证:从页面渲染出的
action取值再 POST → 302;A/B 反证(改回旧值 → 404)证明断言有效。 - ★ 纪律:
$this->url()里的/admin要带(那是最终路径);
而 hook/admin_route_register.php 里 $router->post('/x/y', ...) 不能带 /admin(核心已 group('/admin'))。
2026-10-09 · 清单缺失 min_core → 被兜底成 1.1.2(发 1.0.1 修正)
- 现象:
manifest-mail_resend-1.0.0.json与release/plugins/index.json里min_core都是1.1.2。 - 根因:
plugin.json没有min_core字段 → 打包器cli/release_build.php:1276
'min_core' => (string) ($pj['min_core'] ?? '1.1.2') 兜底到硬编码默认值。
- 正确值 =
1.1.14:本插件依赖核心mail_send_before钩子,该钩子 V1.1.14 才引入
(实测历史镜像:flinthub1.1.10~1.1.13 的 Mailer.php 里出现 0 次,flinthub1.1.14 里 1 次)。
- 危害(不是小事):
min_core是硬门禁 ——PluginUpdater::coreSatisfies()用
Updater::compare($current, $min) >= 0 判定,不满足即拒绝安装/升级。
写成 1.1.2 ⇒ 老站(1.1.2~1.1.13)被放行安装 → 插件激活、后台能进、Key 能填,
但核心 Mailer::send() 里没有 mail_send_before 触发点 → 钩子永不调用 →
发信完全没被接管且零日志(站长以为在用 Resend,实际邮件没发出去)。
- 修复:
plugin.json新增"min_core": "1.1.14";版本1.0.0 → 1.0.1重出包
(1.0.0 已上传 Hub,不可覆盖)。
- ★ 纪律:新插件发布前必须确认
plugin.json有min_core,且值 = 所依赖钩子的引入版本。
(release/plugins/index.json 里 51 个插件当前全是 1.1.2,属普遍遗留。)
2026-10-09 · 核心 1.1.15 修正主题预编码(插件侧零改动)
- 核心
AuthController原先手工 base64 预编码主题(SMTP 专用 hack)→ 插件走 Resend JSON API 时
拿到的是编码态字面串 → 契约错配。
- 核心 1.1.15 已改为传明文 → 插件
ResendClient.php:74的'subject' => $subject透传即正确,
日志(Plugin.php:474)也随之变成可读文案。插件代码无需任何改动。
启用步骤:后台 → 插件 → 找到「邮件 Resend」→ 启用 → 进入配置页填 API Key / 发件人 → 保存 → 用「发送测试邮件」验证。