Skip to content

feat(email): drain queued sys_email rows so app inserts actually deliver#2002

Merged
os-zhuang merged 1 commit into
mainfrom
feat/email-drain-on-insert
Jun 17, 2026
Merged

feat(email): drain queued sys_email rows so app inserts actually deliver#2002
os-zhuang merged 1 commit into
mainfrom
feat/email-drain-on-insert

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Problem (verified empirically, not by reading specs)

An app that can only `api.write` — e.g. a sandboxed action body, which exposes no `api.email` and cannot reach the queue — has exactly one way to "send" mail: INSERT a `sys_email` row. But the transport (and the `queued → sent` transition) only ran inside `EmailService.send()`. A raw insert never invokes it, so the row sat at `status:'queued'` forever — declared-but-never-delivered, not even log-simulated.

This was caught while auditing a real consumer (HotCRM's `send_email` action): it raw-inserts a queued `sys_email` and writes a "completed Email" timeline activity — i.e. the UI claimed delivery that never happened.

Fix

Treat a `sys_email` row inserted as `status:'queued'` with no `message_id` as an outbox entry. A `sys_email` afterInsert hook delivers it through the live transport and finalizes the same row in place (`sent` + `message_id` + `sent_at`, or `failed` + `error`).

  • Delivery is deferred past the insert op so transport I/O never runs inside the insert's transaction, and the committed row is re-read + re-checked (`status==queued`) under system context before sending — idempotent against concurrent drains.
  • Rows that the service's own `send()` inserts are tagged managed (`EmailService.isServiceManaged`) and skipped by the hook, so they are delivered exactly once by `send()` — never double-sent.

Changes

  • `email-service.ts`: `managedRowIds` guard; extract `deliverNormalized()` shared by `send()` and the new `deliverPersistedRow()`; `rowToNormalized()` helper.
  • `email-plugin.ts`: register the `sys_email` afterInsert drain hook (own packageId, unregister-on-reinstall).
  • tests: +5 (managed-guard active at the insert moment; `deliverPersistedRow` delivers without inserting a 2nd row and fails cleanly on bodyless rows; `rowToNormalized` parse/throw).

Verification

  • Empirical (HotCRM, patched dist): raw `INSERT` of a queued `sys_email` → `sent` with a transport `message_id` (`<dev-…@objectstack.local>`) + `sent_at` within ~600ms. Before this change the identical insert stayed `queued` forever (`message_id:null`, `sent_at:null`).
  • Unit: `pnpm exec vitest run` → 20/20 in email-service.test.ts; full package 49→ still green.

🤖 Generated with Claude Code

Apps that can only `api.write` (e.g. sandboxed action bodies, which expose
no `api.email`) cannot reach the email service — the only thing they can do
is INSERT a `sys_email` row. Until now such rows sat at `status:'queued'`
forever: declared-but-never-delivered. The transport (and the queued→sent
transition) only ran inside `EmailService.send()`, which a raw insert never
invokes.

Treat a `sys_email` row inserted as `status:'queued'` with no `message_id`
as an OUTBOX entry: an afterInsert hook delivers it through the live
transport and finalizes the same row in place (sent + message_id + sent_at,
or failed + error). Delivery is deferred past the insert so transport I/O
never runs inside the insert's transaction, and the row is re-read + re-checked
under system context before sending (idempotent against concurrent drains).

Rows that the service's own `send()` inserts are tagged managed
(EmailService.isServiceManaged) and skipped by the hook, so they are
delivered exactly once by send() — never double-sent.

- email-service.ts: managedRowIds guard; extract deliverNormalized() shared
  by send() and the new deliverPersistedRow(); rowToNormalized() helper.
- email-plugin.ts: register the sys_email afterInsert drain hook.
- tests: managed-guard active at insert moment; deliverPersistedRow delivers
  without inserting a 2nd row + fails cleanly on bodyless rows; rowToNormalized.

Verified empirically in HotCRM: a raw INSERT of a queued sys_email now
transitions queued→sent with a transport message_id within ~600ms; before,
the identical insert stayed queued forever.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jun 17, 2026 4:37am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-email.

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

  • content/docs/concepts/packages.mdx (via @objectstack/plugin-email)
  • content/docs/guides/packages.mdx (via @objectstack/plugin-email)

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.

@os-zhuang
os-zhuang merged commit c854f92 into main Jun 17, 2026
14 checks passed
@os-zhuang
os-zhuang deleted the feat/email-drain-on-insert branch June 17, 2026 05:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant