本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月07日 统计字数: 14445字 阅读时间: 29分钟阅读 本文链接: https://soulteary.com/2026/09/07/phorge-modernization-part-7-migrate-mail-service.html ----- # Phorge 现代化改造实战(七):迁移邮件服务,先分清哪些失败不该重试 本文是“Phorge 现代化改造实战”系列第七篇。最近两篇分别处理了纯计算兼容和实时通知协议;这一篇进入带外部 provider、失败分类和队列重投的邮件链路,重点不再是“能否发出一封测试邮件”,而是谁应该看见哪一种失败。 ## 系列导航 1. [从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线](https://soulteary.com/2026/09/06/phorge-modernization-part-1-from-no-official-image-to-docker-compose.html); 2. [改进容器化的七个细节:补齐权限、持久化、依赖和探活](https://soulteary.com/2026/09/06/phorge-modernization-part-2-seven-containerization-details.html); 3. [接入 Stargate:把 Forward Auth 的信任边界做完整](https://soulteary.com/2026/09/06/phorge-modernization-part-3-stargate-forward-auth-trust-boundary.html); 4. [拆分模块到 Gorge:无侵入改造不等于不碰文件](https://soulteary.com/2026/09/06/phorge-modernization-part-4-split-modules-to-gorge.html); 5. [替换 diff 子进程:兼容不等于逐字一致](https://soulteary.com/2026/09/07/phorge-modernization-part-5-replace-diff-subprocess.html); 6. [替换实时通知服务:为什么 HTTP 501 反而表示正常](https://soulteary.com/2026/09/07/phorge-modernization-part-6-replace-realtime-notification-service.html); 7. **迁移邮件服务:先分清哪些失败不该重试**; 8. [迁移搜索服务:写进索引不等于搜得到](https://soulteary.com/2026/09/07/phorge-modernization-part-8-migrate-search-service.html); 9. [迁移文件存储:写得进去也要读得回来](https://soulteary.com/2026/09/07/phorge-modernization-part-9-migrate-file-storage.html); 10. [迁移 Webhook 投递服务:先解决重复投递](https://soulteary.com/2026/09/07/phorge-modernization-part-10-migrate-webhook-delivery.html)。 ## 写在前面 上一篇替换 Aphlict 时,最麻烦的问题是协议坏掉以后仍然返回 200:PHP 认为消息已经交出去,集群面板也全绿,只有浏览器收不到通知。这一篇轮到邮件。 [Gorge 2026.09.07-r1](https://github.com/soulteary/gorge/releases/tag/2026.09.07-r1) 把原来独立维护的 `gorge-mailer` 迁进单仓库,收拢 SMTP、sendmail、SES、SendGrid、Mailgun、Postmark 和测试后端,具体变化可以从 [Gorge r6…r1 的代码差异](https://github.com/soulteary/gorge/compare/2026.09.06-r6...2026.09.07-r1) 中查看;[Phorge 2026.09.07-r1](https://github.com/soulteary/phorge/releases/tag/2026.09.07-r1) 则新增原生邮件适配器、HTTP 客户端、配置检查和容器接入,对应的宿主改动集中在 [Phorge r6…r1 的代码差异](https://github.com/soulteary/phorge/compare/2026.09.06-r6...2026.09.07-r1) 中。 从代码改动看,这应该是目前整个系列里最普通的一次迁移:改包路径,换掉重复的 HTTP 基础设施,接回原来的七个后端,再跑一遍测试。 真正花时间的仍然不是搬代码,而是找出那些已经存在很久、出错时却不会主动告诉你的约定:永久失败从来没有被标成永久;没有后端的进程仍然显示健康;两层配置只完成一层时,所有日志看起来都正常;已经写在配置里的重试参数,其实从未进入发送路径。 **这类问题之所以能活到迁移时,不是因为代码不会报错,而是因为它一直在报“合理的错”。** ### 邮件服务和 Phorge 的功能是什么关系 邮件不是 Phorge 之外的一项附加工具,而是它把站内业务变化送到用户手中的重要异步通道。代码评审产生新评论或状态变化、任务发生更新、用户被提及,以及其他需要离站提醒的事件,最终都可能进入 MetaMTA 的出站邮件流程。 不过,邮件链路里的职责并不都属于 mailer。Phorge 负责理解业务事件、计算收件人、生成主题与正文,并把发送任务放进自己的 worker 队列;真正开始投递时,邮件适配器才把已经准备好的消息交给 Gorge。Gorge 再选择 SMTP 或第三方 provider,处理短时重试和后端切换,最后把结果返回给 Phorge。 ```text 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 模块文档](https://github.com/soulteary/gorge/blob/2026.09.07-r1/docs/modules/mailer.md) 记录了接口、后端和错误契约,后文讨论的所有静默故障,实际上都发生在这三层责任没有被准确传递的时候。 ### 这次迁入的不是纯计算,所以仍然单独运行 前面的 highlight 和 diff 都并在 `gorge-render` 里:请求进来,结果出去,没有数据库、缓存或下游服务,也没有必须跨请求保存的状态。 mailer 不一样。它持有一组适配器配置,要主动连接 SMTP 或第三方 provider,还需要回答“我现在到底有没有能力尝试投递”。这些性质决定了它继续独占一个二进制和 `:8110`: ```text 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 侧不需要为“服务进了哪个仓库”承担协议变化。这恰好说明平台层适合承载什么:不是邮件业务,而是各个域原本都在手抄、并且最容易逐渐漂移的进程级约定。 不过,删除重复代码并不是这次迁移最有价值的部分。真正值得留下的是重读发送路径时发现的第一处静默故障。 ## 第一处静默故障:永久失败从来没有被返回过 旧实现里已经有这个类型: ```go 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 记录下来的状态看起来就像用户填错了地址。 代码注释里把这个偏好写得很直接: ```go // 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 排序后端。单个后端临时失败时,会在有限次数内重试;仍然失败,才切换到下一个后端。 永久失败则立刻结束: ```go 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”只能算当前实现,而不能写成普适原则。 ## 谁才是重试的权威 永久失败分类补好之后,另一个问题跟着暴露出来。旧配置里有两个字段: ```text 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 队列 | 分钟级、跨请求、可持久化的权威重投 | 测试没有只把 `2` 和 `2` 写死,还额外守住 `MaxRetries × RetryWait <= 10s`。重要的不是这两个字面值,而是 Go 内层的等待必须显著小于 PHP 客户端的 30 秒预算。 这一类测试比“默认值等于多少”更有生命力。将来把策略改成 3 次、1 秒仍然合理;改成 10 次、5 秒时,它会迫使修改者先回答:为什么服务端可以比调用方等得更久? ## 第二处静默故障:进程健康,但一封信也发不出去 旧服务在没有配置任何后端时仍然会启动,`/healthz` 也会返回 200。 严格地说,这不是 health check 的 bug。`/healthz` 问的是进程是否还活着,进程确实活着。问题在于,如果编排和监控只有这一盏灯,它们就会把“活着但完全没有投递能力”显示成绿色。 平台层已经提供 `/readyz`,所以 mailer 只需要交给它一个真实的判据: ```go func (d *Dispatcher) Ready() error { if len(d.adapters) == 0 { return errors.New("no mailers configured") } return nil } ``` `main.go` 把它注册到 server: ```go 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 的叠加编排探 `/healthz`。`phorge` 使用 `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 已有的扩展点: ```php final class PhabricatorMailGorgeAdapter extends PhabricatorMailAdapter { const ADAPTERTYPE = 'gorge'; } ``` `PhutilClassMapQuery` 会发现这个 [`PhabricatorMailGorgeAdapter`](https://github.com/soulteary/phorge/blob/2026.09.07-r1/src/applications/metamta/adapter/PhabricatorMailGorgeAdapter.php) 子类,`cluster.mailers` 负责选择它并提供 options;适配器再通过 [`PhabricatorGorgeMailerClient`](https://github.com/soulteary/phorge/blob/2026.09.07-r1/src/infrastructure/cluster/PhabricatorGorgeMailerClient.php) 调用 Gorge。无须修改工厂,无须维护手写注册表,也无须在另一处保存服务地址。 一条最小配置是: ```json [ { "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 生成条目时固定写入: ```json "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: ```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