Redesign transactional email templates to match Echo brand

- Replace bare inline HTML with a shared emailShell template system
- Use brand colors (warm neutrals), Quicksand font, and rounded card layout
- Add hidden preheader text for better inbox previews
- Proper table-based layout for email client compatibility (Outlook, Gmail, Apple Mail)
- Include Mso conditionals for Outlook button rendering
- Add dev-only email preview route at /api/dev/email-preview (gated behind NODE_ENV)
- Fix invitation email copy bug ("has invited you" / "You've been invited")

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-16 20:46:05 -05:00
parent c9f5b1e2ee
commit 7ab2fe6894
2 changed files with 304 additions and 30 deletions

View file

@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "Not available" }, { status: 404 });
}
// Dynamically import to access the private emailShell via the public wrappers
// We'll just render inline for preview purposes
const { searchParams } = req.nextUrl;
const template = searchParams.get("template") || "verification";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
const BRAND = {
dark: "#2d2b28",
accent: "#3d3a36",
muted: "#8a8580",
bg: "#f7f5f2",
cardBg: "#ffffff",
border: "#e8e4df",
cta: "#2d2b28",
ctaText: "#fafaf8",
link: "#5c5650",
font: "'Quicksand', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
};
const templates: Record<string, { preheader: string; heading: string; body: string; ctaLabel: string; ctaUrl: string; footer: string }> = {
verification: {
preheader: "Confirm your email to start scanning cards with Echo.",
heading: "Verify your email address",
body: `
<p style="margin:0 0 16px;">Welcome to Echo! Before you can start scanning response cards, we need to verify your email address.</p>
<p style="margin:0;">Click the button below to confirm your email and get started.</p>
`,
ctaLabel: "Verify Email Address",
ctaUrl: "#",
footer: "If you didn't create an Echo account, you can safely ignore this email. This link expires in 24 hours.",
},
invitation: {
preheader: "Sarah invited you to join Grace Community Church on Echo.",
heading: "You're invited to Grace Community Church",
body: `
<p style="margin:0 0 16px;"><strong>Sarah Johnson</strong> has invited you to join <strong>Grace Community Church</strong> on Echo AI-powered response card scanning for churches and ministries.</p>
<p style="margin:0;">Accept the invitation below to join the team and start collaborating.</p>
`,
ctaLabel: "Accept Invitation",
ctaUrl: "#",
footer: "This invitation expires in 7 days. If you weren't expecting this email, you can safely ignore it.",
},
};
const t = templates[template] || templates.verification;
const html = `<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${t.heading}</title>
<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:${BRAND.font};-webkit-font-smoothing:antialiased;">
<div style="display:none;max-height:0;overflow:hidden;">${t.preheader}</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:${BRAND.bg};">
<tr>
<td align="center" style="padding:40px 16px 32px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
<tr>
<td align="center" style="padding-bottom:32px;">
<a href="${SITE_URL}" style="text-decoration:none;" target="_blank">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="background-color:${BRAND.dark};border-radius:8px;width:30px;height:30px;text-align:center;line-height:30px;font-size:16px;" valign="middle">
<span style="color:${BRAND.ctaText};">&#10711;</span>
</td>
<td style="padding-left:10px;font-family:${BRAND.font};font-size:20px;font-weight:700;color:${BRAND.dark};letter-spacing:-0.3px;" valign="middle">
Echo
</td>
</tr>
</table>
</a>
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;background-color:${BRAND.cardBg};border-radius:16px;border:1px solid ${BRAND.border};box-shadow:0 1px 3px rgba(0,0,0,0.04),0 4px 16px rgba(0,0,0,0.03);">
<tr>
<td style="padding:48px 40px 44px;">
<h1 style="margin:0 0 16px;font-family:${BRAND.font};font-size:24px;font-weight:700;color:${BRAND.dark};line-height:1.3;letter-spacing:-0.3px;">${t.heading}</h1>
<div style="margin:0 0 32px;font-family:${BRAND.font};font-size:15px;color:${BRAND.accent};line-height:1.65;">${t.body}</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
<a href="${t.ctaUrl}" target="_blank" style="display:inline-block;padding:14px 32px;background-color:${BRAND.cta};color:${BRAND.ctaText};font-family:${BRAND.font};font-size:15px;font-weight:600;text-decoration:none;border-radius:10px;letter-spacing:-0.2px;text-align:center;">
${t.ctaLabel}
</a>
</td>
</tr>
</table>
<p style="margin:28px 0 0;font-family:${BRAND.font};font-size:13px;color:${BRAND.muted};line-height:1.55;">${t.footer}</p>
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
<tr>
<td align="center" style="padding:28px 0 8px;">
<p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};line-height:1.5;">AI-powered response card scanning for churches</p>
<p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;line-height:1.5;"><a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;" target="_blank">echoocr.com</a></p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
}

View file

@ -9,6 +9,147 @@ const DEFAULT_SENDER = {
email: process.env.EMAIL_FROM_ADDRESS || "mars@noreply.stillwell.cloud",
};
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
/* ------------------------------------------------------------------ */
/* Shared template shell */
/* ------------------------------------------------------------------ */
const BRAND = {
dark: "#2d2b28",
accent: "#3d3a36",
muted: "#8a8580",
bg: "#f7f5f2",
cardBg: "#ffffff",
border: "#e8e4df",
cta: "#2d2b28",
ctaText: "#fafaf8",
link: "#5c5650",
font: "'Quicksand', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
};
function emailShell({
preheader,
heading,
body,
ctaLabel,
ctaUrl,
footer,
}: {
preheader: string;
heading: string;
body: string;
ctaLabel: string;
ctaUrl: string;
footer: string;
}) {
return `<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<meta name="supported-color-schemes" content="light" />
<title>${heading}</title>
<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!--[if mso]>
<style>
* { font-family: 'Segoe UI', Roboto, Arial, sans-serif !important; }
</style>
<![endif]-->
</head>
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:${BRAND.font};-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;">
<!-- Preheader (hidden preview text) -->
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${preheader}&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:${BRAND.bg};">
<tr>
<td align="center" style="padding:40px 16px 32px;">
<!-- Logo -->
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
<tr>
<td align="center" style="padding-bottom:32px;">
<a href="${SITE_URL}" style="text-decoration:none;" target="_blank">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="background-color:${BRAND.dark};border-radius:8px;width:30px;height:30px;text-align:center;line-height:30px;font-size:16px;" valign="middle">
<span style="color:${BRAND.ctaText};">&#10711;</span>
</td>
<td style="padding-left:10px;font-family:${BRAND.font};font-size:20px;font-weight:700;color:${BRAND.dark};letter-spacing:-0.3px;" valign="middle">
Echo
</td>
</tr>
</table>
</a>
</td>
</tr>
</table>
<!-- Card -->
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;background-color:${BRAND.cardBg};border-radius:16px;border:1px solid ${BRAND.border};box-shadow:0 1px 3px rgba(0,0,0,0.04),0 4px 16px rgba(0,0,0,0.03);">
<tr>
<td style="padding:48px 40px 44px;">
<!-- Heading -->
<h1 style="margin:0 0 16px;font-family:${BRAND.font};font-size:24px;font-weight:700;color:${BRAND.dark};line-height:1.3;letter-spacing:-0.3px;">
${heading}
</h1>
<!-- Body -->
<div style="margin:0 0 32px;font-family:${BRAND.font};font-size:15px;color:${BRAND.accent};line-height:1.65;">
${body}
</div>
<!-- CTA Button -->
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
<a href="${ctaUrl}" target="_blank"
style="display:inline-block;padding:14px 32px;background-color:${BRAND.cta};color:${BRAND.ctaText};font-family:${BRAND.font};font-size:15px;font-weight:600;text-decoration:none;border-radius:10px;letter-spacing:-0.2px;mso-padding-alt:0;text-align:center;">
<!--[if mso]><i style="mso-font-width:150%;mso-text-raise:22pt;" hidden>&nbsp;</i><![endif]-->
<span style="mso-text-raise:11pt;">${ctaLabel}</span>
<!--[if mso]><i style="mso-font-width:150%;" hidden>&nbsp;</i><![endif]-->
</a>
</td>
</tr>
</table>
<!-- Footer note -->
<p style="margin:28px 0 0;font-family:${BRAND.font};font-size:13px;color:${BRAND.muted};line-height:1.55;">
${footer}
</p>
</td>
</tr>
</table>
<!-- Bottom branding -->
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
<tr>
<td align="center" style="padding:28px 0 8px;">
<p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};line-height:1.5;">
AI-powered response card scanning for churches
</p>
<p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;line-height:1.5;">
<a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;" target="_blank">echoocr.com</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
/* ------------------------------------------------------------------ */
/* Core send function */
/* ------------------------------------------------------------------ */
export async function sendEmail(to: string, subject: string, html: string) {
await brevo.transactionalEmails.sendTransacEmail({
sender: DEFAULT_SENDER,
@ -18,9 +159,13 @@ export async function sendEmail(to: string, subject: string, html: string) {
});
}
/* ------------------------------------------------------------------ */
/* Verification email */
/* ------------------------------------------------------------------ */
export async function generateVerificationToken(email: string): Promise<string> {
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.verificationToken.deleteMany({
where: { identifier: email },
@ -41,24 +186,26 @@ export async function sendVerificationEmail(email: string, baseUrl: string) {
const token = await generateVerificationToken(email);
const verifyUrl = `${baseUrl}/api/auth/verify-email/confirm?token=${token}`;
const html = `
<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#1a1a1a">
<h2 style="margin-bottom:16px">Verify your email address</h2>
<p>Click the button below to verify your email for Echo OCR.</p>
<a href="${verifyUrl}"
style="display:inline-block;margin:24px 0;padding:12px 24px;background:#6366f1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">
Verify Email
</a>
<p style="font-size:13px;color:#666">
If you didn't create an account, you can ignore this email.
This link expires in 24 hours.
</p>
</div>
`;
const html = emailShell({
preheader: "Confirm your email to start scanning cards with Echo.",
heading: "Verify your email address",
body: `
<p style="margin:0 0 16px;">Welcome to Echo! Before you can start scanning response cards, we need to verify your email address.</p>
<p style="margin:0;">Click the button below to confirm your email and get started.</p>
`,
ctaLabel: "Verify Email Address",
ctaUrl: verifyUrl,
footer:
"If you didn't create an Echo account, you can safely ignore this email. This link expires in 24 hours.",
});
await sendEmail(email, "Verify your email — Echo OCR", html);
await sendEmail(email, "Verify your email — Echo", html);
}
/* ------------------------------------------------------------------ */
/* Invitation email */
/* ------------------------------------------------------------------ */
export async function sendInvitationEmail(
email: string,
baseUrl: string,
@ -68,19 +215,25 @@ export async function sendInvitationEmail(
) {
const inviteUrl = `${baseUrl}/invite/${inviteToken}`;
const html = `
<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#1a1a1a">
<h2 style="margin-bottom:16px">You're invited to ${orgName}</h2>
<p>${inviterName ? `${inviterName} has` : "You've been"} invited you to join <strong>${orgName}</strong> on Echo OCR.</p>
<a href="${inviteUrl}"
style="display:inline-block;margin:24px 0;padding:12px 24px;background:#6366f1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">
Accept Invitation
</a>
<p style="font-size:13px;color:#666">
This invitation expires in 7 days. If you weren't expecting this, you can ignore it.
</p>
</div>
`;
const inviterLine = inviterName
? `<strong>${inviterName}</strong> has invited you to join`
: "You've been invited to join";
await sendEmail(email, `You're invited to ${orgName} — Echo OCR`, html);
const html = emailShell({
preheader: `${inviterName || "Your team"} invited you to join ${orgName} on Echo.`,
heading: `You're invited to ${orgName}`,
body: `
<p style="margin:0 0 16px;">${inviterLine} <strong>${orgName}</strong> on Echo AI-powered response card scanning for churches and ministries.</p>
<p style="margin:0;">Accept the invitation below to join the team and start collaborating.</p>
`,
ctaLabel: "Accept Invitation",
ctaUrl: inviteUrl,
footer: `This invitation expires in 7 days. If you weren't expecting this email, you can safely ignore it.`,
});
await sendEmail(
email,
`You're invited to ${orgName} — Echo`,
html
);
}