feat(auth): deliver magic-link sign-in via the shared email-hook runner#161
Merged
Conversation
Better-Auth's magicLink plugin slot existed but had no email-delivery path — the caller was forced to supply a `sendMagicLink` closure. Every other auth mail (verification, reset, welcome, invitation, new-device) already ships through the shared email-hook runner with a React-email template; magic-link was the odd one out. This wires magic-link into that same system: - New `magic-link` email-hook kind + planner payload (`buildEmailHookPayload`) and a `sendMagicLink` runner method, mirroring password-reset. - New `src/core/email/templates/magic-link.tsx` + `CORE_EMAIL_TEMPLATES` catalogue entry. - `buildBetterAuth`: `magicLink.sendMagicLink` is now OPTIONAL. When `emailHooks` are wired the link is delivered through the shared hook runner (templated `magic-link` mail); an explicit caller closure still wins. The plugin is registered after `hookRunner` is built so the send closure can reference it. - `BetterAuthModule` enables it via `features.magicLink.enabled` — no caller closure needed for the batteries-included path. Story tests cover the planner payload shape + empty-url throw, the runner forwarding, the rendered template, and the catalogue assertion. Verified live downstream (BYND): POST /api/auth/sign-in/magic-link → templated mail captured in Mailpit → opening the link sets better-auth.session_token. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CI `format` gate (oxfmt --check) collapses the empty-url throw assertion onto a single line; align with it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
The gap
Better-Auth's
magicLinkplugin slot already existed inbetter-auth.ts, and there's amagicLinkfeature flag insrc/core/features/features.ts. But upstream forced the caller to supply asendMagicLinkclosure — there was no email-delivery path. Every other auth mail (verification, password-reset, welcome, invitation, new-device) is delivered through the shared email-hook runner with a React-email template. Magic-link was the odd one out: a consumer who just wanted "send the link by email" had to hand-write the closure + wiring.The fix
Wire magic-link into that same email-hook system, mirroring how
password-resetalready works:better-auth-email-hooks.ts): newmagic-linkhook kind +MagicLinkHookInput;buildEmailHookPayloadreturns{ template: "magic-link", to, vars: { recipientName, appName, magicLinkUrl } }and throws on an empty url.better-auth-email-hooks.runner.ts): newsendMagicLink(data)method that routes through the sharedsend(...)path (outbox-aware, error-swallowing) just likesendResetPassword.src/core/email/templates/magic-link.tsx): new React-email template, structure/imports mirrored frompassword-reset.tsx; subject pullsappName(Your Acme sign-in link). Added toCORE_EMAIL_TEMPLATES.better-auth.ts):magicLink.sendMagicLinkis now optional. WhenemailHooksare wired and no explicit closure is supplied, the link is delivered through the shared hook runner; an explicit callersendMagicLinkstill overrides. The plugin registration moved to afterhookRunneris constructed so its closure can reference it.better-auth.module.ts): enables the plugin onfeatures.magicLink.enabled— batteries-included, no caller closure needed.Tests (TDD: red then green)
better-auth-email-hooks.story.test.ts— planner payload shape + empty-url throw.better-auth-email-hook-runner.story.test.ts—sendMagicLinkforwards to themagic-linktemplate.email-templates-react.story.test.ts— the rendered template (recipient, sign-in URL, appName-in-subject).email-builder.story.test.ts—CORE_EMAIL_TEMPLATEScatalogue assertion now listsmagic-link.Verified live downstream (BYND)
POST /api/auth/sign-in/magic-linkthen templated mail captured in Mailpit then opening the link setsbetter-auth.session_token.Quality gates
All six green locally:
lint(0 errors),test:unit(245),test:e2e(4550),test:types,test:coverage(exit 0, src/core thresholds held),build.Generated with Claude Code