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,
} from '@/lib/email/templates/residential-inquiry';
import { resolveSubject } from '@/lib/email/resolve-subject';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { env } from '@/lib/env';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
@@ -141,18 +142,22 @@ async function sendResidentialNotifications(args: {
}): Promise<void> {
const { portId, data, crmDeepLink } = args;
const branding = await getBrandingShell(portId);
// Client confirmation
const confirmation = residentialClientConfirmation({
const confirmation = residentialClientConfirmation(
{
firstName: data.firstName,
contactEmail: 'sales@portnimara.com',
});
},
{ branding },
);
const confirmationSubject = await resolveSubject({
key: 'residential_inquiry_client_confirmation',
portId,
fallback: confirmation.subject,
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;
// fall back to the inquiry_contact_email if available.
@@ -181,7 +186,8 @@ async function sendResidentialNotifications(args: {
return;
}
const alert = residentialSalesAlert({
const alert = residentialSalesAlert(
{
fullName: `${data.firstName} ${data.lastName}`.trim(),
email: data.email,
phone: data.phone,
@@ -190,7 +196,9 @@ async function sendResidentialNotifications(args: {
notes: data.notes,
preferences: data.preferences,
crmDeepLink,
});
},
{ branding },
);
const alertSubject = await resolveSubject({
key: 'residential_inquiry_sales_alert',
portId,

View File

@@ -1,27 +1,42 @@
const BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
const LOGO_URL =
const DEFAULT_BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
const DEFAULT_LOGO_URL =
'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,
* password set/reset/activate, forgot-password. Renders the blurred Port
* Nimara overhead background, the circular logo, and a centered white card
* that consumers populate with their own form/content.
* password set/reset/activate, forgot-password. Renders the blurred
* background, the logo, and a centered white card that consumers
* 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 (
<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
aria-hidden
className="fixed inset-0 -z-10"
style={{
backgroundImage: `url('${BG_URL}')`,
backgroundImage: `url('${DEFAULT_BG_URL}')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
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="flex justify-center mb-6">
{/* 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>
{children}
</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 {
link: string;
ttlHours: number;
recipientName?: string;
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 =
'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 {
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>`;
interface RenderOpts {
branding?: BrandingShell | null;
}
export function crmInviteEmail(data: InviteData): {
export function crmInviteEmail(
data: InviteData,
overrides?: RenderOpts,
): {
subject: string;
html: 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 role = data.isSuperAdmin ? 'super administrator' : 'administrator';
const accent = brandingPrimaryColor(overrides?.branding);
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Welcome to the Port Nimara CRM
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
Welcome to the ${escapeHtml(portName)} CRM
</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;">
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
link expires in ${data.ttlHours} hours.
</p>
<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
</a>
</p>
<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 />
<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 style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>Port Nimara CRM</strong>
<strong>${escapeHtml(portName)} CRM</strong>
</p>`;
const text = [
`Welcome to the Port Nimara CRM`,
`Welcome to the ${portName} CRM`,
'',
`You've been invited as a ${role}.`,
`Set up your account: ${data.link}`,
@@ -85,10 +61,14 @@ export function crmInviteEmail(data: InviteData): {
`The link expires in ${data.ttlHours} hours.`,
'',
`Thank you,`,
`Port Nimara CRM`,
`${portName} CRM`,
].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 {

View File

@@ -1,40 +1,32 @@
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
export interface InquiryClientConfirmationData {
firstName: string;
mooringNumber: string | null;
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 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
? `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>
<html>
<head>
<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>
const accent = brandingPrimaryColor(overrides?.branding);
const body = `
<p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p>
<p style="margin-bottom:10px; font-size:16px;">
Thank you for expressing interest in ${escapeHtml(berthText)}.
@@ -43,20 +35,12 @@ export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
</p>
<p style="margin-bottom:10px; font-size:16px;">
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 style="font-size:16px;">
Best regards,<br />
The Port Nimara Sales Team
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
The ${escapeHtml(portName)} Sales Team
</p>`;
const text = [
`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}.`,
'',
'Best regards,',
'The Port Nimara Sales Team',
`The ${portName} Sales Team`,
].join('\n');
return { subject, html, text };
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
function escapeHtml(str: string): string {

View File

@@ -1,73 +1,60 @@
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
export interface InquirySalesNotificationData {
fullName: string;
email: string;
phone: string;
mooringNumber: string | null;
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 portName = data.portName ?? 'Port Nimara';
const mooringDisplay = mooringNumber || 'None';
const subject = `New Interest - ${portName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const subject = 'New Interest - Port Nimara';
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>
const body = `
<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>Email:</strong> ${escapeHtml(email)}</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-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="font-size:16px;">Thank you,<br/>Port Nimara CRM</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
<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/>${escapeHtml(portName)} CRM</p>`;
const text = [
'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}`,
`Email: ${email}`,
`Telephone: ${phone}`,
`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',
'Port Nimara CRM',
`${portName} CRM`,
].join('\n');
return { subject, html, text };
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
function escapeHtml(str: string): string {

View File

@@ -3,6 +3,8 @@
* Used by the notification-digest scheduler (queued in `email` worker).
*/
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
interface DigestData {
portName: string;
recipientName: string;
@@ -19,45 +21,8 @@ interface DigestData {
inboxLink: string;
}
const LOGO_URL =
'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 {
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;');
interface RenderOpts {
branding?: BrandingShell | null;
}
const TYPE_LABELS: Record<string, string> = {
@@ -75,18 +40,22 @@ const TYPE_LABELS: Record<string, string> = {
berth_released: 'Berth released',
};
export function notificationDigestEmail(data: DigestData): {
export function notificationDigestEmail(
data: DigestData,
overrides?: RenderOpts,
): {
subject: string;
html: string;
text: string;
} {
const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`;
const accent = brandingPrimaryColor(overrides?.branding);
const itemsHtml = data.items
.map((item) => {
const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
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>`;
const desc = item.description
? `<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 =
data.totalUnread > data.items.length
? `<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 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
</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}`,
].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 {
portName: string;
link: string;
@@ -12,47 +14,14 @@ interface ResetData {
recipientName?: string;
}
const LOGO_URL =
'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 {
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>`;
interface RenderOpts {
subject?: string | null;
branding?: BrandingShell | null;
}
export function activationEmail(
data: ActivationData,
overrides?: { subject?: string | null },
overrides?: RenderOpts,
): {
subject: string;
html: string;
@@ -65,9 +34,10 @@ export function activationEmail(
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
: `Activate your ${data.portName} client portal account`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
const accent = brandingPrimaryColor(overrides?.branding);
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)}
</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.
</p>
<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
</a>
</p>
<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 />
<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 style="font-size:16px; margin-top:30px;">
Thank you,<br />
@@ -102,12 +72,16 @@ export function activationEmail(
`${data.portName} CRM`,
].join('\n');
return { subject, html: shell({ title: subject, body }), text };
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
export function resetEmail(
data: ResetData,
overrides?: { subject?: string | null },
overrides?: RenderOpts,
): { subject: string; html: string; text: string } {
const subject = overrides?.subject
? overrides.subject
@@ -116,9 +90,10 @@ export function resetEmail(
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
: `Reset your ${data.portName} client portal password`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,';
const accent = brandingPrimaryColor(overrides?.branding);
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
</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.
</p>
<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
</a>
</p>
@@ -152,7 +127,11 @@ export function resetEmail(
`${data.portName} CRM`,
].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 {

View File

@@ -1,69 +1,47 @@
const LOGO_URL =
'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';
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
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>`;
interface RenderOpts {
branding?: BrandingShell | null;
}
export interface ResidentialClientConfirmationData {
firstName: string;
contactEmail: string;
/** Display name; falls back to "Port Nimara". */
portName?: string;
}
export function residentialClientConfirmation(data: ResidentialClientConfirmationData) {
const subject = 'Thank You for Your Interest - Port Nimara Residences';
export function residentialClientConfirmation(
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 = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Welcome to Port Nimara
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
Welcome to ${escapeHtml(portName)}
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
Dear ${escapeHtml(data.firstName)},
</p>
<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
more information.
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
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 style="font-size:16px; margin-top:30px;">
Best regards,<br />
<strong>The Port Nimara Residential Team</strong>
<strong>The ${escapeHtml(portName)} Residential Team</strong>
</p>`;
return { subject, html: shell({ title: subject, body }) };
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}
export interface ResidentialSalesAlertData {
@@ -75,12 +53,15 @@ export interface ResidentialSalesAlertData {
notes?: string;
preferences?: 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 accent = brandingPrimaryColor(overrides?.branding);
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
</p>
<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.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
</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>` : ''}
<p style="font-size:14px; color:#666;">- Port Nimara CRM</p>`;
return { subject, html: shell({ title: subject, body }) };
${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;">- ${escapeHtml(portName)} CRM</p>`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}
function escapeHtml(str: string): string {

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ import { getPortReminderConfig } from '@/lib/services/port-config';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { resolveSubject } from '@/lib/email/resolve-subject';
import { getBrandingShell } from '@/lib/email/branding-resolver';
const DIGEST_LOOKBACK_MS = 24 * 60 * 60 * 1000;
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;
// 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);
for (const u of portUsers) {
@@ -133,7 +138,8 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
const visible = rows.slice(0, MAX_ITEMS_PER_USER);
const inboxLink = `${env.APP_URL}/notifications`;
const result = notificationDigestEmail({
const result = notificationDigestEmail(
{
portName: port.name,
recipientName: u.name ?? '',
items: visible.map((r) => ({
@@ -145,7 +151,9 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
})),
totalUnread: rows.length,
inboxLink,
});
},
{ branding },
);
// The per-port subject override key for the digest is the
// 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 { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
import { loadSubjectOverride } from '@/lib/email/template-overrides';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import {
CodedError,
ConflictError,
@@ -118,13 +119,14 @@ async function issueActivationToken(
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
const branding = await getBrandingShell(portId);
const { subject, html, text } = activationEmail(
{
portName,
link,
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
},
{ subject: subjectOverride },
{ subject: subjectOverride, branding },
);
try {
@@ -378,13 +380,14 @@ export async function requestPasswordReset(email: string): Promise<void> {
const portName = port?.name ?? 'Port Nimara';
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
const branding = await getBrandingShell(user.portId);
const { subject, html, text } = resetEmail(
{
portName,
link,
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
},
{ subject: subjectOverride },
{ subject: subjectOverride, branding },
);
try {