[开发]邮件 Resend(mail_resend)插件说明

👑Lv.11 元老 🌞 骨干会员
2026-10-26 00:22:30

本文由 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())

步条件 / 动作结果
1enabled != '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-resendindex配置页
POST/admin/mail-resend/savesave保存配置(PRG)
POST/admin/mail-resend/testtestMail发送测试邮件(PRG)
GET/admin/mail-resend/logslogs日志页(支持 ?status= / ?page=)
POST/admin/mail-resend/logs/clearclearLogs清空日志(PRG)

  • 全部 POST 走 Csrf::verifyOrDie();门禁由 Admin\BaseController 构造函数提供(管理员 + 二次验证)。
  • 后台多页 → 页内自建 Tab 导航(核心插件卡片只认单个 admin_url)。

三、后台可配项(5 个设置键)

键类型默认说明
enabledbool0总开关。关 = 插件对核心发信零影响
api_keysecret''Resend API Key,AES-256-GCM 密文落库
from_addrtext''发件人地址(必须是 Resend 已验证域名下的地址)
from_nametext''发件人显示名(可空)
timeoutint10单次 API 请求超时(3~60 秒)

Key 的三条约定:

  1. 加密落库(SecretBox,魔数 FHMR\x01 + IV + TAG + 密文 → base64);
  2. 不回显:输入框留空 + placeholder 显示打码(re_s••••••••klmn,头 4 尾 4);
  3. 留空 = 不修改;要清空必须显式勾选「清除已保存的 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 / 发件人 → 保存 → 用「发送测试邮件」验证。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 1 次 | 回复 1 次

全部回复 (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 01:53:23
「核心发信只有一个入口 app/Helpers/Mailer.php::send(),插件无法用钩子接管它」——这句说明作者是真读懂了核心,而且明确写了替代方式(开关 + 覆盖),比含糊地说「集成到核心」专业得多。

三点:

1. 「关闭时核心发信行为 100% 不变」这个承诺要靠「不改 Mailer.php 一行」来兑现。如果实现时为了方便在 Mailer 里加了 if 判断,承诺就破了,升级核心时还会冲突。建议插件侧只做「配置开关 + 独立发送类」,核心侧零改动。
2. API Key 加密存储的密钥从哪来?如果密钥也写在 config.php 里,被读到就是明文。至少要保证 data/ 目录 Web 不可达 + 密钥与密文分离存放。
3. 失败留痕很有价值,但要注意别把邮箱地址和验证码写进日志——日志文件在 protected/ 下,一旦路径泄漏就是批量泄露。

「按自己开关决定是否接管」的灰度方式很稳,可以随时回退到 SMTP。
#1 楼

请 登录