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:
parent
c9f5b1e2ee
commit
7ab2fe6894
2 changed files with 304 additions and 30 deletions
121
src/app/api/dev/email-preview/route.ts
Normal file
121
src/app/api/dev/email-preview/route.ts
Normal 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};">⧗</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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,147 @@ const DEFAULT_SENDER = {
|
||||||
email: process.env.EMAIL_FROM_ADDRESS || "mars@noreply.stillwell.cloud",
|
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}͏‌ ͏‌ ͏‌ ͏‌ ͏‌ ͏‌ </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};">⧗</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> </i><![endif]-->
|
||||||
|
<span style="mso-text-raise:11pt;">${ctaLabel}</span>
|
||||||
|
<!--[if mso]><i style="mso-font-width:150%;" hidden> </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) {
|
export async function sendEmail(to: string, subject: string, html: string) {
|
||||||
await brevo.transactionalEmails.sendTransacEmail({
|
await brevo.transactionalEmails.sendTransacEmail({
|
||||||
sender: DEFAULT_SENDER,
|
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> {
|
export async function generateVerificationToken(email: string): Promise<string> {
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
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({
|
await prisma.verificationToken.deleteMany({
|
||||||
where: { identifier: email },
|
where: { identifier: email },
|
||||||
|
|
@ -41,24 +186,26 @@ export async function sendVerificationEmail(email: string, baseUrl: string) {
|
||||||
const token = await generateVerificationToken(email);
|
const token = await generateVerificationToken(email);
|
||||||
const verifyUrl = `${baseUrl}/api/auth/verify-email/confirm?token=${token}`;
|
const verifyUrl = `${baseUrl}/api/auth/verify-email/confirm?token=${token}`;
|
||||||
|
|
||||||
const html = `
|
const html = emailShell({
|
||||||
<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#1a1a1a">
|
preheader: "Confirm your email to start scanning cards with Echo.",
|
||||||
<h2 style="margin-bottom:16px">Verify your email address</h2>
|
heading: "Verify your email address",
|
||||||
<p>Click the button below to verify your email for Echo OCR.</p>
|
body: `
|
||||||
<a href="${verifyUrl}"
|
<p style="margin:0 0 16px;">Welcome to Echo! Before you can start scanning response cards, we need to verify your email address.</p>
|
||||||
style="display:inline-block;margin:24px 0;padding:12px 24px;background:#6366f1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">
|
<p style="margin:0;">Click the button below to confirm your email and get started.</p>
|
||||||
Verify Email
|
`,
|
||||||
</a>
|
ctaLabel: "Verify Email Address",
|
||||||
<p style="font-size:13px;color:#666">
|
ctaUrl: verifyUrl,
|
||||||
If you didn't create an account, you can ignore this email.
|
footer:
|
||||||
This link expires in 24 hours.
|
"If you didn't create an Echo account, you can safely ignore this email. This link expires in 24 hours.",
|
||||||
</p>
|
});
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sendEmail(email, "Verify your email — Echo OCR", html);
|
await sendEmail(email, "Verify your email — Echo", html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Invitation email */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export async function sendInvitationEmail(
|
export async function sendInvitationEmail(
|
||||||
email: string,
|
email: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
|
|
@ -68,19 +215,25 @@ export async function sendInvitationEmail(
|
||||||
) {
|
) {
|
||||||
const inviteUrl = `${baseUrl}/invite/${inviteToken}`;
|
const inviteUrl = `${baseUrl}/invite/${inviteToken}`;
|
||||||
|
|
||||||
const html = `
|
const inviterLine = inviterName
|
||||||
<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#1a1a1a">
|
? `<strong>${inviterName}</strong> has invited you to join`
|
||||||
<h2 style="margin-bottom:16px">You're invited to ${orgName}</h2>
|
: "You've been invited to join";
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue