三十秒的耐心:一间临时邮局的错误可见性、凭据边界与上游契约
摘要 临时邮箱的用户只给三十秒耐心:建箱、收信、拿验证码、离开。本文以一个 fork 自 MIT 社区项目、git 历史重置后自主演进 40 天(153 次提交)的临时邮局为对象,考察三条工程线。(1)错误可见性是产品级约束:实测发现一整族「静默答错」的失效——错误提示被更高层级的遮罩盖住(z-index 1001 < 1002,用户视角是「点了没反应」)、函数定义缺失但构建通过(运行时被框架静默吞掉)、健康检查被静态资源回退以 200 应答而恒绿、路由排除规则把 API 端点静默答成 HTML、数据库迁移版本号按字符串比较得出 v0.0.9 > v0.0.10、测试文件存在却从未挂进门禁(一个月后才发现「文件存在 ≠ 测试会跑」)。对应的对策没有一个是「加个测试」:错误层级令牌、原子写回、语义化探针、门禁接线与变异验证。(2)凭据边界是矩阵而非单点:八种凭据(匿名指纹、用户令牌、管理口令、v1/v2 API 密钥、地址密码、一次性恢复令牌等)各有独立 scope 与限流;越权修复三连——scope 分家、邮箱归属校验(统一 404 防枚举)、一次性恢复凭证;匿名建箱的并发正确性交给数据库主键(槽位预约表:至多 N 行存在 = 至多 N 次创建)。(3)对不承诺语义的上游服务商(契约冻结为单端点、六动作、一会话一邮箱),健康信号必须自建:「首页可达」判据被实测否证(其首页无论会话死活恒 200),「死会话伪装限流」以双信号根治——create-403 连续 3 次且跨 120 秒滑动窗口即退休,另有 cron 每 5 分钟对空闲会话探活;CI 以假上游(六动作 + 故障注入)重放,永不触碰真服务商。AI agent 被作为一等用户:专用 skill 刻意不处理建箱(人机验证必须由人完成),长轮询 25 秒内验证码直达——三十秒命题的 API 化。
关键词 错误可见性;凭据边界;上游契约;投递语义;零构建前端;Cloudflare Workers
1 问题的提出
临时邮箱产品的用户只给三十秒:打开页面、建一个箱、等验证码、复制、关掉。这个约束决定了产品的全部工程取向——首屏要快、收信要快、错误要在第一次点击时就被看见,因为没有第二次访问来弥补信任。
本项目 fork 自一个 MIT 许可的社区项目(原始版权保留于 LICENSE)。2026-09-19 的第一批提交做了一件不寻常的事:把上游的 700 次提交与 65 个版本 tag 压缩为单一快照(git 历史重写,CHANGELOG 保留源流声明),随后解除与上游的自动同步——从「跟随上游」转为「自主演进」。分叉点有两处:前端整体重写为零构建静态站,以及新建 /v1、/v2 两套对外 API 面。40 天、153 次提交构成了本文的观察窗口。
考察三个问题:
- Q1(错误可见性):一个以「快速拿到结果」为唯一承诺的产品,哪些失效不会报错?对策属于哪一层?
- Q2(凭据边界):访客、用户、管理员、外部程序、AI agent 同时存在时,权限怎么不越界?
- Q3(上游契约):收信依赖一个不承诺语义的第三方服务,健康与自愈怎么建?
工程事实先行:40 天 153 次提交、27 份数据库迁移与 23 张表、worker 端 117 个 TS 文件约 2 万行、前端 32 个 JS 模块约 25 页三面(访客 / 用户 / 管理)、端到端 79 个 spec 跑在 19 个容器服务上。
2 系统与方法
2.1 三面与零构建前端
三面同仓:访客面(分拣台建箱、槽位、收信、验证码)、用户面(注册登录、Passkey / OAuth2、地址簿、发件箱)、管理面(约 25 个作业页)。前端是零构建的:浏览器原生 ES modules、无打包步骤、无 npm 依赖,第三方库(DOMPurify、postal-mime、SimpleWebAuthn)以本地 vendor 文件引入——「文件即部署产物,改完即生效」,部署配置里没有构建步骤。视觉体系叫「邮政分拣台」:14 色板(邮戳红、象牙纸底、墨黑)、打字机展示体加等宽数据体、全站直角(唯一例外是 50% 圆形邮戳)、阴影一律「零模糊偏移实心影」;验证码以「图章砸落」动效呈现(缩放 2.6→1、倾斜 −24°→−7°),未读回执 2 分钟自焚。样式按 L0 令牌到 L6 偏好查询七层组织,z-index 全站只有一个来源。中英双语就地注册、路由前缀切换,四个 i18n 断言(键位对齐、字面键、动态键家族、图标存在性)进静态门禁。
2.2 收信链路与投递语义
邮件经 Cloudflare Email Routing [1] 投入 Worker,随后是一条八段旁路的管线:黑名单(故障时放行,不拒收)、垃圾判定、未知地址策略、大附件剥离、原始信件入库,之后并行触发转发、AI 验证码提取、Telegram、webhook、Web Push、自动回复。这条管线的语义约束来自投递系统本身:Email Routing 是至少一次投递 [4],任何一段旁路抛错都会让整个处理失败、触发重投——所以每段旁路独立 try/catch,重复投递则被去重短路、零副作用。
去重键的演化是一条由真实回归驱动的链:无去重 → (地址, Message-ID) 永久去重 → 24 小时窗口 → (地址, Message-ID, 内容指纹) 三元组。方向始终一致:宁可重复,不可丢失——丢一封验证码的代价远大于偶尔多看一封。MIME 解析 [2] 现由 postal-mime 承担;仓库里另有一份 Rust WASM 解析器的 npm 包源码(约 185 行 Rust)作为兼容性备选——README 保留着上游的「高性能 Rust WASM」宣传语而实现已是 postal-mime,这处文档与实现的脱节见 5。
2.3 凭据矩阵
八种凭据各有边界:匿名访客(浏览器指纹 + IP 双槽计数,默认 strict 口径——清指纹或换 IP 都不能回血)、注册用户(账号令牌 + 一小时角色令牌自动续期)、管理口令(其关闭开关的状态不向匿名前端广播,防扫描器侦察)、/v1 API 密钥(tmk_live_ 前缀,服务端只存 SHA-256,明文只显示一次)、/v2 网关凭据、地址密码(前端散列后传输、服务端不回显)、一次性恢复凭证(tmr1.<id>.<secret>,建箱成功时一次性发放,换取 30 分钟短期地址令牌;secret 只存加盐散列)、Telegram webhook。匿名建箱的并发正确性不在应用层,在数据库:槽位预约表以(口径、日期、槽位)为主键,至多 N 行存在即是至多 N 次创建——并发超额由主键冲突直接拒绝;建箱失败退还槽位,主动销毁不返还。
2.4 上游网关与自愈
/v2 网关把一个第三方临时邮箱服务商(temptom)的会话当作资源池管理。其契约被冻结为最小面:单端点 api.php、恰好六个动作、一个 PHP 会话对应一个并发邮箱——全仓只允许一个文件出现该端点字样;会话由人类人工过人机验证后导入,禁止任何打码或自动化规避。自愈是三层设计:读路径遇验证过期(428)立即退休;创建路径的失败用连续 403 计数判定——3 次且落在 120 秒滑动窗口内才退休,成功即清零;另有 cron 每 5 分钟对空闲会话做廉价探活(每会话 10 分钟节流、最久未探测优先轮转)。会话凭据 AES-GCM 加密落库,并发轮转靠 cred_version 乐观锁(输家重读合并)——防慢写者覆盖新凭据后误杀健康会话。
2.5 验证体系
端到端是全仓的门面:79 个 spec、5 个 Playwright 项目、19 个容器服务,另配 11 种 worker 变体各证一种部署形态。两条纪律:其一,CI 永不打真上游——假上游实现同样的六动作并带故障注入场景(引导 403、仅创建要人机验证),使「死会话」这类故障可以在 CI 里重放;其二,对生产站的活体探针独立成目录(鉴权拒绝面、全量 API、真实收信——由发信器发随机验证码再比对 AI 提取结果;该目录含真实密钥,不入库)。视觉回归只有一种可靠手段:40 组合 A/B 截图加纯 Node 的像素对比——「量几何比读图可靠」,某次首元素选择器不命中的 CSS 塌陷,结构校验、i18n 门禁与类名覆盖全绿,只有像素对比抓住了它。
3 结果
3.1 静默答错的六例
这个项目最贵的缺陷都不报错,只答错。
**其一,错误被遮罩盖住。**用户实测反馈「点了没反应」。排查发现错误提示的 z-index: 1001 低于弹窗遮罩的 1002——错误其实弹了,全程被压着。代码没错、逻辑没错、用户就是看不见。修复是把它提级,并把「弹窗内反馈必须可见」写成层级令牌的注释约定。
**其二,补丁只写了一半。**一条提交信息写着「补上 pruneDeadMailboxes 函数定义(上次 patch 写回前中断导致只有调用没有定义)」——上一次自动化补丁在写回前中断,构建通过、产物里带着调用,运行时被框架静默吞掉,清理从未执行。
其三,健康检查恒绿。/health_check 一度被静态资源回退以 index.html 加 200 应答——服务死了,监控全绿。
**其四,路由规则吞掉 API。**前端路由配置里一条 /*.json 的排除规则盖过了包含规则,站点域名下所有带扩展名的 API 路径静默返回 200 的 HTML——上游时代即存在,长期无人发现。
**其五,版本号按字符串比。**数据库迁移版本 "v0.0.9" > "v0.0.10" 按字典序为真——老库的迁移端点永久 500。
**其六,测试存在但从不运行。**管理台导航注册表的守护测试 09-25 就提交了、五项断言全过,但从未挂进 npm test——测试运行器只执行脚本里显式列出的文件。10-28 接入后门禁从 7 项变 12 项,并用两次变异注入(删标签键、造重复 slug)证明它真的会红。
六例的共同点:门禁通过、页面能开、日志无错,唯独功能不在。对策也分属两层——「错误必须可见」是产品级约束(层级令牌、语义化探针),「写入必须原子」「测试必须接线」是工具级约束(原子写回、把测试挂进门禁并用变异验证它会红)。两者都不能靠「多加测试」自动获得:六例里有四例,出事时测试或检查本来就是绿的。
3.2 死会话伪装限流
09-26 是最难的一天。上游把「会话的人机验证过期」与「创建频率超限」返回同一个 403 need_turnstile。而本地的处理逻辑是:读路径遇到 428 会退休会话,创建路径却一律把它放回空闲池——结果是死会话永生:每次建箱认领它、403、再被报成「限流」。
第一版判据是「看引导页是否可达」:页面能开但创建被拒 = 真限流;连引导页都拒 = 会话已死。这个判据被实测否证——该上游的首页无论会话死活都返回 200(人机验证只挡接口),探针永远触发不了。第二次才根治,方案是 2.4 的双信号:创建连续 403 计数(3 次且跨 120 秒)加 cron 定期探活,全部参数是代码里可指认的常量。教训不在判据本身:**凡拿「上游某个页面能不能打开」当健康信号,等于把判据外包给一个不承诺语义的接口。**配套动作是把这套故障搬进 CI:假上游带故障注入,「死会话」从此可以在每次提交里重放。
3.3 凭据边界的越权三连修
09-23 到 09-25 收口三件越权:API 密钥按代际分家(v1 与 v2 密钥互拒,存量兼容档放行);/v1 的邮箱令牌增加归属校验——此前任何合法密钥拿到他人的地址与令牌就能完整操作那个邮箱,修复后严格比对来源、无权与不存在统一 404 防枚举、默认关闭;凭据恢复改为一次性发放的 tmr1 凭证,换 30 分钟短期令牌,secret 只存加盐散列。后续两批安全收口:10-24 的 19 个提交做了 Telegram webhook 的常量时间比较(先散列定宽再比,错误长度与错误值无法用时序区分 [3])、/v1 幂等键重放时重签邮箱令牌(201 是令牌唯一出处,防超时重试拿到死邮箱)、/v2 会话凭据的乐观锁;10-25 的六修做了发信显示名的 CRLF 控制字符剥离(防邮件头注入)、自动回复无限回环的根治,以及一次性凭证从 KV 迁入 D1——KV 的最终一致传播窗内一次性令牌可能被双消费,改成单行 SELECT→DELETE 以变更行数判定恰一赢家。
3.4 AI agent 作为一等用户
三十秒命题有一个机器版本:程序取验证码比人更快。系统为此提供了两层。/v1 公共 API(14 端点,OpenAPI 3.1 [5])给强契约:长轮询 25 秒内返回新信,验证码直达 code 字段——三十秒命题的 API 化。专用 agent skill 则刻意收窄边界:不处理建箱——人机验证必须由人完成,agent 只拿用户自供的地址令牌,先验活、再取已解析信件(不可用时降级为拉原始信件本地解析),轮询纪律写死(3 秒起步、指数退避封顶 10 秒、绝不快于每秒一次、尊重 429)。给 agent 的入口与给人的入口遵守同一套限流与凭据矩阵——「Agent 友好」不是放行,是把同样的规则写给机器看。
3.5 匿名额度的三段演进
匿名建箱的配额三个月里改了三次口径:按出口 IP 计数(换网络即重置,同出口互蹭)→ 指纹优先(单点修复)→ strict 双槽(IP 与指纹都计数,取大者)→ 最终以槽位预约表实现,把「额度」从计数器问题变成主键问题。额度算错和额度被绕开,是两个不同的问题;而并发正确性,计数器永远给不了——主键可以。
4 讨论
4.1 错误可见性是产品级约束
六例静默答错横跨 UI、构建、监控、路由、数据库、测试六层,共同点是「报 200、答错误」。这与姊妹篇《不会报错的错误》同题不同例:那边是数据静默(删除复活、备份被拒),这边是界面与门禁静默(错误被盖住、监控恒绿);与 ART/RANK 的「百分比差异会骗人」、钢琴系统的「承诺—实现对账」同属一族——**跨系统看,失效的第一形态不是崩溃,是安静地答错。**对策因此都在失效的显形通道上:层级令牌让错误浮上来,语义化探针让健康有定义,门禁接线加变异验证让测试的「存在」变成测试的「生效」。
4.2 「能查到」与「该查到」
凭据系统里,「能查到」和「该查到」是两个判断,而代码通常只实现了前者——三连修对应的三类越权(跨代际、跨账户、恢复通道)都属此类。收口的两个模式值得记录:统一 404 防枚举(拒绝与不存在不可区分),以及散列后常量时间比较(时序侧信道 [3] 连密钥长度都测不出来)。八种凭据不是复杂度,是边界的最小颗粒度:每一种凭据对应一类主体,越权修复才能落在「哪两个主体之间」而不是「哪个接口上」。
4.3 上游契约:冻结、自愈、重放
对不承诺语义的依赖,本系统的三步走有一般性:先冻结契约(单文件承载全部端点字样,六动作之外无面),再自建健康信号(不信任上游页面,只用自己可计数的失败),最后重放进 CI(假上游 + 故障注入,让最坏情况在每次提交里预演)。人工过验证导入的会话池是这套设计的现实约束——红线明文禁止打码与自动化规避,自愈的目的不是绕过人机验证,是让「验证过期」这种正常损耗不至于伪装成限流。
4.4 fork 的自主演进
git 历史重置通常被理解为抹除,这里的作用是改变跟随对象:上游仓库的自动同步被解除后,「上游」一词从 git 仓库变成了邮件服务商——需要冻结与重放的对象随之明确。分叉点的两处投资(零构建前端、/v1 /v2 API 面)都发生在解除同步之后。fork 的价值不在代码快照,在契约自主权。
5 威胁效度
**其一,「功能占比」不可考。**按提交前缀,feat 占 30%(46/153),但提交分类不是功能度量——本文不主张「功能少」或「功能多」,只记录成本去向。旧稿曾写「加功能不到一成」,无仓库依据,已撤。
**其二,解析器的宣传与实现脱节。**README 保留上游「高性能 Rust WASM」表述,实现实际是 postal-mime;全仓无解析性能基准,本文不对两者作性能比较。
**其三,安全收口是欠账不是迭代。**常量时间比较、幂等重放、CRLF 剥离都发生在功能上线近一个月后(10-24/10-25 两批),它们本该随功能落地。
**其四,发信的关闭不是安全决策。**开放发信在 09-20 按文档启用、29 分钟后关闭,原因是当时账户的发信绑定只允许发往已验证地址——能力受限,而非「开放中继风险」的主动权衡(旧稿此说无出处,已撤)。发信能力此后以受限形态存在(用户面发件箱、余额制)。
**其五,活体探针含真实密钥。**对生产站的 API 测试台不入库(gitignore),其配置含真实凭据——复现时需自建。
**其六,样本单一。**一个产品、一个上游服务商、40 天窗口;「少做高危功能与没出事故」之间没有因果证据。
6 结论
三个问题的答案。错误可见性:静默答错的失效横跨六层,对策没有一个是「多加测试」——层级令牌、原子写回、语义化探针、门禁接线,都是让失效显形的通道工程;四例出事时检查本来就是绿的。凭据边界:八种凭据的最小颗粒度让越权修复落在主体之间而非接口之上;并发正确性可以下沉到数据库主键。上游契约:判据不可外包——「首页可达」被一次实测否证;健康信号要自建、契约要冻结、最坏情况要用假上游在 CI 里重放。
回到三十秒命题:一个只有三十秒耐心的产品,工程价值几乎不体现在功能清单上。它体现在三处——错误被看见、凭据不越界、依赖可重放。这三处都没有用户会注意到,而每一处失效,用户都会在三十秒内离开。
数据与复现
代码在 https://github.com/tripodxu/cloudflare_temp_email,线上站点 https://mails.logicc.top(API mail.logicc.top)。收信管线在 worker/src/email/(去重与旁路隔离见 storage.ts 与 index.ts 注释);匿名配额与槽位表在 worker/src/anonymous_create_limit.ts;上游网关契约冻结在 worker/src/upstream_gateway/temptom.ts,自愈参数在 upstream_gateway/service.ts 与 refresh.ts;视觉体系见 DESIGN.md 与 frontend/css/dispatch.css;agent skill 在 skills/cf-temp-mail-agent-mail/SKILL.md;端到端的假上游与 11 种部署变体见 e2e/README.md。fork 源流声明在 CHANGELOG.md「历史说明」,原始版权在 LICENSE。项目记忆在 MEMORY.md(最新在上),多 agent 协作规范在 docs/agents/。
参考文献
[1] Cloudflare. Email Routing documentation. https://developers.cloudflare.com/email-routing/.
[2] Resnick P, ed. Internet Message Format. RFC 5322, IETF, 2008.
[3] Kocher P C. Timing attacks on implementations of Diffie-Hellman, RSA, DSS, and other systems. Advances in Cryptology — CRYPTO ‘96, LNCS 1109. Berlin: Springer, 1996: 104-113.
[4] Kleppmann M. Designing Data-Intensive Applications. Sebastopol: O’Reilly Media, 2017.
[5] OpenAPI Initiative. OpenAPI Specification 3.1.0. 2021. https://spec.openapis.org/oas/v3.1.0.