feat(branding): wire per-port branding through every transactional email + auth shell (R2-H15)
Multi-tenant branding admin (/admin/branding) was saving 5 settings
that no code read — every port's emails shipped Port Nimara's logo
and color regardless. Now wired end-to-end:
New shared infrastructure:
- src/lib/email/shell.ts — renderShell() + brandingPrimaryColor()
helpers; takes BrandingShell { logoUrl, primaryColor,
emailHeaderHtml, emailFooterHtml }, falls back to Port Nimara
defaults when null.
- src/lib/email/branding-resolver.ts — getBrandingShell(portId)
thin wrapper over getPortBrandingConfig() that returns null on
error / missing portId so senders never break on misconfig.
All 6 transactional templates refactored to use renderShell + the
shared accent color; portName now flows through every template
(crm-invite, portal activation/reset, both inquiries, both
residential templates, notification digest).
All 6 senders pass branding via getBrandingShell:
- portal-auth.service.ts (activation + reset)
- crm-invite.service.ts (resend path; create-invite has no portId
yet so falls through to defaults)
- email worker (inquiry confirmation + sales notification)
- residential-inquiries route (client confirmation + sales alert)
- notification-digest.service.ts (digest)
BrandedAuthShell takes an optional `branding` prop with logoUrl +
appName (parent page server-fetches via getPortBrandingConfig).
Defaults to Port Nimara if omitted, so single-tenant deployments
are unaffected.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
residentialSalesAlert,
|
residentialSalesAlert,
|
||||||
} from '@/lib/email/templates/residential-inquiry';
|
} from '@/lib/email/templates/residential-inquiry';
|
||||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||||
|
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
@@ -141,18 +142,22 @@ async function sendResidentialNotifications(args: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { portId, data, crmDeepLink } = args;
|
const { portId, data, crmDeepLink } = args;
|
||||||
|
|
||||||
|
const branding = await getBrandingShell(portId);
|
||||||
// Client confirmation
|
// Client confirmation
|
||||||
const confirmation = residentialClientConfirmation({
|
const confirmation = residentialClientConfirmation(
|
||||||
|
{
|
||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
contactEmail: 'sales@portnimara.com',
|
contactEmail: 'sales@portnimara.com',
|
||||||
});
|
},
|
||||||
|
{ branding },
|
||||||
|
);
|
||||||
const confirmationSubject = await resolveSubject({
|
const confirmationSubject = await resolveSubject({
|
||||||
key: 'residential_inquiry_client_confirmation',
|
key: 'residential_inquiry_client_confirmation',
|
||||||
portId,
|
portId,
|
||||||
fallback: confirmation.subject,
|
fallback: confirmation.subject,
|
||||||
tokens: { portName: 'Port Nimara', recipientName: data.firstName },
|
tokens: { portName: 'Port Nimara', recipientName: data.firstName },
|
||||||
});
|
});
|
||||||
await sendEmail(data.email, confirmationSubject, confirmation.html);
|
await sendEmail(data.email, confirmationSubject, confirmation.html, undefined, undefined, portId);
|
||||||
|
|
||||||
// Sales-team alert - pull recipients from system_settings if configured;
|
// Sales-team alert - pull recipients from system_settings if configured;
|
||||||
// fall back to the inquiry_contact_email if available.
|
// fall back to the inquiry_contact_email if available.
|
||||||
@@ -181,7 +186,8 @@ async function sendResidentialNotifications(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const alert = residentialSalesAlert({
|
const alert = residentialSalesAlert(
|
||||||
|
{
|
||||||
fullName: `${data.firstName} ${data.lastName}`.trim(),
|
fullName: `${data.firstName} ${data.lastName}`.trim(),
|
||||||
email: data.email,
|
email: data.email,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
@@ -190,7 +196,9 @@ async function sendResidentialNotifications(args: {
|
|||||||
notes: data.notes,
|
notes: data.notes,
|
||||||
preferences: data.preferences,
|
preferences: data.preferences,
|
||||||
crmDeepLink,
|
crmDeepLink,
|
||||||
});
|
},
|
||||||
|
{ branding },
|
||||||
|
);
|
||||||
const alertSubject = await resolveSubject({
|
const alertSubject = await resolveSubject({
|
||||||
key: 'residential_inquiry_sales_alert',
|
key: 'residential_inquiry_sales_alert',
|
||||||
portId,
|
portId,
|
||||||
|
|||||||
@@ -1,27 +1,42 @@
|
|||||||
const BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
const DEFAULT_BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||||
const LOGO_URL =
|
const DEFAULT_LOGO_URL =
|
||||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||||
|
|
||||||
|
interface BrandedAuthShellProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Per-port branding override resolved server-side by the page that
|
||||||
|
* renders the shell. When omitted, falls back to the Port Nimara
|
||||||
|
* defaults so single-tenant deployments remain unaffected. Pages
|
||||||
|
* that know their portId at render time should pass the result of
|
||||||
|
* `getPortBrandingConfig(portId)`. */
|
||||||
|
branding?: {
|
||||||
|
logoUrl?: string | null;
|
||||||
|
appName?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Branded shell shared by every auth/form surface - CRM login, portal login,
|
* Branded shell shared by every auth/form surface - CRM login, portal login,
|
||||||
* password set/reset/activate, forgot-password. Renders the blurred Port
|
* password set/reset/activate, forgot-password. Renders the blurred
|
||||||
* Nimara overhead background, the circular logo, and a centered white card
|
* background, the logo, and a centered white card that consumers
|
||||||
* that consumers populate with their own form/content.
|
* populate with their own form/content.
|
||||||
|
*
|
||||||
|
* Multi-tenant note (R2-H15): the per-port logoUrl from
|
||||||
|
* /admin/branding is rendered when the parent page passes a `branding`
|
||||||
|
* prop. The background image stays as the marina default for all
|
||||||
|
* deployments — admin-authored backgrounds aren't part of the v1
|
||||||
|
* branding surface.
|
||||||
*/
|
*/
|
||||||
export function BrandedAuthShell({ children }: { children: React.ReactNode }) {
|
export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps) {
|
||||||
|
const logoUrl = branding?.logoUrl || DEFAULT_LOGO_URL;
|
||||||
|
const altText = branding?.appName || 'Port Nimara';
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen min-h-[100dvh] flex items-center justify-center px-4 py-8">
|
<div className="relative min-h-screen min-h-[100dvh] flex items-center justify-center px-4 py-8">
|
||||||
{/*
|
|
||||||
Full-viewport background layer - pinned to the visible viewport via
|
|
||||||
`fixed inset-0` so the marina image always reaches the actual screen
|
|
||||||
edges regardless of the iOS Safari URL bar showing/hiding. The shell's
|
|
||||||
layout layer above sits on top via z-index.
|
|
||||||
*/}
|
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="fixed inset-0 -z-10"
|
className="fixed inset-0 -z-10"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url('${BG_URL}')`,
|
backgroundImage: `url('${DEFAULT_BG_URL}')`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
backgroundColor: '#f2f2f2',
|
backgroundColor: '#f2f2f2',
|
||||||
@@ -31,7 +46,7 @@ export function BrandedAuthShell({ children }: { children: React.ReactNode }) {
|
|||||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||||
<div className="flex justify-center mb-6">
|
<div className="flex justify-center mb-6">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={LOGO_URL} alt="Port Nimara" className="w-24 h-auto" />
|
<img src={logoUrl} alt={altText} className="w-24 h-auto" />
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
src/lib/email/branding-resolver.ts
Normal file
28
src/lib/email/branding-resolver.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Resolve the per-port branding shell for transactional emails.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* falls back to the Port Nimara defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||||
|
import type { BrandingShell } from '@/lib/email/shell';
|
||||||
|
|
||||||
|
export async function getBrandingShell(
|
||||||
|
portId: string | null | undefined,
|
||||||
|
): Promise<BrandingShell | null> {
|
||||||
|
if (!portId) return null;
|
||||||
|
try {
|
||||||
|
const cfg = await getPortBrandingConfig(portId);
|
||||||
|
return {
|
||||||
|
logoUrl: cfg.logoUrl,
|
||||||
|
primaryColor: cfg.primaryColor,
|
||||||
|
emailHeaderHtml: cfg.emailHeaderHtml,
|
||||||
|
emailFooterHtml: cfg.emailFooterHtml,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/lib/email/shell.ts
Normal file
80
src/lib/email/shell.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Shared HTML shell for transactional emails. Centralises the table-
|
||||||
|
* based layout + the per-port branding override surface so templates
|
||||||
|
* don't each inline a different copy of the boilerplate.
|
||||||
|
*
|
||||||
|
* Per-port branding (R2-H15):
|
||||||
|
* - logoUrl — replaces the default Port Nimara logo image
|
||||||
|
* - primaryColor — used for the page-title accent color
|
||||||
|
* - emailHeaderHtml / emailFooterHtml — admin-authored HTML that
|
||||||
|
* appears above / below the body content (e.g. legal footer,
|
||||||
|
* custom marketing strip). When unset, the existing minimal
|
||||||
|
* "Thank you, {{portName}} CRM" sign-off is rendered by callers.
|
||||||
|
*
|
||||||
|
* Senders resolve a `BrandingShell` via `resolveBrandingShell(portId)`
|
||||||
|
* (or pass `null` for no override) and forward it to the template
|
||||||
|
* function. Templates call `renderShell({ title, body, branding })`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_LOGO_URL =
|
||||||
|
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||||
|
const DEFAULT_BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||||
|
const DEFAULT_PRIMARY_COLOR = '#0F4C81';
|
||||||
|
|
||||||
|
export interface BrandingShell {
|
||||||
|
logoUrl: string | null;
|
||||||
|
primaryColor: string | null;
|
||||||
|
emailHeaderHtml: string | null;
|
||||||
|
emailFooterHtml: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShellOpts {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
branding?: BrandingShell | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderShell({ title, body, branding }: ShellOpts): string {
|
||||||
|
const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL;
|
||||||
|
const headerHtml = branding?.emailHeaderHtml ?? '';
|
||||||
|
const footerHtml = branding?.emailFooterHtml ?? '';
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<title>${title}</title>
|
||||||
|
<style type="text/css">
|
||||||
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||||
|
img { border: 0; display: block; }
|
||||||
|
p { margin: 0; padding: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||||
|
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${DEFAULT_BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:30px 16px;">
|
||||||
|
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||||
|
<center>
|
||||||
|
<img src="${logoUrl}" alt="Port logo" width="100" style="margin-bottom:20px;" />
|
||||||
|
</center>
|
||||||
|
${headerHtml ? `<div>${headerHtml}</div>` : ''}
|
||||||
|
${body}
|
||||||
|
${footerHtml ? `<div style="margin-top:24px;">${footerHtml}</div>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Surface the brand primary color to template bodies. */
|
||||||
|
export function brandingPrimaryColor(branding?: BrandingShell | null): string {
|
||||||
|
return branding?.primaryColor ?? DEFAULT_PRIMARY_COLOR;
|
||||||
|
}
|
||||||
@@ -1,83 +1,59 @@
|
|||||||
|
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||||
|
|
||||||
interface InviteData {
|
interface InviteData {
|
||||||
link: string;
|
link: string;
|
||||||
ttlHours: number;
|
ttlHours: number;
|
||||||
recipientName?: string;
|
recipientName?: string;
|
||||||
isSuperAdmin: boolean;
|
isSuperAdmin: boolean;
|
||||||
|
/** Display name for the port — falls back to "Port Nimara" so the
|
||||||
|
* pre-multi-tenant default still reads correctly. */
|
||||||
|
portName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOGO_URL =
|
interface RenderOpts {
|
||||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
branding?: BrandingShell | null;
|
||||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
|
||||||
|
|
||||||
function shell(opts: { title: string; body: string }): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
||||||
<title>${opts.title}</title>
|
|
||||||
<style type="text/css">
|
|
||||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
|
||||||
img { border: 0; display: block; }
|
|
||||||
p { margin: 0; padding: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
|
||||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:30px 16px;">
|
|
||||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
|
||||||
<center>
|
|
||||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
|
||||||
</center>
|
|
||||||
${opts.body}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function crmInviteEmail(data: InviteData): {
|
export function crmInviteEmail(
|
||||||
|
data: InviteData,
|
||||||
|
overrides?: RenderOpts,
|
||||||
|
): {
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
text: string;
|
text: string;
|
||||||
} {
|
} {
|
||||||
const subject = `You're invited to the Port Nimara CRM`;
|
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 greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
|
||||||
const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
|
const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||||
Welcome to the Port Nimara CRM
|
Welcome to the ${escapeHtml(portName)} CRM
|
||||||
</p>
|
</p>
|
||||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</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;">
|
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
||||||
You've been invited to the Port Nimara CRM as a ${role}. Click the
|
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
|
button below to set your password and activate your account. The
|
||||||
link expires in ${data.ttlHours} hours.
|
link expires in ${data.ttlHours} hours.
|
||||||
</p>
|
</p>
|
||||||
<p style="text-align:center; margin:30px 0;">
|
<p style="text-align:center; margin:30px 0;">
|
||||||
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
<a href="${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
|
Set up your account
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
|
<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 />
|
If the button doesn't work, paste this link into your browser:<br />
|
||||||
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
<a href="${data.link}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size:16px; margin-top:30px;">
|
<p style="font-size:16px; margin-top:30px;">
|
||||||
Thank you,<br />
|
Thank you,<br />
|
||||||
<strong>Port Nimara CRM</strong>
|
<strong>${escapeHtml(portName)} CRM</strong>
|
||||||
</p>`;
|
</p>`;
|
||||||
|
|
||||||
const text = [
|
const text = [
|
||||||
`Welcome to the Port Nimara CRM`,
|
`Welcome to the ${portName} CRM`,
|
||||||
'',
|
'',
|
||||||
`You've been invited as a ${role}.`,
|
`You've been invited as a ${role}.`,
|
||||||
`Set up your account: ${data.link}`,
|
`Set up your account: ${data.link}`,
|
||||||
@@ -85,10 +61,14 @@ export function crmInviteEmail(data: InviteData): {
|
|||||||
`The link expires in ${data.ttlHours} hours.`,
|
`The link expires in ${data.ttlHours} hours.`,
|
||||||
'',
|
'',
|
||||||
`Thank you,`,
|
`Thank you,`,
|
||||||
`Port Nimara CRM`,
|
`${portName} CRM`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return { subject, html: shell({ title: subject, body }), text };
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|||||||
@@ -1,40 +1,32 @@
|
|||||||
|
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||||
|
|
||||||
export interface InquiryClientConfirmationData {
|
export interface InquiryClientConfirmationData {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
mooringNumber: string | null;
|
mooringNumber: string | null;
|
||||||
contactEmail: string;
|
contactEmail: string;
|
||||||
|
/** Display name; falls back to "Port Nimara". */
|
||||||
|
portName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
|
interface RenderOpts {
|
||||||
|
branding?: BrandingShell | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inquiryClientConfirmation(
|
||||||
|
data: InquiryClientConfirmationData,
|
||||||
|
overrides?: RenderOpts,
|
||||||
|
) {
|
||||||
const { firstName, mooringNumber, contactEmail } = data;
|
const { firstName, mooringNumber, contactEmail } = data;
|
||||||
|
const portName = data.portName ?? 'Port Nimara';
|
||||||
|
|
||||||
const berthText = mooringNumber ? `Berth ${mooringNumber}` : 'a Port Nimara Berth';
|
const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
|
||||||
|
|
||||||
const subject = mooringNumber
|
const subject = mooringNumber
|
||||||
? `Thank You for Your Interest in Berth ${mooringNumber}`
|
? `Thank You for Your Interest in Berth ${mooringNumber}`
|
||||||
: 'Thank You for Your Interest in a Port Nimara Berth';
|
: `Thank You for Your Interest in a ${portName} Berth`;
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
<html>
|
|
||||||
<head>
|
const body = `
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
||||||
<title>${subject}</title>
|
|
||||||
<style type="text/css">
|
|
||||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
|
||||||
img { border: 0; display: block; }
|
|
||||||
p { margin: 0; padding: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
|
||||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:30px;">
|
|
||||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
|
|
||||||
<center>
|
|
||||||
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
|
||||||
</center>
|
|
||||||
<p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p>
|
<p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p>
|
||||||
<p style="margin-bottom:10px; font-size:16px;">
|
<p style="margin-bottom:10px; font-size:16px;">
|
||||||
Thank you for expressing interest in ${escapeHtml(berthText)}.
|
Thank you for expressing interest in ${escapeHtml(berthText)}.
|
||||||
@@ -43,20 +35,12 @@ export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
|
|||||||
</p>
|
</p>
|
||||||
<p style="margin-bottom:10px; font-size:16px;">
|
<p style="margin-bottom:10px; font-size:16px;">
|
||||||
If you have any questions, please feel free to reach out to us at
|
If you have any questions, please feel free to reach out to us at
|
||||||
<a href="mailto:${escapeHtml(contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
|
<a href="mailto:${escapeHtml(contactEmail)}" style="color:${accent}; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size:16px;">
|
<p style="font-size:16px;">
|
||||||
Best regards,<br />
|
Best regards,<br />
|
||||||
The Port Nimara Sales Team
|
The ${escapeHtml(portName)} Sales Team
|
||||||
</p>
|
</p>`;
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
const text = [
|
const text = [
|
||||||
`Dear ${firstName},`,
|
`Dear ${firstName},`,
|
||||||
@@ -66,10 +50,14 @@ export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
|
|||||||
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
|
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
|
||||||
'',
|
'',
|
||||||
'Best regards,',
|
'Best regards,',
|
||||||
'The Port Nimara Sales Team',
|
`The ${portName} Sales Team`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return { subject, html, text };
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|||||||
@@ -1,73 +1,60 @@
|
|||||||
|
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||||
|
|
||||||
export interface InquirySalesNotificationData {
|
export interface InquirySalesNotificationData {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
mooringNumber: string | null;
|
mooringNumber: string | null;
|
||||||
crmUrl: string;
|
crmUrl: string;
|
||||||
|
/** Display name; falls back to "Port Nimara". */
|
||||||
|
portName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inquirySalesNotification(data: InquirySalesNotificationData) {
|
interface RenderOpts {
|
||||||
|
branding?: BrandingShell | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inquirySalesNotification(
|
||||||
|
data: InquirySalesNotificationData,
|
||||||
|
overrides?: RenderOpts,
|
||||||
|
) {
|
||||||
const { fullName, email, phone, mooringNumber, crmUrl } = data;
|
const { fullName, email, phone, mooringNumber, crmUrl } = data;
|
||||||
|
const portName = data.portName ?? 'Port Nimara';
|
||||||
const mooringDisplay = mooringNumber || 'None';
|
const mooringDisplay = mooringNumber || 'None';
|
||||||
|
const subject = `New Interest - ${portName}`;
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
const subject = 'New Interest - Port Nimara';
|
const body = `
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
||||||
<title>New Interest - Port Nimara</title>
|
|
||||||
<style type="text/css">
|
|
||||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
|
||||||
img { border: 0; display: block; }
|
|
||||||
p { margin: 0; padding: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
|
||||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:30px;">
|
|
||||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
|
|
||||||
<center>
|
|
||||||
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
|
||||||
</center>
|
|
||||||
<p style="margin-bottom:10px; font-size:16px;">Dear Administrator,</p>
|
<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>Port Nimara</strong>. Here are their details:</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>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>Email:</strong> ${escapeHtml(email)}</p>
|
||||||
<p style="margin-bottom:0; font-size:16px;"><strong>Telephone:</strong> ${escapeHtml(phone)}</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: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:#007bff; text-decoration:underline;">Port Nimara CRM</a> to view more information.</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/>Port Nimara CRM</p>
|
<p style="font-size:16px;">Thank you,<br/>${escapeHtml(portName)} CRM</p>`;
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
const text = [
|
const text = [
|
||||||
'Dear Administrator,',
|
'Dear Administrator,',
|
||||||
'',
|
'',
|
||||||
`${fullName} has expressed their interest in Port Nimara. Here are their details:`,
|
`${fullName} has expressed their interest in ${portName}. Here are their details:`,
|
||||||
'',
|
'',
|
||||||
`Name: ${fullName}`,
|
`Name: ${fullName}`,
|
||||||
`Email: ${email}`,
|
`Email: ${email}`,
|
||||||
`Telephone: ${phone}`,
|
`Telephone: ${phone}`,
|
||||||
`Berths Selected: ${mooringDisplay}`,
|
`Berths Selected: ${mooringDisplay}`,
|
||||||
'',
|
'',
|
||||||
`Please visit the Port Nimara CRM (${crmUrl}) to view more information.`,
|
`Please visit the ${portName} CRM (${crmUrl}) to view more information.`,
|
||||||
'',
|
'',
|
||||||
'Thank you',
|
'Thank you',
|
||||||
'Port Nimara CRM',
|
`${portName} CRM`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return { subject, html, text };
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* Used by the notification-digest scheduler (queued in `email` worker).
|
* Used by the notification-digest scheduler (queued in `email` worker).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||||
|
|
||||||
interface DigestData {
|
interface DigestData {
|
||||||
portName: string;
|
portName: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
@@ -19,45 +21,8 @@ interface DigestData {
|
|||||||
inboxLink: string;
|
inboxLink: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOGO_URL =
|
interface RenderOpts {
|
||||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
branding?: BrandingShell | null;
|
||||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
|
||||||
|
|
||||||
function shell(opts: { title: string; body: string }): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>${opts.title}</title>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
|
||||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:30px 16px;">
|
|
||||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:24px; font-family: Arial, sans-serif; color:#333333;">
|
|
||||||
<center>
|
|
||||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
|
||||||
</center>
|
|
||||||
${opts.body}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -75,18 +40,22 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
berth_released: 'Berth released',
|
berth_released: 'Berth released',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function notificationDigestEmail(data: DigestData): {
|
export function notificationDigestEmail(
|
||||||
|
data: DigestData,
|
||||||
|
overrides?: RenderOpts,
|
||||||
|
): {
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
text: string;
|
text: string;
|
||||||
} {
|
} {
|
||||||
const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`;
|
const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`;
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
const itemsHtml = data.items
|
const itemsHtml = data.items
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
|
const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
|
||||||
const titleHtml = item.link
|
const titleHtml = item.link
|
||||||
? `<a href="${item.link}" style="color:#007bff; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
|
? `<a href="${item.link}" style="color:${accent}; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
|
||||||
: `<strong>${escapeHtml(item.title)}</strong>`;
|
: `<strong>${escapeHtml(item.title)}</strong>`;
|
||||||
const desc = item.description
|
const desc = item.description
|
||||||
? `<div style="font-size:13px; color:#666; margin-top:4px;">${escapeHtml(item.description)}</div>`
|
? `<div style="font-size:13px; color:#666; margin-top:4px;">${escapeHtml(item.description)}</div>`
|
||||||
@@ -102,13 +71,13 @@ export function notificationDigestEmail(data: DigestData): {
|
|||||||
const tail =
|
const tail =
|
||||||
data.totalUnread > data.items.length
|
data.totalUnread > data.items.length
|
||||||
? `<p style="margin-top:14px; font-size:13px; color:#666;">…and ${data.totalUnread - data.items.length} more.
|
? `<p style="margin-top:14px; font-size:13px; color:#666;">…and ${data.totalUnread - data.items.length} more.
|
||||||
<a href="${data.inboxLink}" style="color:#007bff;">Open the inbox</a> to see everything.</p>`
|
<a href="${data.inboxLink}" style="color:${accent};">Open the inbox</a> to see everything.</p>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,';
|
const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,';
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
<p style="font-size:18px; font-weight:bold; color:#0F4C81; margin:0 0 6px;">
|
<p style="font-size:18px; font-weight:bold; color:${accent}; margin:0 0 6px;">
|
||||||
Your ${escapeHtml(data.portName)} CRM digest
|
Your ${escapeHtml(data.portName)} CRM digest
|
||||||
</p>
|
</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 14px;">${greeting}</p>
|
||||||
@@ -134,5 +103,18 @@ export function notificationDigestEmail(data: DigestData): {
|
|||||||
`Inbox: ${data.inboxLink}`,
|
`Inbox: ${data.inboxLink}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return { subject, html: shell({ title: subject, body }), text };
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||||
|
|
||||||
interface ActivationData {
|
interface ActivationData {
|
||||||
portName: string;
|
portName: string;
|
||||||
link: string;
|
link: string;
|
||||||
@@ -12,47 +14,14 @@ interface ResetData {
|
|||||||
recipientName?: string;
|
recipientName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOGO_URL =
|
interface RenderOpts {
|
||||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
subject?: string | null;
|
||||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
branding?: BrandingShell | null;
|
||||||
|
|
||||||
function shell(opts: { title: string; body: string }): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
||||||
<title>${opts.title}</title>
|
|
||||||
<style type="text/css">
|
|
||||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
|
||||||
img { border: 0; display: block; }
|
|
||||||
p { margin: 0; padding: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
|
||||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:30px 16px;">
|
|
||||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
|
||||||
<center>
|
|
||||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
|
||||||
</center>
|
|
||||||
${opts.body}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activationEmail(
|
export function activationEmail(
|
||||||
data: ActivationData,
|
data: ActivationData,
|
||||||
overrides?: { subject?: string | null },
|
overrides?: RenderOpts,
|
||||||
): {
|
): {
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
@@ -65,9 +34,10 @@ export function activationEmail(
|
|||||||
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
|
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
|
||||||
: `Activate your ${data.portName} client portal account`;
|
: `Activate your ${data.portName} client portal account`;
|
||||||
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
|
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||||
Welcome to ${escapeHtml(data.portName)}
|
Welcome to ${escapeHtml(data.portName)}
|
||||||
</p>
|
</p>
|
||||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
||||||
@@ -77,13 +47,13 @@ export function activationEmail(
|
|||||||
The link expires in ${data.ttlHours} hours.
|
The link expires in ${data.ttlHours} hours.
|
||||||
</p>
|
</p>
|
||||||
<p style="text-align:center; margin:30px 0;">
|
<p style="text-align:center; margin:30px 0;">
|
||||||
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
<a href="${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;">
|
||||||
Activate account
|
Activate account
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
|
<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 />
|
If the button doesn't work, paste this link into your browser:<br />
|
||||||
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
<a href="${data.link}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size:16px; margin-top:30px;">
|
<p style="font-size:16px; margin-top:30px;">
|
||||||
Thank you,<br />
|
Thank you,<br />
|
||||||
@@ -102,12 +72,16 @@ export function activationEmail(
|
|||||||
`${data.portName} CRM`,
|
`${data.portName} CRM`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return { subject, html: shell({ title: subject, body }), text };
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetEmail(
|
export function resetEmail(
|
||||||
data: ResetData,
|
data: ResetData,
|
||||||
overrides?: { subject?: string | null },
|
overrides?: RenderOpts,
|
||||||
): { subject: string; html: string; text: string } {
|
): { subject: string; html: string; text: string } {
|
||||||
const subject = overrides?.subject
|
const subject = overrides?.subject
|
||||||
? overrides.subject
|
? overrides.subject
|
||||||
@@ -116,9 +90,10 @@ export function resetEmail(
|
|||||||
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
|
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
|
||||||
: `Reset your ${data.portName} client portal password`;
|
: `Reset your ${data.portName} client portal password`;
|
||||||
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,';
|
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,';
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||||
Password reset
|
Password reset
|
||||||
</p>
|
</p>
|
||||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
||||||
@@ -128,7 +103,7 @@ export function resetEmail(
|
|||||||
The link expires in ${data.ttlMinutes} minutes.
|
The link expires in ${data.ttlMinutes} minutes.
|
||||||
</p>
|
</p>
|
||||||
<p style="text-align:center; margin:30px 0;">
|
<p style="text-align:center; margin:30px 0;">
|
||||||
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
<a href="${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;">
|
||||||
Reset password
|
Reset password
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -152,7 +127,11 @@ export function resetEmail(
|
|||||||
`${data.portName} CRM`,
|
`${data.portName} CRM`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return { subject, html: shell({ title: subject, body }), text };
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|||||||
@@ -1,69 +1,47 @@
|
|||||||
const LOGO_URL =
|
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
|
||||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
|
||||||
|
|
||||||
function shell(opts: { title: string; body: string }): string {
|
interface RenderOpts {
|
||||||
return `<!DOCTYPE html>
|
branding?: BrandingShell | null;
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
||||||
<title>${opts.title}</title>
|
|
||||||
<style type="text/css">
|
|
||||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
|
||||||
img { border: 0; display: block; }
|
|
||||||
p { margin: 0; padding: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
|
||||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:30px 16px;">
|
|
||||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
|
||||||
<center>
|
|
||||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
|
||||||
</center>
|
|
||||||
${opts.body}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResidentialClientConfirmationData {
|
export interface ResidentialClientConfirmationData {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
contactEmail: string;
|
contactEmail: string;
|
||||||
|
/** Display name; falls back to "Port Nimara". */
|
||||||
|
portName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function residentialClientConfirmation(data: ResidentialClientConfirmationData) {
|
export function residentialClientConfirmation(
|
||||||
const subject = 'Thank You for Your Interest - Port Nimara Residences';
|
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 = `
|
const body = `
|
||||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||||
Welcome to Port Nimara
|
Welcome to ${escapeHtml(portName)}
|
||||||
</p>
|
</p>
|
||||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
||||||
Dear ${escapeHtml(data.firstName)},
|
Dear ${escapeHtml(data.firstName)},
|
||||||
</p>
|
</p>
|
||||||
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
||||||
Thank you for expressing interest in Port Nimara residences. Our residential
|
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
|
sales team has received your inquiry and will reach out to you shortly with
|
||||||
more information.
|
more information.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
||||||
If you have any questions in the meantime, please reach us at
|
If you have any questions in the meantime, please reach us at
|
||||||
<a href="mailto:${escapeHtml(data.contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
|
<a href="mailto:${escapeHtml(data.contactEmail)}" style="color:${accent}; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size:16px; margin-top:30px;">
|
<p style="font-size:16px; margin-top:30px;">
|
||||||
Best regards,<br />
|
Best regards,<br />
|
||||||
<strong>The Port Nimara Residential Team</strong>
|
<strong>The ${escapeHtml(portName)} Residential Team</strong>
|
||||||
</p>`;
|
</p>`;
|
||||||
return { subject, html: shell({ title: subject, body }) };
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResidentialSalesAlertData {
|
export interface ResidentialSalesAlertData {
|
||||||
@@ -75,12 +53,15 @@ export interface ResidentialSalesAlertData {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
preferences?: string;
|
preferences?: string;
|
||||||
crmDeepLink?: string;
|
crmDeepLink?: string;
|
||||||
|
portName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function residentialSalesAlert(data: ResidentialSalesAlertData) {
|
export function residentialSalesAlert(data: ResidentialSalesAlertData, overrides?: RenderOpts) {
|
||||||
|
const portName = data.portName ?? 'Port Nimara';
|
||||||
const subject = `New Residential Inquiry - ${data.fullName}`;
|
const subject = `New Residential Inquiry - ${data.fullName}`;
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
const body = `
|
const body = `
|
||||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||||
New residential inquiry
|
New residential inquiry
|
||||||
</p>
|
</p>
|
||||||
<table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;">
|
<table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;">
|
||||||
@@ -92,9 +73,12 @@ export function residentialSalesAlert(data: ResidentialSalesAlertData) {
|
|||||||
${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</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>` : ''}
|
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
|
||||||
</table>
|
</table>
|
||||||
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${data.crmDeepLink}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
|
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${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;">- Port Nimara CRM</p>`;
|
<p style="font-size:14px; color:#666;">- ${escapeHtml(portName)} CRM</p>`;
|
||||||
return { subject, html: shell({ title: subject, body }) };
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ export const emailWorker = new Worker(
|
|||||||
await import('@/lib/email/templates/inquiry-client-confirmation');
|
await import('@/lib/email/templates/inquiry-client-confirmation');
|
||||||
const { sendEmail } = await import('@/lib/email/index');
|
const { sendEmail } = await import('@/lib/email/index');
|
||||||
const { resolveSubject } = await import('@/lib/email/resolve-subject');
|
const { resolveSubject } = await import('@/lib/email/resolve-subject');
|
||||||
const email = inquiryClientConfirmation({ firstName, mooringNumber, contactEmail });
|
const { getBrandingShell } = await import('@/lib/email/branding-resolver');
|
||||||
|
const branding = await getBrandingShell(portId);
|
||||||
|
const email = inquiryClientConfirmation(
|
||||||
|
{ firstName, mooringNumber, contactEmail, portName },
|
||||||
|
{ branding },
|
||||||
|
);
|
||||||
const subject = await resolveSubject({
|
const subject = await resolveSubject({
|
||||||
key: 'inquiry_client_confirmation',
|
key: 'inquiry_client_confirmation',
|
||||||
portId,
|
portId,
|
||||||
@@ -60,13 +65,19 @@ export const emailWorker = new Worker(
|
|||||||
await import('@/lib/email/templates/inquiry-sales-notification');
|
await import('@/lib/email/templates/inquiry-sales-notification');
|
||||||
const { sendEmail } = await import('@/lib/email/index');
|
const { sendEmail } = await import('@/lib/email/index');
|
||||||
const { resolveSubject } = await import('@/lib/email/resolve-subject');
|
const { resolveSubject } = await import('@/lib/email/resolve-subject');
|
||||||
const notification = inquirySalesNotification({
|
const { getBrandingShell } = await import('@/lib/email/branding-resolver');
|
||||||
|
const branding = await getBrandingShell(portId);
|
||||||
|
const notification = inquirySalesNotification(
|
||||||
|
{
|
||||||
fullName,
|
fullName,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
mooringNumber,
|
mooringNumber,
|
||||||
crmUrl,
|
crmUrl,
|
||||||
});
|
portName,
|
||||||
|
},
|
||||||
|
{ branding },
|
||||||
|
);
|
||||||
const subject = await resolveSubject({
|
const subject = await resolveSubject({
|
||||||
key: 'inquiry_sales_notification',
|
key: 'inquiry_sales_notification',
|
||||||
portId,
|
portId,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { env } from '@/lib/env';
|
|||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
|
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
|
||||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||||
|
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||||
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { hashToken, mintToken } from '@/lib/portal/passwords';
|
import { hashToken, mintToken } from '@/lib/portal/passwords';
|
||||||
|
|
||||||
@@ -230,12 +231,16 @@ export async function resendCrmInvite(
|
|||||||
.where(eq(crmUserInvites.id, inviteId));
|
.where(eq(crmUserInvites.id, inviteId));
|
||||||
|
|
||||||
const link = `${env.APP_URL}/set-password?token=${raw}`;
|
const link = `${env.APP_URL}/set-password?token=${raw}`;
|
||||||
const result = crmInviteEmail({
|
const branding = await getBrandingShell(meta.portId);
|
||||||
|
const result = crmInviteEmail(
|
||||||
|
{
|
||||||
link,
|
link,
|
||||||
ttlHours: INVITE_TTL_HOURS,
|
ttlHours: INVITE_TTL_HOURS,
|
||||||
recipientName: invite.name ?? undefined,
|
recipientName: invite.name ?? undefined,
|
||||||
isSuperAdmin: invite.isSuperAdmin,
|
isSuperAdmin: invite.isSuperAdmin,
|
||||||
});
|
},
|
||||||
|
{ branding },
|
||||||
|
);
|
||||||
// Resend uses the dedicated portal_invite_resend key so admins can
|
// Resend uses the dedicated portal_invite_resend key so admins can
|
||||||
// word the resend differently from the original.
|
// word the resend differently from the original.
|
||||||
const subject = await resolveSubject({
|
const subject = await resolveSubject({
|
||||||
@@ -248,7 +253,7 @@ export async function resendCrmInvite(
|
|||||||
ttlHours: INVITE_TTL_HOURS,
|
ttlHours: INVITE_TTL_HOURS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sendEmail(invite.email, subject, result.html, undefined, result.text);
|
await sendEmail(invite.email, subject, result.html, undefined, result.text, meta.portId);
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { getPortReminderConfig } from '@/lib/services/port-config';
|
|||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||||
|
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||||
|
|
||||||
const DIGEST_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
const DIGEST_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||||
const MAX_ITEMS_PER_USER = 20;
|
const MAX_ITEMS_PER_USER = 20;
|
||||||
@@ -103,6 +104,10 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
|||||||
|
|
||||||
if (portUsers.length === 0) continue;
|
if (portUsers.length === 0) continue;
|
||||||
|
|
||||||
|
// Resolve branding once per port — every user on this port gets
|
||||||
|
// the same shell.
|
||||||
|
const branding = await getBrandingShell(port.id);
|
||||||
|
|
||||||
const since = new Date(now.getTime() - DIGEST_LOOKBACK_MS);
|
const since = new Date(now.getTime() - DIGEST_LOOKBACK_MS);
|
||||||
|
|
||||||
for (const u of portUsers) {
|
for (const u of portUsers) {
|
||||||
@@ -133,7 +138,8 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
|||||||
|
|
||||||
const visible = rows.slice(0, MAX_ITEMS_PER_USER);
|
const visible = rows.slice(0, MAX_ITEMS_PER_USER);
|
||||||
const inboxLink = `${env.APP_URL}/notifications`;
|
const inboxLink = `${env.APP_URL}/notifications`;
|
||||||
const result = notificationDigestEmail({
|
const result = notificationDigestEmail(
|
||||||
|
{
|
||||||
portName: port.name,
|
portName: port.name,
|
||||||
recipientName: u.name ?? '',
|
recipientName: u.name ?? '',
|
||||||
items: visible.map((r) => ({
|
items: visible.map((r) => ({
|
||||||
@@ -145,7 +151,9 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
|||||||
})),
|
})),
|
||||||
totalUnread: rows.length,
|
totalUnread: rows.length,
|
||||||
inboxLink,
|
inboxLink,
|
||||||
});
|
},
|
||||||
|
{ branding },
|
||||||
|
);
|
||||||
|
|
||||||
// The per-port subject override key for the digest is the
|
// The per-port subject override key for the digest is the
|
||||||
// existing 'crm_invite' / 'portal_*' family — digest is its own
|
// existing 'crm_invite' / 'portal_*' family — digest is its own
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { env } from '@/lib/env';
|
|||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
|
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
|
||||||
import { loadSubjectOverride } from '@/lib/email/template-overrides';
|
import { loadSubjectOverride } from '@/lib/email/template-overrides';
|
||||||
|
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||||
import {
|
import {
|
||||||
CodedError,
|
CodedError,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
@@ -118,13 +119,14 @@ async function issueActivationToken(
|
|||||||
|
|
||||||
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
|
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
|
||||||
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
|
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
|
||||||
|
const branding = await getBrandingShell(portId);
|
||||||
const { subject, html, text } = activationEmail(
|
const { subject, html, text } = activationEmail(
|
||||||
{
|
{
|
||||||
portName,
|
portName,
|
||||||
link,
|
link,
|
||||||
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
|
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
|
||||||
},
|
},
|
||||||
{ subject: subjectOverride },
|
{ subject: subjectOverride, branding },
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -378,13 +380,14 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
|||||||
const portName = port?.name ?? 'Port Nimara';
|
const portName = port?.name ?? 'Port Nimara';
|
||||||
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
|
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
|
||||||
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
|
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
|
||||||
|
const branding = await getBrandingShell(user.portId);
|
||||||
const { subject, html, text } = resetEmail(
|
const { subject, html, text } = resetEmail(
|
||||||
{
|
{
|
||||||
portName,
|
portName,
|
||||||
link,
|
link,
|
||||||
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
|
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
|
||||||
},
|
},
|
||||||
{ subject: subjectOverride },
|
{ subject: subjectOverride, branding },
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user