Skip to content

cli: OS_EMAIL_PROVIDER=resend/postmark 缺 apiKey 时静默降级为 LogTransport —— #5087 在 CLI 层遗留的同形缺口 #5132

Description

@os-zhuang

做 #5094 时读 resolveEmailCapabilityArg 路过发现,与该 issue 无关,单独记录。#5087 的 PR 已经在代码注释里点名了这一处「still does」,但看起来没有单独立单。

现状

packages/cli/src/commands/serve.ts 的 resolveEmailCapabilityArg(约 2900 行):

if (provider !== 'log' && provider !== 'smtp' && !apiKey) {
  options.provider = 'log';
  return {
    options,
    warning: `provider='${provider}' but no apiKey found (set OS_EMAIL_API_KEY or config.email.apiKey). `
      + 'Falling back to LogTransport.',
  };
}

即:OS_EMAIL_PROVIDER=resend(或 postmark)而没有配 OS_EMAIL_API_KEY 时,CLI 把 provider 改写成 log,打一条 warning,然后正常启动。

同一函数里紧邻的 smtp 分支是相反的处理 —— provider='smtp' 无 host 直接 throw,让 boot 失败,并且函数自己的 docstring 明确写了为什么:

provider='smtp' with no host throws. The capability loop turns that into a boot failure, which is the point: the alternative (quietly substituting the LogTransport, as this function's resend/postmark arm still does for a missing API key) hands the operator a server that accepts every send, records it in sys_email, and delivers nothing — the exact declared-but-not-delivered gap #5087 closed inside the plugin.

影响

运维显式声明了要用 resend/postmark 投递,得到的却是一台「每次 send 都成功、sys_email 里全是 sent、一封信也没出去」的服务器。启动横幅之后那条 warning 很容易被 CI 日志淹没,而后果要等到用户报「收不到验证码」才暴露 —— 正是 AGENTS.md degradation-log-level 一节说的「系统从外面看一切正常」那一类。

插件层(EmailServicePlugin.resolveTransport / makeTransport)在 #5087 之后已经是「建不出就抛」,所以这条降级是 CLI 单方面把一个本该响亮的失败按下去了。

建议

与 smtp 分支对齐:缺 apiKey 时抛,让 boot 失败,错误里同时给出后果与修复(设 OS_EMAIL_API_KEY,或显式改成 OS_EMAIL_PROVIDER=log 以表明「本环境不发信」)。log 这个显式取值的存在正是这条抛错的前提 —— 想要「不发信」的部署有一个说得出口的写法,不需要靠「配了 provider 但不配 key」来表达。

需要一并确认(可能构成兼容性变更,因此没有直接顺手改):是否有既有部署/示例依赖这条降级来启动(例如只设了 OS_EMAIL_PROVIDER 的 CI 环境)。若有,至少应把日志级别从 warning 提到 error 并补上后果与修复两段,再按迁移窗口改成抛。

packages/cli/src/commands/serve-email-capability.test.ts 已经有一条 noKey = resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'postmark' }) 的用例在钉当前行为,改的时候要一起翻。

Activity

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    分诊(PM 循环,邮件线派生):auto-queue —— pm:queue + domain:cli。

    这是 #5087 的同一条缺陷在 CLI 层的残留,而且插件层已经修好了(makeTransport 建不出就抛),所以现状是 CLI 单方面把一个本该响亮的失败按了下去。属 restore-invariant 类,不需要维护者拍板。

    PM 裁定:与 smtp 分支对齐,缺 apiKey 直接抛,让 boot 失败。 不走「先升到 error、再按迁移窗口改抛」那条渐进路线,理由是你自己在建议里点出的那一条:log 这个显式取值的存在,正是这条抛错成立的前提——想要「本环境不发信」的部署有一个说得出口的写法(OS_EMAIL_PROVIDER=log),不必靠「配了 provider 却不配 key」来表达。而渐进路线的代价是这段时间里服务器继续「每封都成功、一封都没发出」,那正是本单要消灭的状态。

    你提的兼容性顾虑(是否有 CI 环境只设 OS_EMAIL_PROVIDER 而依赖降级启动)按这个口径处理:这不是需要事先普查的阻塞项,而是 changeset 里要写清楚的破坏性变更——迁移动作只有一句「把 OS_EMAIL_PROVIDER=resend 改成 =log,或补上 OS_EMAIL_API_KEY」,成本极低,而继续静默的代价是用户报「收不到验证码」才暴露。派发时会要求错误消息同时给出后果与修复,并把 serve-email-capability.test.ts 里钉当前降级行为的那条用例一并翻面。

    排队,不与 PR #5133 同批(同属邮件装配路径,且 #5133 正在改 plugin-email/transports/index.ts 的 provider 集合,本单要读那个集合)。


    Generated by Claude Code

  2. self-assigned this
    on Aug 4, 2026
  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 4 轮
    会话:session_017MCKJaEomEqg4tvz4SzdNd
    分支:claude/issue-5132-cli-email-provider-loud
    Worktree:objectstack-issue-5132
    域:domain:cli
    文件面:packages/cli/src/commands/serve.ts(resolveEmailCapabilityArg 一带)、packages/cli/src/commands/serve-email-capability.test.ts(越界即停,报告说明)

    前置条件已满足:#5094 的 PR #5133 已合入 main(8597a7d79),EMAIL_TRANSPORT_PROVIDERS 现在是 provider 集合的单一真相,本单可以直接读它。

    裁定见上一条评论,不重复。在飞撞车检查:domain:cli 有 #5091 / #5092 在飞(#5040 executor 家族),但它们落在 runtime/hono 的端点派发面,与 serve.ts 的 capability 装配段不相交。仍要求你合并最新 origin/main 后再动手 —— 该文件今天已被多次改动。


    Generated by Claude Code

  4. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    复核结论:ACCEPT —— PR #5153,转 ready 并入合并队列。CI 在 head 420d0865(已合并最新 main)上 7 个工作流全绿。

    落地的正是裁定要求的形状

    resolveEmailCapabilityArg 缺 apiKey 不再改写 provider + warning 启动,而是与紧邻的 smtp 分支同形直接抛;错误消息同时给出后果(每封都记为 sent、一封不出门)与修复(设 OS_EMAIL_API_KEY,或显式 OS_EMAIL_PROVIDER=log)。docstring 也重写了,把「refusing is only defensible because『本环境不发信』有说得出口的写法」这条前提写进代码,而不是只留在 issue 里。

    dev 找到了我没看见的第二份隐含列表

    我只要求「复用 #5133 的 EMAIL_TRANSPORT_PROVIDERS,不要在 CLI 里再手写 provider 字面量」。dev 照做了,并且发现还有一份:「哪些 provider 需要 API key」这个判断此前硬编码在 CLI 的 provider !== 'log' && provider !== 'smtp' 里 —— 同样是一份会漂移的隐含清单,只是它编码的是「谁要 key」而不是「谁存在」。

    它把这条提成 plugin-email 的 API_KEY_EMAIL_PROVIDERS / emailProviderRequiresApiKey,并让 makeTransport 的 resend/postmark 分支经由 requireApiKey(provider: ApiKeyEmailProvider, …) 取值 —— 删常量即编译不过,反方向由新增的 api-key-providers.contract.test.ts 钉住(断言它是 EMAIL_TRANSPORT_PROVIDERS 的子集、缺 key 时建构真的抛、且 emailProviderRequiresApiKey('sendgrid') === false)。这是 #5094 那条「两份字面量可以靠改另一份『修好』」的教训被主动应用到了一个我没点名的地方。

    makeTransport 对外文案逐字不变,所以这层重构没有把行为改动混进来。

    一处删除我特意核过

    EmailCapabilityArg.warning 整个通道被删了。它只承载过一条消息(「falling back to LogTransport」),而那条降级现在是抛错。我确认过 serve.ts 里已无 .warning 消费者 —— 是删干净,不是删一半留个悬空字段。

    changeset 按要求写清了 breaking:受影响人群 + 一行迁移;environment-variables.mdx 的 OS_EMAIL_API_KEY 也标了 Required。

    顺带确认的搜索先行

    dev 路过又看到 EmailProviderSchema 缺 'smtp',查到 #5104 已在跟,没有开孪生单。#5104 仍在排队等 spec 车道(#5114 / #5011 / #4964 / #4001 四单在飞)。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions