diff --git a/convex/betterAuth.test.ts b/convex/betterAuth.test.ts index 4c04133..dd1e093 100644 --- a/convex/betterAuth.test.ts +++ b/convex/betterAuth.test.ts @@ -60,6 +60,15 @@ describe("magic-link delivery HTTP contract", () => { expect(await response.json()).toEqual({ status: true }); expect(send).toHaveBeenCalledTimes(1); }); + it("sends the branded template with the frontend verify URL in both parts", async () => { + await request(); + const message = send.mock.calls[0][0]; + expect(message).toMatchObject({ from: "SongScript ", to: "test@example.com", subject: "Sign in to SongScript" }); + const verifyUrl = message.html.match(/ { expect((await request("https://untrusted.example")).status).toBe(403); expect(send).not.toHaveBeenCalled(); diff --git a/convex/betterAuth.ts b/convex/betterAuth.ts index 7d25e2e..65f46f0 100644 --- a/convex/betterAuth.ts +++ b/convex/betterAuth.ts @@ -9,6 +9,7 @@ import { components, internal } from "./_generated/api"; import type { GenericCtx } from "@convex-dev/better-auth"; import type { DataModel } from "./_generated/dataModel"; import { Resend } from "resend"; +import { renderMagicLinkEmail } from "./lib/magicLinkEmail"; // Auth functions for triggers (must match internal.auth exports) const authFunctions: AuthFunctions = internal.auth; @@ -18,6 +19,9 @@ const authFunctions: AuthFunctions = internal.auth; const siteUrl = process.env.SITE_URL!; const resendApiKey = process.env.RESEND_API_KEY; +// Better Auth's default magic-link lifetime, pinned so the email copy stays truthful. +const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 5; + // Initialize Resend client (only if API key is present) const resend = resendApiKey ? new Resend(resendApiKey) : null; @@ -134,6 +138,7 @@ export const createAuth = (ctx: GenericCtx) => { crossDomain({ siteUrl }), // Magic link passwordless authentication magicLink({ + expiresIn: MAGIC_LINK_EXPIRES_IN_SECONDS, sendMagicLink: async ({ email, url, token }) => { // Extract frontend origin from the callbackURL in the original URL // The URL looks like: .../verify?token=xxx&callbackURL=http://localhost:3001/dashboard @@ -156,144 +161,16 @@ export const createAuth = (ctx: GenericCtx) => { if (!resend) deliveryFailure("not_configured"); let failureCode: string | undefined; try { + const { subject, html, text } = renderMagicLinkEmail({ + url: frontendVerifyUrl, + expiresInSeconds: MAGIC_LINK_EXPIRES_IN_SECONDS, + }); const { error } = await resend.emails.send({ from: "SongScript ", to: email, - subject: "Your SongScript Login Link", - html: ` - - - - - - Your SongScript Login Link - - - -
-
-

SongScript

-
-
-

Click the button below to sign in to your account:

-
Sign In -
-

Or copy and paste this link into your browser:
- ${frontendVerifyUrl} -

-

This link will expire in 1 hour.

-
-
- -
- - - `, + subject, + html, + text, }); if (error) { diff --git a/convex/lib/magicLinkEmail.test.ts b/convex/lib/magicLinkEmail.test.ts new file mode 100644 index 0000000..5a704a7 --- /dev/null +++ b/convex/lib/magicLinkEmail.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { MAGIC_LINK_SUBJECT, renderMagicLinkEmail } from "./magicLinkEmail"; + +const url = "https://songscript-eight.vercel.app/auth/verify?token=abc123DEF456ghi789"; + +function anchors(html: string) { + return [...html.matchAll(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g)].map(([tag, href, inner]) => ({ tag, href, inner })); +} + +describe("magic-link email template", () => { + const email = renderMagicLinkEmail({ url, expiresInSeconds: 300 }); + + it("uses the sign-in subject", () => { + expect(email.subject).toBe("Sign in to SongScript"); + expect(MAGIC_LINK_SUBJECT).toBe(email.subject); + }); + + it("renders exactly one white, non-underlined button pointing at the verify URL", () => { + const buttons = anchors(email.html).filter((a) => a.tag.includes('class="ss-button"')); + expect(buttons).toHaveLength(1); + expect(buttons[0].href).toBe(url); + expect(buttons[0].tag).toContain("color:#ffffff"); + expect(buttons[0].tag).toContain("text-decoration:none"); + expect(buttons[0].inner).toContain("Sign in"); + }); + + it("links only to the verify URL and shows a shortened fallback without the token", () => { + const links = anchors(email.html); + expect(links).toHaveLength(2); + expect(links.every((a) => a.href === url)).toBe(true); + const fallback = links.find((a) => !a.tag.includes("ss-button"))!; + expect(fallback.inner).toBe("songscript-eight.vercel.app/auth/verify"); + expect(email.html.split("abc123DEF456ghi789")).toHaveLength(3); // the two hrefs only + }); + + it("is email-safe: table layout, 600px max width, dark-mode meta, no images or pixels", () => { + expect(email.html).toContain('role="presentation"'); + expect(email.html).toContain("max-width:600px"); + expect(email.html).toContain(''); + expect(email.html).toContain(''); + expect(email.html).not.toMatch(/ { + // A double quote inside a value (e.g. a font name) would end the attribute early. + expect(email.html).not.toMatch(/="[^"]*"[^\s>/]/); + }); + + it("states the real expiry in both parts", () => { + expect(email.html).toContain("expires in 5 minutes"); + expect(email.text).toContain("expires in 5 minutes"); + expect(renderMagicLinkEmail({ url, expiresInSeconds: 3600 }).text).toContain("expires in 1 hour"); + }); + + it("has a plain-text part with the full link", () => { + expect(email.text).toContain("Sign in to SongScript"); + expect(email.text.split("\n")).toContain(url); + expect(email.text).not.toMatch(/<[a-z]/i); + }); + + it("escapes the URL inside HTML attributes", () => { + const html = renderMagicLinkEmail({ url: `${url}&x=">`, expiresInSeconds: 300 }).html; + expect(html).not.toContain('">'); + expect(html).toContain("&x="><b>"); + }); +}); diff --git a/convex/lib/magicLinkEmail.ts b/convex/lib/magicLinkEmail.ts new file mode 100644 index 0000000..9bdd7b1 --- /dev/null +++ b/convex/lib/magicLinkEmail.ts @@ -0,0 +1,135 @@ +/** + * Magic-link sign-in email + * + * Email clients strip most CSS, so this template uses a table layout with + * inline styles and bgcolor fallbacks. Colours follow the app: gray-950 page, + * gray-900 card, emerald buttons, and the brand-gradient wordmark + * (green → white → red), rendered as per-letter colours because + * background-clip: text is not supported in email. + */ + +export const MAGIC_LINK_SUBJECT = "Sign in to SongScript"; + +// Interpolated from .brand-gradient in src/styles.css (#22c55e → #ffffff → #ef4444) +const WORDMARK: ReadonlyArray = [ + ["S", "#22c55e"], ["o", "#53d282"], ["n", "#84dfa6"], ["g", "#b5ecc9"], ["S", "#e6f9ed"], + ["c", "#fdeaea"], ["r", "#fac1c1"], ["i", "#f69797"], ["p", "#f36e6e"], ["t", "#ef4444"], +]; + +const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"; + +// emerald-700: white text on the site's emerald-600 is 3.77:1, below WCAG AA. +const BUTTON = "#047857"; +const BUTTON_HOVER = "#065f46"; + +// Keeps clients from pulling body copy into the inbox preview after the preheader. +const PREHEADER_PAD = " ͏".repeat(40); + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Host + path only; the token stays in the href, not the visible text. */ +function shortLink(url: string): string { + const { host, pathname } = new URL(url); + return `${host}${pathname}`; +} + +function expiryLabel(seconds: number): string { + const minutes = Math.round(seconds / 60); + if (minutes >= 60 && minutes % 60 === 0) { + const hours = minutes / 60; + return `${hours} ${hours === 1 ? "hour" : "hours"}`; + } + return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`; +} + +export function renderMagicLinkEmail({ url, expiresInSeconds }: { url: string; expiresInSeconds: number }) { + const href = escapeHtml(url); + const expiry = expiryLabel(expiresInSeconds); + const expiryHtml = expiry.replace(" ", " "); + const wordmark = WORDMARK.map(([letter, color]) => `${letter}`).join(""); + + const html = ` + + + + + + + + +${MAGIC_LINK_SUBJECT} + + + +
Your sign-in link works once and expires in ${expiry}.${PREHEADER_PAD}
+ + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + +
${wordmark}
Sign in to SongScript
This link works once and expires in ${expiryHtml}.
+ + + + +
+ Sign in +
+
Button not working? Open ${escapeHtml(shortLink(url))}
+
Didn't ask for this? You can ignore it; nothing changes without the link.
+
+ +`; + + const text = [ + "Sign in to SongScript", + "", + `This link works once and expires in ${expiry}:`, + url, + "", + "Didn't ask for this? You can ignore it; nothing changes without the link.", + ].join("\n"); + + return { subject: MAGIC_LINK_SUBJECT, html, text }; +} diff --git a/docs/pr-assets/magic-link-email/after-390.png b/docs/pr-assets/magic-link-email/after-390.png new file mode 100644 index 0000000..45e1fc7 Binary files /dev/null and b/docs/pr-assets/magic-link-email/after-390.png differ diff --git a/docs/pr-assets/magic-link-email/after-desktop.png b/docs/pr-assets/magic-link-email/after-desktop.png new file mode 100644 index 0000000..a7c9a69 Binary files /dev/null and b/docs/pr-assets/magic-link-email/after-desktop.png differ diff --git a/docs/pr-assets/magic-link-email/before-390.png b/docs/pr-assets/magic-link-email/before-390.png new file mode 100644 index 0000000..e7f5b44 Binary files /dev/null and b/docs/pr-assets/magic-link-email/before-390.png differ diff --git a/docs/pr-assets/magic-link-email/before-desktop.png b/docs/pr-assets/magic-link-email/before-desktop.png new file mode 100644 index 0000000..60f94b1 Binary files /dev/null and b/docs/pr-assets/magic-link-email/before-desktop.png differ