1 Commits

Author SHA1 Message Date
caaebd77fa fix(intake): client inquiry emails mirror website copy + never show "Port Nimara CRM"
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m49s
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-25 22:07:47 +02:00
8 changed files with 197 additions and 105 deletions

View File

@@ -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: '',
},

View File

@@ -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<nodemailer.SentMessageInfo> {
const cfg = portId ? await getPortEmailConfig(portId) : null;
const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
@@ -150,13 +154,14 @@ export async function sendEmail(
`Port Nimara CRM <noreply@${env.SMTP_HOST}>`;
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 } : {}),

View File

@@ -27,18 +27,13 @@ function ClientConfirmationBody({
}) {
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Thank you for getting in touch
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Thank you for contacting {portName}. We have received your message and a member of our team
will be in touch with you shortly.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
Dear {firstName},
</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
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.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
If anything else comes to mind in the meantime, please write to us at{' '}
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
If you have any questions in the meantime, please feel free to reach out to us at{' '}
<Link
href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }}
@@ -47,10 +42,10 @@ function ClientConfirmationBody({
</Link>
.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<Text style={{ fontSize: '16px' }}>
Best regards,
<br />
<strong>The {portName} Team</strong>
The {portName} Team
</Text>
</>
);
@@ -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(
<ClientConfirmationBody
@@ -74,8 +69,19 @@ export async function contactFormClientConfirmation(
/>,
{ 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,
};
}

View File

@@ -32,12 +32,12 @@ function ClientConfirmationBody({
<>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Thank you for your interest in {berthText}. We&apos;ve noted your enquiry, and a member of
our team will be in touch shortly through your preferred channel with the details
you&apos;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.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Should anything come to mind in the meantime, please don&apos;t hesitate to write to us at{' '}
If you have any questions, please feel free to reach out to us at{' '}
<Link
href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }}
@@ -47,7 +47,7 @@ function ClientConfirmationBody({
.
</Text>
<Text style={{ fontSize: '16px' }}>
With warm regards,
Best regards,
<br />
The {portName} Sales Team
</Text>
@@ -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');

View File

@@ -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 (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Welcome to {portName}
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
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.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
Dear {firstName},
</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
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&apos;ve requested.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
Should anything come to mind in the meantime, please don&apos;t hesitate to write to us at{' '}
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
If you have any questions, please feel free to reach out to us at{' '}
<Link
href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }}
@@ -65,10 +59,10 @@ function ClientConfirmationBody({
</Link>
.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<Text style={{ fontSize: '16px' }}>
Best regards,
<br />
<strong>The {portName} Residential Team</strong>
The {portName} Residences Team
</Text>
</>
);
@@ -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({
</Text>
) : null}
</div>
{data.crmDeepLink ? (
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Open the{' '}
<Link
href={safeUrl(data.crmDeepLink)}
style={{ color: accent, textDecoration: 'underline' }}
>
{portName} CRM
</Link>{' '}
to follow up.
</Text>
) : null}
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
<Text style={{ fontSize: '16px' }}>- {portName} Residences</Text>
</>
);
}
@@ -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(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
const body = await render(<SalesAlertBody portName={portName} data={data} />, {
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 {

View File

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

View File

@@ -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');
});
});

View File

@@ -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 () => {