Repository navigation
fix(plugin-email): wire sys_email persistence + the template loader in init, so a boot-time verification send stops throwing TEMPLATE_NOT_FOUND - #24113
Conversation
…n 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>
…el:ready A real LiteKernel boot orders a stand-in for plugin-auth's dev seed (depends on ObjectQL only, registered ahead of the email plugin) whose kernel:ready hook sends `auth.verify_email` before the email plugin's own kernel:ready wiring. The send must render, persist its sys_email row at `sent` and deliver once, with no TEMPLATE_NOT_FOUND. Reuses this file's engine double. Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
…ail-template-loader-boot-order
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5c286664065044c975c41597d97330023760584f && git checkout 5c286664065044c975c41597d97330023760584f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b75129fba73a5b041fbc48cd16d50360ebd3a9ab 404db56cca27abb1f38f063a2949bb0c77e8bdad && git checkout -B drift-repro b75129fba73a5b041fbc48cd16d50360ebd3a9ab && git merge --no-ff 404db56cca27abb1f38f063a2949bb0c77e8bdad
node scripts/docs-audit/affected-docs.mjs --json b75129fba73a5b041fbc48cd16d50360ebd3a9ab
|
席位核对 · ACCEPT ·
|
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:That verification send also left no
sys_emailrow.Premise, checked on
origin/main(8a83cebc15) before any changeexamples/app-showcase:OS_AUTH_REQUIRE_EMAIL_VERIFICATION=true,objectstack dev --ui --seed-admin -p PORT -d file:FRESH/data.db.sys_emailrows.email_verified = 1.AuthPlugin.maybeSeedDevAdmincallssignUpEmail.runInBackgroundOrAwait, and with no background handler configured it awaitssendVerificationEmail.EmailService.sendTemplate, thenresolveAndRenderTemplate, which throws (email-service.ts:1315).kernel:readyhook.Authahead ofEmailServicePlugin(boot plugin list:…, Auth, …, SettingsServicePlugin, EmailServicePlugin, …). The kernel runskernel:readyhandlers one at a time, in the order the plugins started.EmailServicePlugininstalled persistence and the loader (setPersistence/setTemplateLoader) only inside its ownkernel:readyhook. So the seed's send reached a service that had neither.packages/cli/src/commands/dev.ts"signs the admin up in-process". In factdev.tsonly forwardsOS_SEED_ADMIN. The sign-up runs in plugin-auth:AuthPlugin.maybeSeedDevAdmin, akernel:readyhook.The fix: option (a), wire persistence + the template loader in
initEmailServicePlugin.initnow resolves the data engine (objectql, elsedata) and installssys_emailpersistence and the registry template loader. It does this before it registers theemailservice. The wiring itself moved unchanged into awireEnginemethod that runs once per engine. Thekernel:readyhook calls the same method, which wires only a host whose engine registered afterinit, soinitrecords nothing when the engine is absent.Everything the two read is available at
init. Each point was checked in the code, not assumed:objectqlis registered inObjectQLPlugin.init(packages/objectql/src/plugin.ts:645). ObjectQL is a harddependenciesentry of this plugin, so it inits first.engine.registryis a field set when the engine is constructed (packages/objectql/src/engine.ts:4448).protocolon every load, not at wiring time.engine.insert/engine.updateonly 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'skernel:readyhook, by better-auth and then by the seed. The kernel runs this plugin'skernel:readyhandler 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 answerqueuedwith 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 thaninit.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 inpackages/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 realLiteKernel(the sameresolvePluginOrderasObjectKernel) with three plugins, registered in this order:protocolthat serves the templates the manifest door was handed.AuthPlugin, it depends only on ObjectQL. From its ownkernel:readyit sendsauth.verify_emailwith the same arguments plugin-auth uses.EmailServicePlugin.What the pin asserts:
status: sent.sys_emailrow exists, atsent, with the rendered subjectVerify your Acme email address,related_object: sys_userand the expectedrelated_id.Ablation used
node scripts/ablation-replace.mjsto delete theinitcallif (engine) this.wireEngine(ctx, engine);. The fix was committed first, so HEAD holds it.8e8ca0e6d6a6became855acf5ca71b.expected Error: TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService to be undefined. The other 13 tests in the file stayed green.8e8ca0e6d6a6, equal to HEAD,git diff HEADis empty, andgit statusis clean../email-plugin.jsrelatively, so vitest readssrc/and nodist/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.ERRORlines, 0TEMPLATE_NOT_FOUND, 0Failed to run background task, and 1Server is ready. TheDev admin … seeded on empty DBbanner is present.sys_emailrow:status: sent, subjectVerify your ObjectStack email address,related_object: sys_user, andrelated_idequal to the seeded admin'ssys_user.id.email_verified = 1.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 --noEmitpassed.check:test-typecheckreported OK with 0 errors.tsc -p tsconfig.test.json --listFilesincludes the edited test file.Gates (local subset, at final head
404db56cca)The branch merges
origin/mainatbe076dad28. 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.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat404db56cca. That gave 64 commands (30pnpm, 34 directnode), the same list as before the merge.--ran: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.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-loadsandcheck:type-check-coverage.d879052e5b, was also all 0. In that runcheck:dual-build-cjs-loadsfirst exited 3 withPREREQUISITE NOT MET, because eight packages had nodist/. It went green once they were built.test:repolocally. They belong to this PR's CI check-runs.Acceptance notes
mailsettings namespace (Settings, then Mail) is still applied at this plugin's ownkernel: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:send()in the same window already behaved this way before this change, with no row at all.OS_EMAIL_QUEUE_ENABLED) still delivers a send in this window inline, because the delivery mode is applied atkernel:ready. This is unchanged, except that the send now leaves its row.init/start, before ObjectQL connects its drivers, would now attempt thesys_emailinsert. That insert fails non-fatally (the existingwarn) and the mail still goes out inline. No such sender was measured.sendOnSignUp. It is unchanged, and moot as described above.Generated by Claude Code