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>
264 lines
8.3 KiB
TypeScript
264 lines
8.3 KiB
TypeScript
import { getTransporter, emailConfigured, FROM_EMAIL, APP_NAME } from "./client"
|
|
import {
|
|
BRAND,
|
|
emailShell,
|
|
detailTable,
|
|
statusBadge,
|
|
paragraph,
|
|
escapeHtml,
|
|
htmlToText,
|
|
} from "./layout"
|
|
|
|
// 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, text, from }: SendEmailOptions) {
|
|
if (!emailConfigured()) {
|
|
console.warn(`[email] SMTP not configured — skipping "${subject}" to ${to}`)
|
|
return { success: false, error: "email not configured" }
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|
|
|
|
// ── Email templates ──────────────────────────────────────────────
|
|
|
|
export function rentDueReminderHtml({
|
|
tenantName,
|
|
propertyName,
|
|
unitNumber,
|
|
amount,
|
|
dueDate,
|
|
paymentLink,
|
|
}: {
|
|
tenantName: string
|
|
propertyName: string
|
|
unitNumber: string
|
|
amount: string
|
|
dueDate: string
|
|
paymentLink?: string
|
|
}) {
|
|
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({
|
|
tenantName,
|
|
propertyName,
|
|
unitNumber,
|
|
amount,
|
|
dueDate,
|
|
}: {
|
|
tenantName: string
|
|
propertyName: string
|
|
unitNumber: string
|
|
amount: string
|
|
dueDate: string
|
|
}) {
|
|
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({
|
|
tenantName,
|
|
propertyName,
|
|
unitNumber,
|
|
leaseEnd,
|
|
daysLeft,
|
|
}: {
|
|
tenantName: string
|
|
propertyName: string
|
|
unitNumber: string
|
|
leaseEnd: string
|
|
daysLeft: number
|
|
}) {
|
|
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({
|
|
tenantName,
|
|
title,
|
|
status,
|
|
resolutionNotes,
|
|
}: {
|
|
tenantName: string
|
|
title: string
|
|
status: string
|
|
resolutionNotes?: string
|
|
}) {
|
|
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)}.`,
|
|
})
|
|
}
|