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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
+30
-6
@@ -1,9 +1,33 @@
|
||||
import { Resend } from "resend"
|
||||
import nodemailer, { type Transporter } from "nodemailer"
|
||||
|
||||
// Use a placeholder when no key is configured so the constructor doesn't throw
|
||||
// at module load (it's imported on the auth path). Sends will fail gracefully
|
||||
// and are caught in sendEmail().
|
||||
export const resend = new Resend(process.env.RESEND_API_KEY || "re_placeholder")
|
||||
// SMTP transport (SMTP2GO). Created lazily so importing this on the auth path
|
||||
// doesn't require SMTP to be configured. Sends fail gracefully in sendEmail().
|
||||
const SMTP_HOST = process.env.SMTP_HOST
|
||||
const SMTP_PORT = Number(process.env.SMTP_PORT ?? 587)
|
||||
const SMTP_USER = process.env.SMTP_USER
|
||||
const SMTP_PASS = process.env.SMTP_PASS
|
||||
|
||||
export const FROM_EMAIL = process.env.RESEND_FROM_EMAIL ?? "noreply@propertymanagement.network"
|
||||
export const FROM_EMAIL =
|
||||
process.env.EMAIL_FROM ??
|
||||
process.env.SMTP_FROM ??
|
||||
"postmaster@propertymanagement.network"
|
||||
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME ?? "Property Management Network"
|
||||
|
||||
/** True when SMTP is configured (host + credentials present). */
|
||||
export function emailConfigured(): boolean {
|
||||
return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS)
|
||||
}
|
||||
|
||||
let _transporter: Transporter | null = null
|
||||
export function getTransporter(): Transporter {
|
||||
if (!_transporter) {
|
||||
_transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
// Port 465 uses implicit TLS/SSL; 587/2525/etc. negotiate STARTTLS.
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: SMTP_USER && SMTP_PASS ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
||||
})
|
||||
}
|
||||
return _transporter
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// 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()
|
||||
}
|
||||
+187
-95
@@ -1,35 +1,47 @@
|
||||
import { resend, FROM_EMAIL, APP_NAME } from "./client"
|
||||
import { getTransporter, emailConfigured, FROM_EMAIL, APP_NAME } from "./client"
|
||||
import {
|
||||
BRAND,
|
||||
emailShell,
|
||||
detailTable,
|
||||
statusBadge,
|
||||
paragraph,
|
||||
escapeHtml,
|
||||
htmlToText,
|
||||
} from "./layout"
|
||||
|
||||
export function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
// Re-exported so existing importers (`@/lib/email/send`) keep working.
|
||||
export { escapeHtml }
|
||||
|
||||
interface SendEmailOptions {
|
||||
to: string
|
||||
subject: string
|
||||
html: string
|
||||
/** Optional plain-text part. Auto-derived from `html` when omitted. */
|
||||
text?: string
|
||||
from?: string
|
||||
}
|
||||
|
||||
export async function sendEmail({ to, subject, html, from }: SendEmailOptions) {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: from ?? `${APP_NAME} <${FROM_EMAIL}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
export async function sendEmail({ to, subject, html, text, from }: SendEmailOptions) {
|
||||
if (!emailConfigured()) {
|
||||
console.warn(`[email] SMTP not configured — skipping "${subject}" to ${to}`)
|
||||
return { success: false, error: "email not configured" }
|
||||
}
|
||||
|
||||
if (error) {
|
||||
try {
|
||||
const info = await getTransporter().sendMail({
|
||||
from: from ?? `${APP_NAME} <${FROM_EMAIL}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
// A plain-text alternative improves inbox placement and gives clients
|
||||
// that can't render HTML something clean to show.
|
||||
text: text ?? htmlToText(html),
|
||||
})
|
||||
return { success: true, id: info.messageId }
|
||||
} catch (error) {
|
||||
console.error("Email send failed:", error)
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
return { success: true, id: data?.id }
|
||||
}
|
||||
|
||||
// ── Email templates ──────────────────────────────────────────────
|
||||
@@ -49,29 +61,22 @@ export function rentDueReminderHtml({
|
||||
dueDate: string
|
||||
paymentLink?: string
|
||||
}) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: sans-serif; background: #09090b; color: #fff; padding: 40px 20px; max-width: 560px; margin: 0 auto;">
|
||||
<div style="background: #16161f; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 32px;">
|
||||
<h1 style="font-size: 20px; margin: 0 0 8px; color: #fff;">Rent Due Reminder</h1>
|
||||
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
|
||||
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
|
||||
Your rent payment of <strong style="color:#fff">${escapeHtml(amount)}</strong> for
|
||||
<strong style="color:#fff">${escapeHtml(propertyName)} — Unit ${escapeHtml(unitNumber)}</strong>
|
||||
is due on <strong style="color:#fff">${escapeHtml(dueDate)}</strong>.
|
||||
</p>
|
||||
${paymentLink ? `
|
||||
<a href="${escapeHtml(paymentLink)}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">
|
||||
Pay Rent Now
|
||||
</a>
|
||||
` : ""}
|
||||
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">
|
||||
Property Management Network
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
return emailShell({
|
||||
preheader: `Your rent of ${amount} is due on ${dueDate}.`,
|
||||
eyebrow: "Rent reminder",
|
||||
accent: BRAND.indigo,
|
||||
title: "Your rent is due soon",
|
||||
intro: `Hi ${escapeHtml(tenantName)}, this is a friendly reminder about your upcoming rent payment.`,
|
||||
body: detailTable([
|
||||
{ label: "Property", value: `${propertyName} — Unit ${unitNumber}` },
|
||||
{ label: "Amount due", value: amount, accent: true },
|
||||
{ label: "Due date", value: dueDate },
|
||||
]),
|
||||
button: paymentLink ? { href: paymentLink, label: "Pay rent now" } : undefined,
|
||||
footerNote: paymentLink
|
||||
? "If the button doesn't work, contact your landlord for alternative payment options."
|
||||
: "Please arrange payment before the due date. Reach out to your landlord with any questions.",
|
||||
})
|
||||
}
|
||||
|
||||
export function rentOverdueHtml({
|
||||
@@ -87,25 +92,25 @@ export function rentOverdueHtml({
|
||||
amount: string
|
||||
dueDate: string
|
||||
}) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: sans-serif; background: #09090b; color: #fff; padding: 40px 20px; max-width: 560px; margin: 0 auto;">
|
||||
<div style="background: #16161f; border: 1px solid rgba(239,68,68,0.3); border-radius: 12px; padding: 32px;">
|
||||
<h1 style="font-size: 20px; margin: 0 0 8px; color: #ef4444;">Rent Overdue</h1>
|
||||
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
|
||||
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
|
||||
Your rent payment of <strong style="color:#fff">${escapeHtml(amount)}</strong> for
|
||||
<strong style="color:#fff">${escapeHtml(propertyName)} — Unit ${escapeHtml(unitNumber)}</strong>
|
||||
was due on <strong style="color:#ef4444">${escapeHtml(dueDate)}</strong> and is now overdue.
|
||||
Please make payment as soon as possible.
|
||||
</p>
|
||||
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">
|
||||
Property Management Network
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
return emailShell({
|
||||
preheader: `Your rent payment of ${amount} is now overdue.`,
|
||||
eyebrow: "Action needed",
|
||||
accent: BRAND.red,
|
||||
title: "Your rent is overdue",
|
||||
intro: `Hi ${escapeHtml(tenantName)}, our records show the payment below hasn't been received yet.`,
|
||||
body:
|
||||
detailTable(
|
||||
[
|
||||
{ label: "Property", value: `${propertyName} — Unit ${unitNumber}` },
|
||||
{ label: "Amount due", value: amount, accent: true },
|
||||
{ label: "Was due", value: dueDate, accent: true },
|
||||
],
|
||||
BRAND.red
|
||||
) +
|
||||
paragraph(
|
||||
"Please make payment as soon as possible to avoid any late fees. If you've already paid, you can disregard this notice."
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export function leaseExpiryHtml({
|
||||
@@ -121,24 +126,22 @@ export function leaseExpiryHtml({
|
||||
leaseEnd: string
|
||||
daysLeft: number
|
||||
}) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: sans-serif; background: #09090b; color: #fff; padding: 40px 20px; max-width: 560px; margin: 0 auto;">
|
||||
<div style="background: #16161f; border: 1px solid rgba(245,158,11,0.3); border-radius: 12px; padding: 32px;">
|
||||
<h1 style="font-size: 20px; margin: 0 0 8px; color: #f59e0b;">Lease Expiring Soon</h1>
|
||||
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
|
||||
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
|
||||
Your lease for <strong style="color:#fff">${escapeHtml(propertyName)} — Unit ${escapeHtml(unitNumber)}</strong>
|
||||
expires on <strong style="color:#f59e0b">${escapeHtml(leaseEnd)}</strong>
|
||||
(${escapeHtml(daysLeft)} days from now). Please contact your landlord to discuss renewal.
|
||||
</p>
|
||||
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">
|
||||
Property Management Network
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
return emailShell({
|
||||
preheader: `Your lease ends on ${leaseEnd} (${daysLeft} days away).`,
|
||||
eyebrow: "Lease update",
|
||||
accent: BRAND.amber,
|
||||
title: "Your lease is expiring soon",
|
||||
intro: `Hi ${escapeHtml(tenantName)}, your current lease is coming to an end.`,
|
||||
body:
|
||||
detailTable(
|
||||
[
|
||||
{ label: "Property", value: `${propertyName} — Unit ${unitNumber}` },
|
||||
{ label: "Lease ends", value: leaseEnd, accent: true },
|
||||
{ label: "Time remaining", value: `${daysLeft} days` },
|
||||
],
|
||||
BRAND.amber
|
||||
) + paragraph("Please contact your landlord to discuss renewal options or next steps."),
|
||||
})
|
||||
}
|
||||
|
||||
export function maintenanceUpdateHtml({
|
||||
@@ -152,20 +155,109 @@ export function maintenanceUpdateHtml({
|
||||
status: string
|
||||
resolutionNotes?: string
|
||||
}) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: sans-serif; background: #09090b; color: #fff; padding: 40px 20px; max-width: 560px; margin: 0 auto;">
|
||||
<div style="background: #16161f; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 32px;">
|
||||
<h1 style="font-size: 20px; margin: 0 0 8px;">Maintenance Update</h1>
|
||||
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
|
||||
<p style="color: rgba(255,255,255,0.6); margin: 0 0 12px;">
|
||||
Your maintenance request "<strong style="color:#fff">${escapeHtml(title)}</strong>"
|
||||
has been updated to: <strong style="color:#6366f1; text-transform: capitalize;">${escapeHtml(status).replace("_", " ")}</strong>
|
||||
</p>
|
||||
${resolutionNotes ? `<p style="color: rgba(255,255,255,0.5); margin: 0 0 24px; font-size: 14px;">${escapeHtml(resolutionNotes)}</p>` : ""}
|
||||
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">Property Management Network</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
const prettyStatus = status.replace(/_/g, " ")
|
||||
const fontStack =
|
||||
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
|
||||
return emailShell({
|
||||
preheader: `Your maintenance request is now "${prettyStatus}".`,
|
||||
eyebrow: "Maintenance",
|
||||
accent: BRAND.indigo,
|
||||
title: "Update on your maintenance request",
|
||||
intro: `Hi ${escapeHtml(tenantName)}, there's an update on your request.`,
|
||||
body:
|
||||
`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:8px 0 20px;background:${BRAND.panel};border:1px solid ${BRAND.line};border-radius:12px;">
|
||||
<tr><td style="padding:18px 22px;">
|
||||
<p style="margin:0 0 12px;font-family:${fontStack};font-size:15px;font-weight:600;color:${BRAND.ink};">${escapeHtml(title)}</p>
|
||||
${statusBadge(prettyStatus, BRAND.indigo)}
|
||||
</td></tr>
|
||||
</table>` +
|
||||
(resolutionNotes ? paragraph(escapeHtml(resolutionNotes), { muted: true }) : ""),
|
||||
})
|
||||
}
|
||||
|
||||
export function resetPasswordHtml(url: string) {
|
||||
return emailShell({
|
||||
preheader: "Reset your Property Management Network password.",
|
||||
eyebrow: "Security",
|
||||
title: "Reset your password",
|
||||
intro:
|
||||
"We received a request to reset your password. Click the button below to choose a new one — this link will expire shortly for your security.",
|
||||
button: { href: url, label: "Reset password" },
|
||||
footerNote:
|
||||
"If you didn't request a password reset, you can safely ignore this email — your password won't change.",
|
||||
})
|
||||
}
|
||||
|
||||
export function verifyEmailHtml(url: string) {
|
||||
return emailShell({
|
||||
preheader: "Confirm your email to finish setting up your account.",
|
||||
eyebrow: "Welcome",
|
||||
title: "Verify your email address",
|
||||
intro:
|
||||
"Thanks for signing up! Please confirm your email address to finish setting up your account.",
|
||||
button: { href: url, label: "Verify email" },
|
||||
footerNote: "If you didn't create an account, you can safely ignore this email.",
|
||||
})
|
||||
}
|
||||
|
||||
export function teamInviteHtml({
|
||||
inviterName,
|
||||
inviteUrl,
|
||||
role,
|
||||
}: {
|
||||
inviterName: string
|
||||
inviteUrl: string
|
||||
role: "member" | "viewer"
|
||||
}) {
|
||||
const roleLabel = role === "viewer" ? "view" : "manage"
|
||||
return emailShell({
|
||||
preheader: `${inviterName} invited you to their property portfolio.`,
|
||||
eyebrow: "Team invitation",
|
||||
title: "You've been invited to a team",
|
||||
intro: `<strong style="color:${BRAND.ink};">${escapeHtml(inviterName)}</strong> has invited you to ${escapeHtml(roleLabel)} their property portfolio on ${escapeHtml(APP_NAME)}.`,
|
||||
button: { href: inviteUrl, label: "Accept invitation" },
|
||||
footerNote:
|
||||
"If you don't have an account yet, you'll be asked to sign in or sign up first.",
|
||||
})
|
||||
}
|
||||
|
||||
export function followUpHtml(message: string) {
|
||||
return emailShell({
|
||||
preheader: message.slice(0, 140),
|
||||
accent: BRAND.indigo,
|
||||
title: "A quick note from your landlord",
|
||||
body: paragraph(escapeHtml(message).replace(/\n/g, "<br/>")),
|
||||
footerNote: "Sent automatically by your landlord's follow-up system.",
|
||||
})
|
||||
}
|
||||
|
||||
export function paymentLinkHtml({
|
||||
tenantName,
|
||||
amount,
|
||||
dueDate,
|
||||
propertyLabel,
|
||||
senderName,
|
||||
paymentLink,
|
||||
}: {
|
||||
tenantName: string
|
||||
amount: string
|
||||
dueDate: string
|
||||
propertyLabel: string
|
||||
senderName: string
|
||||
paymentLink?: string
|
||||
}) {
|
||||
return emailShell({
|
||||
preheader: `Rent payment of ${amount} due ${dueDate}.`,
|
||||
eyebrow: "Rent payment",
|
||||
accent: BRAND.indigo,
|
||||
title: "Your rent payment is due",
|
||||
intro: `Hi ${escapeHtml(tenantName)}, here are the details for your upcoming rent payment.`,
|
||||
body: detailTable([
|
||||
{ label: "Property", value: propertyLabel },
|
||||
{ label: "Amount due", value: amount, accent: true },
|
||||
{ label: "Due date", value: dueDate },
|
||||
]),
|
||||
button: paymentLink ? { href: paymentLink, label: "Pay rent now" } : undefined,
|
||||
footerNote: `Sent by ${escapeHtml(senderName)} via ${escapeHtml(APP_NAME)}.`,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user