feat(auth): deliver verification, reset, and magic-link emails through the email module#165
Closed
wmadden-electric wants to merge 12 commits into
Closed
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
commit: |
wmadden-electric
force-pushed
the
claude/auth-s2-email-flows
branch
from
July 23, 2026 21:34
d34b1ea to
9f2fedb
Compare
`auth()` and `authService()` gain an `email` boundary dependency (`emailSender(authTemplates)`), so the root decides which email module instance delivers auth's mail. New `templates.ts` ships the three templates (verification, password reset, magic link) with the pinned subjects, HTML-escaped interpolations, and `safeLink`, which refuses a link whose origin differs from the app's `baseUrl`. Better Auth's three send callbacks now call the matching template method instead of logging a not-wired line. Each send carries an idempotency key derived from purpose, recipient, and token, so a Better Auth retry of the same event dedups to one outbox row. A callback never throws — a failed or rejected send is logged, because Better Auth treats a callback throw as a request failure and a down mail path must not brick signup, reset, or magic-link. `requireEmailVerification` flips on: real delivery is what makes that setting usable at all. `startLocalAuthServer` accepts an `EmailSender<AuthTemplates>`; its default renders through the real templates and captures the result in memory, so a local flow can still read its live links with nothing else running. Tests: template rendering and escaping, the send callbacks' keys and failure handling, and an integration test that wires the local auth server to the email module's own local server and reads verification and magic-link mail back through the outbox port. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Closes the S2 checkpoint's architecture-coverage gap: templates.ts was the one file the pre-commit hook's coverage check actually flagged (the two new test files under __tests__/ are already excluded from the check by design, same as every other test file in the package). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The S2 checkpoint's build was failing (root typecheck depends on it): tsdown's declaration-bundling pass for the auth-entrypoint and testing outputs walks arkregex's .d.ts re-export chain (arktype -> arkregex's charset.d.ts importing StringDigit from escape.d.ts) and hits a rolldown bug there, unrelated to this package's own types. This is new in S2: S1's auth-options.ts never imported arktype: templates.ts does, and it's now pulled into the same entrypoint/testing bundles as better-auth. Fix: `deps.dts.neverBundle` on those two tsdown passes leaves arktype/arkregex as unbundled references in the DECLARATION output only -- the JS bundle still inlines them at runtime via the existing `noExternal` list, so the deployed entrypoint stays self-contained. Confirmed dist/testing.d.mts still carries its real exported types (LocalAuthServer, CapturedAuthEmail) afterward -- those are consumed by composer-prisma-cloud's auth-testing.ts re-export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Finishes the S2 email wiring the checkpoint left undone in examples/auth: module.ts provisions email() (deliveryMode/from via envParam, deliveryCredential via envSecret -- the same junk-credential shape as the email example) and wires mail.send into auth()'s email dep. Without this the module's own factory-shape change made the example fail typecheck outright (email is now a required dep). Also adds a smoke-only /admin/find-sent-email route to the ops app, backed by a new outbox dep on the ops service -- the ops app never touches the module's outbox port directly from the smoke script, mirroring the email example's own mailer app pattern. requireEmailVerification: true (already in the checkpoint) means sign-in now 403s until the address is verified, so the local integration test and the deployed smoke script both needed updating: they read the verification email back through the outbox (ops's own route locally hydrates against startLocalEmailServer; the deployed smoke script hits the real ops app) and follow the link before logging in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…field) + dispatch plan Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…auth bundles rpc contract satisfaction is nominal (a contract object satisfies only itself). dist/auth inlined its own copy of @internal/email, so the emailSendContract inside auth() was a different object from the one the email() module exposes, and every auth <- email wiring failed Load at deploy with "The deps for auth.email do not satisfy the slot required contract". Externalize @internal/email from the auth library/testing bundles and rewrite it to this package own ./email subpath (package self-reference), matching the existing framework-internals pattern. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…t conflict, stale smokes Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric
force-pushed
the
claude/auth-s2-email-flows
branch
from
July 23, 2026 22:08
63b9951 to
e96d083
Compare
Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…he 0.16 field-rename risk Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
… extension hashes; PN must record them Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…edParam + ADR-0041 migration Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wiring the auth module now takes one more dependency — a mail sender from the email module:
With that in place, signup sends a verification email, login is refused until the link is followed, password reset and magic-link sign-in deliver real emails, and magic-link login works end to end — locally and on a real Prisma Cloud deploy. This is also the first time one Composer module depends on another, which is why the PR touches packaging as well as the auth package itself.
Linear: TML-3077 · Stacked on #163 (S1, the core auth module)
The decision
Auth treats mail as best-effort, with the email module's outbox as the operational record. A send that fails — delivery error, or a link that fails validation — is logged and dropped; it never fails the signup/reset/login request that triggered it. If you need to know whether an email went out, you ask the outbox, not the auth flow. Every send carries a deterministic idempotency key (
sha256(purpose:email:token)), so a retried Better Auth callback can't double-deliver.How it's built
auth/src/templates.ts): the three emails as plain synchronous template functions with fixed subjects. Every interpolated value is HTML-escaped, and each link must matchbaseUrl's origin (safeLink) — a link pointing anywhere else fails that send instead of reaching a user.auth/src/auth-options.ts): the previous no-op senders replaced with real sends through the wired sender, wrapped in one log-never-throw path.requireEmailVerificationflips on.auth/testing):startLocalAuthServercaptures sends in memory by default; pass it a sender hydrated against the email module's own local server and the tests exercise the exact client a deployment uses.examples/auth): the example provisions both modules, and its e2e reads the verification link back out of the email module's outbox port (through a dedicated ops route — the api service never sees admin or send, the ops service never sees send). The deployed smoke runs this for real: 13/13 steps on Prisma Cloud, clean teardown.What the deploy taught us
Two packaging bugs only a real build/deploy could surface, fixed here:
email()exposes, and the two modules refused to wire together on a deployed graph. The auth bundles now import the package's own./emailsubpath instead of inlining (composer-prisma-cloud/tsdown.config.ts), so both modules share one instance.Alternatives considered
.tsxprecompile step into this package's deploy. Consumers can still use react-email for their own templates.Verified: root typecheck, 105 auth tests (incl. outbox-readback and magic-link e2e), email suite untouched-green, lint/depcruise/casts, deployed smoke 13/13 with clean teardown. Review round 1: satisfied, zero findings.
🤖 Generated with Claude Code