Files
pn-new-crm/src/lib/email/index.ts
Matt caaebd77fa
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m49s
fix(intake): client inquiry emails mirror website copy + never show "Port Nimara CRM"
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

189 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import nodemailer, { type Transporter } from 'nodemailer';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { getPortEmailConfig, type PortEmailConfig } from '@/lib/services/port-config';
/**
* Creates and returns a new Nodemailer SMTP transporter using env defaults.
* For port-scoped configuration use {@link createPortTransporter} instead.
*
* A new instance is created on each call so the factory can be used in
* contexts where connection pooling is managed externally (e.g. per-request
* in serverless, or once at worker startup).
*/
// Nodemailer's default `connectionTimeout` is 2 minutes and there is no
// `socketTimeout`, so a hung SMTP server would hold a BullMQ `email`
// worker concurrency slot for up to 2 min × 5 retry attempts = 10 min
// per job. With concurrency 5, all slots can be starved by a single
// flaky upstream. Explicit timeouts cap the worst case under a minute.
export const SMTP_TIMEOUTS = {
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 30_000,
} as const;
export function createTransporter(): Transporter {
return nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
// Implicitly secure when port is 465; STARTTLS for all other ports.
secure: env.SMTP_PORT === 465,
...SMTP_TIMEOUTS,
...(env.SMTP_USER && env.SMTP_PASS
? { auth: { user: env.SMTP_USER, pass: env.SMTP_PASS } }
: {}),
});
}
function createTransporterFromConfig(cfg: PortEmailConfig): Transporter {
return nodemailer.createTransport({
host: cfg.smtpHost,
port: cfg.smtpPort,
secure: cfg.smtpPort === 465,
...SMTP_TIMEOUTS,
...(cfg.smtpUser && cfg.smtpPass ? { auth: { user: cfg.smtpUser, pass: cfg.smtpPass } } : {}),
});
}
export interface EmailAttachmentRef {
fileId: string;
filename?: string;
}
export interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
from?: string;
/** When provided, port-level email settings override env defaults. */
portId?: string;
text?: string;
/**
* File attachments to fetch from MinIO and attach to the message.
* Resolution + cross-port enforcement happens via `resolveAttachments`
* before the SMTP call.
*/
attachments?: EmailAttachmentRef[];
}
/**
* Resolve attachment refs to nodemailer attachment payloads. Reads each file
* from MinIO and enforces port-isolation: an attachment that doesn't belong
* to `portId` throws ForbiddenError. Returns an empty array when no refs
* are provided.
*/
async function resolveAttachments(
refs: EmailAttachmentRef[] | undefined,
portId: string | undefined,
): Promise<Array<{ filename: string; content: Buffer; contentType?: string }>> {
if (!refs || refs.length === 0) return [];
const { db } = await import('@/lib/db');
const { files } = await import('@/lib/db/schema/documents');
const { eq } = await import('drizzle-orm');
const { ForbiddenError, NotFoundError } = await import('@/lib/errors');
// Pluggable storage backend (s3 OR filesystem). Direct MinIO imports
// break the filesystem-mode deployment path documented in CLAUDE.md.
const { getStorageBackend } = await import('@/lib/storage');
const backend = await getStorageBackend();
return Promise.all(
refs.map(async (ref) => {
const file = await db.query.files.findFirst({ where: eq(files.id, ref.fileId) });
if (!file) throw new NotFoundError('File');
if (portId && file.portId !== portId) {
throw new ForbiddenError('File belongs to a different port');
}
const stream = await backend.get(file.storagePath);
const chunks: Buffer[] = [];
for await (const chunk of stream as AsyncIterable<Buffer | string>) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return {
filename: ref.filename ?? file.originalName,
content: Buffer.concat(chunks),
...(file.mimeType ? { contentType: file.mimeType } : {}),
};
}),
);
}
/**
* Sends a single email via SMTP.
*
* Returns the nodemailer info object on success. Propagates errors to the
* caller - callers in background jobs should wrap in try/catch and handle
* retries via BullMQ.
*/
export async function sendEmail(
to: string | string[],
subject: string,
html: string,
from?: string,
text?: string,
portId?: string,
attachments?: EmailAttachmentRef[],
// M-EM02: optional CC / BCC. Mirror the same EMAIL_REDIRECT_TO scrub
// as `to` so dev-mode redirects don't accidentally leak a CC outside
// 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();
const requestedTo = Array.isArray(to) ? to.join(', ') : to;
const effectiveTo = env.EMAIL_REDIRECT_TO ?? requestedTo;
const effectiveSubject = env.EMAIL_REDIRECT_TO
? `[redirected from ${requestedTo}] ${subject}`
: subject;
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO - the redirect target
// already gets the message; CCing additional recipients would defeat
// the dev safety net.
const effectiveCc = env.EMAIL_REDIRECT_TO ? undefined : cc;
const effectiveBcc = env.EMAIL_REDIRECT_TO ? undefined : bcc;
const fromHeader =
from ??
(cfg ? `${cfg.fromName} <${cfg.fromAddress}>` : undefined) ??
env.SMTP_FROM ??
`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,
...(effectiveReplyTo ? { replyTo: effectiveReplyTo } : {}),
...(text ? { text } : {}),
...(effectiveCc ? { cc: effectiveCc } : {}),
...(effectiveBcc ? { bcc: effectiveBcc } : {}),
...(resolvedAttachments.length > 0 ? { attachments: resolvedAttachments } : {}),
});
// When EMAIL_REDIRECT_TO is set we elevate to `warn` so the dev-only
// safety net is visible in any logger config. Prod boot already refuses
// when both are set (see env.ts superRefine) - this catches the dev /
// staging window where someone left it in a .env by mistake.
if (env.EMAIL_REDIRECT_TO) {
logger.warn(
{ messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId },
'Email sent (REDIRECTED via EMAIL_REDIRECT_TO - recipient overridden)',
);
} else {
logger.debug(
{ messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId },
'Email sent',
);
}
return info;
}