Files
pn-new-crm/src/lib/email/templates/crm-invite.ts
Matt 16ef609e1b 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

82 lines
2.9 KiB
TypeScript

import { brandingPrimaryColor, renderShell, safeUrl, 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;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
export function crmInviteEmail(
data: InviteData,
overrides?: RenderOpts,
): {
subject: string;
html: string;
text: string;
} {
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:${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 ${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="${safeUrl(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="${safeUrl(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>${escapeHtml(portName)} CRM</strong>
</p>`;
const text = [
`Welcome to the ${portName} CRM`,
'',
`You've been invited as a ${role}.`,
`Set up your account: ${data.link}`,
'',
`The link expires in ${data.ttlHours} hours.`,
'',
`Thank you,`,
`${portName} CRM`,
].join('\n');
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;');
}