Skip to content

feat(auth): deliver verification, reset, and magic-link emails through the email module#165

Closed
wmadden-electric wants to merge 12 commits into
claude/prisma-composer-auth-module-0ade44from
claude/auth-s2-email-flows
Closed

feat(auth): deliver verification, reset, and magic-link emails through the email module#165
wmadden-electric wants to merge 12 commits into
claude/prisma-composer-auth-module-0ade44from
claude/auth-s2-email-flows

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Wiring the auth module now takes one more dependency — a mail sender from the email module:

// module.ts (the root)
const mail = provision(email(), {
  id: 'mail',
  params: { deliveryMode: envParam('EMAIL_DELIVERY_MODE'), from: envParam('EMAIL_FROM') },
  secrets: { deliveryCredential: envSecret('EMAIL_DELIVERY_CREDENTIAL') },
});
const identity = provision(auth(), {
  id: 'auth',
  deps: { db, email: mail.send },          // ← new in this PR
  params: { baseUrl: envParam('AUTH_BASE_URL') },
});

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

  • Templates (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 match baseUrl's origin (safeLink) — a link pointing anywhere else fails that send instead of reaching a user.
  • Send callbacks (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. requireEmailVerification flips on.
  • Local testing (auth/testing): startLocalAuthServer captures 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.
  • The proof (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:

  • Module wiring is checked by object identity: the published auth bundle had inlined its own private copy of the email package, so its send contract was a different object from the one email() exposes, and the two modules refused to wire together on a deployed graph. The auth bundles now import the package's own ./email subpath instead of inlining (composer-prisma-cloud/tsdown.config.ts), so both modules share one instance.
  • The declaration build crashed on a rolldown bug in arktype's type re-exports (this is the package's first arktype import). Fixed by leaving arktype unbundled in the declaration output only — the runtime bundles still inline everything, so the deployed artifact stays self-contained.

Alternatives considered

  • Fail the auth request when the email fails. Rejected: a down mail path would brick signup and password reset entirely. The outbox already records delivery state, so surfacing the failure there loses nothing.
  • Auth owns its own delivery (SMTP client in the auth service). Rejected: duplicates the email module and forfeits its outbox, idempotency, and delivery modes. The boundary dependency is the point of having modules.
  • Async / react-email templates. The email contract permits async render, but auth's templates stay sync: react-email would drag a .tsx precompile step into this package's deploy. Consumers can still use react-email for their own templates.
  • A second, test-only email client for the local server. Rejected: the tests would then prove a path production never runs. The testing hook takes a real hydrated sender instead.

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

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a05a39bc-2e10-4c2d-95e8-588a46a9b54c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/auth-s2-email-flows
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/auth-s2-email-flows

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@165
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@165

commit: d594b19

@wmadden-electric wmadden-electric changed the title feat(auth): S2 email flows — verification, reset, and magic-link delivery via the email module feat(auth): deliver verification, reset, and magic-link emails through the email module Jul 23, 2026
@wmadden-electric
wmadden-electric force-pushed the claude/auth-s2-email-flows branch from d34b1ea to 9f2fedb Compare July 23, 2026 21:34
wmadden-electric and others added 8 commits July 24, 2026 00:06
`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
wmadden-electric force-pushed the claude/auth-s2-email-flows branch from 63b9951 to e96d083 Compare July 23, 2026 22:08
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>
@wmadden-electric

Copy link
Copy Markdown
Contributor Author

Superseded by #173, which folds S2's email flows into the ADR-0042 migration (base #161). Same email-module wiring, outbox readback, and magic-link flow, re-applied on the new input model and proven by #173's 13/13 deployed smoke. Closing; review moves to #173.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant