/**
* 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 })`.
*/
import type * as React from 'react';
import { absolutizeBrandingUrl } from '@/lib/branding/url';
// Neutral defaults - no tenant-specific imagery leaks across ports.
// When branding hasn't been configured the email renders without a logo
// and on a plain off-white background. Admins upload their own assets via
// /admin/branding which then flow through via getPortBrandingConfig().
const DEFAULT_LOGO_URL: string | null = null;
const DEFAULT_BACKGROUND_URL: string | null = null;
const DEFAULT_PRIMARY_COLOR = '#1e293b';
export interface BrandingShell {
logoUrl: string | null;
/** Phase 5: blurred page-background image rendered behind the white
* card. Defaults to the Port Nimara overhead image. Ports with
* their own marina photography override via system_settings. */
backgroundUrl: 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 {
// Branding URLs are stored path-only (so in-app rendering works across
// any host). Mail clients have no app origin, so re-absolutize here.
const logoUrl = absolutizeBrandingUrl(branding?.logoUrl ?? DEFAULT_LOGO_URL);
const backgroundUrl = absolutizeBrandingUrl(branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL);
const headerHtml = branding?.emailHeaderHtml ?? '';
const footerHtml = branding?.emailFooterHtml ?? '';
const wrapperStyle = backgroundUrl
? `background-image: url('${backgroundUrl}'); background-size: cover; background-position: center; background-color:#f2f2f2;`
: 'background-color:#f2f2f2;';
const logoBlock = logoUrl
? `
`
: '';
return `
${title}
|
${logoBlock}
${headerHtml ? ` ${headerHtml} ` : ''}
${body}
${footerHtml ? `${footerHtml} ` : ''}
|
|
`;
}
/** Surface the brand primary color to template bodies. */
export function brandingPrimaryColor(branding?: BrandingShell | null): string {
return branding?.primaryColor ?? DEFAULT_PRIMARY_COLOR;
}
/**
* Shared style conventions for transactional email bodies.
*
* Templates compose these instead of inlining one-off `style={{...}}` objects
* so the visual rhythm stays consistent across every email - centered title
* in the brand accent, body paragraphs left-aligned at 16px / 1.5 line-height,
* centered CTA button, fine-print block separated by a soft divider, centered
* sign-off in the same accent. Modeled on the hand-rolled templates from the
* original portal (signature-notifications.ts) so the look carries forward.
*
* Functions accept an `accent` color (the resolved port primary) where it's
* load-bearing; constants do not.
*/
export const emailStyle = {
/** Page heading: centered, brand-accent, bold. Used once at the top. */
title: (accent: string): React.CSSProperties => ({
textAlign: 'center',
fontSize: '22px',
fontWeight: 'bold',
color: accent,
margin: '0 0 16px 0',
}),
/** Body paragraph: 16px / 1.5 line-height, left-aligned for readability. */
paragraph: {
fontSize: '16px',
lineHeight: '1.5',
margin: '0 0 16px 0',
color: '#333333',
} satisfies React.CSSProperties,
/** Soft hairline divider above fine-print blocks. */
divider: {
border: 'none',
borderTop: '1px solid #eee',
margin: '28px 0 0 0',
} satisfies React.CSSProperties,
/** Fine print: 14px muted, line-height 1.5. */
finePrint: {
fontSize: '14px',
color: '#666666',
lineHeight: '1.5',
margin: '12px 0 0 0',
} satisfies React.CSSProperties,
/** Sign-off block: left-aligned, 16px, sits BETWEEN the last body
* paragraph and the primary CTA so the email reads like a letter
* (greeting -> body -> sign-off -> button -> button-fallback fine
* print). Top margin is intentionally modest because preceding
* paragraphs already carry 16px bottom margin. */
signoff: {
textAlign: 'left',
fontSize: '16px',
color: '#333333',
margin: '8px 0 0 0',
} satisfies React.CSSProperties,
/** Outer wrapper that centers the primary CTA button. */
buttonRow: {
textAlign: 'center',
margin: '28px 0',
} satisfies React.CSSProperties,
/** Primary CTA button style. Compose with `buttonRow` for the surrounding center. */
button: (accent: string): React.CSSProperties => ({
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}),
} as const;
/**
* URL-safe escaper for `href="..."` interpolations inside email
* templates. The email-deliverability audit flagged that every template
* inlined `${data.link}` directly into href + visible text without
* escaping - a `"` (or worse, a `javascript:` scheme) would break out
* of the attribute or trigger an XSS when the recipient opens the email
* in a webmail client that runs scripts.
*
* Two-step defense:
* 1. Scheme allow-list - only http(s), mailto, tel survive; everything
* else (javascript:, data:, vbscript:, file:, …) is rewritten to
* `about:blank`.
* 2. HTML-attribute escape on `"`, `<`, `>`, `&`, `'`, backtick.
*/
export function safeUrl(url: string | null | undefined): string {
if (!url) return 'about:blank';
const trimmed = String(url).trim();
// Block dangerous schemes. The allow-list is intentionally short.
const lower = trimmed.toLowerCase();
const ok =
lower.startsWith('http://') ||
lower.startsWith('https://') ||
lower.startsWith('mailto:') ||
lower.startsWith('tel:') ||
// Relative or root-relative paths are also acceptable - they
// resolve against the host the email links to (rare in transactional
// mail but used by tracking pixels and unsubscribe headers).
lower.startsWith('/') ||
lower.startsWith('#');
if (!ok) return 'about:blank';
return trimmed
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(//g, '>')
.replace(/`/g, '`');
}