Skip to content

Commit 33228c5

Browse files
fix(plugin-email): wire sys_email persistence + the template loader in init, so a boot-time verification send stops throwing TEMPLATE_NOT_FOUND (#24113)
Fixes #23946 Clause-②: no ## What was wrong On a boot with email verification required, `objectstack dev --seed-admin` (the default) logged this once, before the server was ready: ``` ERROR [Better Auth]: Failed to run background task: Error: TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService ``` That verification send also left no `sys_email` row. ## Premise, checked on `origin/main` (`8a83cebc15`) before any change - Reproduced once on a fresh DB, from `examples/app-showcase`: `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=true`, `objectstack dev --ui --seed-admin -p PORT -d file:FRESH/data.db`. - The boot printed the ERROR line exactly once. - Afterwards the DB had **0** `sys_email` rows. - The seeded admin's row had `email_verified = 1`. - The order, as the stack trace shows it: 1. `AuthPlugin.maybeSeedDevAdmin` calls `signUpEmail`. 2. better-auth calls `runInBackgroundOrAwait`, and with no background handler configured it **awaits** `sendVerificationEmail`. 3. That reaches `EmailService.sendTemplate`, then `resolveAndRenderTemplate`, which throws (`email-service.ts:1315`). - Why the loader was missing at that point: - The seed runs in plugin-auth's own `kernel:ready` hook. - The served composition starts `Auth` ahead of `EmailServicePlugin` (boot plugin list: `…, Auth, …, SettingsServicePlugin, EmailServicePlugin, …`). The kernel runs `kernel:ready` handlers one at a time, in the order the plugins started. - `EmailServicePlugin` installed persistence and the loader (`setPersistence` / `setTemplateLoader`) only inside **its own** `kernel:ready` hook. So the seed's send reached a service that had neither. - The card says `packages/cli/src/commands/dev.ts` "signs the admin up in-process". In fact `dev.ts` only forwards `OS_SEED_ADMIN`. The sign-up runs in plugin-auth: `AuthPlugin.maybeSeedDevAdmin`, a `kernel:ready` hook. ## The fix: option (a), wire persistence + the template loader in `init` `EmailServicePlugin.init` now resolves the data engine (`objectql`, else `data`) and installs `sys_email` persistence and the registry template loader. It does this before it registers the `email` service. The wiring itself moved unchanged into a `wireEngine` method that runs once per engine. The `kernel:ready` hook calls the same method, which wires only a host whose engine registered after `init`, so `init` records nothing when the engine is absent. Everything the two read is available at `init`. Each point was checked in the code, not assumed: - `objectql` is registered in `ObjectQLPlugin.init` (`packages/objectql/src/plugin.ts:645`). ObjectQL is a hard `dependencies` entry of this plugin, so it inits first. - `engine.registry` is a field set when the engine is constructed (`packages/objectql/src/engine.ts:4448`). - The loader resolves `protocol` on every load, not at wiring time. - Persistence calls `engine.insert` / `engine.update` only when a message is sent. The live boot below shows that in the seed's window the row lands. **Why not (b), holding the send until the plugin's own `kernel:ready`.** The early send in this boot is awaited inside AuthPlugin's `kernel:ready` hook, by better-auth and then by the seed. The kernel runs this plugin's `kernel:ready` handler only after that hook returns. A hold that waits would therefore block until its bounded wait ran out, on every verification-armed dev boot, and then still not send. A hold that does not wait would answer `queued` with no row behind it. A crash would lose that message, and the resend path would lose its error. Option (a) needs neither, because nothing the loader reads arrives later than `init`. **The card's other option (the dev seed not firing `sendOnSignUp`) is now moot.** Measured below: the send is accounted for, and the ERROR is gone. As noted above, that option would land in plugin-auth, not in `packages/cli`. The log level is not lowered and no error is swallowed. A missing template still throws `TEMPLATE_NOT_FOUND: NAME (locale=…)`, as before. ## Pin and ablation The pin is in `email-plugin.outbox-sweep.test.ts`. It reuses that file's existing engine double, so the engine-double ledger does not change. It boots a real `LiteKernel` (the same `resolvePluginOrder` as `ObjectKernel`) with three plugins, registered in this order: 1. ObjectQL's slot: the engine, the manifest door, and a `protocol` that serves the templates the manifest door was handed. 2. A stand-in for plugin-auth's seed. Like `AuthPlugin`, it depends only on ObjectQL. From its own `kernel:ready` it sends `auth.verify_email` with the same arguments plugin-auth uses. 3. `EmailServicePlugin`. What the pin asserts: - **Positive control.** When the send runs, the email plugin's outbox drain hook is not installed yet (0). After boot it is (1). - No error is thrown. - The send answers `status: sent`. - Exactly one `sys_email` row exists, at `sent`, with the rendered subject `Verify your Acme email address`, `related_object: sys_user` and the expected `related_id`. - The transport was called exactly once, so the boot sweep that runs afterwards does not send it again. Ablation used `node scripts/ablation-replace.mjs` to delete the `init` call `if (engine) this.wireEngine(ctx, engine);`. The fix was committed first, so HEAD holds it. - The anchor went from 1 to 0 and the marker from 0 to 1. Blob `8e8ca0e6d6a6` became `855acf5ca71b`. - The pin went **red** with `expected Error: TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService to be undefined`. The other 13 tests in the file stayed green. - Restore: blob is back to `8e8ca0e6d6a6`, equal to HEAD, `git diff HEAD` is empty, and `git status` is clean. - The pin imports `./email-plugin.js` relatively, so vitest reads `src/` and no `dist/` rebuild leg applied. ## Live reading: one verification-armed boot on the fixed build This used the same command on a fresh DB, after `pnpm --filter @objectstack/plugin-email build`. - The boot log has 0 `ERROR` lines, 0 `TEMPLATE_NOT_FOUND`, 0 `Failed to run background task`, and 1 `Server is ready`. The `Dev admin … seeded on empty DB` banner is present. - The DB has 1 `sys_email` row: `status: sent`, subject `Verify your ObjectStack email address`, `related_object: sys_user`, and `related_id` equal to the seeded admin's `sys_user.id`. - The seeded admin is also `email_verified = 1`. - So the send happens and is persisted. It went through the boot's `LogTransport`, because no mail transport is configured in dev, so it was logged rather than delivered. The seed then stamps the admin verified. ## Tests - `pnpm --filter @objectstack/plugin-email test`: 34 files, 552 tests passed. - `pnpm --filter @objectstack/plugin-email typecheck`: `tsc --noEmit` passed. `check:test-typecheck` reported OK with 0 errors. `tsc -p tsconfig.test.json --listFiles` includes the edited test file. ## Gates (local subset, at final head `404db56cca`) The branch merges `origin/main` at `be076dad28`. The incoming commits touch the hono adapter, a showcase report and an ADR note, none of which overlap plugin-email. After the merge I rebuilt `@objectstack/hono`. - I derived the commands with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `404db56cca`. That gave 64 commands (30 `pnpm`, 34 direct `node`), the same list as before the merge. - All 64 were run, each exit code captured before any pipe, and every one exited 0. - Reconciliation with `--ran`: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. - Among the 64: `check:engine-double-contract`, `check:nul-bytes`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:durability-log-level`, `check:plugin-teardown-shape`, `check:published-files`, `check:dual-build-cjs-loads` and `check:type-check-coverage`. - An earlier run of the same 64, at the pre-merge head `d879052e5b`, was also all 0. In that run `check:dual-build-cjs-loads` first exited 3 with `PREREQUISITE NOT MET`, because eight packages had no `dist/`. It went green once they were built. - I did not run the CI-owned whole-repo scans or `test:repo` locally. They belong to this PR's CI check-runs. ## Acceptance notes - **A send in this window uses the transport and sender the boot was configured with.** The `mail` settings namespace (Settings, then Mail) is still applied at this plugin's own `kernel:ready`, which comes after. On a deployment whose only SMTP configuration lives on the settings page, such a send would go through the constructor transport. Two qualifications: - A plain `send()` in the same window already behaved this way before this change, with no row at all. - The only sender measured in the window is plugin-auth's dev seed, which is development-only and stamps the admin verified right after. - No production sender in the window was measured, so nothing was filed. Carrier: none. - **A deployment that declares queue delivery (`OS_EMAIL_QUEUE_ENABLED`)** still delivers a send in this window inline, because the delivery mode is applied at `kernel:ready`. This is unchanged, except that the send now leaves its row. - **A send during another plugin's `init` / `start`, before ObjectQL connects its drivers,** would now attempt the `sys_email` insert. That insert fails non-fatally (the existing `warn`) and the mail still goes out inline. No such sender was measured. - **Out of scope for this PR:** the card's option 1, the dev seed not firing `sendOnSignUp`. It is unchanged, and moot as described above. - Labels: the dispatch named none and the diff ships a changeset, so no label was written. --- _Generated by [Claude Code](https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c40a0dd commit 33228c5

3 files changed

Lines changed: 231 additions & 61 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/plugin-email": patch
3+
---
4+
5+
Clause-②: no
6+
7+
fix(plugin-email): a template send issued before `EmailServicePlugin`'s own `kernel:ready` renders, persists its `sys_email` row and delivers, instead of throwing `TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService`
8+
9+
`EmailServicePlugin` now wires `sys_email` persistence and the registry template loader in `init`, before it registers the `email` service. Before this, both were wired in the plugin's own `kernel:ready` hook, and a plugin that starts earlier runs its `kernel:ready` hooks first. The dev admin seed (`objectstack dev --seed-admin`, the default) signs its account up from one of those hooks, so on a boot with email verification required the seed's verification mail failed. Each such boot logged `ERROR [Better Auth]: Failed to run background task: Error: TEMPLATE_NOT_FOUND …` and left no `sys_email` row. That send now goes through the transport and default sender the boot was configured with. The `mail` settings namespace is still applied at `kernel:ready`. Sends made after boot behave as before.

‎packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import { describe, it, expect, vi, beforeEach } from 'vitest';
1616
import { assertEngineDeleteDispatch } from '@objectstack/objectql';
1717
import { DbQueueAdapter } from '@objectstack/service-queue';
18+
import { LiteKernel } from '@objectstack/core';
19+
import type { Plugin, PluginContext } from '@objectstack/core';
1820
import { EmailServicePlugin } from './email-plugin.js';
1921
import { EmailService, EMAIL_SEND_QUEUE } from './email-service.js';
2022
import { encodeAttachmentsForRow, encodeHeadersForRow } from './sys-email-payload.js';
@@ -493,3 +495,113 @@ describe('a datasource that re-keys a sys_email insert', () => {
493495
expect(h.sysEmail()[0]).toMatchObject({ id: 'db-pk-7', status: 'sent' });
494496
});
495497
});
498+
499+
// ── a send issued before this plugin's own kernel:ready (#23946) ────────────
500+
501+
/**
502+
* Registers what the email plugin resolves at `init`, in ObjectQL's slot (its
503+
* hard dependency): this file's engine as `objectql`, the manifest door, and a
504+
* `protocol` whose layered `email_template` list serves exactly what that door
505+
* was handed — the email plugin's own code-declared built-ins.
506+
*/
507+
class EngineFixturePlugin implements Plugin {
508+
name = 'com.objectstack.engine.objectql';
509+
type = 'standard' as const;
510+
version = '1.0.0';
511+
providesServices = ['objectql', 'manifest', 'protocol'];
512+
readonly engine = fakeEngine();
513+
private readonly manifests: Array<Record<string, any>> = [];
514+
init(ctx: PluginContext): void {
515+
ctx.registerService('objectql', this.engine);
516+
ctx.registerService('manifest', { register: (m: Record<string, any>) => { this.manifests.push(m); } });
517+
ctx.registerService('protocol', {
518+
getMetaItemsForExecution: async ({ type }: { type: string }) => ({
519+
items: type === 'email_template' ? this.manifests.flatMap((m) => m.emailTemplates ?? []) : [],
520+
}),
521+
});
522+
}
523+
}
524+
525+
/**
526+
* Stands in for plugin-auth's dev admin seed: a plugin that depends on ObjectQL
527+
* only (as `AuthPlugin` does), is registered ahead of the email plugin (as the
528+
* served composition orders them), and from its own `kernel:ready` hook makes
529+
* the verification send plugin-auth hands better-auth. A throw is caught and
530+
* kept — better-auth's `runInBackgroundOrAwait` logs it and swallows it.
531+
*/
532+
class EarlySenderPlugin implements Plugin {
533+
name = 'test.early-verification-sender';
534+
type = 'standard' as const;
535+
version = '1.0.0';
536+
dependencies = ['com.objectstack.engine.objectql'];
537+
readonly seen: { drainHooksAtSend?: number; result?: { id: string; status: string }; error?: unknown } = {};
538+
constructor(private readonly engine: ReturnType<typeof fakeEngine>) {}
539+
init(): void { /* nothing to register */ }
540+
start(ctx: PluginContext): void {
541+
ctx.hook('kernel:ready', async () => {
542+
// The email plugin installs its outbox drain from ITS kernel:ready, so
543+
// "none yet" is the measured proof that this send runs ahead of it.
544+
this.seen.drainHooksAtSend = this.engine.afterInsertHooks().length;
545+
try {
546+
const email = ctx.getService<EmailService>('email');
547+
this.seen.result = await email.sendTemplate({
548+
template: 'auth.verify_email',
549+
to: { address: 'admin@example.test', name: 'Dev Admin' },
550+
data: {
551+
user: { name: 'Dev Admin', email: 'admin@example.test', id: 'usr_seed' },
552+
verificationUrl: 'https://example.test/verify?token=t1',
553+
token: 't1',
554+
expiresInMinutes: 60,
555+
appName: 'Acme',
556+
},
557+
relatedObject: 'sys_user',
558+
relatedId: 'usr_seed',
559+
});
560+
} catch (err) {
561+
this.seen.error = err;
562+
}
563+
});
564+
}
565+
}
566+
567+
describe('a template send from an earlier-starting plugin\'s kernel:ready (#23946)', () => {
568+
it('renders, persists its sys_email row and delivers once — no TEMPLATE_NOT_FOUND', async () => {
569+
const transport = { send: vi.fn(async (_m: Record<string, any>) => ({ messageId: '<early@x>' })) };
570+
const kernel = new LiteKernel({ logger: { level: 'error' } });
571+
const fixture = new EngineFixturePlugin();
572+
const sender = new EarlySenderPlugin(fixture.engine);
573+
const plugin = new EmailServicePlugin({ transport, defaultFrom: { address: 'no-reply@example.test' } });
574+
kernel.use(fixture);
575+
kernel.use(sender);
576+
kernel.use(plugin);
577+
await kernel.bootstrap();
578+
await plugin.outboxSweepSettled;
579+
await flush();
580+
581+
// POSITIVE CONTROL — the send really ran before the email plugin's own
582+
// kernel:ready wiring, and that wiring did run afterwards.
583+
expect(sender.seen.drainHooksAtSend).toBe(0);
584+
expect(fixture.engine.afterInsertHooks()).toHaveLength(1);
585+
586+
// THE PIN. Before the fix this was `TEMPLATE_NOT_FOUND: no templateLoader
587+
// configured on EmailService`, and no row.
588+
expect(sender.seen.error).toBeUndefined();
589+
expect(sender.seen.result).toMatchObject({ status: 'sent' });
590+
const rows = fixture.engine.rows('sys_email');
591+
expect(rows).toHaveLength(1);
592+
expect(rows[0]).toMatchObject({
593+
id: sender.seen.result?.id,
594+
status: 'sent',
595+
message_id: '<early@x>',
596+
subject: 'Verify your Acme email address',
597+
to_addresses: 'Dev Admin <admin@example.test>',
598+
related_object: 'sys_user',
599+
related_id: 'usr_seed',
600+
});
601+
// Delivered once: the boot sweep that runs after it finds nothing stranded.
602+
expect(transport.send).toHaveBeenCalledTimes(1);
603+
expect(transport.send.mock.calls[0][0]).toMatchObject({ subject: 'Verify your Acme email address' });
604+
605+
await kernel.shutdown();
606+
});
607+
});

‎packages/plugins/plugin-email/src/email-plugin.ts‎

Lines changed: 110 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -249,17 +249,30 @@ function assertSendableDefaultFrom(from: EmailAddress | undefined): void {
249249
);
250250
}
251251

252+
/**
253+
* The data engine this plugin persists through and reads templates beside:
254+
* `objectql`, else `data`, else `null`. Read, never recorded — a `null` here
255+
* is not a verdict (see {@link EmailServicePlugin.init}).
256+
*/
257+
function resolveDataEngine(ctx: PluginContext): IDataEngine | null {
258+
try { return ctx.getService<IDataEngine>('objectql') ?? null; }
259+
catch {
260+
try { return ctx.getService<IDataEngine>('data') ?? null; } catch { return null; }
261+
}
262+
}
263+
252264
/**
253265
* EmailServicePlugin — registers the `email` service.
254266
*
255267
* Lifecycle:
256268
* - `init`: register sys_email + sys_email_template and the built-in auth
257269
* templates (code-declared `email_template` items) via manifest;
258270
* build transport (config → provider+apiKey → LogTransport fallback);
259-
* register a transport-only EmailService so dependents can resolve it.
260-
* - `start` (kernel:ready): wire ObjectQL-backed sys_email persistence
261-
* + the registry TemplateLoader, and close the `sys_email_template`
262-
* organization door.
271+
* wire ObjectQL-backed sys_email persistence + the registry
272+
* TemplateLoader; register the EmailService so dependents can resolve it.
273+
* - `start` (kernel:ready): bind the `mail` settings namespace, the
274+
* delivery mode, the outbox drain, the queue subscribers and the boot
275+
* sweep, and close the `sys_email_template` organization door.
263276
*/
264277
export class EmailServicePlugin implements Plugin {
265278
name = 'com.objectstack.service.email';
@@ -301,6 +314,8 @@ export class EmailServicePlugin implements Plugin {
301314

302315
private readonly options: EmailServicePluginOptions;
303316
private service?: EmailService;
317+
/** The engine {@link wireEngine} installed persistence + the template loader over, and what it built. */
318+
private engineWiring?: { engine: IDataEngine; persistence: EmailPersistence | undefined };
304319
/** Engine carrying the closed `sys_email_template` door — unbound in destroy(). */
305320
private boundEngine?: IDataEngine;
306321
/** SMTP transport currently in use, if any — closed in dispose(). */
@@ -383,25 +398,105 @@ export class EmailServicePlugin implements Plugin {
383398
);
384399
}
385400

386-
// Persistence + templateLoader are wired in `start` once the
387-
// ObjectQL engine is available; here we register the service
388-
// synchronously so dependents can resolve it.
389401
this.service = new EmailService({
390402
transport,
391403
defaultFrom: this.options.defaultFrom,
392404
retries: this.options.retries,
393405
defaultTemplateContext: this.options.defaultTemplateContext,
394406
logger: ctx.logger,
395407
});
408+
409+
// [#23946] Persistence + the template loader are wired HERE, before the
410+
// service is handed out, and not at this plugin's `kernel:ready`. A
411+
// plugin that STARTS before this one runs its own `kernel:ready` hooks
412+
// first, and those can send mail: plugin-auth's dev admin seed signs its
413+
// account up from one, and with verification armed better-auth awaits
414+
// `sendVerificationEmail` → `sendTemplate` inside that hook. Wired at
415+
// `kernel:ready`, that send met a service with no loader and threw
416+
// `TEMPLATE_NOT_FOUND` into better-auth's background-task log, with no
417+
// `sys_email` row. Holding the send until `kernel:ready` cannot work: the
418+
// kernel runs `kernel:ready` handlers one after another, so this plugin's
419+
// handler waits behind the very hook that is waiting on the send.
420+
//
421+
// Everything the two read is already there: ObjectQL is this plugin's
422+
// hard dependency and registers `objectql` in its own `init`, the
423+
// engine's registry is fixed at construction, and the loader resolves
424+
// `protocol` at every load. An engine registered only later is wired at
425+
// `kernel:ready` instead, so absence here records nothing.
426+
const engine = resolveDataEngine(ctx);
427+
if (engine) this.wireEngine(ctx, engine);
428+
396429
ctx.registerService('email', this.service);
397430
ctx.logger.info('EmailServicePlugin: email service registered');
398431
}
399432

433+
/**
434+
* Install ObjectQL-backed `sys_email` persistence and the registry
435+
* {@link TemplateLoader} on the service, once per engine. Returns the
436+
* persistence it installed (`undefined` under `persist: false`).
437+
*/
438+
private wireEngine(ctx: PluginContext, engine: IDataEngine): EmailPersistence | undefined {
439+
if (!this.service) return undefined;
440+
if (this.engineWiring?.engine === engine) return this.engineWiring.persistence;
441+
442+
const persistence: EmailPersistence | undefined = this.options.persist === false
443+
? undefined
444+
: {
445+
async insert(row) {
446+
const created = await (engine as any).insert('sys_email', row, {
447+
context: SYSTEM_CTX,
448+
});
449+
// The engine's OWN answer is forwarded, not laundered into
450+
// `row.id`. ObjectQL honours the id it is handed, so this confirms
451+
// `row.id` — and on the day some driver re-keys the row instead,
452+
// `EmailPersistence`'s insert contract (#5523) says so loudly here
453+
// rather than letting the outbox drain hook double-send the
454+
// message. `created` without a usable id means the engine reported
455+
// no identity at all, which is not a disagreement: confirm the row
456+
// we asked for.
457+
return created?.id ? { id: String(created.id) } : { id: String(row.id) };
458+
},
459+
async update(id, patch) {
460+
await (engine as any).update('sys_email', { id, ...patch }, {
461+
context: SYSTEM_CTX,
462+
});
463+
},
464+
// [#8149] The privileged readback for the `internal: true`
465+
// `headers_json` column. Taken off the RAW engine on purpose: the
466+
// dereference is a separately-named privileged verb (#8118)
467+
// precisely so it cannot be reached from a query string, and the
468+
// delivery paths above re-read rows through `find`, which omits the
469+
// column for every caller including `SYSTEM_CTX`. Inert — and never
470+
// calls the accessor — when the column is not flagged.
471+
async readHeadersJson(rowIds) {
472+
return readInternalHeadersJson(engine as any, rowIds);
473+
},
474+
};
475+
476+
// [ADR-0131 D6/D10] Templates resolve from the registry: the protocol's
477+
// layered `email_template` list (environment over code) when a metadata
478+
// door is mounted, the ObjectQL registry otherwise. Both are looked up
479+
// at each load — resolve where it is used — so a Studio save is live on
480+
// the next send, with no row, no projector and no restart.
481+
const registry = (engine as { registry?: unknown }).registry;
482+
const templateLoader: TemplateLoader = createRegistryEmailTemplateLoader(() => {
483+
let protocol: unknown;
484+
try { protocol = ctx.getService('protocol'); } catch { /* no metadata door on this host */ }
485+
return { protocol, registry } as never;
486+
});
487+
488+
// Mutate the existing service instance so consumers that already
489+
// captured a reference (e.g. AuthManager) see the upgrade.
490+
if (persistence) this.service.setPersistence(persistence);
491+
this.service.setTemplateLoader(templateLoader);
492+
this.engineWiring = { engine, persistence };
493+
ctx.logger.info('EmailServicePlugin: sys_email persistence + template loader enabled');
494+
return persistence;
495+
}
496+
400497
async start(ctx: PluginContext): Promise<void> {
401498
ctx.hook('kernel:ready', async () => {
402-
let engine: IDataEngine | null = null;
403-
try { engine = ctx.getService<IDataEngine>('objectql'); }
404-
catch { try { engine = ctx.getService<IDataEngine>('data'); } catch { /* ignore */ } }
499+
const engine = resolveDataEngine(ctx);
405500
if (!engine || !this.service) return;
406501

407502
// ── Bind to the `mail` settings namespace (Phase 1) ──────────────
@@ -586,57 +681,11 @@ export class EmailServicePlugin implements Plugin {
586681
// settings service not registered — env/constructor opts remain authoritative.
587682
}
588683

589-
const persistence: EmailPersistence | undefined = this.options.persist === false
590-
? undefined
591-
: {
592-
async insert(row) {
593-
const created = await (engine as any).insert('sys_email', row, {
594-
context: SYSTEM_CTX,
595-
});
596-
// The engine's OWN answer is forwarded, not laundered into
597-
// `row.id`. ObjectQL honours the id it is handed, so this confirms
598-
// `row.id` — and on the day some driver re-keys the row instead,
599-
// `EmailPersistence`'s insert contract (#5523) says so loudly here
600-
// rather than letting the outbox drain hook double-send the
601-
// message. `created` without a usable id means the engine reported
602-
// no identity at all, which is not a disagreement: confirm the row
603-
// we asked for.
604-
return created?.id ? { id: String(created.id) } : { id: String(row.id) };
605-
},
606-
async update(id, patch) {
607-
await (engine as any).update('sys_email', { id, ...patch }, {
608-
context: SYSTEM_CTX,
609-
});
610-
},
611-
// [#8149] The privileged readback for the `internal: true`
612-
// `headers_json` column. Taken off the RAW engine on purpose: the
613-
// dereference is a separately-named privileged verb (#8118)
614-
// precisely so it cannot be reached from a query string, and the
615-
// delivery paths above re-read rows through `find`, which omits the
616-
// column for every caller including `SYSTEM_CTX`. Inert — and never
617-
// calls the accessor — when the column is not flagged.
618-
async readHeadersJson(rowIds) {
619-
return readInternalHeadersJson(engine as any, rowIds);
620-
},
621-
};
622-
623-
// [ADR-0131 D6/D10] Templates resolve from the registry: the protocol's
624-
// layered `email_template` list (environment over code) when a metadata
625-
// door is mounted, the ObjectQL registry otherwise. Both are looked up
626-
// at each load — resolve where it is used — so a Studio save is live on
627-
// the next send, with no row, no projector and no restart.
628-
const registry = (engine as { registry?: unknown }).registry;
629-
const templateLoader: TemplateLoader = createRegistryEmailTemplateLoader(() => {
630-
let protocol: unknown;
631-
try { protocol = ctx.getService('protocol'); } catch { /* no metadata door on this host */ }
632-
return { protocol, registry } as never;
633-
});
634-
635-
// Mutate the existing service instance so consumers that already
636-
// captured a reference (e.g. AuthManager) see the upgrade.
637-
if (persistence) this.service.setPersistence(persistence);
638-
this.service.setTemplateLoader(templateLoader);
639-
ctx.logger.info('EmailServicePlugin: sys_email persistence + template loader enabled');
684+
// Persistence + the template loader were wired in `init` (see there) on
685+
// every boot whose engine was registered by then; this wires them only
686+
// for a host that registered its engine later, and otherwise answers
687+
// what `init` installed.
688+
const persistence = this.wireEngine(ctx, engine);
640689

641690
// Out-of-row attachment content (#5172). A thunk for the same reason the
642691
// queue is one: `storage` is a SwappableStorageService whose

0 commit comments

Comments
 (0)