Skip to content

Commit d6f0c77

Browse files
committed
fix(plugin-email): wire sys_email persistence + the template loader in init
A plugin that starts before EmailServicePlugin runs its kernel:ready hooks first, and plugin-auth's dev admin seed signs its account up from one. With email verification armed, better-auth awaits sendVerificationEmail -> sendTemplate inside that hook, which met a service whose loader is only installed at this plugin's own kernel:ready: TEMPLATE_NOT_FOUND into better-auth's background-task log, and no sys_email row. Everything the loader and persistence read is available at init (ObjectQL is a hard dependency and registers `objectql` in its own init; the registry is a construction-time field; `protocol` is resolved per load), so wire both there, before the service is registered. kernel:ready wires them only for a host whose engine registered later. Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a83ceb commit d6f0c77

1 file changed

Lines changed: 110 additions & 61 deletions

File tree

‎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)