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>
138 lines
5.2 KiB
TypeScript
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, "&")
|
|
.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>`;
|
|
}
|