本文是“Phorge 现代化改造实战”系列第七篇。最近两篇分别处理了纯计算兼容和实时通知协议;这一篇进入带外部 provider、失败分类和队列重投的邮件链路,重点不再是“能否发出一封测试邮件”,而是谁应该看见哪一种失败。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整
  4. 拆分模块到 Gorge:无侵入改造不等于不碰文件
  5. 替换 diff 子进程:兼容不等于逐字一致
  6. 替换实时通知服务:为什么 HTTP 501 反而表示正常
  7. 迁移邮件服务:先分清哪些失败不该重试
  8. 迁移搜索服务:写进索引不等于搜得到
  9. 迁移文件存储:写得进去也要读得回来
  10. 迁移 Webhook 投递服务:先解决重复投递

写在前面

上一篇替换 Aphlict 时,最麻烦的问题是协议坏掉以后仍然返回 200:PHP 认为消息已经交出去,集群面板也全绿,只有浏览器收不到通知。这一篇轮到邮件。

Gorge 2026.09.07-r1 把原来独立维护的 gorge-mailer 迁进单仓库,收拢 SMTP、sendmail、SES、SendGrid、Mailgun、Postmark 和测试后端,具体变化可以从 Gorge r6…r1 的代码差异 中查看;Phorge 2026.09.07-r1 则新增原生邮件适配器、HTTP 客户端、配置检查和容器接入,对应的宿主改动集中在 Phorge r6…r1 的代码差异 中。

从代码改动看,这应该是目前整个系列里最普通的一次迁移:改包路径,换掉重复的 HTTP 基础设施,接回原来的七个后端,再跑一遍测试。

真正花时间的仍然不是搬代码,而是找出那些已经存在很久、出错时却不会主动告诉你的约定:永久失败从来没有被标成永久;没有后端的进程仍然显示健康;两层配置只完成一层时,所有日志看起来都正常;已经写在配置里的重试参数,其实从未进入发送路径。

这类问题之所以能活到迁移时,不是因为代码不会报错,而是因为它一直在报“合理的错”。

邮件服务和 Phorge 的功能是什么关系

邮件不是 Phorge 之外的一项附加工具,而是它把站内业务变化送到用户手中的重要异步通道。代码评审产生新评论或状态变化、任务发生更新、用户被提及,以及其他需要离站提醒的事件,最终都可能进入 MetaMTA 的出站邮件流程。

不过,邮件链路里的职责并不都属于 mailer。Phorge 负责理解业务事件、计算收件人、生成主题与正文,并把发送任务放进自己的 worker 队列;真正开始投递时,邮件适配器才把已经准备好的消息交给 Gorge。Gorge 再选择 SMTP 或第三方 provider,处理短时重试和后端切换,最后把结果返回给 Phorge。

Phorge 业务事件
  → MetaMTA 生成邮件与持久化任务
  → Phorge worker 调用 Gorge adapter
  → gorge-mailer 有限重试与后端切换
  → SMTP / 邮件 provider
  → 收件人邮箱

这意味着 gorge-mailer 不是新的邮件队列,也不会接管 Phorge 的通知规则。它不知道一封邮件来自 Differential、Maniphest 还是其他应用,也不决定谁应该收到邮件。它接走的是一个更窄的投递执行层:按照已经确定的信封、正文和附件调用具体后端,再用 Phorge 能理解的结果说明这次投递成功、可以稍后重试,还是永远不应再试。

这个边界正是迁移邮件服务的原因。独立仓库虽然已经能调用七类后端,但它复制了一套 HTTP、鉴权、探针和进程生命周期,同时又没有把失败分类、重试预算和“是否真正具备投递能力”说清楚。把它迁进 Gorge,不是为了把 PHP 里的 MetaMTA 重写成 Go,而是为了让出站执行层复用统一平台,并把会改变上游队列行为的契约纳入同一套测试。

从用户视角看,迁移前后仍然是 Phorge 在发送邮件;从系统边界看,责任变得更明确:Phorge 拥有业务语义和跨请求的持久重投,Gorge 只吸收秒级抖动并执行 provider failover,具体邮件后端则回答一次真实投递是否被接受。Gorge mailer 模块文档 记录了接口、后端和错误契约,后文讨论的所有静默故障,实际上都发生在这三层责任没有被准确传递的时候。

这次迁入的不是纯计算,所以仍然单独运行

前面的 highlight 和 diff 都并在 gorge-render 里:请求进来,结果出去,没有数据库、缓存或下游服务,也没有必须跨请求保存的状态。

mailer 不一样。它持有一组适配器配置,要主动连接 SMTP 或第三方 provider,还需要回答“我现在到底有没有能力尝试投递”。这些性质决定了它继续独占一个二进制和 :8110

Gorge
├── gorge-render
│   ├── /api/highlight/*
│   └── /api/diff/*
├── gorge-notification
│   ├── client :22280
│   └── admin  :22281
└── gorge-mailer :8110
    ├── /api/mailer/send
    └── /api/mailer/mailers

这里的拆分依据不是“每个业务域都要有自己的容器”,而是它已经具备不同的外部依赖、就绪语义和资源曲线。

这也是为什么 mailer 没有继续塞进 gorge-render。如果 SMTP 阻塞、provider 限流或者某个适配器耗尽连接,不应该连带挤占语法高亮和 diff 的进程资源;反过来,纯计算域也不需要理解第三方邮件服务什么时候算就绪。

迁移的第一步是删除

独立仓时代的 mailer 自己实现了一整套服务基础设施:响应信封、token 鉴权、根路径与健康检查、Echo 的启动方式。迁进单仓库以后,这些东西已经由 internal/platform 统一提供,所以第一步不是复制,而是删掉。

原来的自实现 迁入后
apiResponse / apiError httpx.OK / httpx.Fail
自写 X-Service-Token 中间件 auth.Token(deps.Token)
自写 GET //healthz 平台层统一探针
e.Logger.Fatal(e.Start(...)) srv.Run()

最后一行看起来只是换了启动函数,实际带进来的是统一的信号处理、优雅关闭、请求 ID 和结构化日志。旧服务收到 SIGTERM 时直接退出,一封正在投递的邮件可能在网络调用中途被截断;迁入后,server 会先停止接新请求,再给正在处理的请求留下排空窗口。

API 信封没有发生变化,仍然是 {data,error},所以 PHP 侧不需要为“服务进了哪个仓库”承担协议变化。这恰好说明平台层适合承载什么:不是邮件业务,而是各个域原本都在手抄、并且最容易逐渐漂移的进程级约定。

不过,删除重复代码并不是这次迁移最有价值的部分。真正值得留下的是重读发送路径时发现的第一处静默故障。

第一处静默故障:永久失败从来没有被返回过

旧实现里已经有这个类型:

type PermanentError struct {
	Err error
}

调度器认识它,HTTP handler 会把它映射成 422 ERR_PERMANENT_FAILURE,PHP 客户端也知道这个错误码应该变成 PhabricatorMetaMTAPermanentFailureException

从类型、状态码到调用方,整条错误通道都是完整的。

只有一个问题:七个后端没有任何一个真的返回过它。

这会产生一条很长、又看似处处正常的失败链:

环节 看到的现象 做出的决定
SMTP 550 No such user 返回普通 error
gorge-mailer 后端投递失败 返回 502,而不是 422
PHP 客户端 普通远端错误 抛普通异常
Phorge worker 不是永久失败 重新入队

收件人地址写错不会因为多试一次变正确,但 worker 不知道这一点。于是它重新排队,再试,再拿到同样的 550,再重新排队。没有 panic,没有 500 风暴,也没有明显的异常日志,只有队列里慢慢积起一批持续重试、却永远发不出去的邮件。

永久和临时,不能只看“是不是 4xx”

补上分类逻辑并不复杂,真正需要谨慎的是划线。r1 把规则统一成下面这张表:

后端 永久失败 临时失败
SMTP 5xx 应答 4xx 应答;连接、DNS、TLS 等没有应答码的错误
SES / SendGrid / Mailgun / Postmark HTTP 4xx 429、5xx、网络错误
Postmark 额外识别 ErrorCode 300 / 406 / 409 / 422 其他未登记的 ErrorCode
sendmail 退出码 64 / 65 / 66 / 67 / 68 / 77 / 78 75、71、74,以及未知退出码
Mailgun 本地准备 附件不是合法 base64

其中有两条比表本身更重要。

第一,429 是 4xx 里必须单独拿出来的例外。 其他 4xx 通常是在说地址、凭据、发送域或消息本身不被接受;429 说的是“现在太多了”。把限流判成永久,会让一封本来稍后可以发出的信被立刻标成 FAIL

第二,不认识的信号一律按临时失败处理。 两个方向的代价不对称:把永久失败误判成临时,只会多浪费几轮 worker;把临时失败误判成永久,会静默丢掉本可成功的邮件,而且 Phorge 记录下来的状态看起来就像用户填错了地址。

代码注释里把这个偏好写得很直接:

// When in doubt, do not return this: a transient failure misreported as
// permanent drops mail silently, while a permanent one misreported as
// transient only wastes worker cycles.

不过,这张表也暴露出第三条尚未闭合的边界:“永久”描述的是时间,未必描述作用域。 当前实现把除 429 外的 provider HTTP 4xx 都归为永久失败,其中既包括无效收件人,也包括凭据撤销、发送域未验证等配置问题。前者通常对整封邮件成立,后者可能只对当前后端成立;备用后端仍可能成功。SMTP 5xx 也同样既可能是收件人不存在,也可能是当前 relay 拒绝转发。

当前实现为什么既不重试,也不切换后端

新的 dispatcher 先按 priority 排序后端。单个后端临时失败时,会在有限次数内重试;仍然失败,才切换到下一个后端。

永久失败则立刻结束:

messageID, err := d.sendThrough(ctx, adapter, msg)
if err == nil {
	return result, nil
}
if IsPermanent(err) {
	return nil, err
}
// 只有临时失败才继续下一个后端

这段代码把任何 PermanentError 都解释为“对整封邮件永久”,因此不是为了省几次请求,而是在定义失败属于谁。

  • SMTP 连接被拒、provider 503,描述的是当前后端的状态,换一个可能成功;
  • 收件人不存在通常描述的是这封邮件,换一个后端只会更慢地得到相同结论。

所以 PermanentError 不只是错误类型,它同时控制三层行为:Go 侧不再重试,不再 failover;HTTP 返回 422;Phorge worker 停止重新入队。把它误删成普通 error,代码仍然能编译,接口也仍然会返回一份合法的错误信封,但系统行为已经完全不同。

问题在于,当前模型只有“是否值得以后再试”一个维度,没有表达“只对当前后端成立,还是对整封邮件成立”。更完整的分类至少要区分三种情况:

失败类型 重试当前后端 切换备用后端 交给外层队列
临时失败 有限重试 全部失败后重投
后端永久失败 需要结合告警策略
邮件永久失败

r1 还没有这第二个维度。它解决了“所有错误都被当成临时错误”的旧问题,却可能把某个后端的永久配置错误过早升级成整封邮件的永久失败。更合理的后续方向,是把 PermanentError 拆成“message permanent”和“backend permanent”,或者额外携带 scope;否则“永久失败不 failover”只能算当前实现,而不能写成普适原则。

谁才是重试的权威

永久失败分类补好之后,另一个问题跟着暴露出来。旧配置里有两个字段:

MaxRetries = 250
RetryWait  = 15s

文档里有,示例里也有,但发送路径从来没有读取它们。过去每个后端其实只尝试一次。

迁移时如果只是把这两个字段接进循环,就会把一份死配置突然变成真实行为:250 × 15s 约等于 62 分钟,而 PHP 客户端的请求超时只有 30 秒。客户端早已放弃,Go 进程还在替一个已经不存在的调用者重试;与此同时,Phorge 的 worker 队列本身还会在外层重新投递。

这不是“重试更充分”,而是两套互不知情的重试叠在一起。

r1 把默认值改为 2 次重试、间隔 2 秒。这里要特别注意措辞:MaxRetries=2 表示第一次失败后最多再试两次,所以单个后端最多调用三次,等待时间上界是 4 秒,不是只投递两次。

整个循环还受请求 context 约束:HTTP provider 直接拿这个 context 发请求;客户端断开时,等待中的 timer 会立即结束。net/smtp 本身不支持 context,所以 SMTP 只能在两次尝试之间观察取消,但至少不会在调用方走掉以后继续进入下一轮。

新的责任边界因此很清楚:

负责什么
单个适配器 一次真实投递
Gorge dispatcher 吸收秒级抖动,在临时失败时有限重试和 failover
Phorge worker 队列 分钟级、跨请求、可持久化的权威重投

测试没有只把 22 写死,还额外守住 MaxRetries × RetryWait <= 10s。重要的不是这两个字面值,而是 Go 内层的等待必须显著小于 PHP 客户端的 30 秒预算。

这一类测试比“默认值等于多少”更有生命力。将来把策略改成 3 次、1 秒仍然合理;改成 10 次、5 秒时,它会迫使修改者先回答:为什么服务端可以比调用方等得更久?

第二处静默故障:进程健康,但一封信也发不出去

旧服务在没有配置任何后端时仍然会启动,/healthz 也会返回 200。

严格地说,这不是 health check 的 bug。/healthz 问的是进程是否还活着,进程确实活着。问题在于,如果编排和监控只有这一盏灯,它们就会把“活着但完全没有投递能力”显示成绿色。

平台层已经提供 /readyz,所以 mailer 只需要交给它一个真实的判据:

func (d *Dispatcher) Ready() error {
	if len(d.adapters) == 0 {
		return errors.New("no mailers configured")
	}
	return nil
}

main.go 把它注册到 server:

srv := httpx.New(httpx.Config{
	ListenAddr: cfg.ListenAddr,
	BodyLimit:  mailer.TransportBodyLimit,
	Ready:      dispatcher.Ready,
})

于是两个探针开始说不同的话:

状态 /healthz /readyz
进程运行,至少一个后端可构造 200 200
进程运行,零后端 200 503
进程不可达 失败 失败

为什么 readiness 不去拨测 SMTP

很容易进一步把 Ready() 写成“逐个连接 SMTP 或 ping provider”。这看起来更真实,实际会混淆两类问题。

至少一个适配器被成功构造,说明本服务具备发起投递的能力;第三方此刻是否可用,则是每次发送时才有答案的运行时状态。若把后者塞进 readiness,provider 的短暂抖动会让容器不断进出流量池,而多后端 failover 本来就是为吸收这种抖动存在的。

因此 /readyz 的 200 只承诺:这个进程能尝试发送。 它不承诺下一封信一定到达。

为什么两个 Compose 又故意探不同的路径

这里还有一处看似不统一、实际是部署语境不同的设计。

  • Gorge 自己的独立服务栈探 /readyz。这个栈的目的就是运行 mailer,没有后端时让它显示 not ready 是正确答案。
  • Phorge 的叠加编排探 /healthzphorge 使用 depends_on: service_healthy 等 mailer;若这里改探 /readyz,一个尚未配置后端的邮件服务会把整个站点堵在启动阶段。

发不出邮件应该被明确报告,但不应该等价于 Wiki、代码评审和登录页面全部无法启动。因此,Phorge 侧新增的 PhabricatorGorgeMailerSetupCheck 会分开检查两件事:先访问 /healthz 判断服务是否可达,再访问 /readyz 判断是否至少配置了一个后端,并在 Config 页面给出不同的修复提示。

探针不是越严格越好。它要回答的是当前调用方真正需要的问题。

第三处静默故障:设了地址,Phorge 仍然不会用它

这一处发生在 Go 与 PHP 的接缝上。

旧接入需要两层配置:先设置一个全局地址,例如 go-mailer.url;再在 cluster.mailers 中添加一个选择 Go adapter 的条目。容器启动脚本只完成了第一层。

于是很容易得到这样一个系统:服务已经启动,地址在配置页面里可见,网络也能连通,但 Phorge 根本没有选择这个适配器。邮件不会进入 Gorge,Gorge 日志自然也没有错误。

新实现没有再为 mailer 增加全局配置项,而是使用 Phorge 已有的扩展点:

final class PhabricatorMailGorgeAdapter
  extends PhabricatorMailAdapter {

  const ADAPTERTYPE = 'gorge';
}

PhutilClassMapQuery 会发现这个 PhabricatorMailGorgeAdapter 子类,cluster.mailers 负责选择它并提供 options;适配器再通过 PhabricatorGorgeMailerClient 调用 Gorge。无须修改工厂,无须维护手写注册表,也无须在另一处保存服务地址。

一条最小配置是:

[
  {
    "key": "gorge-mailer",
    "type": "gorge",
    "inbound": false,
    "media": ["email"],
    "options": {
      "uri": "http://gorge-mailer:8110",
      "token": "...",
      "timeout": 30,
      "supports-message-id": false
    }
  }
]

地址、token、超时和能力声明都跟着选择它的条目走,配置源只剩一个。这样还自然支持两个指向不同 Gorge 实例的 adapter 条目,优先级和 failover 继续交给 Phorge 原生机制。

这里有一个值得保留的工程判据:写新客户端之前,先找宿主有没有现成的扩展点。

Gorge 到现在替换过的几项能力,接入深度完全不同:

能力 宿主扩展点 PHP 接入深度
syntax highlight 没有可直接替换的远端后端 新客户端、高亮器、Future 代理、引擎、配置检查,以及手工切换
notification 外部 Aphlict 协议本身就是边界 线协议兼容,PHP 代码 0 改动
mailer 已有 PhabricatorMailAdapter 一个 adapter 子类接入;另加 client 与 setup check,不改工厂、不加全局配置项

接入深度不是风格选择,而是由你替换的边界决定。无视已有扩展点,通常会平白制造第二套配置和第二套生命周期。

配置列表不能覆盖,只能合并

把所有信息放回 cluster.mailers 以后,容器启动脚本不能再像写单值配置那样整体覆盖。

这个列表不归 Gorge 独占。用户可能已经配置了 SMTP、Postmark 或其他 adapter;若启动时直接写入只包含 Gorge 的新数组,容器仍然会成功启动,配置命令也会成功,但其他发信路径会在一次重启后悄悄消失。

r1 的 entrypoint 采用三步合并:

  1. bin/config get cluster.mailers 读取 local 配置源;
  2. 删除 key == GORGE_MAILER_KEY 的旧托管条目;
  3. 保留其余条目的相对顺序,把本次生成的新条目追加后写回。

“按 key 删除”同时解决了幂等问题。Phorge 会拒绝重复 key,如果不先剔除,容器第二次启动就无法写入配置。它又刻意不按 type 删除:用户完全可以自己配置第二个 type: gorge、指向另一个服务实例,只要使用不同的 key,就不会被启动脚本接管。

合并前还有两层防护。

第一,只读取 source=local 的值。bin/config get 同时展示 local 和 database 来源;若把 database 的结果复制回 local,后者会开始遮蔽数据库配置,以后在数据库里的修改看起来“保存成功”却永远不生效。

第二,读取或解析旧列表失败时宁可不写。因为此时脚本无法证明自己理解了用户已有配置,把错误当空数组继续执行就等于授权自己删除未知内容。

这是配置迁移里很重要的一条:合并失败可以重试,覆盖错了往往没有现场。

两个看似次要的选项,也会制造静默故障

新的 adapter 还把两个容易被忽略的能力声明写得相对保守。

inbound 必须显式为 false

Gorge mailer 只做出站,但 cluster.mailers 条目里的 inbound 默认是 true,而 adapter 没有另一个钩子能覆盖它。漏掉这一项不会让出站邮件立刻失败,只会让 Phorge 在计算收信能力时把它当成一个入口。

所以 entrypoint 生成条目时固定写入:

"inbound": false

supports-message-id 默认必须是 false

SMTP、sendmail 和 SES 走 Gorge 自己构建的 MIME,可以保留调用方指定的 Message-ID;SendGrid 和 Postmark 往往由 provider 自己生成并覆盖。PHP adapter 看不到最终用了哪个后端,因此不能统一声称支持。

默认 false 是保守但诚实的答案。谎报 true 不会导致投递报错,邮件也照样送达,只是客户端里的会话串接可能慢慢失效——又是一个只有用户体验变差、日志仍然干净的故障。

错误码不是报告格式,而是控制协议

mailer 的 HTTP API 只有两个域级错误码:

状态 错误码 Phorge 行为
422 ERR_PERMANENT_FAILURE 抛永久失败异常,停止重投
502 ERR_SEND_FAILED 当作临时错误,留给 worker 重试

无效 JSON、缺发件人、缺收件人或缺主题则返回 400 ERR_BAD_REQUEST。这些请求根本没有到达后端,不能用 422;否则 Phorge 会把自己的序列化错误误记成“邮件永久无法投递”。

这就是为什么错误信封必须先按 error.code 解析,再把 HTTP 状态当兜底。对这个接口来说,状态码不是只给日志和监控看的,它会改变队列状态。

另一个容易静默出错的契约是附件。PHP 在 JSON 中把任意字节编码成 base64,Gorge 的 contract 也明确规定 Attachment.data 已经是 base64:SendGrid 和 Postmark 直接透传,SMTP、sendmail 和 SES 在构建 MIME 时按相应格式处理,Mailgun 则需要解码成原始字节。

如果任意一层“好心”地多编码或少解码一次,请求仍然是合法 JSON,provider 也可能返回成功,但用户收到的是损坏文件。因此 mailer 把传输层 body limit 从平台默认的 2M 提到 10M;base64 本身约有三分之一的体积膨胀,不能只按附件原始大小估算请求。

测试不只证明能发,还要证明失败方式正确

这一版给 mailer 留下了三层测试。

单元测试:直接钉住分类与调度

表驱动用例覆盖 provider 状态码和 SMTP 应答码;sendmail 用 stub 二进制走真实的 exec.ExitError 路径;SES 因为 endpoint 可配置,使用 httptest 完成一次真实 HTTP 往返和 SigV4 请求。

dispatcher 的测试则关心行为:

  • 临时失败会先在当前适配器内重试,再 failover;
  • MaxRetries=N 产生至多 N+1 次调用;
  • 永久失败只调用一次,也绝不触碰备用后端;
  • context 到期后,即使配置了 1000 次重试也必须尽快退出。

契约固件:固定 PHP 真正依赖的线上形状

仓库新增了 10 份 mailer 固件,覆盖成功发送、附件、后端列表、鉴权、错误 JSON、缺字段、永久失败和临时失败。

其中 malformed body 的 request.body 必须是字符串,而不是嵌套 JSON 对象。只有字符串才能表达一份本身就不完整的 JSON:

{
  "name": "an invalid JSON body is 400 ERR_BAD_REQUEST, not 500",
  "description": "A 500 would send Phorge's worker into a retry loop over a request that can never succeed.",
  "request": {
    "method": "POST",
    "path": "/api/mailer/send",
    "body": "{\"message\": {\"from\":"
  },
  "expect": {
    "status": 400,
    "jsonEquals": {
      "error.code": "ERR_BAD_REQUEST"
    }
  }
}

description 也不是装饰。它写的不是“测试做了什么”,而是“为什么不能改”:若把 400 改成 500,Phorge 会对一份永远不可能成功的坏请求持续重试。

端到端测试:不要只看 200

e2e 脚本用测试后端完整走一遍进程边界。发送成功时,它不仅断言 200,还要求响应里存在 data.mailerKey。只看状态码,无法区分“某个后端真的接受了邮件”和“handler 提前返回了一份看似成功的空信封”。

这一层还会检查 /readyz、附件、未知 mailer key、鉴权和错误请求。不过它刻意不假装覆盖所有真实 provider:SMTP 的明文与 implicit TLS 发送路径,以及 SendGrid、Mailgun、Postmark 的 HTTP 往返仍是已登记的测试缺口。分类函数测到了,不等于每个 provider 的请求形状都已经被完整验证。

还有两件事没有顺手“修完”。迁移把一些问题暴露出来,不意味着都应该塞进同一个版本解决。

其他:没有处理完的事情

配置 JSON 写错时,服务仍然不会退出

旧代码会忽略 json.Unmarshal 的错误,得到零个后端。现在它至少会写一条错误日志,并通过 /readyz 返回 503,不再伪装成可投递。

但“JSON 写错”和“暂时还没配置”在进程退出码上仍然一样。直接改成启动失败也不一定对,因为先起服务、再注入后端配置是一种合理工作流。更合适的后续方案是增加显式严格模式:生产部署要求解析失败即退出,开发或分阶段配置允许零后端启动。

架构守卫仍然需要人工维护

平台层有一个五十多行的测试,用 go/parser 扫描 import,禁止 platform 反向依赖 render、diff、notification、mailer 和 contracts。mailer 迁入时又手工加了一行包前缀。

这条测试确实能让架构规则在 CI 中失败,但它自己的域列表会过期:下次新增域时忘了登记,守卫照样是绿色,只是没有保护新代码。

把规则变成测试,不等于测试本身没有维护输入。这个问题已经记入 findings,后续可以从目录结构自动推导域包;这一版没有为了“顺手修干净”扩大迁移范围。

回头看:迁移真正改变的是责任边界

从接口上看,r1 前后仍然是同样的七个后端、两条 API 路径和一份 {data,error} 信封。但几处关键责任已经被重新说清楚:

问题 权威答案放在哪里
一次投递是否被接受 具体后端
失败是否值得重试 Gorge 的错误分类(作用域仍待细分)
秒级抖动是否需要再试 Gorge dispatcher
跨请求、分钟级重投 Phorge worker 队列
服务是否活着 /healthz
是否至少能尝试投递 /readyz
Phorge 是否启用 Gorge mailer cluster.mailers 单一配置源
用户已有 mailer 是否保留 entrypoint 的按 key 合并

这张表比“迁了多少行代码”更接近这次工作的产物。

如果只把独立仓里的文件复制进来,服务也许第一天就能跑。但永久失败仍然会持续重投,零后端仍然全绿,旧的两层配置仍然可能只完成一半,250 次重试一旦真正接通还会制造一个新的小时级阻塞。

迁移给了我们一次重读整条链路的正当理由。最值得问的不是“它还能不能发送一封测试邮件”,而是下面四个问题:

  1. 哪些失败会改变上游行为,而不只是改变日志内容?
  2. 哪一层拥有重试、超时和 failover 的最终决定权?
  3. 健康、就绪和业务可用分别由谁观察?
  4. 配置只写了一半、写错或被覆盖时,系统会不会仍然显示成功?

有明确报错的 bug 通常会自己找上门。真正能在生产里安静活几个月的,是那些每一层都做出了一个局部合理决定,拼起来却让系统永远做错事的约定。

最后

失败必须让真正负责处理它的那一层看得见。

但要让这句话完全成立,下一步还必须补上失败的作用域,避免“当前后端永久失败”被误报成“整封邮件永久失败”。

–EOF