Repository navigation
plugin-email: 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_queue(重试+DLQ),可配置开关 #5160
Description
Activity
- addedenhancementNew feature or requestNew feature or requestand removed
on Aug 4, 2026 认领: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
复核结论: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/ CLIOS_EMAIL_QUEUE_ENABLED(PD#9 的_ENABLED形状,比派发词里的裸OS_EMAIL_QUEUE更对)/ 设置页 toggle 热生效,四语翻译齐。 - 重试收敛为一个预算:队列模式下
maxAttempts = retries + 1、行内循环钉死 1 次尝试,两层结构上不可能相乘;publish同时填maxAttempts与 legacyretries两个字段(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;既有测试文件零触碰,默认路径逐字节一致的验收成立。
衍生单(均已归位)
- plugin-email: sys_email 的 queued 行崩溃后永久滞留 —— 无任何轮询者,且 drain 钩子把失败 warn 掉 #5161(queued 行清扫,Blocked-by 本单,合入即派)、plugin-email/platform-objects: sys_email 加 headers_json + 有界 attachments_json —— 小附件与自定义 header 取得队列投递资格(#5172 一期) #5177(headers_json,排 plugin-email: sys_email 的 queued 行崩溃后永久滞留 —— 无任何轮询者,且 drain 钩子把失败 warn 掉 #5161 后)、plugin-email: sys_email 无附件/自定义 header 列,带附件的邮件拿不到队列投递的持久化保证(#5160 落地后) #5172(附件 storage 引用,维护者已拍板,排 plugin-email/platform-objects: sys_email 加 headers_json + 有界 attachments_json —— 小附件与自定义 header 取得队列投递资格(#5172 一期) #5177 后)、service-queue: completed 任务行无人清理 —— purge() 零生产调用方、sys_job_queue 未声明 retention,队列表只增不减 #5179(sys_job_queue completed 行清理,可与 plugin-email: sys_email 的 queued 行崩溃后永久滞留 —— 无任何轮询者,且 drain 钩子把失败 warn 掉 #5161 并批);
- plugin-email: EmailService.managedRowIds 泄漏 persistedId —— insert 返回不同 id 时永不清理 #5169(
finding持有)、core: kernel:ready 钩子抛错在 ObjectKernel 上失败 boot、在 LiteKernel 上被吞成一条 error 日志 —— 同一钩子两套失败语义 #5170(needs-user-decision:两内核kernel:ready失败语义相反,等维护者)。
Generated by Claude Code
- 三门齐备,默认全关:构造期
- added 6 commits that reference this issue
on Aug 4, 2026
维护者已拍板要队列(2026-08-04,PM 会话内决策)。本单是接线,不是造基建 —— 三块部件都已存在,缺的是把它们连起来并给出显式开关。
现状(#5087 落地后)
投递有三层,前两层同步、第三层无人使用:
SmtpTransport.send()同步跑完 SMTP 会话(超时默认 20s×3 段),调用方 await 里完成;EmailService.deliverNormalized进程内重试:retries + 1次、指数退避封顶 2000ms、全在同一个 await 里,进程一死重试即消失;sys_email.status的queued只是发送前一瞬的预备状态,failed是终态、无人再捞;EmailServicePlugin已订阅email.send.async(handler 里send()返回 failed 即抛、交队列重试/DLQ),service-queue的 DB adapter +sys_job_queue有完整测试(退避重试、maxAttempts 耗尽转dlq、listFailed)——但仓内没有任何生产者publish到这个主题,直接调IEmailService.send()永远走内联。目标
默认行为不变(内联投递);新增队列投递模式,开启后
send()的语义变为:sys_email行(status: 'queued')—— 与现在相同;publish('email.send.async', { rowId }, { maxAttempts, backoff }),引用已落的行;{ id, status: 'queued' }——'queued'已在EmailDeliveryStatus枚举里,因此不需要碰packages/spec;deliverPersistedRow(row)投递,把同一行推进到sent/failed;maxAttempts耗尽由队列转 DLQ。必须一并修的既有缺陷:现订阅者的重复插行
现在的
email.send.asynchandler 是svc.send(msg.data)—— 每次队列重试都会插一条新的sys_email行。改为按rowId→deliverPersistedRow,一信一行,attempt_count累计在同一行上。(兼容:老消息若带的是 sendInput 而非 rowId,按旧路径处理一个迁移窗口,由实现判断。)配置门(与 #5087 三门同构)
EmailServicePluginOptions.queueDelivery?: boolean(命名可议,与现有选项风格对齐);mail命名空间新增 toggle(保存热生效,走现有applyMailSettings路径);注意 fix(service-settings): 保存期强制 select 声明的 options —— 声明即强制 (#5131) #5151 已强制 select options,新增字段照 manifest 规范写;OS_EMAIL_QUEUE(OS_{DOMAIN}_{FEATURE}形状)是否值得加由实现判断,加了就要有测试。边界语义(PM 裁定,可反驳)
mail/test永远同步内联真发,不入队 —— 测试按钮必须当场回答 SMTP 服务器的真实答复(535 等),回「已入队」等于回到 plugin-email: 实现 SMTP transport —— 设置页可选 SMTP 但后端无实现 #5087 修掉的那种假信号。queue服务不存在:分两路,与本线既有判例一致 ——send()返回的status:'queued'是真话 —— 行在库里、任务在sys_job_queue里,进程死了 worker 还会捞起来。这正是与现状queued一瞬即逝的本质区别。不在本单
sys_email存量queued行的启动清扫 / drain 钩子吞错 —— 另单(同文件,串行在本单之后)。packages/spec('queued'已在枚举,无需动;车道另有四单在飞)、⛔content/docs/releases/。验收
send()立返queued,worker 异步推进同一行到sent(含message_id);SMTP 535 时队列按 backoff 重试、耗尽转 DLQ,sys_email行终态failed且 error 在案;