维护者已拍板要队列(2026-08-04,PM 会话内决策)。本单是接线,不是造基建 —— 三块部件都已存在,缺的是把它们连起来并给出显式开关。
投递有三层,前两层同步、第三层无人使用:
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;
- 队列 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 累计)。
维护者已拍板要队列(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 在案;