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

@@ -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,