brackt/app/lib/email.server.ts
Chris Parsons 04a92ec7de
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>
2026-05-10 11:18:57 -07:00

138 lines
5.2 KiB
TypeScript

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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)}&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;</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);">
&copy; ${year} Brackt. All rights reserved.
</p>
</td>
</tr>
</table>
<!--[if mso]>
</td></tr></table>
<![endif]-->
</td>
</tr>
</table>
</body>
</html>`;
}