- 日期:2026-10-30
- 状态:规划稿,未写任何代码
- 参考件:
temp_email_1.9.2.php1(bbs1 单文件插件,1029 行) - 目标:纯血 FlintHub 插件,自包含、解耦、不污染核心
决策记录
| # | 事项 | 结论 |
|---|---|---|
| 1 | 后台配置项范围 | 做 9 项:需求指定 4 项 + enabled / refresh_seconds / cf_token / custom_auth + max_response_bytes(只读上限) |
| 2 | 正文超 500 KB 处理 | 待定(§15-1) |
| 3 | 邮件缓存保留期 | 待定(§15-2) |
一、参考件核查结论
1.1 bbs1 版做了什么(值得借鉴的部分)
| 设计点 | 做法 | 评价 |
|---|---|---|
| 单文件插件 | 全部逻辑 + CSS + JS 内联在一个 plugin.php,返回配置数组 |
形态不适合我们(规范要求 Plugin.php + Controller + views + hook 分离),但内聚思路可取 |
| 地址与邮件分离 | addresses 表存邮箱凭据,mails 表按地址缓存邮件 |
正确,直接沿用 |
| 邮件缓存 TTL | refresh_seconds(30–300s)+ force=1 跳过缓存 |
正确,避免每次打开收件箱都打远程 API |
| 外部 API 适配 | 多字段名兜底(jwt/token/access_token、address/email、mails/items/messages) |
正确,Worker 部署版本差异大,必须有适配层 |
| 双端点回退 | 先 /api/parsed_mails,失败再 /api/mails |
务实,但会掩盖首个端点的真实错误(见 1.2-④) |
| 渐变色头像 | hue = (i * 47 + 210) % 360 由索引派生,非随机 |
好细节:同一邮箱每次渲染颜色稳定,不会闪变 |
| 卡片式布局 | 网格 minmax(260px,1fr) + hover 位移 |
视觉方向正确,可作为 UI 基准 |
| 前端自动刷新 | 倒计时 + document.hidden 时暂停 + 回到前台立即刷新 |
好细节,省流量也省 API 配额 |
| 渐层防护 | 校验 parse_url scheme/host、禁 user:pass@、拒 localhost/.local/内网 IP |
方向对,但实现有缺陷(见 1.2-③) |
1.2 参考件中不能照抄的四处(触碰我们的红线)
| # | 参考件写法 | 问题 | 我们的做法 |
|---|---|---|---|
| ① | UPDATE app_users SET points=points-? WHERE id=? AND points>=? |
直接写核心用户表,明确违反本轮红线 | Points::deduct(),见 §5 |
| ② | strip_tags($html, $allowed) + 正则删 on* / 协议 |
正则清洗 HTML 不可靠:<a href="jav	ascript:...">、畸形标签、<svg><animate onbegin=...> 等可绕过;且 strip_tags 会保留未闭合标签 |
Content::sanitizeHtml()(DOMDocument + 属性协议白名单 + CSS 清洗),见 §7 |
| ③ | 自写 cURL + 自写内网 IP 判断 | 该判断有运算符优先级 bug(&& 与 ` |
|
| ④ | parsed_mails 失败静默回退 mails |
用户只看到"失败",真实原因(鉴权过期 / 地址已删 / 限流)被吞掉 | 归一化错误码,前端按码给不同文案,见 §6.4 |
另有一处与本次需求不符:参考件把
jwt写进 Cookie 来标记"当前邮箱"。我们没有跨请求的"当前邮箱"概念——收件箱用 URL 上的id定位并在服务端校验归属,更简单也更安全(不引入会话态)。
二、架构总览
2.1 目录结构
plugins/temp_email/
├── plugin.json # 元数据:name/version/min_core/permissions...
├── Plugin.php # 主类:DDL / 设置 KV / 核心业务静态方法
├── RemoteClient.php # 外部 API 适配层(cURL + 超时 + 体积上限 + 错误归一)
├── FrontController.php # 前台:首页 / 收件箱 / 邮件详情 / AJAX 动作
├── AdminController.php # 后台:配置页 + 保存 + 连通性自检
├── assets/
│ ├── style.css # 卡片 / 渐变头像 / 邮件列表 / 正文排版(零内联)
│ └── script.js # 申请 / 删除 / 赠予 / 刷新倒计时
├── hook/
│ ├── init_after.php # 建表兜底(幂等)
│ ├── route_register.php # 前台路由 + AJAX 路由
│ ├── admin_route_register.php # 后台路由
│ └── nav_plugin_links.php # 前台入口「临时邮箱」
├── lang/
│ ├── zh.php / zh_tw.php / en.php # 三语键集与键序严格一致
├── views/
│ ├── index.php # 我的邮箱(卡片列表 + 申请表单)
│ ├── inbox.php # 收件箱
│ ├── mail.php # 邮件详情
│ └── admin.php # 后台配置
└── data/ # 运行期自建(.htaccess / web.config 由核心自动补写)
2.2 钩子与权限
{
"hooks": {
"init_after": "hook/init_after.php",
"route_register": "hook/route_register.php",
"admin_route_register": "hook/admin_route_register.php",
"nav_plugin_links": "hook/nav_plugin_links.php"
},
"permissions": ["route:admin", "net:fetch"]
}
route:admin:后台路由注册必需。net:fetch:Plugin::http()强制校验;本插件自写 cURL 也必须声明,否则将来若改用Plugin::http()会在强制模式下被 DENY。- 不声明
system:settings——配置存插件自有 KV 表,不写核心 settings。
2.3 请求链路
前台 /temp-email
├─ GET ?id=N → FrontController::inbox() (归属校验 → 读缓存 → 必要时拉远程)
├─ GET ?id=N&mid=XXX → FrontController::mail() (归属校验 → 定位单封 → 输出清洗后正文)
├─ POST /api/temp-email/apply → 申请(扣积分 → 调 API → 落库)
├─ POST /api/temp-email/delete → 删除邮箱(本地删 + 远程尽力删)
├─ POST /api/temp-email/gift → 赠予站内用户
└─ POST /api/temp-email/refresh → 强制刷新收件箱(force,跳过缓存)
后台 /admin/temp-email
└─ GET/POST 配置页 + 保存 + 「测试连通性」
三、数据模型(独立库 plugin_temp_email)
3.1 表 1:邮箱地址
CREATE TABLE IF NOT EXISTS plugin_temp_email_addresses (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL, -- 核心用户 ID(仅存值,绝不 JOIN 核心库)
address TEXT NOT NULL, -- 完整邮箱,如 abc@mail.example.com
remote_id TEXT NOT NULL DEFAULT '', -- 远程 address_id
remote_token TEXT NOT NULL DEFAULT '', -- 远程 jwt(凭据,永不输出到前端)
label TEXT NOT NULL DEFAULT '', -- 用户可选的备注名(v1 可留空)
status INTEGER NOT NULL DEFAULT 1, -- 1 正常 / 0 已失效(远程返回 401/404)
transferred_from INTEGER NOT NULL DEFAULT 0, -- 赠予来源用户 ID,0 = 自己申请
created_at TEXT NOT NULL,
UNIQUE(address)
);
CREATE INDEX IF NOT EXISTS idx_te_addr_user ON plugin_temp_email_addresses(user_id, created_at);
设计说明:
UNIQUE(address):同一邮箱不能被两个站内用户同时持有(参考件也做了这个检查,值得保留)。status:远程地址被删/过期时置 0,前端显示"已失效"而不是直接消失,避免用户以为被吞。remote_token与address同库存储:库在plugins/temp_email/data/,核心Plugin::db()会自动补.htaccess/web.config拒绝 Web 直下(Nginx 侧由全局配置覆盖)。视图层绝不输出该字段。
3.2 表 2:邮件缓存
CREATE TABLE IF NOT EXISTS plugin_temp_email_mails (
id INTEGER PRIMARY KEY,
address TEXT NOT NULL, -- 归属邮箱(用 address 而非自增 id,赠予后缓存天然跟随)
mail_id TEXT NOT NULL, -- 远程邮件 ID
from_addr TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '', -- 已 strip_tags 的纯文本主题
body_html TEXT NOT NULL DEFAULT '', -- ★ 已 sanitizeHtml 的正文(写入即清洗,输出再清洗一次)
preview TEXT NOT NULL DEFAULT '', -- 列表用纯文本摘要(≤200 字)
received_at TEXT NOT NULL DEFAULT '', -- 远程时间(归一为 Y-m-d H:i:s)
fetched_at INTEGER NOT NULL DEFAULT 0, -- 本行写入时间戳(用于 TTL)
UNIQUE(address, mail_id)
);
CREATE INDEX IF NOT EXISTS idx_te_mail_addr ON plugin_temp_email_mails(address, received_at DESC);
与参考件的关键差异:参考件把整个邮件列表塞进 payload 一个 TEXT 字段。
| 参考件(单 JSON blob) | 本方案(逐封一行) | |
|---|---|---|
| 打开详情 | 必须拉整页列表再筛选 | 按 (address, mail_id) 单行直取 |
| 新邮件增量 | 整体覆盖重写 | INSERT OR IGNORE,只加不减 |
| 单封删除 | 重写整块 | DELETE 一行 |
| 体积增长 | 一个字段随邮件数线性膨胀,单行可能超 1MB | 单行固定,索引可用 |
3.3 表 3:设置 KV
CREATE TABLE IF NOT EXISTS plugin_temp_email_settings (
k TEXT PRIMARY KEY,
v TEXT NOT NULL DEFAULT ''
);
沿用 community_bank 的 defaults() + seedDefaults() 模式:新增键必须同时写进 defaults(),否则后台"有效默认值"列显示为空(规范 §11.18)。
3.4 缓存与"减少远程请求"的落点
打开收件箱
├─ 查本地 plugin_temp_email_mails
├─ 最新 fetched_at 距今 < refresh_seconds → 直接渲染本地(0 次远程请求)
└─ 否则 → 拉远程 → 逐封 UPSERT → 渲染
手动点「刷新」→ 忽略 TTL 强制拉一次
配套:同一个邮箱连续刷新会被 TTL 挡住;refresh_seconds 后台可调(30–600),这是 API 配额的第一道闸。
四、核心红线落地:独立库
| 要求 | 落点 |
|---|---|
| 独立库 | \app\Helpers\Plugin::db('temp_email') → plugins/temp_email/data/temp_email.sqlite |
| 不查改核心用户表 | 全插件零 SELECT/UPDATE ... users。用户名解析走 \app\Models\User::findByUsername()(已有方法);批量取名字走 batchGetNames() |
| 不写核心 settings | 配置存 plugin_temp_email_settings |
| 建表幂等 | init_after + 后台入口双兜底调 ensureSchema(),CREATE TABLE IF NOT EXISTS |
唯一允许"接触"核心数据的点:Points::deduct() / Points::award() / Notification:: —— 全是核心公开 API,不是 SQL。
五、积分事务(本方案最关键的设计)
5.1 一个必须先讲清的机制
Points::deduct() 内部这样判断是否已在事务中:
$db = \app\Core\Database::getInstance();
$inOuterTxn = $db->getConnection()->inTransaction();
它看的是核心库连接。而插件自己的 PDO 是另一条连接(Plugin::db('temp_email'))。
结论:插件事务与积分扣除天然不可能原子化。 在插件 PDO 上 beginTransaction() 后调 deduct(),deduct() 仍会自建事务并立即提交——扣款已落定,后续插件写入若失败就是"钱扣了、邮箱没给"。
所以必须用顺序 + 补偿,而不是"包在一个事务里"。
5.2 申请流程(严格顺序)
① 前置校验(全部本地,零副作用)
├─ 登录?→ 否则 401
├─ CSRF 有效?→ 否则 403
├─ 总开关开?api_base 已配?→ 否则提示未配置
├─ 当前拥有数 < max_addresses?→ 否则拒绝(先查,不扣分)
├─ 前缀格式合法?(4–20 位,[a-z0-9._-],首字符字母数字)
└─ 前端传的 apply_token 未被用过?(防双击/重放,见 5.5)
② 扣积分(cost > 0 时才调)
Points::deduct($uid, $cost, '申请临时邮箱', 0, 'temp_email')
└─ 返回 false → 立即中止:不调 API、不落库、返回「积分不足」
③ 调外部 API(此时积分已扣)
└─ 失败 → 补偿:Points::award(...),返回错误。★ 补偿失败必须 error_log 留痕
④ 落库
├─ 成功 → 返回新邮箱
└─ 失败(含 UNIQUE 冲突)→ 补偿退分 + 尽力远程删除(best-effort,失败只记日志)
要点:
- ③ 在 ② 之后是硬要求——你的原话「如果扣除失败,不能落库或调 API」,顺序上只能先扣后调。
- ② 失败时零副作用:
deduct()内部先UPDATE ... WHERE points >= :p,rowCount()===0即回滚返回 false,不会写points_log。 - ④ 的 UNIQUE 冲突要单独判(远程返回了一个已被本站占用的地址),此时也要退分。
5.3 补偿为什么安全
Points::award() 与 deduct() 对称:写 points_log(正数)、syncLevel()。补偿后用户积分回到原值,points_log 留下两条可对账记录(-N 申请 / +N 申请失败退回)。不做静默改库,这是审计可查的关键。
5.4 为什么要防重放(5.2-① 的 apply_token)
deduct() 是同步调用,但整个流程跨三次 IO(本地查询 → 扣分 → 远程 HTTP)。用户在远程响应未回时连点两次"申请",会走到两条并发扣分。参考件没有防护。
本方案:页面渲染时下发一次性 apply_token(存 plugin_temp_email_settings 或独立小表 + 60s TTL),AJAX 提交携带,服务端 DELETE ... WHERE token=? 命中即放行、未命中直接拒绝。用一次即失效,天然幂等。
5.5 删除与赠予:不动积分
- 删除:只删本地 + 尽力删远程,不退分(邮箱已用掉,退分等于白嫖)。
- 赠予:只改
user_id与transferred_from,双方都不动积分(已支付的申请成本随邮箱一起转移,这是赠予的隐含语义)。
六、外部 API 适配层
6.1 端点约定(可配置,带默认)
| 动作 | 方法 | 路径 | 默认 |
|---|---|---|---|
| 申请 | POST | /api/new_address |
body: {name, domain?, cf_token?} → {jwt, address, address_id} |
| 列表 | GET | /api/parsed_mails?limit=&offset= |
失败回退 /api/mails |
| 删除地址 | DELETE | /api/delete_address |
带 Authorization: Bearer <jwt> |
| 删除单封 | DELETE | /api/mails/{id} |
同上 |
字段名做多键兜底(参考件这点是对的):jwt|token|access_token、address|email、address_id|id、mails|items|messages|results。外层可能包 data 或 result,都要剥。
6.2 为什么自写 cURL 而不用 Plugin::http()
这是本方案里唯一一处刻意不复用核心工具,需要说明理由:
Plugin::http()(app/Helpers/Plugin.php:255)确实做了 SSRF 校验(DNS 解析后逐 IP 判内网/保留段)、超时、方法白名单、禁跟随重定向——但它没有响应体积上限:CURLOPT_RETURNTRANSFER => true 会把整个响应体一次性读进内存。我全文件检索 max_bytes / CURLOPT_WRITEFUNCTION / strlen($body) > 均无命中。
你的红线是「限制最大响应体积(防内存溢出)」,一条恶意或被劫持的 Worker 返回 2GB 就能打死 PHP-FPM。所以:
- 自写 cURL,用
CURLOPT_WRITEFUNCTION边收边计数,超过max_response_bytes(默认 2 MB)立即return 0中断传输(参考件这个写法是对的,直接借鉴)。 - 但把 SSRF 校验补全:
gethostbynamel()解析出真实 IP 后再逐个判FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,而不是只对字面 IP 做过滤(参考件的 bug,见 §1.2-③)。 - 仍然声明
net:fetch权限,保持与核心口径一致,也为将来Plugin::http()补上体积上限后无缝切换留路。
建议同步反馈给核心:
Plugin::http()加一个可选max_bytes(内部同款WRITEFUNCTION计数)。这是核心层的通用缺口,不只是本插件的问题。本方案不依赖它落地。
6.3 网络参数(全部硬编码上限,不给后台随意放大)
| 参数 | 值 | 理由 |
|---|---|---|
CURLOPT_CONNECTTIMEOUT |
5s | 建连慢即判死,避免占住 PHP 进程 |
CURLOPT_TIMEOUT |
15s | 单请求总时长上限 |
max_response_bytes |
2 MB | 超出即中断,防 OOM |
CURLOPT_FOLLOWLOCATION |
false + MAXREDIRS 0 |
防 301 指向内网绕过 SSRF 校验 |
CURLOPT_PROTOCOLS |
`HTTP | HTTPS` |
| 并发 | 无(同步) | 单请求内不做并发外呼,避免叠加延迟 |
6.4 错误归一(补上参考件 1.2-④ 的洞)
远程失败统一折成内部错误码,前端按码给文案:
| 码 | 触发 | 前端提示 |
|---|---|---|
not_configured |
api_base 为空 |
「服务尚未配置,请联系管理员」 |
network |
连接失败 / 超时 | 「邮箱服务暂时不可用,请稍后重试」 |
too_large |
超 2 MB | 同上(不暴露内部细节) |
rate_limited |
429 | 「操作过于频繁,请稍后重试」 |
auth_expired |
401/403 | 「该邮箱已失效」→ 本地置 status=0 |
not_found |
404 | 同上 |
upstream |
其他 5xx | 「邮箱服务暂时不可用」 |
关键:auth_expired / not_found 要把该地址本地 status 置 0,这样不会再反复打远程(否则每次打开收件箱都白跑一次)。
6.5 域名与前缀安全
domain:^[A-Za-z0-9.-]{1,190}$,且不能以./-开头结尾。- 用户输入前缀:
^[a-z0-9][a-z0-9._-]{3,19}$(小写归一),长度 4–20。 - 拼出的完整地址再过一次
filter_var(..., FILTER_VALIDATE_EMAIL),双保险。 api_base只允许http/https、禁user:pass@、禁 query 与 fragment(参考件只校验了前两项)。
七、XSS 清洗(本方案第二个关键点)
7.1 用核心能力,不自造正则
app/Helpers/Content::sanitizeHtml() 已是一套成熟的 DOMDocument 方案:
- 标签白名单(含
table/blockquote/pre/code/img/span/mark) - 属性白名单
href/src/alt/title/target/rel/class/width/height/style/... href/src/data-fullurl走协议白名单(javascript:/vbscript:/data:一律移除)style走sanitizeCssStyle()(拦expression()/behavior/-moz-binding/@import)- 入口
mb_substr($html, 0, 500000)限长,防 DOMDocument OOM - 代码注释明确写了幂等性设计("对已净化过的 HTML 再净化一次"属性顺序不漂移)
因此:
写入时 body_html = Content::sanitizeHtml($remoteBody) ← 落库前清洗(满足红线 3)
输出时 body_html = Content::sanitizeHtml($row['body_html']) ← 再清洗一次(纵深防御,幂等)
7.2 输出时必须 echo 而非 $this->e()
邮件正文是需要渲染的 HTML,不能转义。视图里必须:
<div class="te-mail-body"><?php echo $bodyHtml; /* 已 sanitizeHtml,禁止再拼任何用户输入 */ ?></div>
配套约束:
$bodyHtml只能来自Content::sanitizeHtml()的返回值,不接受任何其他来源。- 主题、发件人、地址等纯文本字段一律
$this->e()。 - 正文外链统一加
rel="noopener noreferrer nofollow"+target="_blank"(sanitizeHtml已处理rel,视图侧再确认一次)。 - 正文容器 CSS 加
word-break: break-word; overflow-wrap: anywhere,防超长串撑破布局。
7.3 其余字段的清洗口径
| 字段 | 处理 |
|---|---|
subject |
strip_tags() → mb_substr(..., 0, 200) → 输出 $this->e() |
from_addr |
filter_var(FILTER_VALIDATE_EMAIL) 不过则存原串截断 120 字 → 输出 $this->e() |
preview |
取 body_html 的 strip_tags() 纯文本,截 200 字 |
address |
生成时已过 FILTER_VALIDATE_EMAIL |
mail_id |
白名单 ^[A-Za-z0-9._:\-]{1,160}$,不合法直接拒绝(进 URL 与 SQL 前都校验) |
八、页面与接口清单
8.1 前台页面(服务端渲染为主)
采用服务端渲染 + AJAX 仅用于变更,而不是参考件的"全 AJAX":
| 页面 | 路由 | 渲染内容 | 为什么不用纯 AJAX |
|---|---|---|---|
| 我的邮箱 | GET /temp-email |
卡片列表(渐变头像 + 地址 + 申请时间)+ 申请表单 + 容量/积分统计 | 首屏即可见,无 JS 也能用 |
| 收件箱 | GET /temp-email?id=N |
邮件列表(头像 + 主题 + 发件人 + 时间)+ 刷新按钮 + 倒计时 | 同上;且列表本身不含 HTML 正文,安全面小 |
| 邮件详情 | GET /temp-email?id=N&mid=XXX |
主题头 + 发件人 + 时间 + 清洗后正文 | 正文必须服务端清洗后直出,不能交给前端 innerHTML |
归属校验:三个页面统一在控制器入口做 address.user_id === 当前用户 判断,不通过直接 404(不返回 403,避免探测他人邮箱是否存在)。
8.2 AJAX 接口(全部 POST + CSRF)
| 接口 | 入参 | 出参 | 副作用 |
|---|---|---|---|
/api/temp-email/apply |
prefix、apply_token |
{ok, address, quota, points} |
扣分 + 远程申请 + 落库 |
/api/temp-email/delete |
id |
{ok, quota} |
删本地 + 尽力删远程 |
/api/temp-email/gift |
id、username |
{ok, quota, target} |
改归属 + 通知对方 |
/api/temp-email/refresh |
id |
{ok, count} |
强制拉远程并 UPSERT |
统一约定:
- 全部
POST,Csrf::verifyOrDie()前置。 - 控制器只做
$this->json();任何异常都在 Plugin 层 try/catch 折成{ok:false, code},绝不让异常逃逸成 HTML 错误页(规范 §10.2 明确点过这个坑)。 - 出参用
code而非中文文案,文案在前端由语言包渲染。
8.3 后台
| 页面 | 内容 |
|---|---|
/admin/temp-email |
配置表单 + 当前统计(邮箱总数 / 邮件缓存条数)+「测试连通性」按钮 |
测试连通性:只对 api_base 发一次 GET /api/parsed_mails?limit=1(无 token),把 §6.4 的归一化错误码翻译成可读结论("可连通" / "DNS 解析失败" / "内网地址被拒" / "超时")。只读、不落库、不产生邮箱。
九、后台配置项
9.1 需求指定四项
| 键 | 类型 | 默认 | 校验 |
|---|---|---|---|
api_base |
url | 空 | http/https、有 host、无 user:pass、无 query/fragment;保存时 rtrim('/') |
domain |
text | 空 | ^[A-Za-z0-9.-]{1,190}$,空则交给 API 分配 |
apply_points |
int | 10 |
0–1000000;0 = 免费 |
max_addresses |
int | 3 |
1–50 |
9.2 追加五项(已定案,一并实现)
| 键 | 默认 | 校验 | 作用 |
|---|---|---|---|
enabled |
1 |
0/1 | 总开关。关闭后前台入口隐藏、AJAX 全部返回 disabled,但数据保留 |
refresh_seconds |
60 |
30–600 |
邮件缓存 TTL,也是 API 配额的第一道闸 |
cf_token |
空 | password 型,留空不改 | Worker 开了 Turnstile 时随 new_address 一起提交 |
custom_auth |
空 | password 型,留空不改;≤512 字、禁 CR/LF 与控制字符 | Worker 配了 x-custom-auth 时的访问口令 |
max_response_bytes |
2097152 |
只读展示,不可调 | 硬上限,防误配把内存防线放大 |
9.3 设置存储与读取口径
- 存
plugin_temp_email_settings(k/v两列),不写核心 settings。 defaults()集中定义全部 9 个键的缺省值,seedDefaults()用INSERT OR IGNORE幂等落库。- 新增键必须同时进
defaults(),否则后台「有效默认值」列会显示为空(规范 §11.18,community_bank 踩过)。 - 请求级静态缓存(
self::$settingsCache),同请求只查一次。 - 两个凭据字段在后台一律
type="password",回显时只显示「已保存,留空不修改」,永不把明文写回 HTML。
9.4 后台表单字段顺序
① 总开关(enabled)
② API 地址(api_base) ← 必填,未填时整页顶部提示"服务未配置"
③ 默认域名(domain)
④ 申请所需积分(apply_points)
⑤ 单用户最多邮箱数(max_addresses)
⑥ 缓存刷新间隔(refresh_seconds)
⑦ Turnstile Token(cf_token) ← password,留空不改
⑧ API 访问口令(custom_auth) ← password,留空不改
⑨ [只读] 响应体积上限 2 MB
[保存] [测试连通性]
测试连通性 走 §8.3 的只读探测,不落在表单提交里,是独立按钮 + 独立 AJAX,避免"保存时顺手打远程"造成保存失败。
十、双模式 URL 落点(伪静态兼容)
严格按规范 §6.2.1,逐处对照:
| 场景 | 用法 | 禁止 |
|---|---|---|
视图里的 href / action |
$this->url('/temp-email') |
❌ 手拼 BASE_PATH . '/temp-email' |
| 视图里带参 | $this->url('/temp-email', ['id' => $id]) |
❌ 字符串拼 ?id= |
| 控制器 / Plugin 里拼 URL | \app\Helpers\Url::to('/temp-email', ['id' => $id]) |
❌ 同上 |
| JS 里调接口 | `(window.FH_WEB | |
| JS 里跳转 | window.FH_WEB + '/temp-email?id=' + id |
❌ 同上 |
| 静态资源 css/js | BASE_PATH 或 Url::base() |
❌ Url::to()(会多出 /index.php → 404) |
| 登录链接 | $this->loginUrl() |
❌ 裸 /login(登录后回首页) |
自检命令(写完必跑):
grep -rnE "BASE_PATH" plugins/temp_email/views plugins/temp_email/hook
# 命中只允许出现在「静态资源」或注释里
grep -rn "window.FH_WEB\|Url::to\|->url(" plugins/temp_email/ | wc -l
视图里把接口基址一次性下发到 data-*,JS 只读属性、不自己拼:
<div class="te-page" data-te data-api="<?php echo $this->e($this->url('/api/temp-email')); ?>">
这样 JS 侧连 window.FH_WEB 都不用直接碰,双模式切换零风险。
十一、UI 方向(沿用参考件的视觉语言,重写实现)
| 元素 | 做法 |
|---|---|
| 邮箱卡片 | 网格 repeat(auto-fit, minmax(260px,1fr)),hover 上移 2px + 阴影 |
| 渐变头像 | hue = (index * 47 + 210) % 360 由列表索引派生(稳定不闪),取地址首字符 |
| 统计胶囊 | 「容量 N/M」「积分 P」两个 pill,数字用等宽字体 |
| 邮件列表 | 单行卡片:头像 + 主题(粗)+ 发件人 · 时间(次要色) |
| 详情 | 头部卡片(大头像 + 主题 + 来自 + 时间 + 返回/删除)+ 正文容器 |
| 空状态 | 图标 + 一句引导(未申请 / 收件箱空 / 未登录三种不同文案) |
| 响应式 | ≤600px 单列、按钮触控区 ≥34px、申请表单纵向铺开 |
| 颜色 | 全部走主题变量(var(--mn-*)),不硬编码色值,自然适配暗色主题 |
零内联:渐变背景用 data-hue 属性 + CSS hsl() 计算,或直接在服务端算好人 style="--te-hue:210"(仅写自定义属性,符合既有约定)。
十二、验收清单
12.1 红线三条
| # | 验收项 | 方法 |
|---|---|---|
| 1 | 全插件零核心表读写 | `grep -rnE "app_users |
| 2 | 扣分只走 Points::deduct |
`grep -rn "Points::deduct |
| 3 | 扣分失败 → 零副作用 | 造余额不足用户,断言:无新地址行、无远程调用(打日志或 mock 计数)、points_log 无记录 |
12.2 功能
- 申请成功 → 卡片出现、容量 +1、积分 −N、
points_log出现-N。 - 达上限 → 拒绝,且不调远程、不扣分(顺序验证:拒绝发生在扣分之前)。
- 前缀非法(太短/大写/中文/含
@)→ 本地即拒,不扣分。 - 收到邮件 → 打开详情正文渲染正常,表格/图片/链接保留。
- 缓存生效:60s 内重复打开收件箱,远程请求次数不增加(用日志计数验证)。
- 手动刷新 → 强制拉取。
- 删除邮箱 → 本地消失、远程尽力删;删完容量 −1,积分不退。
- 赠予 → 对方列表出现、自己列表消失、容量互算正确、对方收到通知。
- 赠予给自己 / 不存在的用户名 / 对方已满 → 三种错误文案正确。
- 越权:用 A 的
id访问 B 的收件箱 → 404。
12.3 安全
- XSS 攻击样本(逐个提交到模拟 API,断言正文里不存在可执行内容):
<script>alert(1)</script><img src=x onerror=alert(1)><a href="javascript:alert(1)">x</a><a href="jav	ascript:alert(1)">x</a><svg><animate onbegin=alert(1)><iframe src="//evil"></iframe><style>@import '//evil'</style><div style="background:url(javascript:alert(1))"><math><mtext><script>alert(1)</script></mtext></math>
- 体积攻击:模拟返回 10 MB → 请求在 2 MB 处中断,返回
too_large,PHP 内存不飙升。 - 超时攻击:模拟 30s 不响应 → 15s 断开,返回
network。 - SSRF:
api_base填http://127.0.0.1:6379/http://169.254.169.254/ 指向内网的域名 → 保存时或调用时被拒。 - CSRF:去掉 token 调四个 AJAX 接口 → 全部 403。
- 重放:同一个
apply_token连提两次 → 第二次被拒,只扣一次分。 - 越权删除:用 A 的 token 删 B 的邮箱 → 404,数据未变。
12.4 兼容与规范
- 伪静态开/关两种模式:所有链接、表单、AJAX、跳转均可用(关模式关键看 URL 是否含
/index.php)。 - 三语键集与键序逐行一致(
str比对 + 数组键序===)。 - 零内联:视图内
style="..."只允许自定义属性(--te-*)。 php -l全插件通过(8.2 + 8.0 双版本)。- 插件禁用后前台入口与路由全部消失,无残留报错。
- 无
net:fetch权限时(临时删掉)自写 cURL 仍可用,但日志提示——确认权限声明与实际行为一致。
十三、风险与不做项
13.1 风险
| 风险 | 影响 | 应对 |
|---|---|---|
| 插件事务与积分扣款非原子 | 极端情况下扣款成功但落库失败 | 顺序执行 + 补偿退分 + 补偿失败必记 error_log,points_log 可对账 |
| 远程 Worker 的字段/端点随版本漂移 | 申请或收件箱失效 | 多键兜底 + 双端点回退 + 后台「测试连通性」快速定位 |
remote_token 是凭据 |
泄露=可读该邮箱全部邮件 | 独立库 + 核心自动补 .htaccess/web.config;视图层永不输出;日志不打该字段 |
Content::sanitizeHtml() 有 500 KB 上限 |
超长邮件被截断 | 截断后追加提示"正文过长已截断",不静默 |
| 外部 API 无限流 | 单站高频申请打爆 Worker | 本地 max_addresses + refresh_seconds TTL + apply_token 防抖;如需更强,后续加按用户日配额 |
| 邮件缓存无限增长 | 库文件膨胀 | 后台提供"清理缓存"按钮(按 fetched_at 删 N 天前);建议保留 30 天 |
13.2 明确不做
- 不发信(只收)。
- 不做附件下载(Worker 返回的附件链接一律按普通外链处理,不代理、不落盘)。
- 不做多 Worker 负载均衡 / 故障转移。
- 不写核心任何表(含 settings)。
- 不改
app/、assets/css/任何文件。 - 不在前端做 HTML 清洗(清洗只在服务端,前端只负责展示)。
- 不做邮件全文搜索(邮件是临时的,价值低、成本高)。
- 不做"邮箱过期自动回收"(临时邮箱本身就会过期,本地只管清理缓存)。
十四、实施顺序建议
① 骨架:plugin.json + Plugin.php(DDL + settings)+ 四个 hook
→ 目标:插件能启用、后台能打开、表能建出来
② RemoteClient:cURL + 超时 + 体积上限 + SSRF + 错误归一
→ 目标:后台「测试连通性」能跑通,这是后续一切的前提
③ 申请流程:扣分 → 调 API → 落库 + 补偿
→ 目标:能申请出第一个邮箱,`points_log` 正确
④ 收件箱 + 缓存:拉取 → 逐封 UPSERT → TTL 生效
⑤ 邮件详情:清洗 → 渲染(此处跑 §12.3-11 全部 XSS 样本)
⑥ 删除 + 赠予 + 通知
⑦ 后台配置页 + 清理缓存
⑧ 三语 + 零内联自查 + 伪静态双模式回归
⑨ 打包发布(走 §12 插件发布流程)
十五、剩余待定两件事
配置项范围已定案:做 9 项(见 §9),本节不再讨论。
-
邮件正文超 500 KB 时怎么处理?
Content::sanitizeHtml()在mb_substr($html, 0, 500000)处硬截断,超出部分静默丢失——若直接渲染,用户会看到"正文被砍断"而不知原因。三个选项:- (a) 截断 + 顶部提示「正文过长,仅显示前 500 KB」——推荐,信息损失可见但不阻断阅读
- (b) 整封转纯文本展示(
strip_tags后nl2br)——最安全,但富文本排版全丢 - (c) 拒绝渲染,只提示"邮件过大,请到远程邮箱查看"——最保守,用户拿不到内容
-
邮件缓存保留多久? 我建议 30 天 + 后台手动清理按钮(按
fetched_at删 N 天前,N 可在后台调)。可选"永不自动清理,只靠手动"——但临时邮箱本身会过期,长期留着等于攒垃圾。
这两项不影响方案结构、不影响开工顺序(分别落在步骤⑤和步骤⑦),可以在对应步骤开始前再定。