Files
property-management-network/lib/email/layout.ts
T

246 lines
11 KiB
TypeScript
Raw Normal View History

// Shared, cross-client email design system.
//
// Every transactional email in the app is composed through `emailShell()` so
// they share one consistent, deliverable-in-every-client look. The markup is
// deliberately table-based with MSO/VML fallbacks and inline styles — that is
// what renders reliably in Outlook, Gmail, Apple Mail, etc. The design is a
// clean, light "premium SaaS" style with a branded header and a colored accent
// bar that gives each email type its own identity.
const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME ?? "Property Management Network"
// Absolute base URL so <img> logos resolve in a recipient's email client
// (relative paths and Next <Image>/SVG don't work in email). Light-themed
// emails use the dark wordmark lockup, which is designed for light surfaces.
const APP_URL = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
const LOGO_WORDMARK_URL = `${APP_URL}/logo-dark.png`
// Brand palette
export const BRAND = {
indigo: "#6366f1",
indigoDark: "#4f46e5",
violet: "#7c3aed",
red: "#dc2626",
amber: "#d97706",
green: "#059669",
ink: "#18181b",
body: "#52525b",
muted: "#8b8f9a",
line: "#eceef2",
panel: "#f7f8fa",
page: "#eef1f6",
} as const
export function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
const FONT_STACK =
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
export interface EmailButton {
href: string
label: string
}
export interface EmailShellOptions {
/** Hidden inbox-preview text shown next to the subject line. */
preheader?: string
/** Accent hex used for the top bar and button. Defaults to brand indigo. */
accent?: string
/** Small uppercase label rendered above the title. */
eyebrow?: string
/** Main heading. Plain text — will be escaped. */
title: string
/** Intro/greeting HTML (already escaped by caller). */
intro?: string
/** Main content HTML block (already escaped by caller). */
body?: string
/** Primary call-to-action button. */
button?: EmailButton
/** Extra note HTML rendered under the button (already escaped by caller). */
footerNote?: string
}
/** Bulletproof, rounded CTA button that degrades to a solid rectangle in Outlook. */
export function emailButton({ href, label }: EmailButton, accent: string = BRAND.indigoDark): string {
const safeHref = escapeHtml(href)
const safeLabel = escapeHtml(label)
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:8px 0 4px;">
<tr>
<td align="center" bgcolor="${accent}" style="border-radius:10px;background:linear-gradient(135deg,${accent},${BRAND.violet});">
<a href="${safeHref}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT_STACK};font-size:16px;font-weight:600;line-height:1;color:#ffffff;text-decoration:none;border-radius:10px;">${safeLabel}</a>
</td>
</tr>
</table>`
}
/** A rounded panel of label/value rows — used for rent/lease detail summaries. */
export function detailTable(
rows: Array<{ label: string; value: string; accent?: boolean }>,
accent: string = BRAND.indigo
): string {
const body = rows
.map(
(r, i) => `
<tr>
<td style="padding:${i === 0 ? "2px" : "10px"} 0 ${i === rows.length - 1 ? "2px" : "10px"};font-family:${FONT_STACK};font-size:14px;color:${BRAND.muted};">${escapeHtml(r.label)}</td>
<td align="right" style="padding:${i === 0 ? "2px" : "10px"} 0 ${i === rows.length - 1 ? "2px" : "10px"};font-family:${FONT_STACK};font-size:14px;font-weight:600;color:${r.accent ? accent : BRAND.ink};">${escapeHtml(r.value)}</td>
</tr>${i === rows.length - 1 ? "" : `\n <tr><td colspan="2" style="border-top:1px solid ${BRAND.line};font-size:0;line-height:0;">&nbsp;</td></tr>`}`
)
.join("")
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:8px 0 24px;background:${BRAND.panel};border:1px solid ${BRAND.line};border-radius:12px;">
<tr>
<td style="padding:18px 22px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">${body}
</table>
</td>
</tr>
</table>`
}
/** A small colored status pill. */
export function statusBadge(label: string, accent: string = BRAND.indigo): string {
return `<span style="display:inline-block;padding:5px 12px;border-radius:999px;background:${accent}1a;color:${accent};font-family:${FONT_STACK};font-size:13px;font-weight:600;line-height:1;text-transform:capitalize;">${escapeHtml(label)}</span>`
}
/** A styled paragraph helper for template bodies. */
export function paragraph(html: string, opts: { muted?: boolean; small?: boolean } = {}): string {
const color = opts.muted ? BRAND.muted : BRAND.body
const size = opts.small ? "13px" : "15px"
return `<p style="margin:0 0 18px;font-family:${FONT_STACK};font-size:${size};line-height:1.65;color:${color};">${html}</p>`
}
export function emailShell(opts: EmailShellOptions): string {
const accent = opts.accent ?? BRAND.indigo
const year = new Date().getFullYear()
const appName = escapeHtml(APP_NAME)
const preheader = opts.preheader
? `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${BRAND.page};opacity:0;">${escapeHtml(opts.preheader)}${"&#8199;&#65279;&#847; ".repeat(60)}</div>`
: ""
const eyebrow = opts.eyebrow
? `<p style="margin:0 0 10px;font-family:${FONT_STACK};font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:${accent};">${escapeHtml(opts.eyebrow)}</p>`
: ""
const intro = opts.intro
? `<p style="margin:0 0 18px;font-family:${FONT_STACK};font-size:15px;line-height:1.65;color:${BRAND.body};">${opts.intro}</p>`
: ""
const button = opts.button ? emailButton(opts.button, accent) : ""
const footerNote = opts.footerNote
? `<p style="margin:20px 0 0;font-family:${FONT_STACK};font-size:13px;line-height:1.6;color:${BRAND.muted};">${opts.footerNote}</p>`
: ""
return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="color-scheme" content="light only" />
<meta name="supported-color-schemes" content="light only" />
<title>${escapeHtml(opts.title)}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<![endif]-->
<style>
:root { color-scheme: light only; supported-color-schemes: light only; }
body { margin:0; padding:0; width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; }
table { border-collapse:collapse; }
img { border:0; outline:none; text-decoration:none; -ms-interpolation-mode:bicubic; }
a { text-decoration:none; }
@media only screen and (max-width:620px) {
.email-card { width:100% !important; border-radius:0 !important; }
.email-pad { padding-left:24px !important; padding-right:24px !important; }
}
</style>
</head>
<body style="margin:0;padding:0;background-color:${BRAND.page};">
${preheader}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:${BRAND.page};">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="600" class="email-card" cellspacing="0" cellpadding="0" border="0" style="width:600px;max-width:600px;background-color:#ffffff;border:1px solid ${BRAND.line};border-radius:16px;overflow:hidden;">
<!-- accent bar -->
<tr><td style="height:4px;background:linear-gradient(90deg,${accent},${BRAND.violet});font-size:0;line-height:0;">&nbsp;</td></tr>
<!-- brand header -->
<tr>
<td class="email-pad" style="padding:28px 40px 4px;">
<a href="${APP_URL}" target="_blank" style="text-decoration:none;">
<img src="${LOGO_WORDMARK_URL}" alt="${appName}" width="264" height="28" style="display:block;height:28px;width:auto;max-width:264px;border:0;outline:none;font-family:${FONT_STACK};font-size:18px;font-weight:700;color:${BRAND.ink};text-decoration:none;" />
</a>
</td>
</tr>
<!-- content -->
<tr>
<td class="email-pad" style="padding:24px 40px 8px;">
${eyebrow}
<h1 style="margin:0 0 14px;font-family:${FONT_STACK};font-size:23px;line-height:1.3;font-weight:700;color:${BRAND.ink};">${escapeHtml(opts.title)}</h1>
${intro}
${opts.body ?? ""}
${button}
${footerNote}
</td>
</tr>
<!-- footer -->
<tr>
<td class="email-pad" style="padding:28px 40px 34px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td style="border-top:1px solid ${BRAND.line};font-size:0;line-height:0;padding-top:22px;">&nbsp;</td></tr>
</table>
<p style="margin:0 0 4px;font-family:${FONT_STACK};font-size:13px;font-weight:600;color:${BRAND.body};">${appName}</p>
<p style="margin:0;font-family:${FONT_STACK};font-size:12px;line-height:1.6;color:${BRAND.muted};">Simple property management for modern landlords.</p>
<p style="margin:12px 0 0;font-family:${FONT_STACK};font-size:11px;color:${BRAND.muted};">&copy; ${year} ${appName}. All rights reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
}
/** Best-effort plain-text version of an HTML email for the multipart fallback. */
export function htmlToText(html: string): string {
return html
.replace(/<head[\s\S]*?<\/head>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
// Links: keep "text (url)" for real links; drop links that only wrap an
// image (e.g. the header logo) so we don't leak a bare URL into the text.
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, href, inner) => {
const text = String(inner).replace(/<[^>]+>/g, "").trim()
return text ? `${text} (${href})` : ""
})
.replace(/<img\b[^>]*>/gi, "")
.replace(/<\/(p|div|tr|h1|h2|h3|h4|li|table)>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/[͏]/g, "")
.replace(/&#8199;|&#65279;|&#847;|&zwnj;|&nbsp;/gi, " ")
.replace(/&copy;/gi, "©")
.replace(/&mdash;/gi, "—")
.replace(/&ndash;/gi, "")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.split("\n")
.map((l) => l.replace(/[ \t]+/g, " ").trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
}