/** * 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 ? `
Logo
` : ''; 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, '`'); }