Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/email-durable-queue-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
"@objectstack/plugin-email": minor
"@objectstack/service-settings": minor
"@objectstack/cli": minor
---

feat(plugin-email): durable email delivery through `sys_job_queue`, opt-in (#5160)

`IEmailService.send()` has always delivered **inline**: the SMTP session ran
inside the caller's `await`, and `EmailService`'s retry loop lived in the same
process — so a crash between the attempt and the retry dropped the message with
no trace beyond a `sys_email` row stuck at `queued`. The pieces for a durable
path all existed (`sys_job_queue`, the `DbQueueAdapter`, an `email.send.async`
subscriber) but nothing in the repo ever published to that topic.

**New: `queueDelivery`.** With it on, `send()` persists the `sys_email` row,
publishes an `email.send.async` job **referencing that row**, and returns
`{ status: 'queued' }` immediately. A worker delivers the row and finalizes it
in place (`sent` + `message_id`, or `failed` + `error`); the queue retries with
exponential backoff (1s → 5min cap) and dead-letters the job when the attempts
run out, so a restart resumes delivery instead of losing it. The `'queued'`
status was already in `EmailDeliveryStatus` — no spec change.

Three ways to turn it on, all default-off:

- `new EmailServicePlugin({ queueDelivery: true })`
- `OS_EMAIL_QUEUE_ENABLED=true` (or `config.email.queueDelivery`) on `os serve`
- Settings → Mail → **Durable queue delivery**, hot-applied without a restart

**One retry budget, not two.** `retries` keeps its meaning — total attempts are
`retries + 1` in both modes. Inline it drives the in-process loop; queued it
becomes the queue's `maxAttempts` and the per-row loop is pinned to one attempt
per delivery. Turning the toggle on changes *where* a retry happens (durable,
backed off) and never *how many* happen, so the two layers cannot multiply.

**Fixed in the same change: the `email.send.async` subscriber inserted a new
`sys_email` row per delivery.** It called `send()` with the message, so a job
the queue retried five times left five rows — four permanently `failed`, none
carrying the real attempt count. It now delivers the referenced row via
`deliverPersistedRow`, so one message is one row and `attempt_count`
accumulates on it. Messages published in the old shape (a bare `SendEmailInput`)
are still accepted and delivered inline for a migration window.

Boundaries worth knowing before you switch it on:

- **"Send test email" always sends inline**, in every mode — the button has to
report the provider's own answer (`535 …`), and "queued" is exactly the
non-answer #5087 removed from it.
- **Messages with attachments or custom headers are delivered inline**, because
`sys_email` has no columns for them and a queued copy would arrive stripped.
Queueing them is tracked separately; this ships the loss-free behaviour.
- **A declaration that cannot be honoured fails the boot.** `queueDelivery: true`
from the constructor or `OS_EMAIL_QUEUE_ENABLED` with no durable queue
registered (or with `persist: false`) throws on `kernel:ready`, naming the
fix — the #5132 judgement, applied to durability. The **settings toggle** is
the opposite trade: it logs at `error` and keeps sending inline, because one
save must not stop the mail.
- The kernel's built-in in-memory `queue` fallback does **not** count as a
durable queue: it delivers synchronously with no retry or DLQ, so publishing
to it would report `queued` for a message nothing could ever recover. Mount
`@objectstack/service-queue` over an ObjectQL engine (the `queue` capability
does this on `os serve`) to get the `sys_job_queue`-backed adapter.

Leaving `queueDelivery` unset keeps today's behaviour byte for byte.
52 changes: 52 additions & 0 deletions packages/cli/src/commands/serve-email-capability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,58 @@ describe('resolveEmailCapabilityArg', () => {
.toThrow(/log \/ resend \/ postmark \/ smtp/);
});

// ── durable queue delivery (#5160) ───────────────────────────────────────

it('leaves queueDelivery unset when nothing declares it', () => {
// Absent, not `false`: the plugin's boot gate fires on an explicit `true`,
// and an option nobody wrote should not appear in what it is constructed
// with at all.
expect(resolveEmailCapabilityArg({}, {})).not.toHaveProperty('options.queueDelivery');
});

it('reads OS_EMAIL_QUEUE_ENABLED as the boolean feature flag it is', () => {
// `_ENABLED` + default-off is the Prime Directive #9 shape for a boolean
// flag; a bare `OS_EMAIL_QUEUE` would read as a config value (a queue
// name), which is the naming trap that rule exists to close.
for (const on of ['1', 'true', 'TRUE', 'yes', 'on']) {
expect(resolveEmailCapabilityArg({}, { OS_EMAIL_QUEUE_ENABLED: on }).options.queueDelivery, on)
.toBe(true);
}
for (const off of ['0', 'false', 'no', '']) {
expect(resolveEmailCapabilityArg({}, { OS_EMAIL_QUEUE_ENABLED: off }).options.queueDelivery, off)
.toBe(false);
}
});

it('accepts the same declaration from objectstack.config.ts, with env winning', () => {
expect(resolveEmailCapabilityArg({ queueDelivery: true }, {}).options.queueDelivery).toBe(true);
expect(
resolveEmailCapabilityArg({ queueDelivery: true }, { OS_EMAIL_QUEUE_ENABLED: 'false' })
.options.queueDelivery,
).toBe(false);
});

it('does not decide here whether the declaration can be honoured', () => {
// No kernel exists at this point, so no service registry can be read. The
// plugin asserts a durable queue on `kernel:ready` — reading a registry
// that is still filling and recording the verdict is the failure mode
// AGENTS.md names. All this function does is carry the declaration.
expect(() => resolveEmailCapabilityArg({}, {
OS_EMAIL_QUEUE_ENABLED: 'true', OS_EMAIL_PROVIDER: 'log',
})).not.toThrow();
});

it('does not add a second retry knob for the queue', () => {
// One "retry" concept, one config. `OS_EMAIL_RETRIES` drives the inline
// loop or becomes the queue's attempt budget — never both, so the two
// layers cannot multiply into 5x5 connections.
const { options } = resolveEmailCapabilityArg({}, {
OS_EMAIL_QUEUE_ENABLED: 'true', OS_EMAIL_RETRIES: '4',
});
expect(options).toMatchObject({ queueDelivery: true, retries: 4 });
expect(Object.keys(options).filter((k) => /retr|attempt/i.test(k))).toEqual(['retries']);
});

it('still derives the fallback from-address and template context', () => {
const { options } = resolveEmailCapabilityArg({}, { OS_APP_NAME: 'Acme CRM' }, 'ignored');
expect(options.defaultTemplateContext).toMatchObject({ appName: 'Acme CRM' });
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2862,6 +2862,11 @@ export interface EmailCapabilityArg {
* from `@objectstack/plugin-email` — the package that has to materialise the
* transport — rather than restated here. Two literals describing one vocabulary
* is how the settings dropdown and the transports drifted apart (#5094).
*
* `OS_EMAIL_QUEUE_ENABLED=true` (or `config.email.queueDelivery`) switches
* delivery from inline to the durable `sys_job_queue` path (#5160). It reuses
* `OS_EMAIL_RETRIES` as its attempt budget rather than adding a second retry
* knob — see `EmailServicePlugin.makeQueueDelivery`.
*/
export function resolveEmailCapabilityArg(
cfgEmail: Record<string, any> = {},
Expand All @@ -2882,6 +2887,15 @@ export function resolveEmailCapabilityArg(
}
}
const retries = env.OS_EMAIL_RETRIES ? Number(env.OS_EMAIL_RETRIES) : cfgEmail.retries;
// `OS_EMAIL_QUEUE_ENABLED` — a boolean feature flag, so `_ENABLED` and
// default-off (Prime Directive #9; a bare `OS_EMAIL_QUEUE` would read as a
// config value, e.g. a queue name). Whether the declaration can be HONOURED
// is not knowable here — no kernel exists yet — so the plugin asserts it on
// `kernel:ready`, where the service registry has settled, and fails the boot
// there if no durable queue showed up.
const queueDelivery = env.OS_EMAIL_QUEUE_ENABLED != null
? ['1', 'true', 'yes', 'on'].includes(String(env.OS_EMAIL_QUEUE_ENABLED).trim().toLowerCase())
: cfgEmail.queueDelivery;
const defaultTemplateContext = {
appName: env.OS_APP_NAME || cfgEmail.appName || configAppName || 'ObjectStack',
...(cfgEmail.defaultTemplateContext || {}),
Expand Down Expand Up @@ -2915,6 +2929,7 @@ export function resolveEmailCapabilityArg(
...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
defaultFrom,
...(retries != null && !Number.isNaN(retries) ? { retries } : {}),
...(queueDelivery != null ? { queueDelivery: !!queueDelivery } : {}),
defaultTemplateContext,
};

Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/plugin-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"nodemailer": "^9.0.3"
},
"devDependencies": {
"@objectstack/objectql": "workspace:*",
"@objectstack/service-queue": "workspace:*",
"@objectstack/service-settings": "workspace:*",
"@types/node": "^26.1.2",
"@types/nodemailer": "^8.0.1",
Expand Down
Loading
Loading