Skip to content

feat(plugin-email): 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_queue,三门可配置 (#5160) - #5173

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-5160-email-queue-delivery
Aug 4, 2026
Merged

feat(plugin-email): 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_queue,三门可配置 (#5160)#5173
os-zhuang merged 3 commits into
mainfrom
claude/issue-5160-email-queue-delivery

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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 }) 抛错,boot 失败
CLI OS_EMAIL_QUEUE_ENABLED=true / config.email.queueDelivery 同上
设置页 Settings → Mail → Durable queue delivery(热生效) error 日志 + 内联回退继续发信

顺带修掉工单点名的既有缺陷:订阅者原本是 svc.send(msg.data),每次队列重试插一条新 sys_email —— 重试 5 次留 5 行,4 行永久 failed,没有一行带真实尝试次数。改为按 rowIddeliverPersistedRow,一信一行,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 直接钉这条。

也没有让 DbQueueAdapterdefaultMaxAttempts: 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:注册表已定型,而 ObjectKernelcontext.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 出去,死循环。
  • 带附件 / 自定义 header 的消息在队列模式下退回内联:sys_email 没有这两类列(rowToNormalized 重建不出来),入队等于投出一封被剥掉附件的邮件。选不丢数据。代价(这类邮件拿不到持久化保证)已另立 plugin-email: sys_email 无附件/自定义 header 列,带附件的邮件拿不到队列投递的持久化保证(#5160 落地后) #5172
  • 设置页的 error 只在保存时报,boot 首次 apply 不报 —— 同 §2 的理由,boot 期的"没有队列"还可能被这次 boot 自己推翻。真到发信时降级了,EmailService 自己有一次性 error 兜底。
  • source: 'default' 的 toggle 不覆盖构造期声明(沿用本文件对 provider 已有的 selected/default 读法),否则一次无关字段的保存就能悄悄关掉部署声明的模式。

验证

pnpm --filter @objectstack/plugin-email --filter @objectstack/service-settings exec vitest run
  service-settings  15 files / 224 tests  passed
  plugin-email      11 files / 170 tests  passed   (新增 16 + 21)

pnpm --filter @objectstack/cli exec vitest run src/commands/serve-email-capability.test.ts
  17 tests passed   (新增 5)

pnpm --filter plugin-email --filter cli typecheck            Done
pnpm check:durability-log-level        12 seam(s), all loud or rethrowing
pnpm check:startup-registry-verdict    40 seam(s), none recording a contradictable verdict
pnpm check:init-service-contract / check:service-providers   ✓

email-plugin.queue-delivery.test.ts 跑的是真的 DbQueueAdapter 架在同时装 sys_emailsys_job_queue 的假引擎上,所以断言的是真实往返:SMTP 535 → 队列按退避重试 3 次 → 转 dlq,全程 sys_email 只有 1 行attempt_count 累计到 3、error 在案、listFailed() 能捞到。桩队列证不了两半是否一致,而两半不一致正是这单要修的缺陷。

默认路径逐字节一致:queueDelivery 未设时 queueundefined,sendInternal 里那段 if (queue) 整块跳过,其余语句与改前逐行相同。现有用例一条断言都没改(diff 里 email-service.test.ts / email-plugin.mail-settings.test.ts / send-template.test.ts 未被触及),新增用例只做加法。

不在本单

sys_email 存量 queued 行的启动清扫 = #5161(串行在本单之后);packages/speccontent/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

claude added 2 commits August 4, 2026 07:27
…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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 7:38am

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/plugin-email, @objectstack/service-settings.

27 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/automation/flows.mdx (via @objectstack/plugin-email)
  • content/docs/automation/hook-bodies.mdx (via packages/cli)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/plugin-email)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/validating-metadata.mdx (via packages/cli)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services/service-settings)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, packages/services/service-settings)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services/service-settings)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/plugins/index.mdx (via @objectstack/cli)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/plugin-email, @objectstack/service-settings)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/plugin-email)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/service-settings)
  • content/docs/releases/v16.mdx (via @objectstack/cli)
  • content/docs/releases/v17.mdx (via @objectstack/cli)
  • content/docs/releases/v9.mdx (via @objectstack/service-settings)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…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
os-zhuang marked this pull request as ready for review August 4, 2026 07:53
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 9c4f174 Aug 4, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5160-email-queue-delivery branch August 4, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants