Files
pn-new-crm/src/lib/email/templates/portal-auth.tsx
Matt ff0667ce52 feat(deps): adopt react-email for portal-auth template
Migrates the activation + reset email templates from hand-strung HTML
strings to React components rendered via @react-email/components.

Concrete wins this lands:
- React auto-escapes interpolation — drops the hand-rolled escapeHtml()
  helper. Eliminates the entire class of "I forgot to escape" XSS bugs.
- @react-email primitives (Button, Hr, Link, Text) render to
  Outlook/Gmail/AppleMail-safe inline-styled HTML.
- JSX over template strings makes the templates editable / reviewable.
- Sets the pattern for the remaining 7 templates (crm-invite,
  document-signing, inquiry-*, notification-digest, admin-email-change,
  residential-inquiry). Migrate opportunistically when those files are
  next touched.

The shell (logo, blurred background, table-based wrapper) stays via
renderShell so this is a strictly inner-body migration — visual parity
preserved.

Vitest config: added @vitejs/plugin-react so .tsx files imported by
tests (transitively via the service that uses the template) transform
correctly under Next's tsconfig `jsx: 'preserve'` setting.

Verified: tsc clean, vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:43:14 +02:00

224 lines
6.9 KiB
TypeScript

import { render } from '@react-email/components';
import { Button, Hr, Link, Text } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface ActivationData {
portName: string;
link: string;
ttlHours: number;
recipientName?: string;
}
interface ResetData {
portName: string;
link: string;
ttlMinutes: number;
recipientName?: string;
}
interface RenderOpts {
subject?: string | null;
branding?: BrandingShell | null;
}
// ─── React-email body components ──────────────────────────────────────────────
// react-email's `render()` auto-escapes string interpolation, so we don't
// need our hand-rolled escapeHtml() on these bodies. Inline styles use
// camelCase per CSSProperties — react-email serialises them to
// email-client-safe inline `style="..."` attributes on output.
function ActivationBody({
portName,
link,
ttlHours,
recipientName,
accent,
}: ActivationData & { accent: string }) {
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome,';
return (
<>
<Text
style={{
marginBottom: '10px',
fontSize: '18px',
fontWeight: 'bold',
color: accent,
}}
>
Welcome to {portName}
</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 access the {portName} client portal. 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',
}}
>
Activate 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>
</>
);
}
function ResetBody({
portName,
link,
ttlMinutes,
recipientName,
accent,
}: ResetData & { accent: string }) {
const greeting = recipientName ? `Dear ${recipientName},` : 'Hello,';
return (
<>
<Text
style={{
marginBottom: '10px',
fontSize: '18px',
fontWeight: 'bold',
color: accent,
}}
>
Password reset
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
We received a request to reset the password on your {portName} client portal account. Click
the button below to choose a new one. The link expires in {ttlMinutes} minutes.
</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',
}}
>
Reset password
</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 you didn&apos;t request this, you can safely ignore this email your password will
remain unchanged.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
Thank you,
<br />
<strong>{portName} CRM</strong>
</Text>
</>
);
}
// ─── Public surface ───────────────────────────────────────────────────────────
export async function activationEmail(
data: ActivationData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{portName\}\}/g, data.portName)
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
: `Activate your ${data.portName} client portal account`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<ActivationBody {...data} accent={accent} />, {
pretty: false,
});
const text = [
`Welcome to ${data.portName}`,
'',
`You've been invited to access the ${data.portName} client portal.`,
`Activate your account by visiting: ${data.link}`,
'',
`The link expires in ${data.ttlHours} hours.`,
'',
`Thank you,`,
`${data.portName} CRM`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
export async function resetEmail(
data: ResetData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{portName\}\}/g, data.portName)
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
: `Reset your ${data.portName} client portal password`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<ResetBody {...data} accent={accent} />, {
pretty: false,
});
const text = [
`Password reset for ${data.portName}`,
'',
`Reset your password by visiting: ${data.link}`,
`The link expires in ${data.ttlMinutes} minutes.`,
'',
`If you didn't request this, you can safely ignore this email.`,
'',
`Thank you,`,
`${data.portName} CRM`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}