feat(branding): wire per-port branding through every transactional email + auth shell (R2-H15)

Multi-tenant branding admin (/admin/branding) was saving 5 settings
that no code read — every port's emails shipped Port Nimara's logo
and color regardless. Now wired end-to-end:

New shared infrastructure:
- src/lib/email/shell.ts — renderShell() + brandingPrimaryColor()
  helpers; takes BrandingShell { logoUrl, primaryColor,
  emailHeaderHtml, emailFooterHtml }, falls back to Port Nimara
  defaults when null.
- src/lib/email/branding-resolver.ts — getBrandingShell(portId)
  thin wrapper over getPortBrandingConfig() that returns null on
  error / missing portId so senders never break on misconfig.

All 6 transactional templates refactored to use renderShell + the
shared accent color; portName now flows through every template
(crm-invite, portal activation/reset, both inquiries, both
residential templates, notification digest).

All 6 senders pass branding via getBrandingShell:
- portal-auth.service.ts (activation + reset)
- crm-invite.service.ts (resend path; create-invite has no portId
  yet so falls through to defaults)
- email worker (inquiry confirmation + sales notification)
- residential-inquiries route (client confirmation + sales alert)
- notification-digest.service.ts (digest)

BrandedAuthShell takes an optional `branding` prop with logoUrl +
appName (parent page server-fetches via getPortBrandingConfig).
Defaults to Port Nimara if omitted, so single-tenant deployments
are unaffected.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-07 00:00:45 +02:00
parent 1a87f28fd4
commit 05babe57a0
14 changed files with 380 additions and 322 deletions

View File

@@ -12,6 +12,7 @@ import {
residentialSalesAlert, residentialSalesAlert,
} from '@/lib/email/templates/residential-inquiry'; } from '@/lib/email/templates/residential-inquiry';
import { resolveSubject } from '@/lib/email/resolve-subject'; import { resolveSubject } from '@/lib/email/resolve-subject';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors'; import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
@@ -141,18 +142,22 @@ async function sendResidentialNotifications(args: {
}): Promise<void> { }): Promise<void> {
const { portId, data, crmDeepLink } = args; const { portId, data, crmDeepLink } = args;
const branding = await getBrandingShell(portId);
// Client confirmation // Client confirmation
const confirmation = residentialClientConfirmation({ const confirmation = residentialClientConfirmation(
firstName: data.firstName, {
contactEmail: 'sales@portnimara.com', firstName: data.firstName,
}); contactEmail: 'sales@portnimara.com',
},
{ branding },
);
const confirmationSubject = await resolveSubject({ const confirmationSubject = await resolveSubject({
key: 'residential_inquiry_client_confirmation', key: 'residential_inquiry_client_confirmation',
portId, portId,
fallback: confirmation.subject, fallback: confirmation.subject,
tokens: { portName: 'Port Nimara', recipientName: data.firstName }, tokens: { portName: 'Port Nimara', recipientName: data.firstName },
}); });
await sendEmail(data.email, confirmationSubject, confirmation.html); await sendEmail(data.email, confirmationSubject, confirmation.html, undefined, undefined, portId);
// Sales-team alert - pull recipients from system_settings if configured; // Sales-team alert - pull recipients from system_settings if configured;
// fall back to the inquiry_contact_email if available. // fall back to the inquiry_contact_email if available.
@@ -181,16 +186,19 @@ async function sendResidentialNotifications(args: {
return; return;
} }
const alert = residentialSalesAlert({ const alert = residentialSalesAlert(
fullName: `${data.firstName} ${data.lastName}`.trim(), {
email: data.email, fullName: `${data.firstName} ${data.lastName}`.trim(),
phone: data.phone, email: data.email,
placeOfResidence: data.placeOfResidence, phone: data.phone,
preferredContactMethod: data.preferredContactMethod, placeOfResidence: data.placeOfResidence,
notes: data.notes, preferredContactMethod: data.preferredContactMethod,
preferences: data.preferences, notes: data.notes,
crmDeepLink, preferences: data.preferences,
}); crmDeepLink,
},
{ branding },
);
const alertSubject = await resolveSubject({ const alertSubject = await resolveSubject({
key: 'residential_inquiry_sales_alert', key: 'residential_inquiry_sales_alert',
portId, portId,

View File

@@ -1,27 +1,42 @@
const BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png'; const DEFAULT_BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
const LOGO_URL = const DEFAULT_LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png'; 'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
interface BrandedAuthShellProps {
children: React.ReactNode;
/** Per-port branding override resolved server-side by the page that
* renders the shell. When omitted, falls back to the Port Nimara
* defaults so single-tenant deployments remain unaffected. Pages
* that know their portId at render time should pass the result of
* `getPortBrandingConfig(portId)`. */
branding?: {
logoUrl?: string | null;
appName?: string | null;
};
}
/** /**
* Branded shell shared by every auth/form surface - CRM login, portal login, * Branded shell shared by every auth/form surface - CRM login, portal login,
* password set/reset/activate, forgot-password. Renders the blurred Port * password set/reset/activate, forgot-password. Renders the blurred
* Nimara overhead background, the circular logo, and a centered white card * background, the logo, and a centered white card that consumers
* that consumers populate with their own form/content. * populate with their own form/content.
*
* Multi-tenant note (R2-H15): the per-port logoUrl from
* /admin/branding is rendered when the parent page passes a `branding`
* prop. The background image stays as the marina default for all
* deployments — admin-authored backgrounds aren't part of the v1
* branding surface.
*/ */
export function BrandedAuthShell({ children }: { children: React.ReactNode }) { export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps) {
const logoUrl = branding?.logoUrl || DEFAULT_LOGO_URL;
const altText = branding?.appName || 'Port Nimara';
return ( return (
<div className="relative min-h-screen min-h-[100dvh] flex items-center justify-center px-4 py-8"> <div className="relative min-h-screen min-h-[100dvh] flex items-center justify-center px-4 py-8">
{/*
Full-viewport background layer - pinned to the visible viewport via
`fixed inset-0` so the marina image always reaches the actual screen
edges regardless of the iOS Safari URL bar showing/hiding. The shell's
layout layer above sits on top via z-index.
*/}
<div <div
aria-hidden aria-hidden
className="fixed inset-0 -z-10" className="fixed inset-0 -z-10"
style={{ style={{
backgroundImage: `url('${BG_URL}')`, backgroundImage: `url('${DEFAULT_BG_URL}')`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundColor: '#f2f2f2', backgroundColor: '#f2f2f2',
@@ -31,7 +46,7 @@ export function BrandedAuthShell({ children }: { children: React.ReactNode }) {
<div className="bg-white rounded-lg shadow-lg p-8"> <div className="bg-white rounded-lg shadow-lg p-8">
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={LOGO_URL} alt="Port Nimara" className="w-24 h-auto" /> <img src={logoUrl} alt={altText} className="w-24 h-auto" />
</div> </div>
{children} {children}
</div> </div>

View File

@@ -0,0 +1,28 @@
/**
* Resolve the per-port branding shell for transactional emails.
*
* Senders that have a portId call this once and pass the result into
* the email template. Senders without a portId (e.g. CRM invite at
* create-time before a port is selected) pass null — the shell
* falls back to the Port Nimara defaults.
*/
import { getPortBrandingConfig } from '@/lib/services/port-config';
import type { BrandingShell } from '@/lib/email/shell';
export async function getBrandingShell(
portId: string | null | undefined,
): Promise<BrandingShell | null> {
if (!portId) return null;
try {
const cfg = await getPortBrandingConfig(portId);
return {
logoUrl: cfg.logoUrl,
primaryColor: cfg.primaryColor,
emailHeaderHtml: cfg.emailHeaderHtml,
emailFooterHtml: cfg.emailFooterHtml,
};
} catch {
return null;
}
}

80
src/lib/email/shell.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* Shared HTML shell for transactional emails. Centralises the table-
* based layout + the per-port branding override surface so templates
* don't each inline a different copy of the boilerplate.
*
* Per-port branding (R2-H15):
* - logoUrl — replaces the default Port Nimara logo image
* - primaryColor — used for the page-title accent color
* - emailHeaderHtml / emailFooterHtml — admin-authored HTML that
* appears above / below the body content (e.g. legal footer,
* custom marketing strip). When unset, the existing minimal
* "Thank you, {{portName}} CRM" sign-off is rendered by callers.
*
* Senders resolve a `BrandingShell` via `resolveBrandingShell(portId)`
* (or pass `null` for no override) and forward it to the template
* function. Templates call `renderShell({ title, body, branding })`.
*/
const DEFAULT_LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
const DEFAULT_BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
const DEFAULT_PRIMARY_COLOR = '#0F4C81';
export interface BrandingShell {
logoUrl: string | null;
primaryColor: string | null;
emailHeaderHtml: string | null;
emailFooterHtml: string | null;
}
interface ShellOpts {
title: string;
body: string;
branding?: BrandingShell | null;
}
export function renderShell({ title, body, branding }: ShellOpts): string {
const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL;
const headerHtml = branding?.emailHeaderHtml ?? '';
const footerHtml = branding?.emailFooterHtml ?? '';
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${DEFAULT_BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${logoUrl}" alt="Port logo" width="100" style="margin-bottom:20px;" />
</center>
${headerHtml ? `<div>${headerHtml}</div>` : ''}
${body}
${footerHtml ? `<div style="margin-top:24px;">${footerHtml}</div>` : ''}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
/** Surface the brand primary color to template bodies. */
export function brandingPrimaryColor(branding?: BrandingShell | null): string {
return branding?.primaryColor ?? DEFAULT_PRIMARY_COLOR;
}

View File

@@ -1,83 +1,59 @@
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
interface InviteData { interface InviteData {
link: string; link: string;
ttlHours: number; ttlHours: number;
recipientName?: string; recipientName?: string;
isSuperAdmin: boolean; isSuperAdmin: boolean;
/** Display name for the port — falls back to "Port Nimara" so the
* pre-multi-tenant default still reads correctly. */
portName?: string;
} }
const LOGO_URL = interface RenderOpts {
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png'; branding?: BrandingShell | null;
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
function shell(opts: { title: string; body: string }): string {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${opts.title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
} }
export function crmInviteEmail(data: InviteData): { export function crmInviteEmail(
data: InviteData,
overrides?: RenderOpts,
): {
subject: string; subject: string;
html: string; html: string;
text: string; text: string;
} { } {
const subject = `You're invited to the Port Nimara CRM`; const portName = data.portName ?? 'Port Nimara';
const subject = `You're invited to the ${portName} CRM`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,'; const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
const role = data.isSuperAdmin ? 'super administrator' : 'administrator'; const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
const accent = brandingPrimaryColor(overrides?.branding);
const body = ` const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;"> <p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
Welcome to the Port Nimara CRM Welcome to the ${escapeHtml(portName)} CRM
</p> </p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p> <p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;"> <p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
You've been invited to the Port Nimara CRM as a ${role}. Click the You've been invited to the ${escapeHtml(portName)} CRM as a ${role}. Click the
button below to set your password and activate your account. The button below to set your password and activate your account. The
link expires in ${data.ttlHours} hours. link expires in ${data.ttlHours} hours.
</p> </p>
<p style="text-align:center; margin:30px 0;"> <p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;"> <a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Set up your account Set up your account
</a> </a>
</p> </p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;"> <p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If the button doesn't work, paste this link into your browser:<br /> If the button doesn't work, paste this link into your browser:<br />
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a> <a href="${data.link}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
</p> </p>
<p style="font-size:16px; margin-top:30px;"> <p style="font-size:16px; margin-top:30px;">
Thank you,<br /> Thank you,<br />
<strong>Port Nimara CRM</strong> <strong>${escapeHtml(portName)} CRM</strong>
</p>`; </p>`;
const text = [ const text = [
`Welcome to the Port Nimara CRM`, `Welcome to the ${portName} CRM`,
'', '',
`You've been invited as a ${role}.`, `You've been invited as a ${role}.`,
`Set up your account: ${data.link}`, `Set up your account: ${data.link}`,
@@ -85,10 +61,14 @@ export function crmInviteEmail(data: InviteData): {
`The link expires in ${data.ttlHours} hours.`, `The link expires in ${data.ttlHours} hours.`,
'', '',
`Thank you,`, `Thank you,`,
`Port Nimara CRM`, `${portName} CRM`,
].join('\n'); ].join('\n');
return { subject, html: shell({ title: subject, body }), text }; return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
} }
function escapeHtml(str: string): string { function escapeHtml(str: string): string {

View File

@@ -1,40 +1,32 @@
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
export interface InquiryClientConfirmationData { export interface InquiryClientConfirmationData {
firstName: string; firstName: string;
mooringNumber: string | null; mooringNumber: string | null;
contactEmail: string; contactEmail: string;
/** Display name; falls back to "Port Nimara". */
portName?: string;
} }
export function inquiryClientConfirmation(data: InquiryClientConfirmationData) { interface RenderOpts {
branding?: BrandingShell | null;
}
export function inquiryClientConfirmation(
data: InquiryClientConfirmationData,
overrides?: RenderOpts,
) {
const { firstName, mooringNumber, contactEmail } = data; const { firstName, mooringNumber, contactEmail } = data;
const portName = data.portName ?? 'Port Nimara';
const berthText = mooringNumber ? `Berth ${mooringNumber}` : 'a Port Nimara Berth'; const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
const subject = mooringNumber const subject = mooringNumber
? `Thank You for Your Interest in Berth ${mooringNumber}` ? `Thank You for Your Interest in Berth ${mooringNumber}`
: 'Thank You for Your Interest in a Port Nimara Berth'; : `Thank You for Your Interest in a ${portName} Berth`;
const html = `<!DOCTYPE html> const accent = brandingPrimaryColor(overrides?.branding);
<html>
<head> const body = `
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${subject}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
<center>
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
<p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p> <p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p>
<p style="margin-bottom:10px; font-size:16px;"> <p style="margin-bottom:10px; font-size:16px;">
Thank you for expressing interest in ${escapeHtml(berthText)}. Thank you for expressing interest in ${escapeHtml(berthText)}.
@@ -43,20 +35,12 @@ export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
</p> </p>
<p style="margin-bottom:10px; font-size:16px;"> <p style="margin-bottom:10px; font-size:16px;">
If you have any questions, please feel free to reach out to us at If you have any questions, please feel free to reach out to us at
<a href="mailto:${escapeHtml(contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(contactEmail)}</a>. <a href="mailto:${escapeHtml(contactEmail)}" style="color:${accent}; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
</p> </p>
<p style="font-size:16px;"> <p style="font-size:16px;">
Best regards,<br /> Best regards,<br />
The Port Nimara Sales Team The ${escapeHtml(portName)} Sales Team
</p> </p>`;
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const text = [ const text = [
`Dear ${firstName},`, `Dear ${firstName},`,
@@ -66,10 +50,14 @@ export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`, `If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
'', '',
'Best regards,', 'Best regards,',
'The Port Nimara Sales Team', `The ${portName} Sales Team`,
].join('\n'); ].join('\n');
return { subject, html, text }; return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
} }
function escapeHtml(str: string): string { function escapeHtml(str: string): string {

View File

@@ -1,73 +1,60 @@
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
export interface InquirySalesNotificationData { export interface InquirySalesNotificationData {
fullName: string; fullName: string;
email: string; email: string;
phone: string; phone: string;
mooringNumber: string | null; mooringNumber: string | null;
crmUrl: string; crmUrl: string;
/** Display name; falls back to "Port Nimara". */
portName?: string;
} }
export function inquirySalesNotification(data: InquirySalesNotificationData) { interface RenderOpts {
branding?: BrandingShell | null;
}
export function inquirySalesNotification(
data: InquirySalesNotificationData,
overrides?: RenderOpts,
) {
const { fullName, email, phone, mooringNumber, crmUrl } = data; const { fullName, email, phone, mooringNumber, crmUrl } = data;
const portName = data.portName ?? 'Port Nimara';
const mooringDisplay = mooringNumber || 'None'; const mooringDisplay = mooringNumber || 'None';
const subject = `New Interest - ${portName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const subject = 'New Interest - Port Nimara'; const body = `
const html = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>New Interest - Port Nimara</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
<center>
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
<p style="margin-bottom:10px; font-size:16px;">Dear Administrator,</p> <p style="margin-bottom:10px; font-size:16px;">Dear Administrator,</p>
<p style="margin-bottom:10px; font-size:16px;">${escapeHtml(fullName)} has expressed their interest in <strong>Port Nimara</strong>. Here are their details:</p> <p style="margin-bottom:10px; font-size:16px;">${escapeHtml(fullName)} has expressed their interest in <strong>${escapeHtml(portName)}</strong>. Here are their details:</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Name:</strong> ${escapeHtml(fullName)}</p> <p style="margin-bottom:0; font-size:16px;"><strong>Name:</strong> ${escapeHtml(fullName)}</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Email:</strong> ${escapeHtml(email)}</p> <p style="margin-bottom:0; font-size:16px;"><strong>Email:</strong> ${escapeHtml(email)}</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Telephone:</strong> ${escapeHtml(phone)}</p> <p style="margin-bottom:0; font-size:16px;"><strong>Telephone:</strong> ${escapeHtml(phone)}</p>
<p style="margin:0 0 16px 0; font-size:16px;"><strong>Berths Selected:</strong> ${escapeHtml(mooringDisplay)}</p> <p style="margin:0 0 16px 0; font-size:16px;"><strong>Berths Selected:</strong> ${escapeHtml(mooringDisplay)}</p>
<p style="margin-bottom:10px; font-size:16px;">Please visit the <a href="${escapeHtml(crmUrl)}" target="_blank" style="color:#007bff; text-decoration:underline;">Port Nimara CRM</a> to view more information.</p> <p style="margin-bottom:10px; font-size:16px;">Please visit the <a href="${escapeHtml(crmUrl)}" target="_blank" style="color:${accent}; text-decoration:underline;">${escapeHtml(portName)} CRM</a> to view more information.</p>
<p style="font-size:16px;">Thank you,<br/>Port Nimara CRM</p> <p style="font-size:16px;">Thank you,<br/>${escapeHtml(portName)} CRM</p>`;
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const text = [ const text = [
'Dear Administrator,', 'Dear Administrator,',
'', '',
`${fullName} has expressed their interest in Port Nimara. Here are their details:`, `${fullName} has expressed their interest in ${portName}. Here are their details:`,
'', '',
`Name: ${fullName}`, `Name: ${fullName}`,
`Email: ${email}`, `Email: ${email}`,
`Telephone: ${phone}`, `Telephone: ${phone}`,
`Berths Selected: ${mooringDisplay}`, `Berths Selected: ${mooringDisplay}`,
'', '',
`Please visit the Port Nimara CRM (${crmUrl}) to view more information.`, `Please visit the ${portName} CRM (${crmUrl}) to view more information.`,
'', '',
'Thank you', 'Thank you',
'Port Nimara CRM', `${portName} CRM`,
].join('\n'); ].join('\n');
return { subject, html, text }; return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
} }
function escapeHtml(str: string): string { function escapeHtml(str: string): string {

View File

@@ -3,6 +3,8 @@
* Used by the notification-digest scheduler (queued in `email` worker). * Used by the notification-digest scheduler (queued in `email` worker).
*/ */
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
interface DigestData { interface DigestData {
portName: string; portName: string;
recipientName: string; recipientName: string;
@@ -19,45 +21,8 @@ interface DigestData {
inboxLink: string; inboxLink: string;
} }
const LOGO_URL = interface RenderOpts {
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png'; branding?: BrandingShell | null;
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
function shell(opts: { title: string; body: string }): string {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${opts.title}</title>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:24px; font-family: Arial, sans-serif; color:#333333;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
@@ -75,18 +40,22 @@ const TYPE_LABELS: Record<string, string> = {
berth_released: 'Berth released', berth_released: 'Berth released',
}; };
export function notificationDigestEmail(data: DigestData): { export function notificationDigestEmail(
data: DigestData,
overrides?: RenderOpts,
): {
subject: string; subject: string;
html: string; html: string;
text: string; text: string;
} { } {
const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`; const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`;
const accent = brandingPrimaryColor(overrides?.branding);
const itemsHtml = data.items const itemsHtml = data.items
.map((item) => { .map((item) => {
const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' '); const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
const titleHtml = item.link const titleHtml = item.link
? `<a href="${item.link}" style="color:#007bff; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>` ? `<a href="${item.link}" style="color:${accent}; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
: `<strong>${escapeHtml(item.title)}</strong>`; : `<strong>${escapeHtml(item.title)}</strong>`;
const desc = item.description const desc = item.description
? `<div style="font-size:13px; color:#666; margin-top:4px;">${escapeHtml(item.description)}</div>` ? `<div style="font-size:13px; color:#666; margin-top:4px;">${escapeHtml(item.description)}</div>`
@@ -102,13 +71,13 @@ export function notificationDigestEmail(data: DigestData): {
const tail = const tail =
data.totalUnread > data.items.length data.totalUnread > data.items.length
? `<p style="margin-top:14px; font-size:13px; color:#666;">…and ${data.totalUnread - data.items.length} more. ? `<p style="margin-top:14px; font-size:13px; color:#666;">…and ${data.totalUnread - data.items.length} more.
<a href="${data.inboxLink}" style="color:#007bff;">Open the inbox</a> to see everything.</p>` <a href="${data.inboxLink}" style="color:${accent};">Open the inbox</a> to see everything.</p>`
: ''; : '';
const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,'; const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,';
const body = ` const body = `
<p style="font-size:18px; font-weight:bold; color:#0F4C81; margin:0 0 6px;"> <p style="font-size:18px; font-weight:bold; color:${accent}; margin:0 0 6px;">
Your ${escapeHtml(data.portName)} CRM digest Your ${escapeHtml(data.portName)} CRM digest
</p> </p>
<p style="font-size:14px; line-height:1.5; margin:0 0 14px;">${greeting}</p> <p style="font-size:14px; line-height:1.5; margin:0 0 14px;">${greeting}</p>
@@ -134,5 +103,18 @@ export function notificationDigestEmail(data: DigestData): {
`Inbox: ${data.inboxLink}`, `Inbox: ${data.inboxLink}`,
].join('\n'); ].join('\n');
return { subject, html: shell({ title: subject, body }), text }; return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }

View File

@@ -1,3 +1,5 @@
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
interface ActivationData { interface ActivationData {
portName: string; portName: string;
link: string; link: string;
@@ -12,47 +14,14 @@ interface ResetData {
recipientName?: string; recipientName?: string;
} }
const LOGO_URL = interface RenderOpts {
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png'; subject?: string | null;
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png'; branding?: BrandingShell | null;
function shell(opts: { title: string; body: string }): string {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${opts.title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
} }
export function activationEmail( export function activationEmail(
data: ActivationData, data: ActivationData,
overrides?: { subject?: string | null }, overrides?: RenderOpts,
): { ): {
subject: string; subject: string;
html: string; html: string;
@@ -65,9 +34,10 @@ export function activationEmail(
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours)) .replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
: `Activate your ${data.portName} client portal account`; : `Activate your ${data.portName} client portal account`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,'; const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
const accent = brandingPrimaryColor(overrides?.branding);
const body = ` const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;"> <p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
Welcome to ${escapeHtml(data.portName)} Welcome to ${escapeHtml(data.portName)}
</p> </p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p> <p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
@@ -77,13 +47,13 @@ export function activationEmail(
The link expires in ${data.ttlHours} hours. The link expires in ${data.ttlHours} hours.
</p> </p>
<p style="text-align:center; margin:30px 0;"> <p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;"> <a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Activate account Activate account
</a> </a>
</p> </p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;"> <p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If the button doesn't work, paste this link into your browser:<br /> If the button doesn't work, paste this link into your browser:<br />
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a> <a href="${data.link}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
</p> </p>
<p style="font-size:16px; margin-top:30px;"> <p style="font-size:16px; margin-top:30px;">
Thank you,<br /> Thank you,<br />
@@ -102,12 +72,16 @@ export function activationEmail(
`${data.portName} CRM`, `${data.portName} CRM`,
].join('\n'); ].join('\n');
return { subject, html: shell({ title: subject, body }), text }; return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
} }
export function resetEmail( export function resetEmail(
data: ResetData, data: ResetData,
overrides?: { subject?: string | null }, overrides?: RenderOpts,
): { subject: string; html: string; text: string } { ): { subject: string; html: string; text: string } {
const subject = overrides?.subject const subject = overrides?.subject
? overrides.subject ? overrides.subject
@@ -116,9 +90,10 @@ export function resetEmail(
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes)) .replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
: `Reset your ${data.portName} client portal password`; : `Reset your ${data.portName} client portal password`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,'; const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,';
const accent = brandingPrimaryColor(overrides?.branding);
const body = ` const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;"> <p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
Password reset Password reset
</p> </p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p> <p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
@@ -128,7 +103,7 @@ export function resetEmail(
The link expires in ${data.ttlMinutes} minutes. The link expires in ${data.ttlMinutes} minutes.
</p> </p>
<p style="text-align:center; margin:30px 0;"> <p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;"> <a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Reset password Reset password
</a> </a>
</p> </p>
@@ -152,7 +127,11 @@ export function resetEmail(
`${data.portName} CRM`, `${data.portName} CRM`,
].join('\n'); ].join('\n');
return { subject, html: shell({ title: subject, body }), text }; return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
} }
function escapeHtml(str: string): string { function escapeHtml(str: string): string {

View File

@@ -1,69 +1,47 @@
const LOGO_URL = import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
function shell(opts: { title: string; body: string }): string { interface RenderOpts {
return `<!DOCTYPE html> branding?: BrandingShell | null;
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${opts.title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
} }
export interface ResidentialClientConfirmationData { export interface ResidentialClientConfirmationData {
firstName: string; firstName: string;
contactEmail: string; contactEmail: string;
/** Display name; falls back to "Port Nimara". */
portName?: string;
} }
export function residentialClientConfirmation(data: ResidentialClientConfirmationData) { export function residentialClientConfirmation(
const subject = 'Thank You for Your Interest - Port Nimara Residences'; data: ResidentialClientConfirmationData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'Port Nimara';
const subject = `Thank You for Your Interest - ${portName} Residences`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = ` const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;"> <p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
Welcome to Port Nimara Welcome to ${escapeHtml(portName)}
</p> </p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;"> <p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
Dear ${escapeHtml(data.firstName)}, Dear ${escapeHtml(data.firstName)},
</p> </p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;"> <p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
Thank you for expressing interest in Port Nimara residences. Our residential Thank you for expressing interest in ${escapeHtml(portName)} residences. Our residential
sales team has received your inquiry and will reach out to you shortly with sales team has received your inquiry and will reach out to you shortly with
more information. more information.
</p> </p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;"> <p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
If you have any questions in the meantime, please reach us at If you have any questions in the meantime, please reach us at
<a href="mailto:${escapeHtml(data.contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>. <a href="mailto:${escapeHtml(data.contactEmail)}" style="color:${accent}; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
</p> </p>
<p style="font-size:16px; margin-top:30px;"> <p style="font-size:16px; margin-top:30px;">
Best regards,<br /> Best regards,<br />
<strong>The Port Nimara Residential Team</strong> <strong>The ${escapeHtml(portName)} Residential Team</strong>
</p>`; </p>`;
return { subject, html: shell({ title: subject, body }) }; return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
} }
export interface ResidentialSalesAlertData { export interface ResidentialSalesAlertData {
@@ -75,12 +53,15 @@ export interface ResidentialSalesAlertData {
notes?: string; notes?: string;
preferences?: string; preferences?: string;
crmDeepLink?: string; crmDeepLink?: string;
portName?: string;
} }
export function residentialSalesAlert(data: ResidentialSalesAlertData) { export function residentialSalesAlert(data: ResidentialSalesAlertData, overrides?: RenderOpts) {
const portName = data.portName ?? 'Port Nimara';
const subject = `New Residential Inquiry - ${data.fullName}`; const subject = `New Residential Inquiry - ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = ` const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;"> <p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
New residential inquiry New residential inquiry
</p> </p>
<table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;"> <table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;">
@@ -92,9 +73,12 @@ export function residentialSalesAlert(data: ResidentialSalesAlertData) {
${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''} ${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''}
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''} ${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
</table> </table>
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${data.crmDeepLink}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''} ${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${data.crmDeepLink}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
<p style="font-size:14px; color:#666;">- Port Nimara CRM</p>`; <p style="font-size:14px; color:#666;">- ${escapeHtml(portName)} CRM</p>`;
return { subject, html: shell({ title: subject, body }) }; return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
} }
function escapeHtml(str: string): string { function escapeHtml(str: string): string {

View File

@@ -30,7 +30,12 @@ export const emailWorker = new Worker(
await import('@/lib/email/templates/inquiry-client-confirmation'); await import('@/lib/email/templates/inquiry-client-confirmation');
const { sendEmail } = await import('@/lib/email/index'); const { sendEmail } = await import('@/lib/email/index');
const { resolveSubject } = await import('@/lib/email/resolve-subject'); const { resolveSubject } = await import('@/lib/email/resolve-subject');
const email = inquiryClientConfirmation({ firstName, mooringNumber, contactEmail }); const { getBrandingShell } = await import('@/lib/email/branding-resolver');
const branding = await getBrandingShell(portId);
const email = inquiryClientConfirmation(
{ firstName, mooringNumber, contactEmail, portName },
{ branding },
);
const subject = await resolveSubject({ const subject = await resolveSubject({
key: 'inquiry_client_confirmation', key: 'inquiry_client_confirmation',
portId, portId,
@@ -60,13 +65,19 @@ export const emailWorker = new Worker(
await import('@/lib/email/templates/inquiry-sales-notification'); await import('@/lib/email/templates/inquiry-sales-notification');
const { sendEmail } = await import('@/lib/email/index'); const { sendEmail } = await import('@/lib/email/index');
const { resolveSubject } = await import('@/lib/email/resolve-subject'); const { resolveSubject } = await import('@/lib/email/resolve-subject');
const notification = inquirySalesNotification({ const { getBrandingShell } = await import('@/lib/email/branding-resolver');
fullName, const branding = await getBrandingShell(portId);
email, const notification = inquirySalesNotification(
phone, {
mooringNumber, fullName,
crmUrl, email,
}); phone,
mooringNumber,
crmUrl,
portName,
},
{ branding },
);
const subject = await resolveSubject({ const subject = await resolveSubject({
key: 'inquiry_sales_notification', key: 'inquiry_sales_notification',
portId, portId,

View File

@@ -10,6 +10,7 @@ import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email'; import { sendEmail } from '@/lib/email';
import { crmInviteEmail } from '@/lib/email/templates/crm-invite'; import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
import { resolveSubject } from '@/lib/email/resolve-subject'; import { resolveSubject } from '@/lib/email/resolve-subject';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { hashToken, mintToken } from '@/lib/portal/passwords'; import { hashToken, mintToken } from '@/lib/portal/passwords';
@@ -230,12 +231,16 @@ export async function resendCrmInvite(
.where(eq(crmUserInvites.id, inviteId)); .where(eq(crmUserInvites.id, inviteId));
const link = `${env.APP_URL}/set-password?token=${raw}`; const link = `${env.APP_URL}/set-password?token=${raw}`;
const result = crmInviteEmail({ const branding = await getBrandingShell(meta.portId);
link, const result = crmInviteEmail(
ttlHours: INVITE_TTL_HOURS, {
recipientName: invite.name ?? undefined, link,
isSuperAdmin: invite.isSuperAdmin, ttlHours: INVITE_TTL_HOURS,
}); recipientName: invite.name ?? undefined,
isSuperAdmin: invite.isSuperAdmin,
},
{ branding },
);
// Resend uses the dedicated portal_invite_resend key so admins can // Resend uses the dedicated portal_invite_resend key so admins can
// word the resend differently from the original. // word the resend differently from the original.
const subject = await resolveSubject({ const subject = await resolveSubject({
@@ -248,7 +253,7 @@ export async function resendCrmInvite(
ttlHours: INVITE_TTL_HOURS, ttlHours: INVITE_TTL_HOURS,
}, },
}); });
await sendEmail(invite.email, subject, result.html, undefined, result.text); await sendEmail(invite.email, subject, result.html, undefined, result.text, meta.portId);
void createAuditLog({ void createAuditLog({
userId: meta.userId, userId: meta.userId,

View File

@@ -28,6 +28,7 @@ import { getPortReminderConfig } from '@/lib/services/port-config';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { resolveSubject } from '@/lib/email/resolve-subject'; import { resolveSubject } from '@/lib/email/resolve-subject';
import { getBrandingShell } from '@/lib/email/branding-resolver';
const DIGEST_LOOKBACK_MS = 24 * 60 * 60 * 1000; const DIGEST_LOOKBACK_MS = 24 * 60 * 60 * 1000;
const MAX_ITEMS_PER_USER = 20; const MAX_ITEMS_PER_USER = 20;
@@ -103,6 +104,10 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
if (portUsers.length === 0) continue; if (portUsers.length === 0) continue;
// Resolve branding once per port — every user on this port gets
// the same shell.
const branding = await getBrandingShell(port.id);
const since = new Date(now.getTime() - DIGEST_LOOKBACK_MS); const since = new Date(now.getTime() - DIGEST_LOOKBACK_MS);
for (const u of portUsers) { for (const u of portUsers) {
@@ -133,19 +138,22 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
const visible = rows.slice(0, MAX_ITEMS_PER_USER); const visible = rows.slice(0, MAX_ITEMS_PER_USER);
const inboxLink = `${env.APP_URL}/notifications`; const inboxLink = `${env.APP_URL}/notifications`;
const result = notificationDigestEmail({ const result = notificationDigestEmail(
portName: port.name, {
recipientName: u.name ?? '', portName: port.name,
items: visible.map((r) => ({ recipientName: u.name ?? '',
type: r.type, items: visible.map((r) => ({
title: r.title, type: r.type,
description: r.description, title: r.title,
link: r.link ? `${env.APP_URL}${r.link}` : null, description: r.description,
createdAt: r.createdAt, link: r.link ? `${env.APP_URL}${r.link}` : null,
})), createdAt: r.createdAt,
totalUnread: rows.length, })),
inboxLink, totalUnread: rows.length,
}); inboxLink,
},
{ branding },
);
// The per-port subject override key for the digest is the // The per-port subject override key for the digest is the
// existing 'crm_invite' / 'portal_*' family — digest is its own // existing 'crm_invite' / 'portal_*' family — digest is its own

View File

@@ -9,6 +9,7 @@ import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email'; import { sendEmail } from '@/lib/email';
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth'; import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
import { loadSubjectOverride } from '@/lib/email/template-overrides'; import { loadSubjectOverride } from '@/lib/email/template-overrides';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { import {
CodedError, CodedError,
ConflictError, ConflictError,
@@ -118,13 +119,14 @@ async function issueActivationToken(
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`; const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation'); const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
const branding = await getBrandingShell(portId);
const { subject, html, text } = activationEmail( const { subject, html, text } = activationEmail(
{ {
portName, portName,
link, link,
ttlHours: ACTIVATION_TOKEN_TTL_HOURS, ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
}, },
{ subject: subjectOverride }, { subject: subjectOverride, branding },
); );
try { try {
@@ -378,13 +380,14 @@ export async function requestPasswordReset(email: string): Promise<void> {
const portName = port?.name ?? 'Port Nimara'; const portName = port?.name ?? 'Port Nimara';
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`; const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset'); const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
const branding = await getBrandingShell(user.portId);
const { subject, html, text } = resetEmail( const { subject, html, text } = resetEmail(
{ {
portName, portName,
link, link,
ttlMinutes: RESET_TOKEN_TTL_MINUTES, ttlMinutes: RESET_TOKEN_TTL_MINUTES,
}, },
{ subject: subjectOverride }, { subject: subjectOverride, branding },
); );
try { try {