Skip to content

plugin-email: 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_queue(重试+DLQ),可配置开关 #5160

Description

@os-zhuang

维护者已拍板要队列(2026-08-04,PM 会话内决策)。本单是接线,不是造基建 —— 三块部件都已存在,缺的是把它们连起来并给出显式开关。

现状(#5087 落地后)

投递有三层,前两层同步、第三层无人使用:

  1. SmtpTransport.send() 同步跑完 SMTP 会话(超时默认 20s×3 段),调用方 await 里完成;
  2. EmailService.deliverNormalized 进程内重试:retries + 1 次、指数退避封顶 2000ms、全在同一个 await 里,进程一死重试即消失;sys_email.status 的 queued 只是发送前一瞬的预备状态,failed 是终态、无人再捞;
  3. EmailServicePlugin 已订阅 email.send.async(handler 里 send() 返回 failed 即抛、交队列重试/DLQ),service-queue 的 DB adapter + sys_job_queue 有完整测试(退避重试、maxAttempts 耗尽转 dlq、listFailed)——但仓内没有任何生产者 publish 到这个主题,直接调 IEmailService.send() 永远走内联。

目标

默认行为不变(内联投递);新增队列投递模式,开启后 send() 的语义变为:

  1. 先落 sys_email 行(status: 'queued')—— 与现在相同;
  2. publish('email.send.async', { rowId }, { maxAttempts, backoff }),引用已落的行;
  3. 立即返回 { id, status: 'queued' } —— 'queued' 已在 EmailDeliveryStatus 枚举里,因此不需要碰 packages/spec;
  4. 队列 worker 用 deliverPersistedRow(row) 投递,把同一行推进到 sent / failed;maxAttempts 耗尽由队列转 DLQ。

必须一并修的既有缺陷:现订阅者的重复插行

现在的 email.send.async handler 是 svc.send(msg.data) —— 每次队列重试都会插一条新的 sys_email 行。改为按 rowId → deliverPersistedRow,一信一行,attempt_count 累计在同一行上。(兼容:老消息若带的是 sendInput 而非 rowId,按旧路径处理一个迁移窗口,由实现判断。)

配置门(与 #5087 三门同构)

边界语义(PM 裁定,可反驳)

不在本单

  • sys_email 存量 queued 行的启动清扫 / drain 钩子吞错 —— 另单(同文件,串行在本单之后)。
  • ⛔ packages/spec('queued' 已在枚举,无需动;车道另有四单在飞)、⛔ content/docs/releases/。

验收

  • 开启队列模式:send() 立返 queued,worker 异步推进同一行到 sent(含 message_id);SMTP 535 时队列按 backoff 重试、耗尽转 DLQ,sys_email 行终态 failed 且 error 在案;
  • 默认(未开启):行为与今天逐字节一致,现有 236+ 用例不改一条断言;
  • 设置页开关热生效;队列服务缺失的两路语义各有用例;
  • 现订阅者重复插行的缺陷有回归测试钉死(同一消息重试 N 次 = 1 行,attempt_count 累计)。

Activity

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

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 5 轮
    会话:session_017MCKJaEomEqg4tvz4SzdNd
    分支:claude/issue-5160-email-queue-delivery
    Worktree:objectstack-issue-5160
    域:domain:services
    文件面:packages/plugins/plugin-email/src/(email-plugin.ts、email-service.ts、测试)、packages/services/service-settings/src/manifests/mail.manifest.ts + translations、必要时 packages/cli/src/commands/serve.ts 的 email capability 段(越界即停,报告说明)

    维护者拍板依据:会话内直接答复「我要的 email 队列」。#5161(queued 行清扫)同文件严格串行,Blocked-by 本单,下一轮派。在飞撞车检查:plugin-email / service-settings 当前无其它单在飞(#5153 刚合入);#5091/#5092 在 runtime/hono 端点面,不相交。


    Generated by Claude Code

  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    复核结论:ACCEPT —— PR #5173,转 ready 并入合并队列。修复 head b169f217 上 7 个工作流全绿。

    中途一次门禁红,处置记录

    首个 head 上 check:engine-double-contract 红:新测试的假引擎 delete() 手抄了守卫而没有路由过 assertEngineDeleteDispatch。让原 dev 带上下文修复(b169f217):按门禁第一条路改造,与其余 13 个 pinned fake 同形,没有走 baseline 豁免。值得记的是手抄守卫确实有真洞——where: { id: { $in: [...] } } 看着像单行 id、实为多行谓词,真引擎在无 multi 时拒绝,手抄版放行;门禁抓的不是形式主义,#4434 的教训在这里又一次兑现。注意最初事件报的是「ESLint 失败」——那个 job 串跑多个门禁,不取完整日志就会去修根本没坏的 lint。

    实现复核(对 diff 独立核过,非采信报告)

    • 三门齐备,默认全关:构造期 queueDelivery / CLI OS_EMAIL_QUEUE_ENABLED(PD#9 的 _ENABLED 形状,比派发词里的裸 OS_EMAIL_QUEUE 更对)/ 设置页 toggle 热生效,四语翻译齐。
    • 重试收敛为一个预算:队列模式下 maxAttempts = retries + 1、行内循环钉死 1 次尝试,两层结构上不可能相乘;publish 同时填 maxAttempts 与 legacy retries 两个字段(MemoryQueueAdapter 只认后者)。
    • 三处对裁定的调整全部成立且更好:抛错时机从 init() 挪到 kernel:ready(注册表定型后再裁决);「有没有 queue」改为「是不是持久化的」——内核的内存 fallback 自挂 __serviceInfo.status === 'degraded' 牌子,resolveDurableQueue 读牌子把它当作没有,否则 send() 会对一条谁也捞不回来的消息回答 queued;顺带修掉订阅者每次重试插新行的既有缺陷(一信一行,attempt_count 累计)。
    • 订阅者多两层我没要求的防御:重复投递幂等守卫(sent/message_id 直接返回,租约过期/双 worker 不发两遍);被删行不烧重试预算、直接 error 说明后果与修复。
    • 端到端用真队列:测试架真 DbQueueAdapter 于同持 sys_email + sys_job_queue 的假引擎,断言 535 → 3 次退避重试 → DLQ、全程 1 行、attempt 1→2→3;既有测试文件零触碰,默认路径逐字节一致的验收成立。

    衍生单(均已归位)


    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