feat(email): port remaining 7 templates to react-email

Phase 2 (single commit) — applies the portal-auth.tsx pattern to every
hand-strung transactional email template. JSX components rendered via
@react-email/components' render() replace inline-style string templates
+ hand-rolled escapeHtml().

Ported (.ts → .tsx, public function signatures become async):
  crm-invite.tsx                — admin/super-admin CRM invite
  admin-email-change.tsx        — sign-in email changed notification
  inquiry-client-confirmation.tsx — public berth inquiry receipt
  inquiry-sales-notification.tsx  — internal sales alert for inquiries
  residential-inquiry.tsx       — pair: client confirmation + sales alert
  notification-digest.tsx       — daily/hourly unread-notification digest
  document-signing.tsx          — triplet: invitation + completed + reminder

Each template now defines its body as a typed React component, drops
escapeHtml() entirely (react-email auto-escapes string interpolation
in JSX text + attributes), and passes the rendered HTML to the existing
renderShell() for shell wrapping. The shell + branding flow is unchanged.

Caller migration (all sync → async):
  src/app/api/public/residential-inquiries/route.ts
  src/lib/queue/workers/email.ts
  src/lib/services/notification-digest.service.ts
  src/lib/services/users.service.ts
  src/lib/services/document-signing-emails.service.ts
  src/lib/services/crm-invite.service.ts

All call sites already lived inside async functions; only the await was
needed. No public API shape changes other than return type (now Promise).

The pattern now applies uniformly across all 8 email templates (portal-
auth.tsx + the 7 in this commit). Email template directory is fully
react-email-based.

1298/1298 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:19:52 +02:00
parent e386c8d83f
commit d8f1c0c34e
20 changed files with 1109 additions and 778 deletions

View File

@@ -144,7 +144,7 @@ async function sendResidentialNotifications(args: {
const branding = await getBrandingShell(portId);
// Client confirmation
const confirmation = residentialClientConfirmation(
const confirmation = await residentialClientConfirmation(
{
firstName: data.firstName,
contactEmail: 'sales@portnimara.com',
@@ -186,7 +186,7 @@ async function sendResidentialNotifications(args: {
return;
}
const alert = residentialSalesAlert(
const alert = await residentialSalesAlert(
{
fullName: `${data.firstName} ${data.lastName}`.trim(),
email: data.email,

View File

@@ -1,93 +0,0 @@
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface AdminEmailChangeData {
recipientName?: string;
/** New address the user should sign in with from now on. */
newEmail: string;
/** Display name of the admin who initiated the change — surfaced so the
* recipient knows who to follow up with. */
changedByDisplayName?: string;
/** Optional URL for the sign-in page. */
loginUrl?: string;
portName?: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
export function adminEmailChangeEmail(
data: AdminEmailChangeData,
overrides?: RenderOpts,
): { subject: string; html: string; text: string } {
const portName = data.portName ?? 'Port Nimara';
const subject = `An administrator updated your ${portName} sign-in email`;
const greeting = data.recipientName ? `Hello ${escapeHtml(data.recipientName)},` : 'Hello,';
const accent = brandingPrimaryColor(overrides?.branding);
const adminLine = data.changedByDisplayName
? `${escapeHtml(data.changedByDisplayName)} (an administrator)`
: 'an administrator';
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
Your sign-in email was changed
</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;">
${adminLine} just updated the email address linked to your ${escapeHtml(
portName,
)} account. From now on, please sign in with the new address below:
</p>
<p style="margin:20px 0; text-align:center; font-size:16px;">
<strong>${escapeHtml(data.newEmail)}</strong>
</p>
${
data.loginUrl
? `<p style="text-align:center; margin:30px 0;">
<a href="${safeUrl(data.loginUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Sign in
</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 you weren't expecting this change, contact your administrator immediately.
Your old address (the one this message was sent to) can no longer be used to
sign in.
</p>
<p style="font-size:16px; margin-top:30px;">
Thanks,<br />
<strong>${escapeHtml(portName)}</strong>
</p>`;
const text = [
`Your sign-in email was changed`,
'',
`${data.changedByDisplayName ?? 'An administrator'} updated the email linked to your ${portName} account.`,
`From now on, sign in with: ${data.newEmail}`,
'',
data.loginUrl ? `Sign in: ${data.loginUrl}` : '',
'',
`If you weren't expecting this change, contact your administrator immediately.`,
]
.filter(Boolean)
.join('\n');
const html = renderShell({
branding: overrides?.branding ?? null,
title: subject,
body,
});
return { subject, html, text };
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -0,0 +1,109 @@
import { Button, Hr, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface AdminEmailChangeData {
recipientName?: string;
newEmail: string;
changedByDisplayName?: string;
loginUrl?: string;
portName?: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
function AdminEmailChangeBody({
portName,
newEmail,
recipientName,
changedByDisplayName,
loginUrl,
accent,
}: AdminEmailChangeData & { portName: string; accent: string }) {
const greeting = recipientName ? `Hello ${recipientName},` : 'Hello,';
const adminLine = changedByDisplayName
? `${changedByDisplayName} (an administrator)`
: 'an administrator';
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Your sign-in email was changed
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
{adminLine} just updated the email address linked to your {portName} account. From now on,
please sign in with the new address below:
</Text>
<Text style={{ margin: '20px 0', textAlign: 'center', fontSize: '16px' }}>
<strong>{newEmail}</strong>
</Text>
{loginUrl ? (
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(loginUrl)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
Sign in
</Button>
</div>
) : null}
<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 weren&apos;t expecting this change, contact your administrator immediately. Your old
address (the one this message was sent to) can no longer be used to sign in.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
Thanks,
<br />
<strong>{portName}</strong>
</Text>
</>
);
}
export async function adminEmailChangeEmail(
data: AdminEmailChangeData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const portName = data.portName ?? 'Port Nimara';
const subject = `An administrator updated your ${portName} sign-in email`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
<AdminEmailChangeBody {...data} portName={portName} accent={accent} />,
{
pretty: false,
},
);
const text = [
`Your sign-in email was changed`,
'',
`${data.changedByDisplayName ?? 'An administrator'} updated the email linked to your ${portName} account.`,
`From now on, sign in with: ${data.newEmail}`,
'',
data.loginUrl ? `Sign in: ${data.loginUrl}` : '',
'',
`If you weren't expecting this change, contact your administrator immediately.`,
]
.filter(Boolean)
.join('\n');
return {
subject,
html: renderShell({ branding: overrides?.branding ?? null, title: subject, body }),
text,
};
}

View File

@@ -1,81 +0,0 @@
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;');
}

View File

@@ -0,0 +1,121 @@
import { Button, Hr, Link, Text, render } from '@react-email/components';
import * as React from 'react';
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;
}
function InviteBody({
portName,
link,
ttlHours,
recipientName,
role,
accent,
}: {
portName: string;
link: string;
ttlHours: number;
recipientName?: string;
role: string;
accent: string;
}) {
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome,';
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Welcome to the {portName} CRM
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
You&apos;ve been invited to the {portName} CRM as a {role}. Click the button below to set
your password and activate your account. The link expires in {ttlHours} hours.
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(link)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
Set up your account
</Button>
</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 the button doesn&apos;t work, paste this link into your browser:
<br />
<Link
href={safeUrl(link)}
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
>
{link}
</Link>
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
Thank you,
<br />
<strong>{portName} CRM</strong>
</Text>
</>
);
}
export async function crmInviteEmail(
data: InviteData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const portName = data.portName ?? 'Port Nimara';
const subject = `You're invited to the ${portName} CRM`;
const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
<InviteBody
portName={portName}
link={data.link}
ttlHours={data.ttlHours}
recipientName={data.recipientName}
role={role}
accent={accent}
/>,
{ pretty: false },
);
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,
};
}

View File

@@ -1,245 +0,0 @@
/**
* Branded transactional emails for the Documenso signing lifecycle.
*
* Three template families:
*
* 1. `signingInvitation` — sent to a single signer when their turn
* to sign comes up. Used both for the initial client invite (after
* EOI/contract/reservation generation) AND for the cascading
* "your turn" emails when an earlier signer completes (developer
* after client signs, approver after developer signs, etc).
*
* 2. `signingCompleted` — sent to ALL signers (with the finalized
* signed PDF as an attachment) when the document reaches a fully
* signed state. Mirrors the old system's
* `sendFinalizedDocumentToSignatories` flow.
*
* 3. `signingReminder` — sent when a rep nudges an unsigned recipient
* manually OR when the rate-limited reminder service fires. Same
* visual shape as `signingInvitation` with different copy.
*
* All three use the per-port `BrandingShell` (logo + primary color +
* header/footer HTML) so each tenant's outbound emails match its
* brand. The signing URL passed in is expected to already be
* embedded-format (e.g. `https://portnimara.com/sign/<type>/<token>`)
* — the caller (interest service / webhook handler) does the
* transformation from the raw Documenso URL.
*/
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
subject?: string | null;
branding?: BrandingShell | null;
}
interface InvitationData {
/** Display name for the recipient — used in the greeting. */
recipientName: string;
/** Friendly document type label. e.g. "Expression of Interest", "Sales Contract", "Reservation Agreement". */
documentLabel: string;
/** Optional. The signer's role: 'client' | 'developer' | 'approver' | 'witness' etc. Drives copy nuance. */
signerRole?: string | null;
/** Embedded signing URL (already wrapped to the public branded host). */
signingUrl: string;
/** Port name to brand the email. */
portName: string;
/** Sales rep / sender name shown in the closing. Falls back to "{portName} team". */
senderName?: string | null;
/** Optional plain-text message from the rep to include above the CTA. */
customMessage?: string | null;
}
export function signingInvitationEmail(
data: InvitationData,
overrides?: RenderOpts,
): { subject: string; html: string; text: string } {
const accent = brandingPrimaryColor(overrides?.branding);
const docLabelEsc = escapeHtml(data.documentLabel);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
.replace(/\{\{recipientName\}\}/g, data.recipientName)
: `${data.documentLabel} ready to sign — ${data.portName}`;
const greeting = `Dear ${escapeHtml(data.recipientName)},`;
const closer = data.senderName
? `${escapeHtml(data.senderName)}<br /><strong>${escapeHtml(data.portName)}</strong>`
: `<strong>The ${escapeHtml(data.portName)} team</strong>`;
// Slightly different lead paragraph based on signer role so the
// developer / approver emails don't read as if they're the client.
const isClient = (data.signerRole ?? 'client') === 'client';
const leadCopy = isClient
? `Your ${docLabelEsc} for <strong>${escapeHtml(data.portName)}</strong> is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.`
: data.signerRole === 'approver'
? `An ${docLabelEsc} is awaiting your approval. The earlier signers have completed their parts; please review and sign to finalise the document.`
: `An ${docLabelEsc} is awaiting your signature. The client has already signed; you're the next signer in the chain.`;
const customMessageBlock = data.customMessage
? `<p style="margin:20px 0; font-size:15px; line-height:1.6; color:#444; padding:14px 18px; background:#f8f9fb; border-left:3px solid ${accent}; border-radius:4px;">${escapeHtml(data.customMessage).replace(/\n/g, '<br />')}</p>`
: '';
const body = `
<p style="margin-bottom:14px; font-size:18px; font-weight:bold; color:${accent};">
${docLabelEsc} ready to sign
</p>
<p style="margin-bottom:14px; font-size:16px; line-height:1.6;">${greeting}</p>
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">${leadCopy}</p>
${customMessageBlock}
<p style="text-align:center; margin:30px 0;">
<a href="${safeUrl(data.signingUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
Review &amp; sign
</a>
</p>
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
If the button doesn't work, paste this link into your browser:<br />
<a href="${safeUrl(data.signingUrl)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; margin-top:18px;">
Signing happens directly inside our website — your data isn't sent to a third-party signing service.
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
${closer}
</p>`;
const text = `${greeting}\n\n${stripTags(leadCopy)}\n\n${data.customMessage ? data.customMessage + '\n\n' : ''}Sign here: ${data.signingUrl}\n\nThank you,\n${data.senderName ?? `The ${data.portName} team`}`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
interface CompletedData {
recipientName: string;
documentLabel: string;
/** Identity of the linked client (the deal's primary subject). */
clientName: string;
portName: string;
/** When the document reached fully-signed state. */
completedAt: Date;
}
export function signingCompletedEmail(
data: CompletedData,
overrides?: RenderOpts,
): { subject: string; html: string; text: string } {
const accent = brandingPrimaryColor(overrides?.branding);
const docLabelEsc = escapeHtml(data.documentLabel);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{clientName\}\}/g, data.clientName)
.replace(/\{\{portName\}\}/g, data.portName)
: `${data.documentLabel} fully signed — ${data.clientName}`;
const greeting = `Dear ${escapeHtml(data.recipientName)},`;
const completedDateStr = data.completedAt.toLocaleString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const body = `
<p style="margin-bottom:14px; font-size:18px; font-weight:bold; color:${accent};">
${docLabelEsc} signed by all parties
</p>
<p style="margin-bottom:14px; font-size:16px; line-height:1.6;">${greeting}</p>
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">
The ${docLabelEsc} for <strong>${escapeHtml(data.clientName)}</strong> has been signed by every party as of ${completedDateStr}.
</p>
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">
The fully signed PDF is attached to this email for your records. A copy has also been stored in the ${escapeHtml(data.portName)} CRM.
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>The ${escapeHtml(data.portName)} team</strong>
</p>`;
const text = `${greeting}\n\nThe ${data.documentLabel} for ${data.clientName} has been signed by all parties on ${completedDateStr}. The signed PDF is attached for your records.\n\nThank you,\nThe ${data.portName} team`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
interface ReminderData {
recipientName: string;
documentLabel: string;
signingUrl: string;
portName: string;
/** Human-readable string of how long ago the original invitation was sent. */
invitedAgo: string;
customMessage?: string | null;
}
export function signingReminderEmail(
data: ReminderData,
overrides?: RenderOpts,
): { subject: string; html: string; text: string } {
const accent = brandingPrimaryColor(overrides?.branding);
const docLabelEsc = escapeHtml(data.documentLabel);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
: `Friendly reminder: ${data.documentLabel} still awaiting your signature — ${data.portName}`;
const greeting = `Dear ${escapeHtml(data.recipientName)},`;
const customMessageBlock = data.customMessage
? `<p style="margin:20px 0; font-size:15px; line-height:1.6; color:#444; padding:14px 18px; background:#f8f9fb; border-left:3px solid ${accent}; border-radius:4px;">${escapeHtml(data.customMessage).replace(/\n/g, '<br />')}</p>`
: '';
const body = `
<p style="margin-bottom:14px; font-size:18px; font-weight:bold; color:${accent};">
Just a quick reminder
</p>
<p style="margin-bottom:14px; font-size:16px; line-height:1.6;">${greeting}</p>
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">
We sent you a ${docLabelEsc} ${escapeHtml(data.invitedAgo)} that's still awaiting your signature. If you've already signed, please disregard this message — it can take a few minutes for our system to catch up.
</p>
${customMessageBlock}
<p style="text-align:center; margin:30px 0;">
<a href="${safeUrl(data.signingUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
Sign now
</a>
</p>
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
Direct link: <a href="${safeUrl(data.signingUrl)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>The ${escapeHtml(data.portName)} team</strong>
</p>`;
const text = `${greeting}\n\nWe sent you a ${data.documentLabel} ${data.invitedAgo} that's still awaiting your signature. ${data.customMessage ? '\n\n' + data.customMessage + '\n\n' : ''}\n\nSign here: ${data.signingUrl}\n\nThank you,\nThe ${data.portName} team`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, '');
}

View File

@@ -0,0 +1,327 @@
/**
* Branded transactional emails for the Documenso signing lifecycle.
*
* Three template families:
*
* 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
* PDF as an attachment) when the document reaches a fully signed state.
*
* 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
* does the transformation from the raw Documenso URL.
*/
import { Button, Hr, Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
subject?: string | null;
branding?: BrandingShell | null;
}
// ─── 1. Invitation ───────────────────────────────────────────────────────────
interface InvitationData {
recipientName: string;
documentLabel: string;
signerRole?: string | null;
signingUrl: string;
portName: string;
senderName?: string | null;
customMessage?: string | null;
}
function InvitationBody({ data, accent }: { data: InvitationData; accent: string }) {
const greeting = `Dear ${data.recipientName},`;
const isClient = (data.signerRole ?? 'client') === 'client';
const leadCopy = isClient
? `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.`
: data.signerRole === 'approver'
? `An ${data.documentLabel} is awaiting your approval. The earlier signers have completed their parts; please review and sign to finalise the document.`
: `An ${data.documentLabel} is awaiting your signature. The client has already signed; you're the next signer in the chain.`;
return (
<>
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
{data.documentLabel} ready to sign
</Text>
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>{leadCopy}</Text>
{data.customMessage ? (
<Text
style={{
margin: '20px 0',
fontSize: '15px',
lineHeight: '1.6',
color: '#444',
padding: '14px 18px',
background: '#f8f9fb',
borderLeft: `3px solid ${accent}`,
borderRadius: '4px',
whiteSpace: 'pre-wrap',
}}
>
{data.customMessage}
</Text>
) : null}
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(data.signingUrl)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 36px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
Review &amp; sign
</Button>
</div>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
<Text style={{ fontSize: '13px', color: '#666', lineHeight: '1.5', padding: '14px 0 0' }}>
If the button doesn&apos;t work, paste this link into your browser:
<br />
<Link
href={safeUrl(data.signingUrl)}
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
>
{data.signingUrl}
</Link>
</Text>
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', marginTop: '18px' }}>
Signing happens directly inside our website your data isn&apos;t sent to a third-party
signing service.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
Thank you,
<br />
{data.senderName ? (
<>
{data.senderName}
<br />
<strong>{data.portName}</strong>
</>
) : (
<strong>The {data.portName} team</strong>
)}
</Text>
</>
);
}
export async function signingInvitationEmail(
data: InvitationData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const accent = brandingPrimaryColor(overrides?.branding);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
.replace(/\{\{recipientName\}\}/g, data.recipientName)
: `${data.documentLabel} ready to sign — ${data.portName}`;
const body = await render(<InvitationBody data={data} accent={accent} />, { pretty: false });
const isClient = (data.signerRole ?? 'client') === 'client';
const leadText = isClient
? `Your ${data.documentLabel} for ${data.portName} is ready for signing.`
: data.signerRole === 'approver'
? `An ${data.documentLabel} is awaiting your approval.`
: `An ${data.documentLabel} is awaiting your signature.`;
const text = `Dear ${data.recipientName},\n\n${leadText}\n\n${data.customMessage ? data.customMessage + '\n\n' : ''}Sign here: ${data.signingUrl}\n\nThank you,\n${data.senderName ?? `The ${data.portName} team`}`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
// ─── 2. Completed ─────────────────────────────────────────────────────────────
interface CompletedData {
recipientName: string;
documentLabel: string;
clientName: string;
portName: string;
completedAt: Date;
}
function CompletedBody({
data,
accent,
completedDateStr,
}: {
data: CompletedData;
accent: string;
completedDateStr: string;
}) {
const greeting = `Dear ${data.recipientName},`;
return (
<>
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
{data.documentLabel} signed by all parties
</Text>
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
The {data.documentLabel} for <strong>{data.clientName}</strong> has been signed by every
party as of {completedDateStr}.
</Text>
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
The fully signed PDF is attached to this email for your records. A copy has also been stored
in the {data.portName} CRM.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
Thank you,
<br />
<strong>The {data.portName} team</strong>
</Text>
</>
);
}
export async function signingCompletedEmail(
data: CompletedData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const accent = brandingPrimaryColor(overrides?.branding);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{clientName\}\}/g, data.clientName)
.replace(/\{\{portName\}\}/g, data.portName)
: `${data.documentLabel} fully signed — ${data.clientName}`;
const completedDateStr = data.completedAt.toLocaleString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const body = await render(
<CompletedBody data={data} accent={accent} completedDateStr={completedDateStr} />,
{ pretty: false },
);
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} for ${data.clientName} has been signed by all parties on ${completedDateStr}. The signed PDF is attached for your records.\n\nThank you,\nThe ${data.portName} team`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
// ─── 3. Reminder ──────────────────────────────────────────────────────────────
interface ReminderData {
recipientName: string;
documentLabel: string;
signingUrl: string;
portName: string;
invitedAgo: string;
customMessage?: string | null;
}
function ReminderBody({ data, accent }: { data: ReminderData; accent: string }) {
const greeting = `Dear ${data.recipientName},`;
return (
<>
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Just a quick reminder
</Text>
<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&apos;s still awaiting your
signature. If you&apos;ve already signed, please disregard this message it can take a few
minutes for our system to catch up.
</Text>
{data.customMessage ? (
<Text
style={{
margin: '20px 0',
fontSize: '15px',
lineHeight: '1.6',
color: '#444',
padding: '14px 18px',
background: '#f8f9fb',
borderLeft: `3px solid ${accent}`,
borderRadius: '4px',
whiteSpace: 'pre-wrap',
}}
>
{data.customMessage}
</Text>
) : null}
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(data.signingUrl)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 36px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
Sign now
</Button>
</div>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
<Text style={{ fontSize: '13px', color: '#666', lineHeight: '1.5', padding: '14px 0 0' }}>
Direct link:{' '}
<Link
href={safeUrl(data.signingUrl)}
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
>
{data.signingUrl}
</Link>
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
Thank you,
<br />
<strong>The {data.portName} team</strong>
</Text>
</>
);
}
export async function signingReminderEmail(
data: ReminderData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const accent = brandingPrimaryColor(overrides?.branding);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
: `Friendly reminder: ${data.documentLabel} still awaiting your signature — ${data.portName}`;
const body = await render(<ReminderBody data={data} accent={accent} />, { pretty: false });
const text = `Dear ${data.recipientName},\n\nWe sent you a ${data.documentLabel} ${data.invitedAgo} that's still awaiting your signature. ${data.customMessage ? '\n\n' + data.customMessage + '\n\n' : ''}\n\nSign here: ${data.signingUrl}\n\nThank you,\nThe ${data.portName} team`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -1,70 +0,0 @@
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
export interface InquiryClientConfirmationData {
firstName: string;
mooringNumber: string | null;
contactEmail: string;
/** Display name; falls back to "Port Nimara". */
portName?: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
export function inquiryClientConfirmation(
data: InquiryClientConfirmationData,
overrides?: RenderOpts,
) {
const { firstName, mooringNumber, contactEmail } = data;
const portName = data.portName ?? 'Port Nimara';
const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
const subject = mooringNumber
? `Thank You for Your Interest in Berth ${mooringNumber}`
: `Thank You for Your Interest in a ${portName} Berth`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = `
<p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p>
<p style="margin-bottom:10px; font-size:16px;">
Thank you for expressing interest in ${escapeHtml(berthText)}.
Our team has registered your interest, and we will reach out to you very shortly
by your preferred method of contact with more information.
</p>
<p style="margin-bottom:10px; font-size:16px;">
If you have any questions, please feel free to reach out to us at
<a href="mailto:${escapeHtml(contactEmail)}" style="color:${accent}; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
</p>
<p style="font-size:16px;">
Best regards,<br />
The ${escapeHtml(portName)} Sales Team
</p>`;
const text = [
`Dear ${firstName},`,
'',
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
'',
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
'',
'Best regards,',
`The ${portName} Sales Team`,
].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;');
}

View File

@@ -0,0 +1,96 @@
import { Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
export interface InquiryClientConfirmationData {
firstName: string;
mooringNumber: string | null;
contactEmail: string;
portName?: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
function ClientConfirmationBody({
firstName,
berthText,
contactEmail,
portName,
accent,
}: {
firstName: string;
berthText: string;
contactEmail: string;
portName: string;
accent: string;
}) {
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Thank you for expressing interest in {berthText}. Our team has registered your interest, and
we will reach out to you very shortly by your preferred method of contact with more
information.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
If you have any questions, please feel free to reach out to us at{' '}
<Link
href={`mailto:${contactEmail}`}
style={{ color: accent, textDecoration: 'underline' }}
>
{contactEmail}
</Link>
.
</Text>
<Text style={{ fontSize: '16px' }}>
Best regards,
<br />
The {portName} Sales Team
</Text>
</>
);
}
export async function inquiryClientConfirmation(
data: InquiryClientConfirmationData,
overrides?: RenderOpts,
) {
const { firstName, mooringNumber, contactEmail } = data;
const portName = data.portName ?? 'Port Nimara';
const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
const subject = mooringNumber
? `Thank You for Your Interest in Berth ${mooringNumber}`
: `Thank You for Your Interest in a ${portName} Berth`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
<ClientConfirmationBody
firstName={firstName}
berthText={berthText}
contactEmail={contactEmail}
portName={portName}
accent={accent}
/>,
{ pretty: false },
);
const text = [
`Dear ${firstName},`,
'',
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
'',
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
'',
'Best regards,',
`The ${portName} Sales Team`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -1,67 +0,0 @@
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
export interface InquirySalesNotificationData {
fullName: string;
email: string;
phone: string;
mooringNumber: string | null;
crmUrl: string;
/** Display name; falls back to "Port Nimara". */
portName?: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
export function inquirySalesNotification(
data: InquirySalesNotificationData,
overrides?: RenderOpts,
) {
const { fullName, email, phone, mooringNumber, crmUrl } = data;
const portName = data.portName ?? 'Port Nimara';
const mooringDisplay = mooringNumber || 'None';
const subject = `New Interest - ${portName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = `
<p style="margin-bottom:10px; font-size:16px;">Dear Administrator,</p>
<p style="margin-bottom:10px; font-size:16px;">${escapeHtml(fullName)} has expressed their interest in <strong>${escapeHtml(portName)}</strong>. Here are their details:</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Name:</strong> ${escapeHtml(fullName)}</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Email:</strong> ${escapeHtml(email)}</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Telephone:</strong> ${escapeHtml(phone)}</p>
<p style="margin:0 0 16px 0; font-size:16px;"><strong>Berths Selected:</strong> ${escapeHtml(mooringDisplay)}</p>
<p style="margin-bottom:10px; font-size:16px;">Please visit the <a href="${escapeHtml(crmUrl)}" target="_blank" style="color:${accent}; text-decoration:underline;">${escapeHtml(portName)} CRM</a> to view more information.</p>
<p style="font-size:16px;">Thank you,<br/>${escapeHtml(portName)} CRM</p>`;
const text = [
'Dear Administrator,',
'',
`${fullName} has expressed their interest in ${portName}. Here are their details:`,
'',
`Name: ${fullName}`,
`Email: ${email}`,
`Telephone: ${phone}`,
`Berths Selected: ${mooringDisplay}`,
'',
`Please visit the ${portName} CRM (${crmUrl}) to view more information.`,
'',
'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;');
}

View File

@@ -0,0 +1,115 @@
import { Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
export interface InquirySalesNotificationData {
fullName: string;
email: string;
phone: string;
mooringNumber: string | null;
crmUrl: string;
portName?: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
function SalesNotificationBody({
fullName,
email,
phone,
mooringDisplay,
crmUrl,
portName,
accent,
}: {
fullName: string;
email: string;
phone: string;
mooringDisplay: string;
crmUrl: string;
portName: string;
accent: string;
}) {
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear Administrator,</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
{fullName} has expressed their interest in <strong>{portName}</strong>. Here are their
details:
</Text>
<Text style={detailStyle}>
<strong>Name:</strong> {fullName}
</Text>
<Text style={detailStyle}>
<strong>Email:</strong> {email}
</Text>
<Text style={detailStyle}>
<strong>Telephone:</strong> {phone}
</Text>
<Text style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
<strong>Berths Selected:</strong> {mooringDisplay}
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Please visit the{' '}
<Link href={crmUrl} style={{ color: accent, textDecoration: 'underline' }}>
{portName} CRM
</Link>{' '}
to view more information.
</Text>
<Text style={{ fontSize: '16px' }}>
Thank you,
<br />
{portName} CRM
</Text>
</>
);
}
export async function inquirySalesNotification(
data: InquirySalesNotificationData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'Port Nimara';
const mooringDisplay = data.mooringNumber || 'None';
const subject = `New Interest - ${portName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
<SalesNotificationBody
fullName={data.fullName}
email={data.email}
phone={data.phone}
mooringDisplay={mooringDisplay}
crmUrl={data.crmUrl}
portName={portName}
accent={accent}
/>,
{ pretty: false },
);
const text = [
'Dear Administrator,',
'',
`${data.fullName} has expressed their interest in ${portName}. Here are their details:`,
'',
`Name: ${data.fullName}`,
`Email: ${data.email}`,
`Telephone: ${data.phone}`,
`Berths Selected: ${mooringDisplay}`,
'',
`Please visit the ${portName} CRM (${data.crmUrl}) to view more information.`,
'',
'Thank you',
`${portName} CRM`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -1,120 +0,0 @@
/**
* Daily / hourly digest email of a CRM user's unread notifications.
* Used by the notification-digest scheduler (queued in `email` worker).
*/
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface DigestData {
portName: string;
recipientName: string;
/** Each notification we want to surface. Trimmed to ~20 by the
* caller — anything longer drops a link to the in-app inbox. */
items: Array<{
type: string;
title: string;
description: string | null;
link: string | null;
createdAt: Date;
}>;
totalUnread: number;
inboxLink: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
const TYPE_LABELS: Record<string, string> = {
reminder_due: 'Reminder due',
reminder_overdue: 'Reminder overdue',
new_registration: 'New inquiry',
eoi_signed: 'EOI signed',
eoi_completed: 'EOI completed',
email_received: 'New email',
duplicate_alert: 'Possible duplicate',
invoice_overdue: 'Invoice overdue',
system_alert: 'System alert',
follow_up_created: 'Follow-up',
tenure_expiring: 'Tenure expiring',
berth_released: 'Berth released',
};
export function notificationDigestEmail(
data: DigestData,
overrides?: RenderOpts,
): {
subject: string;
html: string;
text: string;
} {
const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`;
const accent = brandingPrimaryColor(overrides?.branding);
const itemsHtml = data.items
.map((item) => {
const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
const titleHtml = item.link
? `<a href="${safeUrl(item.link)}" style="color:${accent}; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
: `<strong>${escapeHtml(item.title)}</strong>`;
const desc = item.description
? `<div style="font-size:13px; color:#666; margin-top:4px;">${escapeHtml(item.description)}</div>`
: '';
return `<tr><td style="padding:10px 0; border-bottom:1px solid #eee;">
<div style="font-size:11px; text-transform:uppercase; color:#999; letter-spacing:0.04em;">${label}</div>
<div style="font-size:14px; margin-top:2px;">${titleHtml}</div>
${desc}
</td></tr>`;
})
.join('');
const tail =
data.totalUnread > data.items.length
? `<p style="margin-top:14px; font-size:13px; color:#666;">…and ${data.totalUnread - data.items.length} more.
<a href="${safeUrl(data.inboxLink)}" style="color:${accent};">Open the inbox</a> to see everything.</p>`
: '';
const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,';
const body = `
<p style="font-size:18px; font-weight:bold; color:${accent}; margin:0 0 6px;">
Your ${escapeHtml(data.portName)} CRM digest
</p>
<p style="font-size:14px; line-height:1.5; margin:0 0 14px;">${greeting}</p>
<p style="font-size:14px; line-height:1.5; margin:0 0 16px;">
You have <strong>${data.totalUnread}</strong> unread notification${data.totalUnread === 1 ? '' : 's'} since the last digest.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
${itemsHtml}
</table>
${tail}
<p style="margin-top:24px; font-size:13px; color:#666;">
Thank you,<br />
<strong>${escapeHtml(data.portName)} CRM</strong>
</p>`;
const text = [
`${data.portName} CRM digest`,
'',
`You have ${data.totalUnread} unread notifications.`,
'',
...data.items.map((i) => `• [${i.type.replace(/_/g, ' ')}] ${i.title}`),
'',
`Inbox: ${data.inboxLink}`,
].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;');
}

View File

@@ -0,0 +1,140 @@
import { Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface DigestData {
portName: string;
recipientName: string;
items: Array<{
type: string;
title: string;
description: string | null;
link: string | null;
createdAt: Date;
}>;
totalUnread: number;
inboxLink: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
const TYPE_LABELS: Record<string, string> = {
reminder_due: 'Reminder due',
reminder_overdue: 'Reminder overdue',
new_registration: 'New inquiry',
eoi_signed: 'EOI signed',
eoi_completed: 'EOI completed',
email_received: 'New email',
duplicate_alert: 'Possible duplicate',
invoice_overdue: 'Invoice overdue',
system_alert: 'System alert',
follow_up_created: 'Follow-up',
tenure_expiring: 'Tenure expiring',
berth_released: 'Berth released',
};
function DigestBody({
portName,
recipientName,
items,
totalUnread,
inboxLink,
accent,
}: DigestData & { accent: string }) {
const greeting = recipientName ? `Hi ${recipientName},` : 'Hi,';
return (
<>
<Text style={{ fontSize: '18px', fontWeight: 'bold', color: accent, margin: '0 0 6px' }}>
Your {portName} CRM digest
</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' }}>
You have <strong>{totalUnread}</strong> unread notification
{totalUnread === 1 ? '' : 's'} since the last digest.
</Text>
<table role="presentation" width="100%" cellSpacing={0} cellPadding={0} border={0}>
<tbody>
{items.map((item, i) => {
const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
return (
<tr key={i}>
<td style={{ padding: '10px 0', borderBottom: '1px solid #eee' }}>
<div
style={{
fontSize: '11px',
textTransform: 'uppercase',
color: '#999',
letterSpacing: '0.04em',
}}
>
{label}
</div>
<div style={{ fontSize: '14px', marginTop: '2px' }}>
{item.link ? (
<Link
href={safeUrl(item.link)}
style={{ color: accent, textDecoration: 'none' }}
>
<strong>{item.title}</strong>
</Link>
) : (
<strong>{item.title}</strong>
)}
</div>
{item.description ? (
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
{item.description}
</div>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
{totalUnread > items.length ? (
<Text style={{ marginTop: '14px', fontSize: '13px', color: '#666' }}>
and {totalUnread - items.length} more.{' '}
<Link href={safeUrl(inboxLink)} style={{ color: accent }}>
Open the inbox
</Link>{' '}
to see everything.
</Text>
) : null}
<Text style={{ marginTop: '24px', fontSize: '13px', color: '#666' }}>
Thank you,
<br />
<strong>{portName} CRM</strong>
</Text>
</>
);
}
export async function notificationDigestEmail(
data: DigestData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<DigestBody {...data} accent={accent} />, { pretty: false });
const text = [
`${data.portName} CRM digest`,
'',
`You have ${data.totalUnread} unread notifications.`,
'',
...data.items.map((i) => `• [${i.type.replace(/_/g, ' ')}] ${i.title}`),
'',
`Inbox: ${data.inboxLink}`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -1,91 +0,0 @@
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
branding?: BrandingShell | null;
}
export interface ResidentialClientConfirmationData {
firstName: string;
contactEmail: string;
/** Display name; falls back to "Port Nimara". */
portName?: string;
}
export function residentialClientConfirmation(
data: ResidentialClientConfirmationData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'Port Nimara';
const subject = `Thank You for Your Interest - ${portName} Residences`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
Welcome to ${escapeHtml(portName)}
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
Dear ${escapeHtml(data.firstName)},
</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
Thank you for expressing interest in ${escapeHtml(portName)} residences. Our residential
sales team has received your inquiry and will reach out to you shortly with
more information.
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
If you have any questions in the meantime, please reach us at
<a href="mailto:${escapeHtml(data.contactEmail)}" style="color:${accent}; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
</p>
<p style="font-size:16px; margin-top:30px;">
Best regards,<br />
<strong>The ${escapeHtml(portName)} Residential Team</strong>
</p>`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}
export interface ResidentialSalesAlertData {
fullName: string;
email: string;
phone: string;
placeOfResidence?: string;
preferredContactMethod?: 'email' | 'phone';
notes?: string;
preferences?: string;
crmDeepLink?: string;
portName?: string;
}
export function residentialSalesAlert(data: ResidentialSalesAlertData, overrides?: RenderOpts) {
const portName = data.portName ?? 'Port Nimara';
const subject = `New Residential Inquiry - ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
New residential inquiry
</p>
<table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;">
<tr><td style="color:#666; width:140px;">Name</td><td>${escapeHtml(data.fullName)}</td></tr>
<tr><td style="color:#666;">Email</td><td>${escapeHtml(data.email)}</td></tr>
<tr><td style="color:#666;">Phone</td><td>${escapeHtml(data.phone)}</td></tr>
${data.placeOfResidence ? `<tr><td style="color:#666;">Residence</td><td>${escapeHtml(data.placeOfResidence)}</td></tr>` : ''}
${data.preferredContactMethod ? `<tr><td style="color:#666;">Prefers</td><td>${escapeHtml(data.preferredContactMethod)}</td></tr>` : ''}
${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''}
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
</table>
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${safeUrl(data.crmDeepLink)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
<p style="font-size:14px; color:#666;">- ${escapeHtml(portName)} CRM</p>`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -0,0 +1,190 @@
import { Button, Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
branding?: BrandingShell | null;
}
export interface ResidentialClientConfirmationData {
firstName: string;
contactEmail: string;
portName?: string;
}
function ClientConfirmationBody({
portName,
firstName,
contactEmail,
accent,
}: {
portName: string;
firstName: string;
contactEmail: string;
accent: string;
}) {
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Welcome to {portName}
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
Dear {firstName},
</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
Thank you for expressing interest in {portName} residences. Our residential sales team has
received your inquiry and will reach out to you shortly with more information.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
If you have any questions in the meantime, please reach us at{' '}
<Link
href={`mailto:${contactEmail}`}
style={{ color: accent, textDecoration: 'underline' }}
>
{contactEmail}
</Link>
.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
Best regards,
<br />
<strong>The {portName} Residential Team</strong>
</Text>
</>
);
}
export async function residentialClientConfirmation(
data: ResidentialClientConfirmationData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'Port Nimara';
const subject = `Thank You for Your Interest - ${portName} Residences`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
<ClientConfirmationBody
portName={portName}
firstName={data.firstName}
contactEmail={data.contactEmail}
accent={accent}
/>,
{ pretty: false },
);
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}
export interface ResidentialSalesAlertData {
fullName: string;
email: string;
phone: string;
placeOfResidence?: string;
preferredContactMethod?: 'email' | 'phone';
notes?: string;
preferences?: string;
crmDeepLink?: string;
portName?: string;
}
function SalesAlertBody({
portName,
data,
accent,
}: {
portName: string;
data: ResidentialSalesAlertData;
accent: string;
}) {
const labelCell = { color: '#666', width: '140px' } as const;
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
New residential inquiry
</Text>
<table
role="presentation"
width="100%"
cellPadding={6}
cellSpacing={0}
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }}
>
<tbody>
<tr>
<td style={labelCell}>Name</td>
<td>{data.fullName}</td>
</tr>
<tr>
<td style={labelCell}>Email</td>
<td>{data.email}</td>
</tr>
<tr>
<td style={labelCell}>Phone</td>
<td>{data.phone}</td>
</tr>
{data.placeOfResidence ? (
<tr>
<td style={labelCell}>Residence</td>
<td>{data.placeOfResidence}</td>
</tr>
) : null}
{data.preferredContactMethod ? (
<tr>
<td style={labelCell}>Prefers</td>
<td>{data.preferredContactMethod}</td>
</tr>
) : null}
{data.preferences ? (
<tr>
<td style={labelCell}>Preferences</td>
<td>{data.preferences}</td>
</tr>
) : null}
{data.notes ? (
<tr>
<td style={labelCell}>Notes</td>
<td>{data.notes}</td>
</tr>
) : null}
</tbody>
</table>
{data.crmDeepLink ? (
<div style={{ textAlign: 'center', margin: '24px 0' }}>
<Button
href={safeUrl(data.crmDeepLink)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '12px 28px',
borderRadius: '5px',
fontWeight: 'bold',
}}
>
Open in CRM
</Button>
</div>
) : null}
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
</>
);
}
export async function residentialSalesAlert(
data: ResidentialSalesAlertData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'Port Nimara';
const subject = `New Residential Inquiry - ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false,
});
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}

View File

@@ -32,7 +32,7 @@ export const emailWorker = new Worker(
const { resolveSubject } = await import('@/lib/email/resolve-subject');
const { getBrandingShell } = await import('@/lib/email/branding-resolver');
const branding = await getBrandingShell(portId);
const email = inquiryClientConfirmation(
const email = await inquiryClientConfirmation(
{ firstName, mooringNumber, contactEmail, portName },
{ branding },
);
@@ -67,7 +67,7 @@ export const emailWorker = new Worker(
const { resolveSubject } = await import('@/lib/email/resolve-subject');
const { getBrandingShell } = await import('@/lib/email/branding-resolver');
const branding = await getBrandingShell(portId);
const notification = inquirySalesNotification(
const notification = await inquirySalesNotification(
{
fullName,
email,

View File

@@ -69,7 +69,7 @@ export async function createCrmInvite(args: {
});
const link = `${env.APP_URL}/set-password?token=${raw}`;
const result = crmInviteEmail({
const result = await crmInviteEmail({
link,
ttlHours: INVITE_TTL_HOURS,
recipientName: args.name,
@@ -232,7 +232,7 @@ export async function resendCrmInvite(
const link = `${env.APP_URL}/set-password?token=${raw}`;
const branding = await getBrandingShell(meta.portId);
const result = crmInviteEmail(
const result = await crmInviteEmail(
{
link,
ttlHours: INVITE_TTL_HOURS,

View File

@@ -151,7 +151,7 @@ export async function sendSigningInvitation(args: SigningInvitationArgs): Promis
args.signerRole,
);
const { subject, html, text } = signingInvitationEmail(
const { subject, html, text } = await signingInvitationEmail(
{
recipientName: args.recipient.name,
documentLabel: args.documentLabel,
@@ -194,7 +194,7 @@ export async function sendSigningReminder(args: SigningReminderArgs): Promise<vo
args.signerRole,
);
const { subject, html, text } = signingReminderEmail(
const { subject, html, text } = await signingReminderEmail(
{
recipientName: args.recipient.name,
documentLabel: args.documentLabel,
@@ -242,7 +242,7 @@ export async function sendSigningCompleted(args: SigningCompletedArgs): Promise<
await Promise.all(
args.recipients.map((recipient) =>
sendLimit(async () => {
const { subject, html, text } = signingCompletedEmail(
const { subject, html, text } = await signingCompletedEmail(
{
recipientName: recipient.name,
documentLabel: args.documentLabel,

View File

@@ -138,7 +138,7 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
const visible = rows.slice(0, MAX_ITEMS_PER_USER);
const inboxLink = `${env.APP_URL}/notifications`;
const result = notificationDigestEmail(
const result = await notificationDigestEmail(
{
portName: port.name,
recipientName: u.name ?? '',

View File

@@ -385,7 +385,7 @@ async function notifyAdminEmailChange(args: {
getBrandingShell(args.portId).catch(() => null),
]);
const { subject, html, text } = adminEmailChangeEmail(
const { subject, html, text } = await adminEmailChangeEmail(
{
recipientName: args.displayName,
newEmail: args.newEmail,