Files
property-management-network/lib/email/layout.ts
T
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

246 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()
}