[开发] 临时邮箱插件(temp_email)开发规划方案

👑Lv.11 元老 🌞 骨干会员
2026-10-30 19:45:13
  • 日期: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&#x09;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. 申请成功 → 卡片出现、容量 +1、积分 −N、points_log 出现 -N。
  2. 达上限 → 拒绝,且不调远程、不扣分(顺序验证:拒绝发生在扣分之前)。
  3. 前缀非法(太短/大写/中文/含 @)→ 本地即拒,不扣分。
  4. 收到邮件 → 打开详情正文渲染正常,表格/图片/链接保留。
  5. 缓存生效:60s 内重复打开收件箱,远程请求次数不增加(用日志计数验证)。
  6. 手动刷新 → 强制拉取。
  7. 删除邮箱 → 本地消失、远程尽力删;删完容量 −1,积分不退。
  8. 赠予 → 对方列表出现、自己列表消失、容量互算正确、对方收到通知。
  9. 赠予给自己 / 不存在的用户名 / 对方已满 → 三种错误文案正确。
  10. 越权:用 A 的 id 访问 B 的收件箱 → 404。

12.3 安全

  1. XSS 攻击样本(逐个提交到模拟 API,断言正文里不存在可执行内容):
    • <script>alert(1)</script>
    • <img src=x onerror=alert(1)>
    • <a href="javascript:alert(1)">x</a>
    • <a href="jav&#x09;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>
  2. 体积攻击:模拟返回 10 MB → 请求在 2 MB 处中断,返回 too_large,PHP 内存不飙升。
  3. 超时攻击:模拟 30s 不响应 → 15s 断开,返回 network。
  4. SSRF:api_base 填 http://127.0.0.1:6379 / http://169.254.169.254 / 指向内网的域名 → 保存时或调用时被拒。
  5. CSRF:去掉 token 调四个 AJAX 接口 → 全部 403。
  6. 重放:同一个 apply_token 连提两次 → 第二次被拒,只扣一次分。
  7. 越权删除:用 A 的 token 删 B 的邮箱 → 404,数据未变。

12.4 兼容与规范

  1. 伪静态开/关两种模式:所有链接、表单、AJAX、跳转均可用(关模式关键看 URL 是否含 /index.php)。
  2. 三语键集与键序逐行一致(str 比对 + 数组键序 ===)。
  3. 零内联:视图内 style="..." 只允许自定义属性(--te-*)。
  4. php -l 全插件通过(8.2 + 8.0 双版本)。
  5. 插件禁用后前台入口与路由全部消失,无残留报错。
  6. 无 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 明确不做

  1. 不发信(只收)。
  2. 不做附件下载(Worker 返回的附件链接一律按普通外链处理,不代理、不落盘)。
  3. 不做多 Worker 负载均衡 / 故障转移。
  4. 不写核心任何表(含 settings)。
  5. 不改 app/、assets/css/ 任何文件。
  6. 不在前端做 HTML 清洗(清洗只在服务端,前端只负责展示)。
  7. 不做邮件全文搜索(邮件是临时的,价值低、成本高)。
  8. 不做"邮箱过期自动回收"(临时邮箱本身就会过期,本地只管清理缓存)。

十四、实施顺序建议

① 骨架:plugin.json + Plugin.php(DDL + settings)+ 四个 hook
     → 目标:插件能启用、后台能打开、表能建出来
② RemoteClient:cURL + 超时 + 体积上限 + SSRF + 错误归一
     → 目标:后台「测试连通性」能跑通,这是后续一切的前提
③ 申请流程:扣分 → 调 API → 落库 + 补偿
     → 目标:能申请出第一个邮箱,`points_log` 正确
④ 收件箱 + 缓存:拉取 → 逐封 UPSERT → TTL 生效
⑤ 邮件详情:清洗 → 渲染(此处跑 §12.3-11 全部 XSS 样本)
⑥ 删除 + 赠予 + 通知
⑦ 后台配置页 + 清理缓存
⑧ 三语 + 零内联自查 + 伪静态双模式回归
⑨ 打包发布(走 §12 插件发布流程)

十五、剩余待定两件事

配置项范围已定案:做 9 项(见 §9),本节不再讨论。

  1. 邮件正文超 500 KB 时怎么处理? Content::sanitizeHtml() 在 mb_substr($html, 0, 500000) 处硬截断,超出部分静默丢失——若直接渲染,用户会看到"正文被砍断"而不知原因。三个选项:

    • (a) 截断 + 顶部提示「正文过长,仅显示前 500 KB」——推荐,信息损失可见但不阻断阅读
    • (b) 整封转纯文本展示(strip_tags 后 nl2br)——最安全,但富文本排版全丢
    • (c) 拒绝渲染,只提示"邮件过大,请到远程邮箱查看"——最保守,用户拿不到内容
  2. 邮件缓存保留多久? 我建议 30 天 + 后台手动清理按钮(按 fetched_at 删 N 天前,N 可在后台调)。可选"永不自动清理,只靠手动"——但临时邮箱本身会过期,长期留着等于攒垃圾。

这两项不影响方案结构、不影响开工顺序(分别落在步骤⑤和步骤⑦),可以在对应步骤开始前再定。

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

全部回复 (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-30 20:27:22
用 Points::deduct() 替掉直写用户表是对的。更该警惕的是公共临时邮箱的信誉:同一域名被大量站点复用,验证码邮件经常被上游直接拒收,建议把投递失败率做成后台可见指标。双端点回退也要小心,/api/parsed_mails 失败再打 /api/mails 会掩盖首个端点的真实错误,两次失败都应该留痕而不是静默降级。
#1 楼

请 登录