凌晨两点,Cloudflare 到源站的路径抖了两分钟,我的收件箱进了 24 封邮件:12 封 “X is DOWN”,12 封 “X recovered”。每封都对,每封都没用——它们说的是同一件事。同一天 Google 还投来一封 DMARC 聚合报告,gzip 压缩的 XML 附件,内容是”你域名的邮件全部通过验证”,信息量为零但天天都会来。这篇记录把这两类噪音处理干净的过程,顺带给平台所有出站邮件加了统一模板。
为什么一次抖动会变成 24 封邮件
UptimeFlare 的告警回调是 per-monitor 的onStatusChange。我最初的告警实现直接在回调里调 Resend 发邮件,于是 12 个 monitor 同时恢复就是 12 封邮件。这不是边缘情况——被监控的服务共享同一台 droplet、同一条 CF↔源站路径,故障天然是相关的,per-event 通知在相关性故障下必然爆炸成 N 封。
修法是把”发”从回调里挪出去。回调只往模块级 buffer 里记事件,然后在 worker 的 cron 循环末尾加一个新挂点,所有 monitor 处理完后调一次:
// Flush whatever the per-monitor callbacks buffered this tick (the alert// batcher in uptime.config.ts). Runs once per cron tick so a platform-wide// flap produces one summary email, not one email per monitor.try { await workerConfig.callbacks?.onCycleEnd?.(env, currentTimeSecond)} catch (e) { ... }onCycleEnd 把 buffer 里的宕机和恢复合成一封:DOWN (n): 一段、RECOVERED (n): 一段,结尾带 Still down: … 或 All clear。像”10 个恢复 + 1 个新宕机”发生在同一个 tick 的情况(那天凌晨真实发生了),合成一封正好。
恢复邮件挂回原线程
第二个诉求是恢复邮件别开新会话,要作为原 DOWN 邮件的回复出现。这里有两个事实需要先查清楚。
Gmail 的会话分组规则,按 Google 官方文档,要求 References/In-Reply-To 符合 RFC 2822 且 Subject 匹配——只有引用头对不上主题也不行。所以回复邮件的主题必须是 Re: <原主题>,恢复的内容写在正文里,而不是把主题改成 “X recovered”。
Resend 那边,官方文档明确支持在 headers 里带 In-Reply-To 和 References(收件回复场景还给了示例代码),但自定义 Message-ID 会不会被透传是文档空白——既没说支持也没说会重写。引用一个不存在的 Message-ID,线程化就悬了。我的兜底是发完邮件后回读 Resend 实际分配的 ID:
const { id } = (await resp.json()) as { id: string }const detail = await fetch(`https://api.resend.com/emails/${id}`, { headers: auth })// detail.message_id 是真实投递的 Message-ID;取不到再退回我们自己设的线程状态(根主题、Message-ID 链、哪些 monitor 被告警过)存在 worker 现有的 D1 表里。全部恢复后清掉,下次事故开新线程。References 链保根截中间,防止长时间 flapping 把头撑爆。
顺带修了一个隐性 bug:恢复邮件原来按”宕机时长 ≥ 宽限期”这个代理条件过滤,现在直接对着线程状态里的 alerted 集合过滤——只有 DOWN 邮件真正点过名的服务才报恢复,亚宽限期的抖动彻底静默。
DMARC 报告:让机器读机器写的东西
DMARC 聚合报告是发给 rua= 地址的每日 XML,对单发件域(我只有 Resend 发信)来说 99.9% 的内容是”全部通过”。这种机器生成、格式固定、几乎永远无事的报告,不该出现在人类收件箱里。
处理方式是把 rua= 指到自己域名下的专用地址,用 Cloudflare Email Routing 路由给一个 Email Worker。我没有新建 worker——现有的 mcp worker 走 Workers Builds 自动部署,还持有平台消息总线的 ingest token,加一个 email() export 就够了:
_dmarc TXT: v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@<domain>CF Email Routing: dmarc-reports@ → Send to Worker: mcp解析有几个实践细节值得记。报告容器按魔数识别而不是文件名(Google 发 .zip,有的接收方发 .xml.gz,文件名格式千奇百怪):1f 8b 走 DecompressionStream('gzip'),PK\x03\x04 走自写的最小 ZIP 读取器——从 EOCD 定位 central directory 再回到 local header,这样带 data descriptor 的归档也能读,deflate-raw 解压。XML 没用解析器,Workers 里没有 DOMParser,而报告 schema 是机器生成且稳定的,正则抽字段就够,但要注意 dkim/spf 在 policy_evaluated 和 auth_results 下都出现,必须限定在前者——那才是 DMARC 实际执行的对齐判定。
行为分三态:全 pass 只留 console 日志;出现 fail 行(disposition 不是 none,或对齐 DKIM+SPF 双双失败)往消息总线发一条 priority 7 的消息,由总线决定推送渠道;解析不了或总线拒绝就把原件 message.forward() 回收件箱兜底,永远不静默丢报告。注意转发邮件常见的”SPF fail 但 DKIM pass”不算失败——disposition 是 none,这是转发的正常形态。
故意不做持久化:接收方本来就按天聚合过了,单发件域的”全 pass”历史没有查询价值,forward 兜底就是最后的存档。E2E 验证用了个取巧的办法
所有邮件套模板:改一处而不是改每个生产者
裸文本邮件难看的问题,杠杆点在 emailservice 的 /send 上。平台通知链路是 生产者 → messageservice → notificationservice → emailservice,通知渠道发的永远是 {subject, text}。与其教会每个生产者写 HTML,不如在 /send 里对 text-only 的请求自动套壳:
if html_body is None and text_body is not None: html_body = wrap_text(subject, text_body)wrap_text 把纯文本放进统一的卡片模板:white-space:pre-wrap 保留换行,URL 正则转成可点的链接(先按 URL 分段再逐段 escape,尾缀标点剥离),内容全部 HTML-escape。原 text 保留为 multipart/alternative 的 fallback。调用方自带 html 的原样透传,不会双重包壳。这样 notificationservice、DMARC 告警、任何脚本,一行不改全部升级。
模板本身是邮件客户端安全的写法:单个 table、全内联样式、无图片无 SVG、CJK 字体栈,视觉上对齐平台 admin 的 zinc 设计语言。既有的 magic_link、comment_reply 两个模板也重构到同一个 shell 上,主题、text fallback、escape 行为不变,现有测试全部照过。UptimeFlare 是独立仓库(告警路径故意不依赖 droplet,否则平台挂了连告警一起哑),同风格的 shell 复制了一份进去,DOWN/RECOVERED 用红绿圆点行渲染。
教训
告警和通知类功能,第一版就应该做聚合和线程化,而不是先 ship per-event 直发、等收件箱被灌了再补。被监控对象的故障是相关的,这是告警系统的常态场景不是边缘情况;事后改造要加 worker 挂点、上持久化线程状态、查邮件线程规则,花了一整个 session,而第一版就带上这些不过多几十行。同理,“给邮件加模板”这种事,正确的位置是发送服务的出口(一处),不是每个生产者(N 处)——生产者说 text,出口负责好看。