Skip to content

feat(auth): deliver magic-link sign-in via the shared email-hook runner#161

Merged
pascal-klesse merged 2 commits into
mainfrom
feat/magic-link-email
May 22, 2026
Merged

feat(auth): deliver magic-link sign-in via the shared email-hook runner#161
pascal-klesse merged 2 commits into
mainfrom
feat/magic-link-email

Conversation

@pascal-klesse
Copy link
Copy Markdown
Member

The gap

Better-Auth's magicLink plugin slot already existed in better-auth.ts, and there's a magicLink feature flag in src/core/features/features.ts. But upstream forced the caller to supply a sendMagicLink closure — 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-reset already works:

  • Planner (better-auth-email-hooks.ts): new magic-link hook kind + MagicLinkHookInput; buildEmailHookPayload returns { template: "magic-link", to, vars: { recipientName, appName, magicLinkUrl } } and throws on an empty url.
  • Runner (better-auth-email-hooks.runner.ts): new sendMagicLink(data) method that routes through the shared send(...) path (outbox-aware, error-swallowing) just like sendResetPassword.
  • Template (src/core/email/templates/magic-link.tsx): new React-email template, structure/imports mirrored from password-reset.tsx; subject pulls appName (Your Acme sign-in link). Added to CORE_EMAIL_TEMPLATES.
  • Factory (better-auth.ts): magicLink.sendMagicLink is now optional. When emailHooks are wired and no explicit closure is supplied, the link is delivered through the shared hook runner; an explicit caller sendMagicLink still overrides. The plugin registration moved to after hookRunner is constructed so its closure can reference it.
  • Module (better-auth.module.ts): enables the plugin on features.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.tssendMagicLink forwards to the magic-link template.
  • email-templates-react.story.test.ts — the rendered template (recipient, sign-in URL, appName-in-subject).
  • email-builder.story.test.tsCORE_EMAIL_TEMPLATES catalogue assertion now lists magic-link.

Verified live downstream (BYND)

POST /api/auth/sign-in/magic-link then templated mail captured in Mailpit then opening the link sets better-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

pascal-klesse and others added 2 commits May 22, 2026 08:42
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>
@pascal-klesse pascal-klesse merged commit e600198 into main May 22, 2026
13 checks passed
@pascal-klesse pascal-klesse deleted the feat/magic-link-email branch May 22, 2026 09:32
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