feat(plugin-email): 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_queue,三门可配置 (#5160) - #5173
Merged
Merged
Conversation
…ys_job_queue, opt-in (#5160) `IEmailService.send()` delivered inline only: the SMTP session ran inside the caller's await and the retry loop lived in the same process, so a crash between attempts dropped the message and left a `sys_email` row stuck at `queued`. The durable parts already existed — `sys_job_queue`, `DbQueueAdapter`, an `email.send.async` subscriber — but nothing ever published to that topic. Adds an opt-in queue mode. `send()` persists the row, publishes a job carrying that row's id, and returns `status: 'queued'`; a worker delivers the row and finalizes it in place, with the queue owning retry (exponential backoff, DLQ on exhaustion). Three default-off gates: `EmailServicePluginOptions.queueDelivery`, `OS_EMAIL_QUEUE_ENABLED`, and a `mail` settings toggle that hot-applies. One retry budget across both modes: `retries + 1` total attempts, driving the in-process loop inline or the queue's `maxAttempts` when queued, with the per-row loop pinned to one attempt per delivery so the layers cannot multiply. Also fixes the existing subscriber, which called `send()` and therefore inserted a NEW `sys_email` row on every redelivery — five queue retries left five rows, none with the real attempt count. It now delivers the referenced row through `deliverPersistedRow`, so one message is one row and `attempt_count` accumulates. The pre-#5160 payload shape (a bare `SendEmailInput`) is still accepted and delivered inline for a migration window. Boundaries: `mail/test` always sends inline (the button must report the provider's own reply); messages carrying attachments or custom headers are delivered inline because `sys_email` cannot store them; the kernel's in-memory `queue` fallback is not accepted as durable (no retry, no DLQ); a constructor / env declaration that cannot be honoured throws on `kernel:ready`, while the settings toggle degrades to inline delivery and logs at `error`. With `queueDelivery` unset the inline path is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
…il-queue-delivery
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 27 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 4, 2026
Closed
…ObjectQL's own dispatch (#4550) `check:engine-double-contract` flagged the fake engine in email-plugin.queue-delivery.test.ts: its `delete()` hand-mirrored the engine's guard (`if (opts?.where?.id == null) throw`) instead of routing through `assertEngineDeleteDispatch`. That mirror is looser than the engine on exactly the case a copy always drops — `where: { id: { $in: [...] } }` reads as an id and is a multi-row predicate, which the real engine rejects without `multi`. Routes through the producer's own predicate, same shape as the other 13 pinned doubles, and adds the `@objectstack/objectql` devDependency the import needs (the precedent in plugin-approvals / plugin-sharing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
os-zhuang
marked this pull request as ready for review
August 4, 2026 07:53
This was referenced Aug 4, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5160
接线单,不是造基建:三块部件都在,这里把它们连起来,并给出显式开关。默认行为不变。
做了什么
开启后
send()的语义变为:落sys_email行(queued)→publish('email.send.async', { rowId }, …)→ 立即返回{ id, status: 'queued' };worker 用deliverPersistedRow(row)把同一行推到sent/failed,maxAttempts耗尽由队列转 DLQ。'queued'已在EmailDeliveryStatus里,没碰packages/spec。三门,全部默认关:
new EmailServicePlugin({ queueDelivery: true })OS_EMAIL_QUEUE_ENABLED=true/config.email.queueDelivery顺带修掉工单点名的既有缺陷:订阅者原本是
svc.send(msg.data),每次队列重试插一条新sys_email行 —— 重试 5 次留 5 行,4 行永久failed,没有一行带真实尝试次数。改为按rowId→deliverPersistedRow,一信一行,attempt_count在同一行上累计。老载荷(裸SendEmailInput)仍接受,走内联投递作为迁移窗口。三处对裁定的调整 / 反驳(请重点看)
1. 行内重试与队列重试的关系 —— 收敛成一个预算
工单要我想清楚这条。结论:队列模式下队列独占重试,行内循环钉死为每次投递 1 次尝试;
maxAttempts由现有retries推导(retries + 1),不新开配置。理由:
retries今天的含义就是"总共尝试 retries + 1 次"。沿用它,翻开关改变的是重试发生在哪里(持久化、退避 1s→2s→4s 封顶 5min,而非进程内封顶 2s),而不是发生几次 —— 两层永远不可能相乘成 5×5=25 次连接。用例honours maxAttempts:1 so the queue owns the retry直接钉这条。也没有让
DbQueueAdapter的defaultMaxAttempts: 3兜底 —— 那会变成第二套没人写下来的配置;publish每次都显式传算出来的值。另外maxAttempts和 legacy 的retries两个字段都填且保持一致:MemoryQueueAdapter只读retries,只传maxAttempts会让它静默只试一次。两个字段都在QueuePublishOptions上声明着,这是把契约填完整,不是容忍方言。2. 构造/CLI 门抛错的时机:
kernel:ready,不是init()同意"显式声明兑现不了就响亮失败"(#5132 判例),但断言不能写在
init():Phase 1 期间队列插件可能还没注册,内核的 core fallback 更是在 Phase 1 之后才注入 —— 那正是 AGENTS.md「never record a verdict the boot can still contradict」点名的形状(#4772/#4771/#4769)。放在kernel:ready:注册表已定型,而ObjectKernel的context.trigger会把异常一路抛出bootstrap(),boot 确实失败。一处诚实说明:
LiteKernel用的是triggerHook,它 catch 住 handler 异常只记 error 日志。所以在 LiteKernel 上这个门降级成一条 error 而非 boot 失败。这不是本单能改的内核契约,已另立 #5170 记录这个不对称。3. "有没有
queue服务"不是正确的判据 —— 必须问"是不是持久化的"这条最要紧。
ObjectKernel对没挂队列插件的 boot 总会预注入一个内存 fallback(createMemoryQueue),它同步、不 await handler、无持久化、无重试、无 DLQ。裸判getService('queue')存在就 publish,等于让send()对一条谁也捞不回来的消息回答queued—— #5087 在 transport 层修掉的那个洞,在上一层原样重开。所以
resolveDurableQueue()读它自己挂出来的牌子(__serviceInfo.status === 'degraded',ADR-0076 D12),把它当作"没有队列"。用例throws when the only queue is the kernel in-memory fallback就是钉这一条。其他边界
mail/test永远内联真发:新增EmailService.sendInline(),mail/test与订阅者的 legacy 分支都走它。后者不只是风格问题 —— 队列模式下让 legacy 载荷走send()会把正在消费的消息重新 publish 出去,死循环。sys_email没有这两类列(rowToNormalized重建不出来),入队等于投出一封被剥掉附件的邮件。选不丢数据。代价(这类邮件拿不到持久化保证)已另立 plugin-email: sys_email 无附件/自定义 header 列,带附件的邮件拿不到队列投递的持久化保证(#5160 落地后) #5172。EmailService自己有一次性 error 兜底。source: 'default'的 toggle 不覆盖构造期声明(沿用本文件对provider已有的 selected/default 读法),否则一次无关字段的保存就能悄悄关掉部署声明的模式。验证
email-plugin.queue-delivery.test.ts跑的是真的DbQueueAdapter架在同时装sys_email和sys_job_queue的假引擎上,所以断言的是真实往返:SMTP 535 → 队列按退避重试 3 次 → 转dlq,全程sys_email只有 1 行、attempt_count累计到 3、error在案、listFailed()能捞到。桩队列证不了两半是否一致,而两半不一致正是这单要修的缺陷。默认路径逐字节一致:
queueDelivery未设时queue为undefined,sendInternal里那段if (queue)整块跳过,其余语句与改前逐行相同。现有用例一条断言都没改(diff 里email-service.test.ts/email-plugin.mail-settings.test.ts/send-template.test.ts未被触及),新增用例只做加法。不在本单
sys_email存量queued行的启动清扫 = #5161(串行在本单之后);packages/spec与content/docs/releases/未触碰。顺手记录、未在本 PR 修的三条:#5169(
managedRowIds泄漏persistedId,观察级)、#5170(两个内核对kernel:ready抛错语义相反)、#5172(sys_email无附件列)。🤖 Generated with Claude Code
https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
Generated by Claude Code