@@ -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 */
264277export 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