224 lines
6.9 KiB
TypeScript
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'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'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'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,
|
||
|
|
};
|
||
|
|
}
|