Add branded dark-themed HTML email template (#402)
Introduces a shared email module that wraps all transactional emails in a cohesive dark-themed template matching the site's visual identity (Barlow font, #14171e background, #adf661 CTA buttons, logomark PNG header). - app/lib/email.server.ts: new module with sendEmail, wrapInEmailTemplate, emailButton, emailParagraph, and escapeHtml helpers; Resend instance is lazily initialized as a singleton - public/email-logo.png: 96×124px PNG of the logomark for email use (SVG gradients are not supported in Outlook) - auth.server.ts: password reset and email verification now use the branded template; errors are thrown so Better Auth can surface failures - support.tsx: internal notification uses sendEmail; adds a user-facing branded confirmation email (fire-and-forget so failures don't surface to the user); plain text fallback included - scripts/sync-prod-db.sh: remove first_name/last_name from sanitize query (columns were dropped in #399) - 13 unit tests covering all template helpers Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c8748d1b14
commit
04a92ec7de
6 changed files with 281 additions and 20 deletions
101
app/lib/__tests__/email.server.test.ts
Normal file
101
app/lib/__tests__/email.server.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
escapeHtml,
|
||||||
|
emailParagraph,
|
||||||
|
emailButton,
|
||||||
|
wrapInEmailTemplate,
|
||||||
|
} from "~/lib/email.server";
|
||||||
|
|
||||||
|
describe("escapeHtml", () => {
|
||||||
|
it("escapes all five special characters", () => {
|
||||||
|
expect(escapeHtml('&<>"\'')).toBe("&<>"'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves safe strings unchanged", () => {
|
||||||
|
expect(escapeHtml("Hello World")).toBe("Hello World");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emailParagraph", () => {
|
||||||
|
it("wraps content in a <p> tag", () => {
|
||||||
|
const result = emailParagraph("Hello");
|
||||||
|
expect(result).toContain("<p ");
|
||||||
|
expect(result).toContain("Hello");
|
||||||
|
expect(result).toContain("</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes HTML through unescaped (caller is responsible for escaping)", () => {
|
||||||
|
const result = emailParagraph('<a href="x">link</a>');
|
||||||
|
expect(result).toContain('<a href="x">');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emailButton", () => {
|
||||||
|
it("contains the href and button text", () => {
|
||||||
|
const result = emailButton("https://example.com/verify", "Verify My Email");
|
||||||
|
expect(result).toContain("https://example.com/verify");
|
||||||
|
expect(result).toContain("Verify My Email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a table-wrapped anchor for Outlook compatibility", () => {
|
||||||
|
const result = emailButton("https://example.com", "Click me");
|
||||||
|
expect(result).toContain("<table");
|
||||||
|
expect(result).toContain("<a ");
|
||||||
|
expect(result).toContain('href="https://example.com"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wrapInEmailTemplate", () => {
|
||||||
|
const originalAppUrl = process.env.APP_URL;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.APP_URL = "https://test.brackt.com";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalAppUrl === undefined) {
|
||||||
|
delete process.env.APP_URL;
|
||||||
|
} else {
|
||||||
|
process.env.APP_URL = originalAppUrl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a complete HTML document", () => {
|
||||||
|
const result = wrapInEmailTemplate("<p>Body</p>");
|
||||||
|
expect(result).toContain("<!DOCTYPE html>");
|
||||||
|
expect(result).toContain("<html");
|
||||||
|
expect(result).toContain("</html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects the content into the template", () => {
|
||||||
|
const result = wrapInEmailTemplate("<p>My content</p>");
|
||||||
|
expect(result).toContain("<p>My content</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the preheader text when provided", () => {
|
||||||
|
const result = wrapInEmailTemplate("<p>Body</p>", "Preview text here");
|
||||||
|
expect(result).toContain("Preview text here");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the preheader span when not provided", () => {
|
||||||
|
const result = wrapInEmailTemplate("<p>Body</p>");
|
||||||
|
expect(result).not.toContain("mso-hide:all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses APP_URL for the logo src", () => {
|
||||||
|
const result = wrapInEmailTemplate("<p>Body</p>");
|
||||||
|
expect(result).toContain("https://test.brackt.com/email-logo.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to brackt.com when APP_URL is not set", () => {
|
||||||
|
delete process.env.APP_URL;
|
||||||
|
const result = wrapInEmailTemplate("<p>Body</p>");
|
||||||
|
expect(result).toContain("https://brackt.com/email-logo.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes preheader text to prevent injection", () => {
|
||||||
|
const result = wrapInEmailTemplate("<p>Body</p>", "<script>alert(1)</script>");
|
||||||
|
expect(result).not.toContain("<script>");
|
||||||
|
expect(result).toContain("<script>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,17 +2,15 @@ import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import { Resend } from "resend";
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { generateFlagConfig } from "~/lib/flag-generator";
|
import { generateFlagConfig } from "~/lib/flag-generator";
|
||||||
|
import { sendEmail, wrapInEmailTemplate, emailButton, emailParagraph } from "~/lib/email.server";
|
||||||
|
|
||||||
if (!process.env.BETTER_AUTH_SECRET) {
|
if (!process.env.BETTER_AUTH_SECRET) {
|
||||||
throw new Error("BETTER_AUTH_SECRET is required");
|
throw new Error("BETTER_AUTH_SECRET is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
|
||||||
|
|
||||||
const googleProvider = process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
const googleProvider = process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||||
? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
|
? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
|
||||||
: {};
|
: {};
|
||||||
|
|
@ -86,12 +84,17 @@ export const auth = betterAuth({
|
||||||
bcrypt.compare(password, hash),
|
bcrypt.compare(password, hash),
|
||||||
},
|
},
|
||||||
sendResetPassword: async ({ user, url }) => {
|
sendResetPassword: async ({ user, url }) => {
|
||||||
await resend.emails.send({
|
const { error } = await sendEmail({
|
||||||
from: "Brackt <noreply@brackt.com>",
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: "Reset your Brackt password",
|
subject: "Reset your Brackt password",
|
||||||
html: `<p>Click the link below to reset your password. This link expires in 1 hour.</p><p><a href="${url}">${url}</a></p>`,
|
html: wrapInEmailTemplate(
|
||||||
|
emailParagraph("Click the button below to reset your Brackt password. This link expires in 1 hour.") +
|
||||||
|
emailButton(url, "Reset My Password") +
|
||||||
|
emailParagraph(`Or copy this link: <a href="${url}" style="color:#adf661;word-break:break-all;">${url}</a>`),
|
||||||
|
"Reset your Brackt password — link expires in 1 hour."
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
if (error) throw error;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emailVerification: {
|
emailVerification: {
|
||||||
|
|
@ -99,12 +102,17 @@ export const auth = betterAuth({
|
||||||
requireEmailVerification: true,
|
requireEmailVerification: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
sendVerificationEmail: async ({ user, url }) => {
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
await resend.emails.send({
|
const { error } = await sendEmail({
|
||||||
from: "Brackt <noreply@brackt.com>",
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: "Verify your Brackt email",
|
subject: "Verify your Brackt email",
|
||||||
html: `<p>Thanks for signing up for Brackt!</p><p>Click the link below to verify your email address. This link expires in 24 hours.</p><p><a href="${url}">Verify my email</a></p><p>Or copy this link: ${url}</p>`,
|
html: wrapInEmailTemplate(
|
||||||
|
emailParagraph("Thanks for signing up for Brackt! Click the button below to verify your email address. This link expires in 24 hours.") +
|
||||||
|
emailButton(url, "Verify My Email") +
|
||||||
|
emailParagraph(`Or copy this link: <a href="${url}" style="color:#adf661;word-break:break-all;">${url}</a>`),
|
||||||
|
"Verify your email to start using Brackt."
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
if (error) throw error;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
|
|
|
||||||
138
app/lib/email.server.ts
Normal file
138
app/lib/email.server.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { Resend } from "resend";
|
||||||
|
|
||||||
|
const FONT_STACK =
|
||||||
|
"'Barlow', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
||||||
|
|
||||||
|
const FROM_DEFAULT = "Brackt <noreply@brackt.com>";
|
||||||
|
|
||||||
|
// Lazily initialized so import doesn't throw in test environments without RESEND_API_KEY.
|
||||||
|
let _resend: Resend | null = null;
|
||||||
|
function getResend(): Resend {
|
||||||
|
if (!_resend) {
|
||||||
|
const key = process.env.RESEND_API_KEY;
|
||||||
|
if (!key) throw new Error("RESEND_API_KEY is not set");
|
||||||
|
_resend = new Resend(key);
|
||||||
|
}
|
||||||
|
return _resend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail(opts: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text?: string;
|
||||||
|
replyTo?: string;
|
||||||
|
from?: string;
|
||||||
|
}): Promise<{ error: Error | null }> {
|
||||||
|
const resend = getResend();
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from: opts.from ?? FROM_DEFAULT,
|
||||||
|
to: opts.to,
|
||||||
|
subject: opts.subject,
|
||||||
|
html: opts.html,
|
||||||
|
...(opts.text ? { text: opts.text } : {}),
|
||||||
|
...(opts.replyTo ? { replyTo: opts.replyTo } : {}),
|
||||||
|
});
|
||||||
|
return { error: error ? new Error(error.message) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emailParagraph(html: string): string {
|
||||||
|
return `<p style="margin:0 0 16px;font-family:${FONT_STACK};font-size:16px;line-height:1.6;color:#ffffff;">${html}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emailButton(href: string, text: string): string {
|
||||||
|
return `
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#adf661" style="border-radius:6px;">
|
||||||
|
<a href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:12px 28px;color:#14171e;font-family:${FONT_STACK};font-size:16px;font-weight:700;text-decoration:none;border-radius:6px;">${text}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapInEmailTemplate(
|
||||||
|
content: string,
|
||||||
|
preheader?: string
|
||||||
|
): string {
|
||||||
|
const appUrl = process.env.APP_URL ?? "https://brackt.com";
|
||||||
|
const logoUrl = `${appUrl}/email-logo.png`;
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
const preheaderHtml = preheader
|
||||||
|
? `<span style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(preheader)} ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌</span>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<meta name="supported-color-schemes" content="light dark">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Barlow:wght@400;600;700&display=swap');
|
||||||
|
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||||
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
|
||||||
|
body { margin: 0; padding: 0; background-color: #14171e; color-scheme: light dark; }
|
||||||
|
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#14171e;">
|
||||||
|
${preheaderHtml}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="#14171e" style="background-color:#14171e;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:40px 16px;">
|
||||||
|
<!--[if mso]>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600"><tr><td>
|
||||||
|
<![endif]-->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;background-color:#1c1f26;border-radius:8px;border:1px solid rgba(255,255,255,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:32px 40px 24px;border-bottom:1px solid rgba(255,255,255,0.08);">
|
||||||
|
<img src="${logoUrl}" width="48" height="62" alt="Brackt" style="display:block;width:48px;height:62px;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px 40px;">
|
||||||
|
${content}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:24px 40px;border-top:1px solid rgba(255,255,255,0.08);">
|
||||||
|
<p style="margin:0;font-family:${FONT_STACK};font-size:12px;line-height:1.5;color:rgba(255,255,255,0.45);">
|
||||||
|
© ${year} Brackt. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if mso]>
|
||||||
|
</td></tr></table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { data, useActionData, useLoaderData } from "react-router";
|
import { data, useActionData, useLoaderData } from "react-router";
|
||||||
import { Resend } from "resend";
|
import { sendEmail, wrapInEmailTemplate, emailButton, emailParagraph, escapeHtml } from "~/lib/email.server";
|
||||||
|
import { logger } from "~/lib/logger";
|
||||||
import { Turnstile } from "@marsidev/react-turnstile";
|
import { Turnstile } from "@marsidev/react-turnstile";
|
||||||
import { MessageCircle, Mail, CheckCircle } from "lucide-react";
|
import { MessageCircle, Mail, CheckCircle } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
@ -73,25 +74,40 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email via Resend
|
// Send internal notification to support@brackt.com
|
||||||
const resendKey = process.env.RESEND_API_KEY;
|
const { error: sendError } = await sendEmail({
|
||||||
if (!resendKey) {
|
|
||||||
return data({ error: "Server configuration error. Please try again later." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resend = new Resend(resendKey);
|
|
||||||
const { error: sendError } = await resend.emails.send({
|
|
||||||
from: "Brackt Support Form <noreply@brackt.com>",
|
from: "Brackt Support Form <noreply@brackt.com>",
|
||||||
to: "support@brackt.com",
|
to: "support@brackt.com",
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: `[Support] ${subject}`,
|
subject: `[Support] ${subject}`,
|
||||||
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
|
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
|
||||||
|
html: wrapInEmailTemplate(
|
||||||
|
emailParagraph(`<strong>From:</strong> ${escapeHtml(name)} <${escapeHtml(email)}>`) +
|
||||||
|
emailParagraph(`<strong>Subject:</strong> ${escapeHtml(subject)}`) +
|
||||||
|
emailParagraph(escapeHtml(message).replace(/\n/g, "<br>")),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sendError) {
|
if (sendError) {
|
||||||
return data({ error: "Failed to send your message. Please try again." });
|
return data({ error: "Failed to send your message. Please try again." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send confirmation to the user — fire and forget so a failure doesn't surface as a user error
|
||||||
|
const appUrl = process.env.APP_URL ?? "https://brackt.com";
|
||||||
|
sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: "We received your message — Brackt Support",
|
||||||
|
html: wrapInEmailTemplate(
|
||||||
|
emailParagraph(`Hi ${escapeHtml(name)}, thanks for reaching out!`) +
|
||||||
|
emailParagraph("We've received your message and will get back to you as soon as we can.") +
|
||||||
|
emailButton(appUrl, "Back to Brackt"),
|
||||||
|
"We received your Brackt support request."
|
||||||
|
),
|
||||||
|
text: `Hi ${name}, thanks for reaching out!\n\nWe've received your message and will get back to you as soon as we can.\n\nBrackt — ${appUrl}`,
|
||||||
|
}).then(({ error }) => {
|
||||||
|
if (error) logger.warn("Support confirmation email failed", { error, to: email });
|
||||||
|
});
|
||||||
|
|
||||||
return data({ success: true as const });
|
return data({ success: true as const });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
BIN
public/email-logo.png
Normal file
BIN
public/email-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
|
|
@ -144,8 +144,6 @@ UPDATE leagues SET discord_webhook_url = NULL;
|
||||||
-- (admin accounts are untouched so admin pages remain accessible)
|
-- (admin accounts are untouched so admin pages remain accessible)
|
||||||
UPDATE users SET
|
UPDATE users SET
|
||||||
email = 'user_' || id || '@example.com',
|
email = 'user_' || id || '@example.com',
|
||||||
first_name = 'Test',
|
|
||||||
last_name = 'User',
|
|
||||||
display_name = 'Test User',
|
display_name = 'Test User',
|
||||||
image_url = NULL
|
image_url = NULL
|
||||||
WHERE is_admin = FALSE;
|
WHERE is_admin = FALSE;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue