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
This commit is contained in:
@@ -14,7 +14,7 @@ interface AuthShellBranding {
|
||||
* Pre-port-context surfaces (login, forgot-password, set-password,
|
||||
* the better-auth password-reset email) need branding before the user
|
||||
* has picked a port. Resolve against the first active port in the
|
||||
* system — for a single-tenant deploy that's the right port; for a
|
||||
* system - for a single-tenant deploy that's the right port; for a
|
||||
* multi-tenant deploy the operator should host each tenant on its own
|
||||
* subdomain so the wrong-tenant logo doesn't surface here.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Phase 6 — IMAP bounce parser library.
|
||||
* Phase 6 - IMAP bounce parser library.
|
||||
*
|
||||
* Walks an inbound delivery-status notification (DSN / NDR) message and
|
||||
* extracts the original recipient, bounce class (hard / soft / ooo /
|
||||
@@ -10,12 +10,12 @@
|
||||
* 7-day window before updating the send row's bounce_* columns.
|
||||
*
|
||||
* The parser handles three common NDR shapes:
|
||||
* 1. RFC 3464 multipart/report — Postfix, Exim, Sendmail, Gmail. The
|
||||
* 1. RFC 3464 multipart/report - Postfix, Exim, Sendmail, Gmail. The
|
||||
* message has a `message/delivery-status` part whose headers carry
|
||||
* structured Action / Status / Original-Recipient fields.
|
||||
* 2. Microsoft Outlook / Exchange — non-DSN reports with the original
|
||||
* 2. Microsoft Outlook / Exchange - non-DSN reports with the original
|
||||
* recipient embedded in the subject line + body prose.
|
||||
* 3. Out-of-office auto-replies — distinct from bounces; classed as
|
||||
* 3. Out-of-office auto-replies - distinct from bounces; classed as
|
||||
* `ooo` so the UI banner stays informational rather than alarming.
|
||||
*
|
||||
* When the parser can't extract a recipient it returns
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Senders that have a portId call this once and pass the result into
|
||||
* the email template. Senders without a portId (e.g. CRM invite at
|
||||
* create-time before a port is selected) pass null — the shell then
|
||||
* create-time before a port is selected) pass null - the shell then
|
||||
* falls back to neutral defaults (no logo, plain background, slate
|
||||
* accent). Configure per-port branding via /admin/branding.
|
||||
*/
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function sendEmail(
|
||||
const effectiveSubject = env.EMAIL_REDIRECT_TO
|
||||
? `[redirected from ${requestedTo}] ${subject}`
|
||||
: subject;
|
||||
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO — the redirect target
|
||||
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO - the redirect target
|
||||
// already gets the message; CCing additional recipients would defeat
|
||||
// the dev safety net.
|
||||
const effectiveCc = env.EMAIL_REDIRECT_TO ? undefined : cc;
|
||||
@@ -165,12 +165,12 @@ export async function sendEmail(
|
||||
|
||||
// When EMAIL_REDIRECT_TO is set we elevate to `warn` so the dev-only
|
||||
// safety net is visible in any logger config. Prod boot already refuses
|
||||
// when both are set (see env.ts superRefine) — this catches the dev /
|
||||
// when both are set (see env.ts superRefine) - this catches the dev /
|
||||
// staging window where someone left it in a .env by mistake.
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.warn(
|
||||
{ messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId },
|
||||
'Email sent (REDIRECTED via EMAIL_REDIRECT_TO — recipient overridden)',
|
||||
'Email sent (REDIRECTED via EMAIL_REDIRECT_TO - recipient overridden)',
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { TemplateKey } from '@/lib/email/template-catalog';
|
||||
|
||||
export async function resolveSubject(args: {
|
||||
key: TemplateKey;
|
||||
/** Optional — when omitted (e.g. system-level emails with no port
|
||||
/** Optional - when omitted (e.g. system-level emails with no port
|
||||
* context), only the fallback subject is returned. */
|
||||
portId?: string | null;
|
||||
fallback: string;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* 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
|
||||
* - 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.
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { absolutizeBrandingUrl } from '@/lib/branding/url';
|
||||
|
||||
// Neutral defaults — no tenant-specific imagery leaks across ports.
|
||||
// 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().
|
||||
@@ -100,12 +100,12 @@ export function brandingPrimaryColor(branding?: BrandingShell | null): string {
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
@@ -120,7 +120,7 @@ export function safeUrl(url: string | null | undefined): string {
|
||||
lower.startsWith('https://') ||
|
||||
lower.startsWith('mailto:') ||
|
||||
lower.startsWith('tel:') ||
|
||||
// Relative or root-relative paths are also acceptable — they
|
||||
// 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('/') ||
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface TemplateMetadata {
|
||||
export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
|
||||
portal_activation: {
|
||||
key: 'portal_activation',
|
||||
label: 'Portal — activation invite',
|
||||
label: 'Portal - activation invite',
|
||||
description:
|
||||
'Sent to a client when an admin invites them to activate their portal account. Contains the activation link.',
|
||||
mergeTokens: ['portName', 'recipientName', 'ttlHours'],
|
||||
@@ -53,7 +53,7 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
|
||||
},
|
||||
portal_reset: {
|
||||
key: 'portal_reset',
|
||||
label: 'Portal — password reset',
|
||||
label: 'Portal - password reset',
|
||||
description:
|
||||
'Sent when a portal user requests a password reset. Contains the reset link with a short TTL.',
|
||||
mergeTokens: ['portName', 'recipientName', 'ttlMinutes'],
|
||||
@@ -61,45 +61,45 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
|
||||
},
|
||||
portal_invite_resend: {
|
||||
key: 'portal_invite_resend',
|
||||
label: 'Portal — invite resend',
|
||||
label: 'Portal - invite resend',
|
||||
description: 'Re-sent activation email when an admin resends a pending portal invite.',
|
||||
mergeTokens: ['portName', 'recipientName', 'ttlHours'],
|
||||
defaultSubject: 'Activate your {{portName}} client portal account',
|
||||
},
|
||||
crm_invite: {
|
||||
key: 'crm_invite',
|
||||
label: 'CRM — staff invite',
|
||||
label: 'CRM - staff invite',
|
||||
description: 'Sent to a new staff user when an admin invites them to the CRM.',
|
||||
mergeTokens: ['portName', 'recipientName', 'ttlHours'],
|
||||
defaultSubject: 'You have been invited to {{portName}} CRM',
|
||||
},
|
||||
inquiry_client_confirmation: {
|
||||
key: 'inquiry_client_confirmation',
|
||||
label: 'Inquiry — client confirmation',
|
||||
label: 'Inquiry - client confirmation',
|
||||
description: 'Auto-reply confirmation sent to the client after a website berth inquiry.',
|
||||
mergeTokens: ['portName', 'recipientName', 'mooringNumber'],
|
||||
defaultSubject: 'We received your inquiry — {{portName}}',
|
||||
defaultSubject: 'We received your inquiry - {{portName}}',
|
||||
},
|
||||
inquiry_sales_notification: {
|
||||
key: 'inquiry_sales_notification',
|
||||
label: 'Inquiry — sales notification',
|
||||
label: 'Inquiry - sales notification',
|
||||
description: 'Internal alert sent to the sales team when a new website inquiry arrives.',
|
||||
mergeTokens: ['portName', 'clientName', 'mooringNumber', 'email'],
|
||||
defaultSubject: 'New berth inquiry — {{clientName}}',
|
||||
defaultSubject: 'New berth inquiry - {{clientName}}',
|
||||
},
|
||||
residential_inquiry_client_confirmation: {
|
||||
key: 'residential_inquiry_client_confirmation',
|
||||
label: 'Residential inquiry — client confirmation',
|
||||
label: 'Residential inquiry - client confirmation',
|
||||
description: 'Auto-reply sent to the client after a residential property inquiry.',
|
||||
mergeTokens: ['portName', 'recipientName'],
|
||||
defaultSubject: 'We received your residential inquiry — {{portName}}',
|
||||
defaultSubject: 'We received your residential inquiry - {{portName}}',
|
||||
},
|
||||
residential_inquiry_sales_alert: {
|
||||
key: 'residential_inquiry_sales_alert',
|
||||
label: 'Residential inquiry — sales alert',
|
||||
label: 'Residential inquiry - sales alert',
|
||||
description: 'Internal alert sent to residential sales recipients when an inquiry arrives.',
|
||||
mergeTokens: ['portName', 'clientName', 'email', 'phone'],
|
||||
defaultSubject: 'New residential inquiry — {{clientName}}',
|
||||
defaultSubject: 'New residential inquiry - {{clientName}}',
|
||||
},
|
||||
notification_digest: {
|
||||
key: 'notification_digest',
|
||||
@@ -107,7 +107,7 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
|
||||
description:
|
||||
"Daily roll-up of a rep's pending notifications. Fires from the digest worker; respects per-user opt-out.",
|
||||
mergeTokens: ['portName', 'recipientName', 'unreadCount'],
|
||||
defaultSubject: 'Your {{portName}} CRM digest — {{unreadCount}} updates',
|
||||
defaultSubject: 'Your {{portName}} CRM digest - {{unreadCount}} updates',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -40,5 +40,5 @@ export function applySubjectTokens(
|
||||
}
|
||||
|
||||
// Suppress unused-import lint when the helper is not yet referenced from
|
||||
// every template — every consumer uses `and` once it integrates.
|
||||
// every template - every consumer uses `and` once it integrates.
|
||||
void and;
|
||||
|
||||
@@ -8,7 +8,7 @@ interface InviteData {
|
||||
ttlHours: number;
|
||||
recipientName?: string;
|
||||
isSuperAdmin: boolean;
|
||||
/** Display name for the port — falls back to "Port Nimara" so the
|
||||
/** Display name for the port - falls back to "Port Nimara" so the
|
||||
* pre-multi-tenant default still reads correctly. */
|
||||
portName?: string;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ function InviteBody({
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
You've been invited to join the {portName} CRM as a {role}. Use the button below to set
|
||||
your password and activate your account at your convenience — the link will remain valid for{' '}
|
||||
your password and activate your account at your convenience - the link will remain valid for{' '}
|
||||
{ttlHours} hours.
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
*
|
||||
* Three template families:
|
||||
*
|
||||
* 1. signingInvitation — sent to a single signer when their turn to sign
|
||||
* 1. signingInvitation - sent to a single signer when their turn to sign
|
||||
* comes up. Used both for initial client invites AND cascading "your
|
||||
* turn" emails when an earlier signer completes.
|
||||
*
|
||||
* 2. signingCompleted — sent to ALL signers (with the finalized signed
|
||||
* 2. signingCompleted - sent to ALL signers (with the finalized signed
|
||||
* PDF as an attachment) when the document reaches a fully signed state.
|
||||
*
|
||||
* 3. signingReminder — sent when a rep nudges manually OR when the
|
||||
* 3. signingReminder - sent when a rep nudges manually OR when the
|
||||
* rate-limited reminder service fires.
|
||||
*
|
||||
* All three use the per-port BrandingShell. The signing URL is expected
|
||||
* to already be embedded-format (e.g. /sign/<type>/<token>) — the caller
|
||||
* to already be embedded-format (e.g. /sign/<type>/<token>) - the caller
|
||||
* does the transformation from the raw Documenso URL.
|
||||
*/
|
||||
|
||||
@@ -50,7 +50,7 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
|
||||
// signer.
|
||||
const leadCopy =
|
||||
role === 'client'
|
||||
? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.`
|
||||
? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign - it should only take a couple of minutes.`
|
||||
: role === 'approver'
|
||||
? `An ${data.documentLabel} is awaiting your approval. Please review and sign to finalise the document.`
|
||||
: role === 'developer'
|
||||
@@ -110,7 +110,7 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', marginTop: '18px' }}>
|
||||
Signing happens directly inside our website — your data isn't sent to a third-party
|
||||
Signing happens directly inside our website - your data isn't sent to a third-party
|
||||
signing service.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
@@ -140,7 +140,7 @@ export async function signingInvitationEmail(
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
.replace(/\{\{recipientName\}\}/g, data.recipientName)
|
||||
: `${data.documentLabel} ready to sign — ${data.portName}`;
|
||||
: `${data.documentLabel} ready to sign - ${data.portName}`;
|
||||
|
||||
const body = await render(<InvitationBody data={data} accent={accent} />, { pretty: false });
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function signingCompletedEmail(
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{clientName\}\}/g, data.clientName)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `${data.documentLabel} fully signed — ${data.clientName}`;
|
||||
: `${data.documentLabel} fully signed - ${data.clientName}`;
|
||||
const completedDateStr = formatDate(data.completedAt, 'datetime.medium');
|
||||
|
||||
const body = await render(
|
||||
@@ -250,7 +250,7 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
|
||||
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
We sent you a {data.documentLabel} {data.invitedAgo} that's still awaiting your
|
||||
signature. If you've already signed, please disregard this message — it can take a few
|
||||
signature. If you've already signed, please disregard this message - it can take a few
|
||||
minutes for our system to catch up.
|
||||
</Text>
|
||||
{data.customMessage ? (
|
||||
@@ -315,7 +315,7 @@ export async function signingReminderEmail(
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `Friendly reminder: ${data.documentLabel} still awaiting your signature — ${data.portName}`;
|
||||
: `Friendly reminder: ${data.documentLabel} still awaiting your signature - ${data.portName}`;
|
||||
|
||||
const body = await render(<ReminderBody data={data} accent={accent} />, { pretty: false });
|
||||
|
||||
@@ -350,7 +350,7 @@ function CancelledBody({ data, accent }: { data: CancelledData; accent: string }
|
||||
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
The {data.documentLabel} you were signing for {data.portName} has been cancelled. No further
|
||||
action is required from you — any signing link previously sent is no longer valid.
|
||||
action is required from you - any signing link previously sent is no longer valid.
|
||||
</Text>
|
||||
{data.reason ? (
|
||||
<Text
|
||||
@@ -391,7 +391,7 @@ export async function signingCancelledEmail(
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `${data.documentLabel} cancelled — ${data.portName}`;
|
||||
: `${data.documentLabel} cancelled - ${data.portName}`;
|
||||
const body = await render(<CancelledBody data={data} accent={accent} />, { pretty: false });
|
||||
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} you were signing for ${data.portName} has been cancelled. No further action is required.${data.reason ? '\n\nReason: ' + data.reason : ''}\n\nWith warm regards,\nThe ${data.portName} Team`;
|
||||
return {
|
||||
|
||||
@@ -40,7 +40,7 @@ function SalesNotificationBody({
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
A new enquiry has come in for <strong>{portName}</strong>. {fullName} has asked us to be in
|
||||
touch — full details below:
|
||||
touch - full details below:
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Name:</strong> {fullName}
|
||||
@@ -61,7 +61,7 @@ function SalesNotificationBody({
|
||||
</Link>{' '}
|
||||
to follow up.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px' }}>— {portName} CRM</Text>
|
||||
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export async function inquirySalesNotification(
|
||||
const mooringDisplay = data.mooringNumber || 'None';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New enquiry — ${portName}${data.mooringNumber ? ` (Berth ${data.mooringNumber})` : ''}`;
|
||||
: `New enquiry - ${portName}${data.mooringNumber ? ` (Berth ${data.mooringNumber})` : ''}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
@@ -93,7 +93,7 @@ export async function inquirySalesNotification(
|
||||
const text = [
|
||||
'Hello,',
|
||||
'',
|
||||
`A new enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch — full details below:`,
|
||||
`A new enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch - full details below:`,
|
||||
'',
|
||||
`Name: ${data.fullName}`,
|
||||
`Email: ${data.email}`,
|
||||
@@ -102,7 +102,7 @@ export async function inquirySalesNotification(
|
||||
'',
|
||||
`Open the ${portName} CRM (${data.crmUrl}) to follow up.`,
|
||||
'',
|
||||
`— ${portName} CRM`,
|
||||
`- ${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
|
||||
@@ -53,7 +53,7 @@ function DigestBody({
|
||||
</Text>
|
||||
<Text style={{ fontSize: '14px', lineHeight: '1.5', margin: '0 0 14px' }}>{greeting}</Text>
|
||||
<Text style={{ fontSize: '14px', lineHeight: '1.5', margin: '0 0 16px' }}>
|
||||
Here's what's waiting for you — <strong>{totalUnread}</strong> item
|
||||
Here's what's waiting for you - <strong>{totalUnread}</strong> item
|
||||
{totalUnread === 1 ? '' : 's'} since your last digest.
|
||||
</Text>
|
||||
<table role="presentation" width="100%" cellSpacing={0} cellPadding={0} border={0}>
|
||||
@@ -120,7 +120,7 @@ export async function notificationDigestEmail(
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `Your ${data.portName} update — ${data.totalUnread} new item${data.totalUnread === 1 ? '' : 's'}`;
|
||||
: `Your ${data.portName} update - ${data.totalUnread} new item${data.totalUnread === 1 ? '' : 's'}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(<DigestBody {...data} accent={accent} />, { pretty: false });
|
||||
@@ -128,7 +128,7 @@ export async function notificationDigestEmail(
|
||||
const text = [
|
||||
`Your ${data.portName} update`,
|
||||
'',
|
||||
`Here's what's waiting for you — ${data.totalUnread} item${data.totalUnread === 1 ? '' : 's'} since your last digest.`,
|
||||
`Here's what's waiting for you - ${data.totalUnread} item${data.totalUnread === 1 ? '' : 's'} since your last digest.`,
|
||||
'',
|
||||
...data.items.map((i) => `• [${i.type.replace(/_/g, ' ')}] ${i.title}`),
|
||||
'',
|
||||
|
||||
@@ -27,7 +27,7 @@ interface RenderOpts {
|
||||
|
||||
// react-email's `render()` auto-escapes string interpolation, so we don't
|
||||
// need our hand-rolled escapeHtml() on these bodies. Inline styles use
|
||||
// camelCase per CSSProperties — react-email serialises them to
|
||||
// camelCase per CSSProperties - react-email serialises them to
|
||||
// email-client-safe inline `style="..."` attributes on output.
|
||||
|
||||
function ActivationBody({
|
||||
@@ -53,7 +53,7 @@ function ActivationBody({
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
It's our pleasure to invite you to the {portName} client portal — your private space to
|
||||
It's our pleasure to invite you to the {portName} client portal - your private space to
|
||||
review your berth, manage signed documents, and stay in touch with your sales liaison. The
|
||||
button below will let you set a password and activate your account at your convenience.
|
||||
Please use it within {ttlHours} hours.
|
||||
@@ -119,7 +119,7 @@ function ResetBody({
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
We received a request to reset the password on your {portName} client portal account. Use
|
||||
the button below to choose a new one — the link will remain valid for {ttlMinutes} minutes.
|
||||
the button below to choose a new one - the link will remain valid for {ttlMinutes} minutes.
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
@@ -140,7 +140,7 @@ function ResetBody({
|
||||
</div>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
|
||||
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
|
||||
If you didn't request this, you may safely ignore this message — your existing password
|
||||
If you didn't request this, you may safely ignore this message - your existing password
|
||||
will continue to work.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
@@ -163,7 +163,7 @@ export async function activationEmail(
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
|
||||
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
|
||||
: `Welcome to ${data.portName} — activate your client portal`;
|
||||
: `Welcome to ${data.portName} - activate your client portal`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(<ActivationBody {...data} accent={accent} />, {
|
||||
@@ -173,7 +173,7 @@ export async function activationEmail(
|
||||
const text = [
|
||||
`Welcome to ${data.portName}`,
|
||||
'',
|
||||
`It's our pleasure to invite you to the ${data.portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison.`,
|
||||
`It's our pleasure to invite you to the ${data.portName} client portal - your private space to review your berth, manage signed documents, and stay in touch with your sales liaison.`,
|
||||
`Activate your account by visiting: ${data.link}`,
|
||||
'',
|
||||
`Please use the link within ${data.ttlHours} hours.`,
|
||||
@@ -208,10 +208,10 @@ export async function resetEmail(
|
||||
const text = [
|
||||
`Reset your ${data.portName} portal password`,
|
||||
'',
|
||||
`Use the following link to choose a new password — it will remain valid for ${data.ttlMinutes} minutes:`,
|
||||
`Use the following link to choose a new password - it will remain valid for ${data.ttlMinutes} minutes:`,
|
||||
data.link,
|
||||
'',
|
||||
`If you didn't request this, you may safely ignore this message — your existing password will continue to work.`,
|
||||
`If you didn't request this, you may safely ignore this message - your existing password will continue to work.`,
|
||||
'',
|
||||
`With warm regards,`,
|
||||
`The ${data.portName} Team`,
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function residentialSalesAlert(
|
||||
const portName = data.portName ?? 'our team';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New residential enquiry — ${data.fullName}`;
|
||||
: `New residential enquiry - ${data.fullName}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
||||
pretty: false,
|
||||
|
||||
247
src/lib/email/test-registry.ts
Normal file
247
src/lib/email/test-registry.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Registry of every transactional template the system can emit, with a
|
||||
* pre-baked sample-prop fixture so an admin can fire a realistic
|
||||
* preview to a designated address without needing to trigger the real
|
||||
* upstream flow (a real signing send, a real portal invite, etc.).
|
||||
*
|
||||
* Consumed by `<TestTemplateCard>` (admin → Email page) and
|
||||
* `/api/v1/admin/email/test-template`. New templates land here once
|
||||
* they're plumbed; the UI dropdown reflects the registry at runtime so
|
||||
* adding an entry surfaces it without any UI change.
|
||||
*/
|
||||
|
||||
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
|
||||
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
|
||||
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
|
||||
import { notificationDigestEmail } from '@/lib/email/templates/notification-digest';
|
||||
import {
|
||||
signingInvitationEmail,
|
||||
signingCompletedEmail,
|
||||
signingReminderEmail,
|
||||
signingCancelledEmail,
|
||||
} from '@/lib/email/templates/document-signing';
|
||||
import { inquiryClientConfirmation } from '@/lib/email/templates/inquiry-client-confirmation';
|
||||
import { inquirySalesNotification } from '@/lib/email/templates/inquiry-sales-notification';
|
||||
import {
|
||||
residentialClientConfirmation,
|
||||
residentialSalesAlert,
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
|
||||
export type RenderedEmail = { subject: string; html: string; text?: string };
|
||||
|
||||
export interface TestTemplateMeta {
|
||||
/** Stable id - used as the dropdown value + the POST body key. */
|
||||
id: string;
|
||||
/** Human-facing dropdown label. */
|
||||
label: string;
|
||||
/** One-line description shown under the dropdown to clarify which
|
||||
* real flow fires this template in production. */
|
||||
description: string;
|
||||
/** Renders a fully-formed email with placeholder data baked in. */
|
||||
render: (sample: SampleContext) => Promise<RenderedEmail>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared sample fixture passed to every renderer so the previewed
|
||||
* subject/body line up with the admin's current port. Real flows
|
||||
* resolve these from DB lookups; the tester injects synthetic but
|
||||
* plausible values instead.
|
||||
*/
|
||||
export interface SampleContext {
|
||||
recipientName: string;
|
||||
recipientEmail: string;
|
||||
portName: string;
|
||||
portUrl: string;
|
||||
}
|
||||
|
||||
export const TEST_TEMPLATES: TestTemplateMeta[] = [
|
||||
{
|
||||
id: 'portal_activation',
|
||||
label: 'Portal · Activation invite',
|
||||
description: 'Fires when an admin invites a client to activate their portal account.',
|
||||
render: (s) =>
|
||||
activationEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
link: `${s.portUrl}/portal/activate/sample-token`,
|
||||
ttlHours: 24,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'portal_reset',
|
||||
label: 'Portal · Password reset',
|
||||
description: 'Fires when a portal user requests a password reset link.',
|
||||
render: (s) =>
|
||||
resetEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
link: `${s.portUrl}/portal/reset/sample-token`,
|
||||
ttlMinutes: 120,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'crm_invite',
|
||||
label: 'CRM · Teammate invitation',
|
||||
description: 'Fires when a super-admin invites a new teammate to the CRM.',
|
||||
render: (s) =>
|
||||
crmInviteEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
isSuperAdmin: false,
|
||||
link: `${s.portUrl}/invite/sample-token`,
|
||||
ttlHours: 72,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'admin_email_change',
|
||||
label: 'CRM · Admin email change confirmation',
|
||||
description: 'Fires when an admin updates their CRM login email - confirmation step.',
|
||||
render: (s) =>
|
||||
adminEmailChangeEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
newEmail: s.recipientEmail,
|
||||
changedByDisplayName: 'Sample Admin',
|
||||
loginUrl: `${s.portUrl}/login`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'notification_digest',
|
||||
label: 'Reminders · Notification digest',
|
||||
description: 'Fires on the configured cadence (daily/weekly) with the rep’s open reminders.',
|
||||
render: (s) =>
|
||||
notificationDigestEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
items: [
|
||||
{
|
||||
type: 'reminder',
|
||||
title: 'Follow up with Matthew Ciaccio on Berth A1',
|
||||
description: 'Reservation EOI sent 5 days ago - no response yet.',
|
||||
link: `${s.portUrl}/clients/sample-client-id`,
|
||||
createdAt: new Date(Date.now() - 86_400_000),
|
||||
},
|
||||
{
|
||||
type: 'alert',
|
||||
title: 'Berth B12 PDF parse failed',
|
||||
description: null,
|
||||
link: `${s.portUrl}/berths/sample-berth-id`,
|
||||
createdAt: new Date(Date.now() - 2 * 86_400_000),
|
||||
},
|
||||
],
|
||||
totalUnread: 2,
|
||||
inboxLink: `${s.portUrl}/inbox`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'signing_invitation',
|
||||
label: 'Documenso · Signing invitation',
|
||||
description: 'Fires when the rep dispatches the first signing-invite email for a doc.',
|
||||
render: (s) =>
|
||||
signingInvitationEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
signerRole: 'client',
|
||||
signingUrl: `${s.portUrl}/sign/sample-token`,
|
||||
senderName: 'Sample Sales Manager',
|
||||
customMessage: null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'signing_reminder',
|
||||
label: 'Documenso · Signing reminder',
|
||||
description: 'Fires when a manual reminder is dispatched for an outstanding signer.',
|
||||
render: (s) =>
|
||||
signingReminderEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
signingUrl: `${s.portUrl}/sign/sample-token`,
|
||||
invitedAgo: '5 days ago',
|
||||
customMessage: null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'signing_completed',
|
||||
label: 'Documenso · Fully signed notification',
|
||||
description: 'Fires when every required signer has signed and the document is complete.',
|
||||
render: (s) =>
|
||||
signingCompletedEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
clientName: s.recipientName,
|
||||
completedAt: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'signing_cancelled',
|
||||
label: 'Documenso · Signing cancelled',
|
||||
description: 'Fires when the rep cancels a document mid-signature with notify-recipients.',
|
||||
render: (s) =>
|
||||
signingCancelledEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
reason: 'Customer renegotiated terms; a fresh contract will follow.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'inquiry_client_confirmation',
|
||||
label: 'Public inquiry · Client confirmation',
|
||||
description: 'Fires when a public-site visitor submits the contact form (their copy).',
|
||||
render: (s) =>
|
||||
inquiryClientConfirmation({
|
||||
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
|
||||
mooringNumber: 'A1',
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: s.portName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'inquiry_sales_notification',
|
||||
label: 'Public inquiry · Sales notification',
|
||||
description: 'Fires alongside the client confirmation - alerts the sales rep to a new lead.',
|
||||
render: (s) =>
|
||||
inquirySalesNotification({
|
||||
fullName: s.recipientName,
|
||||
email: s.recipientEmail,
|
||||
phone: '+1 555 0100',
|
||||
mooringNumber: 'A1',
|
||||
crmUrl: `${s.portUrl}/clients/sample-client-id`,
|
||||
portName: s.portName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'residential_client_confirmation',
|
||||
label: 'Residential inquiry · Client confirmation',
|
||||
description: 'Fires when a residential-site visitor submits the contact form.',
|
||||
render: (s) =>
|
||||
residentialClientConfirmation({
|
||||
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: s.portName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'residential_sales_alert',
|
||||
label: 'Residential inquiry · Sales alert',
|
||||
description: 'Fires alongside the residential client confirmation - alerts the sales team.',
|
||||
render: (s) =>
|
||||
residentialSalesAlert({
|
||||
fullName: s.recipientName,
|
||||
email: s.recipientEmail,
|
||||
phone: '+1 555 0100',
|
||||
placeOfResidence: 'Monaco',
|
||||
preferredContactMethod: 'email',
|
||||
notes: 'Looking for year-round mooring + marina apartment access.',
|
||||
crmDeepLink: `${s.portUrl}/residential/clients/sample-id`,
|
||||
portName: s.portName,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function findTestTemplate(id: string): TestTemplateMeta | undefined {
|
||||
return TEST_TEMPLATES.find((t) => t.id === id);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { env } from '@/lib/env';
|
||||
|
||||
interface InjectOptions {
|
||||
/** Public base URL of the CRM (e.g. https://crm.portnimara.com).
|
||||
* Required so the pixel link is absolute — relative URLs break in
|
||||
* Required so the pixel link is absolute - relative URLs break in
|
||||
* email clients. */
|
||||
appBaseUrl: string;
|
||||
/** UUID of the row in `document_sends`. */
|
||||
|
||||
Reference in New Issue
Block a user