Skip to content

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

Closed

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. added
    enhancementNew feature or request
    pm:queueReady for the PM dispatch loop
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 4, 2026
  2. self-assigned this
    on Aug 4, 2026
  3. 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

  4. 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

Labels

domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedenhancementNew feature or requestpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions