2026-05-07 00:00:45 +02:00
|
|
|
/**
|
|
|
|
|
* 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):
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
* - logoUrl - replaces the default Port Nimara logo image
|
|
|
|
|
* - primaryColor - used for the page-title accent color
|
|
|
|
|
* - emailHeaderHtml / emailFooterHtml - admin-authored HTML that
|
2026-05-07 00:00:45 +02:00
|
|
|
* 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 })`.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-05-22 12:28:34 +02:00
|
|
|
import { absolutizeBrandingUrl } from '@/lib/branding/url';
|
|
|
|
|
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
// Neutral defaults - no tenant-specific imagery leaks across ports.
|
2026-05-20 15:54:10 +02:00
|
|
|
// 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';
|
2026-05-07 00:00:45 +02:00
|
|
|
|
|
|
|
|
export interface BrandingShell {
|
|
|
|
|
logoUrl: string | null;
|
2026-05-18 15:12:28 +02:00
|
|
|
/** 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;
|
2026-05-07 00:00:45 +02:00
|
|
|
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 {
|
2026-05-22 12:28:34 +02:00
|
|
|
// 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);
|
2026-05-07 00:00:45 +02:00
|
|
|
const headerHtml = branding?.emailHeaderHtml ?? '';
|
|
|
|
|
const footerHtml = branding?.emailFooterHtml ?? '';
|
|
|
|
|
|
2026-05-20 15:54:10 +02:00
|
|
|
const wrapperStyle = backgroundUrl
|
|
|
|
|
? `background-image: url('${backgroundUrl}'); background-size: cover; background-position: center; background-color:#f2f2f2;`
|
|
|
|
|
: 'background-color:#f2f2f2;';
|
|
|
|
|
const logoBlock = logoUrl
|
|
|
|
|
? `<center><img src="${logoUrl}" alt="Logo" width="100" style="margin-bottom:20px;" /></center>`
|
|
|
|
|
: '';
|
|
|
|
|
|
2026-05-07 00:00:45 +02:00
|
|
|
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;">
|
2026-05-20 15:54:10 +02:00
|
|
|
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="${wrapperStyle}">
|
2026-05-07 00:00:45 +02:00
|
|
|
<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;">
|
2026-05-20 15:54:10 +02:00
|
|
|
${logoBlock}
|
2026-05-07 00:00:45 +02:00
|
|
|
${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;
|
|
|
|
|
}
|
audit: Tier 1/3/4/5/7 batch — SSE, gates, dedup, URL escape, FK constraints
Tier 1.6: S3Backend.put now sets ServerSideEncryption=AES256 — closes
the cleartext-at-rest gap for signed contracts, GDPR exports, pg_dumps.
Tier 3.7: New safeUrl() helper in lib/email/shell.ts. Scheme allow-list
(http/https/mailto/tel/relative only — javascript:/data:/vbscript:/file:
rewritten to about:blank) + HTML-attribute escape. Retrofitted across
all 7 transactional templates (crm-invite, portal-auth, document-signing,
notification-digest, residential-inquiry, admin-email-change).
Tier 4.2: /api/v1/alerts GET now gated on admin.view_audit_log.
Tier 4.3: Documenso webhook handler emits captureErrorEvent on catch.
Admin/errors no longer silent on webhook crashes.
Tier 4.6: Inquiry-funnel email dedup is now case-insensitive
(LOWER(value)) and stores normalized email on insert. Capital-letter
resubmissions no longer spawn duplicate client+yacht+interest rows.
Tier 5.6 + data-model H1: migration 0056 adds FK
user_permission_overrides.user_id → user(id) cascade, same for
user_port_roles.userId, plus partial unique index on
user_email_changes pending rows.
Tier 7.6: @types/node bumped from ^25 to ^20.19.0 — matches the runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:09:14 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
* escaping - a `"` (or worse, a `javascript:` scheme) would break out
|
audit: Tier 1/3/4/5/7 batch — SSE, gates, dedup, URL escape, FK constraints
Tier 1.6: S3Backend.put now sets ServerSideEncryption=AES256 — closes
the cleartext-at-rest gap for signed contracts, GDPR exports, pg_dumps.
Tier 3.7: New safeUrl() helper in lib/email/shell.ts. Scheme allow-list
(http/https/mailto/tel/relative only — javascript:/data:/vbscript:/file:
rewritten to about:blank) + HTML-attribute escape. Retrofitted across
all 7 transactional templates (crm-invite, portal-auth, document-signing,
notification-digest, residential-inquiry, admin-email-change).
Tier 4.2: /api/v1/alerts GET now gated on admin.view_audit_log.
Tier 4.3: Documenso webhook handler emits captureErrorEvent on catch.
Admin/errors no longer silent on webhook crashes.
Tier 4.6: Inquiry-funnel email dedup is now case-insensitive
(LOWER(value)) and stores normalized email on insert. Capital-letter
resubmissions no longer spawn duplicate client+yacht+interest rows.
Tier 5.6 + data-model H1: migration 0056 adds FK
user_permission_overrides.user_id → user(id) cascade, same for
user_port_roles.userId, plus partial unique index on
user_email_changes pending rows.
Tier 7.6: @types/node bumped from ^25 to ^20.19.0 — matches the runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:09:14 +02:00
|
|
|
* of the attribute or trigger an XSS when the recipient opens the email
|
|
|
|
|
* in a webmail client that runs scripts.
|
|
|
|
|
*
|
|
|
|
|
* Two-step defense:
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
* 1. Scheme allow-list - only http(s), mailto, tel survive; everything
|
audit: Tier 1/3/4/5/7 batch — SSE, gates, dedup, URL escape, FK constraints
Tier 1.6: S3Backend.put now sets ServerSideEncryption=AES256 — closes
the cleartext-at-rest gap for signed contracts, GDPR exports, pg_dumps.
Tier 3.7: New safeUrl() helper in lib/email/shell.ts. Scheme allow-list
(http/https/mailto/tel/relative only — javascript:/data:/vbscript:/file:
rewritten to about:blank) + HTML-attribute escape. Retrofitted across
all 7 transactional templates (crm-invite, portal-auth, document-signing,
notification-digest, residential-inquiry, admin-email-change).
Tier 4.2: /api/v1/alerts GET now gated on admin.view_audit_log.
Tier 4.3: Documenso webhook handler emits captureErrorEvent on catch.
Admin/errors no longer silent on webhook crashes.
Tier 4.6: Inquiry-funnel email dedup is now case-insensitive
(LOWER(value)) and stores normalized email on insert. Capital-letter
resubmissions no longer spawn duplicate client+yacht+interest rows.
Tier 5.6 + data-model H1: migration 0056 adds FK
user_permission_overrides.user_id → user(id) cascade, same for
user_port_roles.userId, plus partial unique index on
user_email_changes pending rows.
Tier 7.6: @types/node bumped from ^25 to ^20.19.0 — matches the runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:09:14 +02:00
|
|
|
* 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:') ||
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
// Relative or root-relative paths are also acceptable - they
|
audit: Tier 1/3/4/5/7 batch — SSE, gates, dedup, URL escape, FK constraints
Tier 1.6: S3Backend.put now sets ServerSideEncryption=AES256 — closes
the cleartext-at-rest gap for signed contracts, GDPR exports, pg_dumps.
Tier 3.7: New safeUrl() helper in lib/email/shell.ts. Scheme allow-list
(http/https/mailto/tel/relative only — javascript:/data:/vbscript:/file:
rewritten to about:blank) + HTML-attribute escape. Retrofitted across
all 7 transactional templates (crm-invite, portal-auth, document-signing,
notification-digest, residential-inquiry, admin-email-change).
Tier 4.2: /api/v1/alerts GET now gated on admin.view_audit_log.
Tier 4.3: Documenso webhook handler emits captureErrorEvent on catch.
Admin/errors no longer silent on webhook crashes.
Tier 4.6: Inquiry-funnel email dedup is now case-insensitive
(LOWER(value)) and stores normalized email on insert. Capital-letter
resubmissions no longer spawn duplicate client+yacht+interest rows.
Tier 5.6 + data-model H1: migration 0056 adds FK
user_permission_overrides.user_id → user(id) cascade, same for
user_port_roles.userId, plus partial unique index on
user_email_changes pending rows.
Tier 7.6: @types/node bumped from ^25 to ^20.19.0 — matches the runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:09:14 +02:00
|
|
|
// 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, '>')
|
|
|
|
|
.replace(/`/g, '`');
|
|
|
|
|
}
|