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>
246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
// 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, "&")
|
||
.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 `
|
||
<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;"> </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)}${" ͏ ".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;"> </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;"> </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};">© ${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(/ ||͏|‌| /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()
|
||
}
|