// 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 logos resolve in a recipient's email client // (relative paths and Next /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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") } 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 `
${safeLabel}
` } /** 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) => ` ${escapeHtml(r.label)} ${escapeHtml(r.value)} ${i === rows.length - 1 ? "" : `\n  `}` ) .join("") return `
${body}
` } /** A small colored status pill. */ export function statusBadge(label: string, accent: string = BRAND.indigo): string { return `${escapeHtml(label)}` } /** 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 `

${html}

` } 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 ? `
${escapeHtml(opts.preheader)}${" ͏ ".repeat(60)}
` : "" const eyebrow = opts.eyebrow ? `

${escapeHtml(opts.eyebrow)}

` : "" const intro = opts.intro ? `

${opts.intro}

` : "" const button = opts.button ? emailButton(opts.button, accent) : "" const footerNote = opts.footerNote ? `

${opts.footerNote}

` : "" return ` ${escapeHtml(opts.title)} ${preheader}
` } /** Best-effort plain-text version of an HTML email for the multipart fallback. */ export function htmlToText(html: string): string { return html .replace(//gi, "") .replace(//gi, "") .replace(//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(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, href, inner) => { const text = String(inner).replace(/<[^>]+>/g, "").trim() return text ? `${text} (${href})` : "" }) .replace(/]*>/gi, "") .replace(/<\/(p|div|tr|h1|h2|h3|h4|li|table)>/gi, "\n") .replace(//gi, "\n") .replace(/<[^>]+>/g, "") .replace(/[​‌‍⁠͏ ]/g, "") .replace(/ ||͏|‌| /gi, " ") .replace(/©/gi, "©") .replace(/—/gi, "—") .replace(/–/gi, "–") .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'|'/gi, "'") .split("\n") .map((l) => l.replace(/[ \t]+/g, " ").trim()) .join("\n") .replace(/\n{3,}/g, "\n\n") .trim() }