From caaebd77fa181b07af8d7e55dae667e786083846 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 25 Jun 2026 22:07:47 +0200 Subject: [PATCH] fix(intake): client inquiry emails mirror website copy + never show "Port Nimara CRM" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-facing confirmation emails now: - use the PUBLIC port name ("Port Nimara" via ports.name), never the CRM appName ("Port Nimara CRM") which is reserved for internal/staff surfaces - mirror the website's wording verbatim ("Thank you for expressing interest…", "Best regards,") and drop the CRM-style headings - sign off per category: berth → "Port Nimara Sales Team", contact → "Port Nimara Team", residential → "Port Nimara Residences Team" - show + reply-to a public contact address, admin-configurable per category (inquiry_contact_email → sales@ for berth/residence, contact_form_contact_email → hello@ for contact form), never the noreply From Internal alerts keep the CRM detail-line format + link (name fixed to "Port Nimara"), EXCEPT the residential alert which drops all CRM mention (it reaches an external recipient) and signs "- Port Nimara Residences". sendEmail gains an optional per-message replyTo. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X --- .../admin/settings/settings-manager.tsx | 12 ++- src/lib/email/index.ts | 7 +- .../contact-form-client-confirmation.tsx | 38 +++++---- .../templates/inquiry-client-confirmation.tsx | 22 +++-- .../email/templates/residential-inquiry.tsx | 83 +++++++------------ .../services/website-intake-email.service.ts | 57 +++++++++++-- tests/unit/email/client-confirmations.test.ts | 52 ++++++++++++ tests/unit/email/residential-inquiry.test.ts | 31 ++++--- 8 files changed, 197 insertions(+), 105 deletions(-) create mode 100644 tests/unit/email/client-confirmations.test.ts diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index 6291b9b1..6e17abe2 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -141,9 +141,17 @@ const KNOWN_SETTINGS: Array<{ }, { key: 'inquiry_contact_email', - label: 'Inquiry Contact Email', + label: 'Berth & residence reply-to email', description: - 'Reply-to email shown in client confirmation emails when a new interest is registered', + 'Public "reach out to us at …" address shown to clients in berth + residence inquiry confirmation emails. Defaults to sales@portnimara.com when blank.', + type: 'string', + defaultValue: '', + }, + { + key: 'contact_form_contact_email', + label: 'Contact-form reply-to email', + description: + 'Public "reach out to us at …" address shown to clients in contact-form confirmation emails. Defaults to hello@portnimara.com when blank.', type: 'string', defaultValue: '', }, diff --git a/src/lib/email/index.ts b/src/lib/email/index.ts index 4296b198..5c1ea18c 100644 --- a/src/lib/email/index.ts +++ b/src/lib/email/index.ts @@ -128,6 +128,10 @@ export async function sendEmail( // the safety net. cc?: string | string[], bcc?: string | string[], + // Optional per-message Reply-To. Overrides the port's `email_reply_to` + // setting (`cfg.replyTo`) when provided — used so client inquiry + // confirmations reply to the public sales@/hello@ inbox, not the noreply From. + replyTo?: string, ): Promise { const cfg = portId ? await getPortEmailConfig(portId) : null; const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter(); @@ -150,13 +154,14 @@ export async function sendEmail( `Port Nimara CRM `; const resolvedAttachments = await resolveAttachments(attachments, portId); + const effectiveReplyTo = replyTo ?? cfg?.replyTo ?? undefined; const info = await transporter.sendMail({ from: fromHeader, to: effectiveTo, subject: effectiveSubject, html, - ...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}), + ...(effectiveReplyTo ? { replyTo: effectiveReplyTo } : {}), ...(text ? { text } : {}), ...(effectiveCc ? { cc: effectiveCc } : {}), ...(effectiveBcc ? { bcc: effectiveBcc } : {}), diff --git a/src/lib/email/templates/contact-form-client-confirmation.tsx b/src/lib/email/templates/contact-form-client-confirmation.tsx index 8e149a63..1d140313 100644 --- a/src/lib/email/templates/contact-form-client-confirmation.tsx +++ b/src/lib/email/templates/contact-form-client-confirmation.tsx @@ -27,18 +27,13 @@ function ClientConfirmationBody({ }) { return ( <> - - Thank you for getting in touch + Dear {firstName}, + + Thank you for contacting {portName}. We have received your message and a member of our team + will be in touch with you shortly. - - Dear {firstName}, - - - Thank you for reaching out to {portName}. We have received your message and a member of our - team will be in touch with you shortly. - - - If anything else comes to mind in the meantime, please write to us at{' '} + + If you have any questions in the meantime, please feel free to reach out to us at{' '} . - - With warm regards, + + Best regards,
- The {portName} Team + The {portName} Team
); @@ -60,10 +55,10 @@ export async function contactFormClientConfirmation( data: ContactFormClientConfirmationData, overrides?: RenderOpts, ) { - const portName = data.portName ?? 'our team'; + const portName = data.portName ?? 'Port Nimara'; const subject = overrides?.subject?.trim() ? overrides.subject - : `Thank you for contacting ${portName}`; + : `${portName} — Thank You for Contacting Us`; const accent = brandingPrimaryColor(overrides?.branding); const body = await render( , { pretty: false }, ); + const text = [ + `Dear ${data.firstName},`, + '', + `Thank you for contacting ${portName}. We have received your message and a member of our team will be in touch with you shortly.`, + '', + `If you have any questions in the meantime, please feel free to reach out to us at ${data.contactEmail}.`, + '', + 'Best regards,', + `The ${portName} Team`, + ].join('\n'); return { subject, html: renderShell({ title: subject, body, branding: overrides?.branding }), + text, }; } diff --git a/src/lib/email/templates/inquiry-client-confirmation.tsx b/src/lib/email/templates/inquiry-client-confirmation.tsx index 8d668a03..1350f264 100644 --- a/src/lib/email/templates/inquiry-client-confirmation.tsx +++ b/src/lib/email/templates/inquiry-client-confirmation.tsx @@ -32,12 +32,12 @@ function ClientConfirmationBody({ <> Dear {firstName}, - Thank you for your interest in {berthText}. We've noted your enquiry, and a member of - our team will be in touch shortly through your preferred channel with the details - you've requested. + 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. - Should anything come to mind in the meantime, please don't hesitate to write to us at{' '} + If you have any questions, please feel free to reach out to us at{' '} - With warm regards, + Best regards,
The {portName} Sales Team
@@ -61,12 +61,10 @@ export async function inquiryClientConfirmation( ) { const { firstName, mooringNumber, contactEmail } = data; const portName = data.portName ?? 'Port Nimara'; - const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`; + const berthText = mooringNumber ? `Berth ${mooringNumber}` : 'a Berth'; const subject = overrides?.subject?.trim() ? overrides.subject - : mooringNumber - ? `Thank you for your interest in Berth ${mooringNumber}` - : `Thank you for your interest in ${portName}`; + : `${portName} — Thank You for Your Interest`; const accent = brandingPrimaryColor(overrides?.branding); const body = await render( @@ -83,11 +81,11 @@ export async function inquiryClientConfirmation( const text = [ `Dear ${firstName},`, '', - `Thank you for your interest in ${berthText}. We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel with the details you've requested.`, + `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.`, '', - `Should anything come to mind in the meantime, please don't hesitate to write to us at ${contactEmail}.`, + `If you have any questions, please feel free to reach out to us at ${contactEmail}.`, '', - 'With warm regards,', + 'Best regards,', `The ${portName} Sales Team`, ].join('\n'); diff --git a/src/lib/email/templates/residential-inquiry.tsx b/src/lib/email/templates/residential-inquiry.tsx index 553bb3ca..1d5797cc 100644 --- a/src/lib/email/templates/residential-inquiry.tsx +++ b/src/lib/email/templates/residential-inquiry.tsx @@ -16,14 +16,13 @@ export interface ResidentialClientConfirmationData { } /** - * Human-readable list of the residence types a lead selected, e.g. + * Human-readable phrase for the residence types a lead selected, e.g. * "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa". - * Falls back to a generic phrase when nothing was selected so the copy - * always reads naturally. + * Mirrors the website's phrasing, including its generic fallback. */ function residencePhrase(portName: string, types: string[] | undefined): string { const list = (types ?? []).filter(Boolean); - if (list.length === 0) return `the residences at ${portName}`; + if (list.length === 0) return `a ${portName} Residence`; if (list.length === 1) return `the ${list[0]}`; if (list.length === 2) return `the ${list[0]} and the ${list[1]}`; return `the ${list.slice(0, -1).join(', the ')}, and the ${list[list.length - 1]}`; @@ -44,19 +43,14 @@ function ClientConfirmationBody({ }) { return ( <> - - Welcome to {portName} + Dear {firstName}, + + Thank you for expressing interest in {residencePhraseText}. Our team has registered your + interest, and we will reach out to you very shortly by your preferred method of contact with + more information. - - Dear {firstName}, - - - Thank you for your interest in {residencePhraseText}. Our residential sales team has - received your enquiry, and a member of the team will be in touch shortly with the details - you've requested. - - - Should anything come to mind in the meantime, please don't hesitate to write to us at{' '} + + If you have any questions, please feel free to reach out to us at{' '} . - - With warm regards, + + Best regards,
- The {portName} Residential Team + The {portName} Residences Team
); @@ -78,10 +72,10 @@ export async function residentialClientConfirmation( data: ResidentialClientConfirmationData, overrides?: RenderOpts, ) { - const portName = data.portName ?? 'our team'; + const portName = data.portName ?? 'Port Nimara'; const subject = overrides?.subject?.trim() ? overrides.subject - : `Thank you for your interest in ${portName} Residences`; + : `${portName} — Thank You for Your Interest`; const accent = brandingPrimaryColor(overrides?.branding); const residencePhraseText = residencePhrase(portName, data.residenceTypes); const body = await render( @@ -97,12 +91,12 @@ export async function residentialClientConfirmation( const text = [ `Dear ${data.firstName},`, '', - `Thank you for your interest in ${residencePhraseText}. Our residential sales team has received your enquiry, and a member of the team will be in touch shortly with the details you've requested.`, + `Thank you for expressing interest in ${residencePhraseText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`, '', - `Should anything come to mind in the meantime, please don't hesitate to write to us at ${data.contactEmail}.`, + `If you have any questions, please feel free to reach out to us at ${data.contactEmail}.`, '', - 'With warm regards,', - `The ${portName} Residential Team`, + 'Best regards,', + `The ${portName} Residences Team`, ].join('\n'); return { subject, @@ -120,6 +114,11 @@ export interface ResidentialSalesAlertData { preferredContactMethod?: 'email' | 'phone'; notes?: string; preferences?: string; + /** + * Accepted for backwards-compat with the legacy `/api/public/residential-inquiries` + * route, but intentionally NOT rendered: residential alerts go to external + * recipients and must never mention the CRM. + */ crmDeepLink?: string; portName?: string; } @@ -130,15 +129,7 @@ function formatPreferredContact(method: 'email' | 'phone' | undefined): string | return undefined; } -function SalesAlertBody({ - portName, - data, - accent, -}: { - portName: string; - data: ResidentialSalesAlertData; - accent: string; -}) { +function SalesAlertBody({ portName, data }: { portName: string; data: ResidentialSalesAlertData }) { const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const; const residenceTypes = (data.residenceTypes ?? []).filter(Boolean); const preferredContact = formatPreferredContact(data.preferredContactMethod); @@ -185,19 +176,7 @@ function SalesAlertBody({
) : null} - {data.crmDeepLink ? ( - - Open the{' '} - - {portName} CRM - {' '} - to follow up. - - ) : null} - - {portName} CRM + - {portName} Residences ); } @@ -206,12 +185,11 @@ export async function residentialSalesAlert( data: ResidentialSalesAlertData, overrides?: RenderOpts, ) { - const portName = data.portName ?? 'our team'; + const portName = data.portName ?? 'Port Nimara'; const subject = overrides?.subject?.trim() ? overrides.subject : `New residential enquiry - ${data.fullName}`; - const accent = brandingPrimaryColor(overrides?.branding); - const body = await render(, { + const body = await render(, { pretty: false, }); @@ -231,10 +209,7 @@ export async function residentialSalesAlert( ...(data.preferences ? [`Preferences: ${data.preferences}`] : []), ...(data.notes ? [`Comments: ${data.notes}`] : []), '', - ...(data.crmDeepLink - ? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, ''] - : []), - `- ${portName} CRM`, + `- ${portName} Residences`, ].join('\n'); return { diff --git a/src/lib/services/website-intake-email.service.ts b/src/lib/services/website-intake-email.service.ts index 662b8a57..5d15d385 100644 --- a/src/lib/services/website-intake-email.service.ts +++ b/src/lib/services/website-intake-email.service.ts @@ -16,6 +16,7 @@ import { and, eq, isNull, or } from 'drizzle-orm'; import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; import { systemSettings } from '@/lib/db/schema/system'; import { sendEmail } from '@/lib/email'; import { getBrandingShell } from '@/lib/email/branding-resolver'; @@ -28,7 +29,6 @@ import { } from '@/lib/email/templates/residential-inquiry'; import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert'; import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation'; -import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config'; import { resolveNotificationRecipients } from '@/lib/services/notification-recipients'; import { extractInquiryFields } from '@/lib/services/website-intake-fields'; import { createNotification } from '@/lib/services/notifications.service'; @@ -77,13 +77,36 @@ export async function sendWebsiteSubmissionEmails( const { portId, portSlug, kind, payload } = input; const fields = extractInquiryFields(payload); - const [branding, portBrand, emailCfg] = await Promise.all([ + const [branding, portRow] = await Promise.all([ getBrandingShell(portId), - getPortBrandingConfig(portId).catch(() => null), - getPortEmailConfig(portId).catch(() => null), + db.select({ name: ports.name }).from(ports).where(eq(ports.id, portId)).limit(1), ]); - const portName = portBrand?.appName ?? 'Port Nimara'; - const contactEmail = emailCfg?.fromAddress ?? 'sales@portnimara.com'; + // Client-facing copy uses the PUBLIC port name ("Port Nimara"), never the CRM + // appName ("Port Nimara CRM") which is reserved for internal/staff surfaces. + const portName = portRow[0]?.name ?? 'Port Nimara'; + + // Public reply-to shown to clients in confirmation emails ("reach out to us + // at ..."). Admin-configurable per category via system_settings; contact-form + // enquiries default to the hello@ general inbox, berth + residence to sales@. + // Never the noreply From address. + const contactEmailKey = + kind === 'contact_form' ? 'contact_form_contact_email' : 'inquiry_contact_email'; + const contactEmailDefault = + kind === 'contact_form' ? 'hello@portnimara.com' : 'sales@portnimara.com'; + const [contactRow] = await db + .select({ value: systemSettings.value }) + .from(systemSettings) + .where( + and( + eq(systemSettings.key, contactEmailKey), + or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)), + ), + ) + .limit(1); + const contactEmail = + typeof contactRow?.value === 'string' && contactRow.value.trim() + ? contactRow.value.trim() + : contactEmailDefault; // No interest/client row exists for a raw submission, so link to the // dashboard rather than a (nonexistent) entity detail page. const crmUrl = `${process.env.APP_URL ?? ''}/${portSlug}`; @@ -116,6 +139,10 @@ export async function sendWebsiteSubmissionEmails( undefined, confirmation.text, portId, + undefined, + undefined, + undefined, + contactEmail, ); } @@ -172,6 +199,10 @@ export async function sendWebsiteSubmissionEmails( undefined, confirmation.text, portId, + undefined, + undefined, + undefined, + contactEmail, ); } @@ -186,7 +217,6 @@ export async function sendWebsiteSubmissionEmails( preferredContactMethod: fields.preferredContact ?? undefined, placeOfResidence: fields.placeOfResidence ?? undefined, notes: fields.comments ?? undefined, - crmDeepLink: crmUrl, portName, }, { branding }, @@ -217,7 +247,18 @@ export async function sendWebsiteSubmissionEmails( fallback: confirmation.subject, tokens: { portName, recipientName: fields.firstName }, }); - await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId); + await sendEmail( + fields.email, + subject, + confirmation.html, + undefined, + confirmation.text, + portId, + undefined, + undefined, + undefined, + contactEmail, + ); } // Contact-form alerts go to their own recipient list (the website routed diff --git a/tests/unit/email/client-confirmations.test.ts b/tests/unit/email/client-confirmations.test.ts new file mode 100644 index 00000000..d5a64064 --- /dev/null +++ b/tests/unit/email/client-confirmations.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { inquiryClientConfirmation } from '@/lib/email/templates/inquiry-client-confirmation'; +import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation'; + +// Note: assert prose that spans an interpolation on the plain-text part — the +// React-Email HTML renderer inserts `` markers at value boundaries. + +describe('inquiryClientConfirmation (berth)', () => { + it('mirrors the website copy for a specific berth + signs off as Sales', async () => { + const { subject, html, text } = await inquiryClientConfirmation({ + firstName: 'Jane', + mooringNumber: 'D13', + contactEmail: 'sales@portnimara.com', + portName: 'Port Nimara', + }); + expect(subject).toBe('Port Nimara — Thank You for Your Interest'); + expect(text).toContain('Thank you for expressing interest in Berth D13'); + expect(text).toContain('Our team has registered your interest'); + expect(text).toContain('reach out to us at sales@portnimara.com'); + expect(text).toContain('The Port Nimara Sales Team'); + expect(html).toContain('sales@portnimara.com'); + expect(html).not.toContain('Port Nimara CRM'); + }); + + it('uses "a Berth" when no mooring is given', async () => { + const { text, html } = await inquiryClientConfirmation({ + firstName: 'Jane', + mooringNumber: null, + contactEmail: 'sales@portnimara.com', + portName: 'Port Nimara', + }); + expect(text).toContain('Thank you for expressing interest in a Berth'); + expect(html).not.toContain('Port Nimara CRM'); + }); +}); + +describe('contactFormClientConfirmation', () => { + it('mirrors the website copy + signs off as the Port Nimara Team', async () => { + const { subject, html, text } = await contactFormClientConfirmation({ + firstName: 'Bob', + contactEmail: 'hello@portnimara.com', + portName: 'Port Nimara', + }); + expect(subject).toBe('Port Nimara — Thank You for Contacting Us'); + expect(text).toContain('Thank you for contacting Port Nimara'); + expect(text).toContain('We have received your message'); + expect(text).toContain('reach out to us at hello@portnimara.com'); + expect(text).toContain('The Port Nimara Team'); + expect(html).not.toContain('Port Nimara CRM'); + }); +}); diff --git a/tests/unit/email/residential-inquiry.test.ts b/tests/unit/email/residential-inquiry.test.ts index bf169f43..11825005 100644 --- a/tests/unit/email/residential-inquiry.test.ts +++ b/tests/unit/email/residential-inquiry.test.ts @@ -6,30 +6,36 @@ import { } from '@/lib/email/templates/residential-inquiry'; describe('residentialClientConfirmation', () => { - it('reflects the chosen residence types in the thank-you copy', async () => { - const { html, text } = await residentialClientConfirmation({ + it('mirrors the website copy + reflects the chosen residence types', async () => { + const { subject, html, text } = await residentialClientConfirmation({ firstName: 'Mia', contactEmail: 'sales@portnimara.com', residenceTypes: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'], portName: 'Port Nimara', }); - expect(html).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa'); - expect(text).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa'); - expect(html).toContain('Mia'); + expect(subject).toBe('Port Nimara — Thank You for Your Interest'); + expect(text).toContain( + 'Thank you for expressing interest in the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa', + ); + expect(text).toContain('Our team has registered your interest'); + expect(text).toContain('The Port Nimara Residences Team'); + // Never leak the CRM brand name to a client. + expect(html).not.toContain('Port Nimara CRM'); }); - it('falls back to a generic phrase when no types are selected', async () => { + it('falls back to the website generic phrase when no types are selected', async () => { const { html } = await residentialClientConfirmation({ firstName: 'Sam', contactEmail: 'sales@portnimara.com', portName: 'Port Nimara', }); - expect(html).toContain('the residences at Port Nimara'); + expect(html).toContain('a Port Nimara Residence'); + expect(html).not.toContain('Port Nimara CRM'); }); }); describe('residentialSalesAlert', () => { - it('renders residence type(s) + preferred contact + comments in the detail-line format', async () => { + it('renders residence type(s) + preferred contact + comments, with NO CRM mention', async () => { const { html, text } = await residentialSalesAlert({ fullName: 'Mia Ng', email: 'mia@example.com', @@ -37,21 +43,22 @@ describe('residentialSalesAlert', () => { residenceTypes: ['Two Bedroom Marina Villa'], preferredContactMethod: 'phone', notes: 'Looking for a winter completion.', - crmDeepLink: 'https://crm.portnimara.com/port-nimara', portName: 'Port Nimara', }); - // Uniform with the berth/contact alerts: friendly intro + bold detail lines + CRM link. expect(html).toContain('A new residential enquiry has come in'); expect(html).toContain('Residence type(s):'); expect(html).toContain('Two Bedroom Marina Villa'); expect(html).toContain('Preferred contact:'); expect(html).toContain('Phone call back'); expect(html).toContain('Looking for a winter completion.'); - expect(html).toContain('to follow up'); - // Plain-text part mirrors the other alerts. + expect(text).toContain('- Port Nimara Residences'); + // Residential internal alerts must not mention the CRM (recipient is external). + expect(html).not.toContain('CRM'); + expect(html).not.toContain('to follow up'); expect(text).toContain('Residence type(s): Two Bedroom Marina Villa'); expect(text).toContain('Preferred contact: Phone call back'); expect(text).toContain('Comments: Looking for a winter completion.'); + expect(text).not.toContain('CRM'); }); it('omits optional rows cleanly when absent', async () => {