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

@@ -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 {