diff --git a/src/core/auth/better-auth-email-hooks.runner.ts b/src/core/auth/better-auth-email-hooks.runner.ts index 63d318d2..f1a4720e 100644 --- a/src/core/auth/better-auth-email-hooks.runner.ts +++ b/src/core/auth/better-auth-email-hooks.runner.ts @@ -46,7 +46,13 @@ export interface EmailSenderForHooks { export interface EmailHookErrorContext { template: string; to?: string; - kind: "email-verification" | "password-reset" | "welcome" | "invitation" | "new-device"; + kind: + | "email-verification" + | "password-reset" + | "magic-link" + | "welcome" + | "invitation" + | "new-device"; } export interface EmailHookRunnerOptions { @@ -88,6 +94,12 @@ export interface ResetPasswordHookData { token: string; } +export interface MagicLinkHookData { + user: BetterAuthEmailUser; + url: string; + token: string; +} + export interface WelcomeHookData { user: BetterAuthEmailUser; } @@ -113,6 +125,7 @@ export interface NewDeviceHookData { export interface BetterAuthEmailHookRunner { sendVerificationEmail(data: VerificationHookData): Promise; sendResetPassword(data: ResetPasswordHookData): Promise; + sendMagicLink(data: MagicLinkHookData): Promise; sendWelcome(data: WelcomeHookData): Promise; sendInvitation(data: InvitationHookData): Promise; sendNewDevice(data: NewDeviceHookData): Promise; @@ -198,6 +211,19 @@ export function createEmailHookRunner(options: EmailHookRunnerOptions): BetterAu data.token, ); }, + async sendMagicLink(data) { + await send( + "magic-link", + () => + buildEmailHookPayload({ + kind: "magic-link", + user: data.user, + url: data.url, + appName: options.appName, + }), + data.token, + ); + }, async sendWelcome(data) { await send( "welcome", diff --git a/src/core/auth/better-auth-email-hooks.ts b/src/core/auth/better-auth-email-hooks.ts index a14d1778..e5af82b8 100644 --- a/src/core/auth/better-auth-email-hooks.ts +++ b/src/core/auth/better-auth-email-hooks.ts @@ -39,6 +39,7 @@ export interface BetterAuthEmailUser { export type EmailHookKind = | "email-verification" | "password-reset" + | "magic-link" | "welcome" | "invitation" | "new-device"; @@ -58,6 +59,12 @@ interface PasswordResetHookInput extends BaseHookInput { url: string; } +interface MagicLinkHookInput extends BaseHookInput { + kind: "magic-link"; + /** The one-time sign-in link Better-Auth issued. */ + url: string; +} + interface WelcomeHookInput extends BaseHookInput { kind: "welcome"; } @@ -86,6 +93,7 @@ interface NewDeviceHookInput extends BaseHookInput { export type EmailHookInput = | VerificationHookInput | PasswordResetHookInput + | MagicLinkHookInput | WelcomeHookInput | InvitationHookInput | NewDeviceHookInput; @@ -176,6 +184,17 @@ export function buildEmailHookPayload(input: EmailHookInput): EmailHookPayload { vars: { recipientName, appName, resetUrl: url }, }; } + case "magic-link": { + const url = trim(input.url); + if (!url) { + throw new Error("better-auth-email-hooks: magic-link hook requires a non-empty url"); + } + return { + template: "magic-link", + to: recipient, + vars: { recipientName, appName, magicLinkUrl: url }, + }; + } case "welcome": { return { template: "welcome", diff --git a/src/core/auth/better-auth.module.ts b/src/core/auth/better-auth.module.ts index c25a8d25..0744763b 100644 --- a/src/core/auth/better-auth.module.ts +++ b/src/core/auth/better-auth.module.ts @@ -201,6 +201,10 @@ import { isBetterAuthRateLimitEnabled } from "./rate-limit-flag.js"; ...(features.authMethods.socialProviders.length > 0 ? { socialProviders: pickSocialProviders(features) } : {}), + // Magic-link (passwordless) — when enabled, buildBetterAuth wires + // the link delivery through the shared email-hook runner (the + // `magic-link` template). No caller closure needed. + ...(features.magicLink.enabled ? { magicLink: {} } : {}), // PowerSync needs JWT-with-audience + JWKS — Better-Auth's `jwt` // plugin auto-exposes `/api/auth/.well-known/jwks` once enabled. ...(features.powerSync.enabled ? { jwtPlugin: { audience: "powersync" } } : {}), diff --git a/src/core/auth/better-auth.ts b/src/core/auth/better-auth.ts index 98963f08..26ccbaab 100644 --- a/src/core/auth/better-auth.ts +++ b/src/core/auth/better-auth.ts @@ -152,11 +152,12 @@ export interface BuildBetterAuthInput { }; /** * Switch on the magic-link plugin (Better-Auth 1.6 plugin slot 6/9 - * per the PRD). Caller supplies a `sendMagicLink` closure so the - * email is delivered through the project's `EmailService`. Defaults - * are otherwise Better-Auth's: 5-minute link expiry, single-use. + * per the PRD). When `emailHooks` is wired the link is delivered + * through the shared email-hook runner (templated `magic-link` mail); + * a caller may still supply an explicit `sendMagicLink` closure to + * override that. Defaults otherwise: 5-minute link expiry, single-use. */ - magicLink?: { sendMagicLink: MagicLinkSender }; + magicLink?: { sendMagicLink?: MagicLinkSender }; /** * Switch on the admin plugin (Better-Auth 1.6 plugin slot 4/9). * Provides impersonation + ban + role assignment endpoints under @@ -352,7 +353,7 @@ export function buildBetterAuth(input: BuildBetterAuthInput): ReturnType { - await send({ email, token, url }); - }, - }), - ); - } + // magicLink is registered after `hookRunner` is built (below) so the + // send closure can route templated mail through the shared runner. if (input.adminPlugin) { plugins.push( admin({ @@ -429,6 +422,25 @@ export function buildBetterAuth(input: BuildBetterAuthInput): ReturnType { + if (explicitSend) { + await explicitSend({ email, token, url }); + } else if (hookRunner) { + await hookRunner.sendMagicLink({ user: { id: "", email }, url, token }); + } + }, + }), + ); + } + const passwordPolicyInput = input.passwordPolicy; // Better-Auth's `password.hash` is the canonical place to gate the // policy because it runs on signup AND change-password before the diff --git a/src/core/email/email-builder.ts b/src/core/email/email-builder.ts index 1fa262e5..3648457f 100644 --- a/src/core/email/email-builder.ts +++ b/src/core/email/email-builder.ts @@ -20,6 +20,7 @@ export const CORE_EMAIL_TEMPLATES = [ "email-verification", "password-reset", + "magic-link", "welcome", "invitation", "new-device", diff --git a/src/core/email/templates/magic-link.tsx b/src/core/email/templates/magic-link.tsx new file mode 100644 index 00000000..19611b6d --- /dev/null +++ b/src/core/email/templates/magic-link.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; + +import { Barebone } from "../layouts/Barebone.js"; +import { CTA, Footer, Greeting, Paragraph } from "../blocks/index.js"; +import type { BrandConfig } from "../brand.js"; + +/** + * Magic-link sign-in template. + * + * Sent when a user requests a passwordless sign-in link. Subject pulls + * the appName so it reads natural across multi-brand deployments — + * `Your Acme sign-in link` instead of a generic fallback. + */ +export interface MagicLinkVars { + recipientName: string; + appName: string; + magicLinkUrl: string; +} + +export const magicLinkMeta = { + name: "magic-link", + subject: (vars: MagicLinkVars): string => `Your ${vars.appName} sign-in link`, +}; + +export interface MagicLinkProps extends MagicLinkVars { + brand?: BrandConfig; +} + +export default function MagicLink(props: MagicLinkProps): React.ReactElement { + return ( + + Hello {props.recipientName}, + + Tap the button below to sign in to {props.appName}. This link works once and expires + shortly. + + + Sign in + + + + ); +} diff --git a/tests/stories/better-auth-email-hook-runner.story.test.ts b/tests/stories/better-auth-email-hook-runner.story.test.ts index 913b9f0d..b511988a 100644 --- a/tests/stories/better-auth-email-hook-runner.story.test.ts +++ b/tests/stories/better-auth-email-hook-runner.story.test.ts @@ -82,6 +82,26 @@ describe("Story · Better-Auth email hook runner", () => { }); }); + it("sendMagicLink() forwards to the magic-link template", async () => { + const sender = fakeSender(); + const runner = createEmailHookRunner({ sender, appName }); + await runner.sendMagicLink({ + user, + url: "https://x/auth/magic?t=1", + token: "t1", + }); + expect(sender.calls).toHaveLength(1); + expect(sender.calls[0]).toMatchObject({ + template: "magic-link", + to: "alice@example.com", + vars: { + recipientName: "Alice", + appName: "Acme", + magicLinkUrl: "https://x/auth/magic?t=1", + }, + }); + }); + it("sendWelcome() forwards to the welcome template", async () => { const sender = fakeSender(); const runner = createEmailHookRunner({ sender, appName }); diff --git a/tests/stories/better-auth-email-hooks.story.test.ts b/tests/stories/better-auth-email-hooks.story.test.ts index 9e42962e..015c48e1 100644 --- a/tests/stories/better-auth-email-hooks.story.test.ts +++ b/tests/stories/better-auth-email-hooks.story.test.ts @@ -107,6 +107,30 @@ describe("Story · Better-Auth email hooks (planner)", () => { }); }); + it("maps the magic-link hook to the magic-link template", () => { + const out = buildEmailHookPayload({ + kind: "magic-link", + user, + url: "https://app.example.com/auth/magic?token=xyz", + appName, + }); + expect(out).toEqual({ + template: "magic-link", + to: "a@b.io", + vars: { + recipientName: "Alice", + appName: "Acme", + magicLinkUrl: "https://app.example.com/auth/magic?token=xyz", + }, + }); + }); + + it("rejects an empty url for the magic-link hook", () => { + expect(() => buildEmailHookPayload({ kind: "magic-link", user, url: "", appName })).toThrow( + /url/i, + ); + }); + it("maps the post-verification welcome hook to the welcome template", () => { const out = buildEmailHookPayload({ kind: "welcome", user, appName }); expect(out).toEqual({ diff --git a/tests/stories/email-builder.story.test.ts b/tests/stories/email-builder.story.test.ts index 85de4649..8654107a 100644 --- a/tests/stories/email-builder.story.test.ts +++ b/tests/stories/email-builder.story.test.ts @@ -70,12 +70,13 @@ describe("Story · Email-Builder", () => { }); it("CORE_EMAIL_TEMPLATES catalogues every shipped core template", () => { - // The 5 templates that ship under src/core/email/templates/ — must - // match the on-disk inventory so the dev-portal "Core (default)" + // The transactional templates that ship under src/core/email/templates/ + // — must match the on-disk inventory so the dev-portal "Core (default)" // badge can be derived from this list alone. expect([...CORE_EMAIL_TEMPLATES].sort()).toEqual([ "email-verification", "invitation", + "magic-link", "new-device", "password-reset", "welcome", diff --git a/tests/stories/email-templates-react.story.test.ts b/tests/stories/email-templates-react.story.test.ts index 372e5d03..163e5530 100644 --- a/tests/stories/email-templates-react.story.test.ts +++ b/tests/stories/email-templates-react.story.test.ts @@ -90,6 +90,21 @@ describe("Story · React-Email Templates", () => { expect(out.text).toContain("https://app.example.test/verify?token=preview"); }); + it("magic-link template renders the recipient + sign-in URL + appName in the subject", async () => { + const renderer = new ReactEmailTemplateRenderer({ brand: defaultBrandConfig() }); + const out = await renderer.render("magic-link", "en", { + recipientName: "Pascal", + appName: "Acme", + magicLinkUrl: "https://app.example.test/auth/magic?token=preview", + }); + // Subject pulls the appName so it reads natural per-brand. + expect(out.subject).toContain("Acme"); + expect(out.subject).toMatch(/sign-in/i); + expect(out.html).toContain("Pascal"); + expect(out.html).toContain("https://app.example.test/auth/magic?token=preview"); + expect(out.text).toContain("https://app.example.test/auth/magic?token=preview"); + }); + it("welcome template renders the recipient + appName", async () => { const renderer = new ReactEmailTemplateRenderer({ brand: defaultBrandConfig() }); const out = await renderer.render("welcome", "en", {