diff --git a/src/app/utils/magic-link-email.js b/src/app/utils/magic-link-email.js new file mode 100644 index 0000000..79a10ea --- /dev/null +++ b/src/app/utils/magic-link-email.js @@ -0,0 +1,32 @@ +// Escape a URL for safe embedding in an HTML attribute. +const escapeHtml = (value) => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + +// Build the SendGrid v3 mail/send request body for a magic-link email. +// Tracked separately so the request shape can be unit-tested without a +// SendGrid API call or the auth instance's DB pool. +export const buildMagicLinkPayload = ({ email, url, fromEmail, subject }) => ({ + personalizations: [{ to: [{ email }] }], + from: { email: fromEmail }, + subject, + tracking_settings: { + // Magic-link URL must read as the real codebar.io link, + // not a Sendgrid /LsClick tracking redirect. + click_tracking: { enable: false }, + open_tracking: { enable: false }, + }, + content: [ + { + type: "text/plain", + value: `Click the link below to sign in to codebar:\n\n${url}\n\nThis link expires in 5 minutes.`, + }, + { + type: "text/html", + value: `
Click the button below to sign in to codebar.
This link expires in 5 minutes.
`, + }, + ], +}); diff --git a/src/auth.js b/src/auth.js index e7d8477..596290b 100644 --- a/src/auth.js +++ b/src/auth.js @@ -4,6 +4,7 @@ import { admin, magicLink, jwt } from "better-auth/plugins"; import { oauthProvider } from "@better-auth/oauth-provider"; import appConfig from "./config.js"; import { devMagicLinks } from "./dev/magic-links.js"; +import { buildMagicLinkPayload } from "./app/utils/magic-link-email.js"; // PostgreSQL connection pool for CI/production and local dev // SSL only for non-local connections (Heroku requires it; local/CI does not) @@ -103,17 +104,14 @@ export const auth = betterAuth({ Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - personalizations: [{ to: [{ email }] }], - from: { email: fromEmail }, - subject: "Sign in to codebar", - content: [ - { - type: "text/plain", - value: `Click the link below to sign in to codebar:\n\n${url}\n\nThis link expires in 5 minutes.`, - }, - ], - }), + body: JSON.stringify( + buildMagicLinkPayload({ + email, + url, + fromEmail, + subject: "Sign in to codebar", + }), + ), }); if (!res.ok) { diff --git a/test/unit/magic-link-email.test.js b/test/unit/magic-link-email.test.js new file mode 100644 index 0000000..b6f209b --- /dev/null +++ b/test/unit/magic-link-email.test.js @@ -0,0 +1,51 @@ +import { test } from "tap"; +import { buildMagicLinkPayload } from "../../src/app/utils/magic-link-email.js"; + +test("buildMagicLinkPayload disables SendGrid click tracking", async (t) => { + const payload = buildMagicLinkPayload({ + email: "user@codebar.io", + url: "https://auth.codebar.io/api/auth/magic-link/verify?token=abc", + fromEmail: "auth-noreply@codebar.io", + subject: "Sign in to codebar", + }); + + t.equal(payload.tracking_settings.click_tracking.enable, false); + t.equal(payload.tracking_settings.open_tracking.enable, false); +}); + +test("buildMagicLinkPayload embeds the real URL in the plain-text body", async (t) => { + const url = "https://auth.codebar.io/api/auth/magic-link/verify?token=xyz"; + const payload = buildMagicLinkPayload({ + email: "user@codebar.io", + url, + fromEmail: "auth-noreply@codebar.io", + subject: "Sign in to codebar", + }); + + t.equal(payload.personalizations[0].to[0].email, "user@codebar.io"); + t.equal(payload.from.email, "auth-noreply@codebar.io"); + t.equal(payload.subject, "Sign in to codebar"); + t.equal(payload.content[0].type, "text/plain"); + t.ok(payload.content[0].value.includes(url)); +}); + +test("buildMagicLinkPayload adds HTML part with escaped href", async (t) => { + const url = + "https://auth.codebar.io/api/auth/magic-link/verify?token=a+b&x=1"; + const payload = buildMagicLinkPayload({ + email: "user@codebar.io", + url, + fromEmail: "auth-noreply@codebar.io", + subject: "Sign in to codebar", + }); + + const html = payload.content.find((c) => c.type === "text/html"); + t.ok(html, "has an HTML part"); + t.match(html.value, /