/** * Per-port sales-email configuration (Phase 7 — see plan §4.9). * * Distinct from {@link getPortEmailConfig} (`port-config.ts`) which resolves * the **noreply** account used by automated/system emails. The sales account * is the human-touch outbound: brochure/berth-pdf send-outs from * `document-sends.service.ts`, follow-up emails composed by reps. * * Both inboxes (SMTP + IMAP) live behind the same provider account in 99% of * deployments; both are configured here. The IMAP half is consumed by the * async-bounce monitor (§14.9), out of scope for this service but exposed * via `getSalesImapConfig()`. * * SECURITY (§14.10): SMTP/IMAP passwords are encrypted at rest using the * existing `EMAIL_CREDENTIAL_KEY` symmetric key. Reps cannot read the * decrypted value via the API — only `manage_settings` admins can write, * and even they only ever see a placeholder mask on read (see the admin * route handler). */ import nodemailer, { type Transporter } from 'nodemailer'; import { env } from '@/lib/env'; import { ConflictError } from '@/lib/errors'; import { decrypt, encrypt } from '@/lib/utils/encryption'; import { getSetting, upsertSetting } from '@/lib/services/settings.service'; import type { AuditMeta } from '@/lib/audit'; // ─── Setting keys ──────────────────────────────────────────────────────────── export const SALES_EMAIL_KEYS = { fromAddress: 'sales_from_address', smtpHost: 'sales_smtp_host', smtpPort: 'sales_smtp_port', smtpSecure: 'sales_smtp_secure', smtpUser: 'sales_smtp_user', smtpPassEncrypted: 'sales_smtp_pass_encrypted', imapHost: 'sales_imap_host', imapPort: 'sales_imap_port', imapUser: 'sales_imap_user', imapPassEncrypted: 'sales_imap_pass_encrypted', authMethod: 'sales_auth_method', noreplyFromAddress: 'noreply_from_address', templateBerthPdfBody: 'email_template_send_berth_pdf_body', templateBrochureBody: 'email_template_send_brochure_body', brochureMaxUploadMb: 'brochure_max_upload_mb', emailAttachThresholdMb: 'email_attach_threshold_mb', } as const; // ─── Types ─────────────────────────────────────────────────────────────────── export interface SalesEmailConfig { fromAddress: string; smtpHost: string | null; smtpPort: number; smtpSecure: boolean; smtpUser: string | null; /** Decrypted plaintext, available only inside server-side service code. */ smtpPass: string | null; authMethod: string; /** Whether the config is complete enough to actually send. */ isUsable: boolean; } export interface SalesImapConfig { imapHost: string | null; imapPort: number; imapUser: string | null; imapPass: string | null; isUsable: boolean; } export interface SalesContentConfig { noreplyFromAddress: string; templateBerthPdfBody: string; templateBrochureBody: string; brochureMaxUploadMb: number; emailAttachThresholdMb: number; } // ─── Defaults ──────────────────────────────────────────────────────────────── const DEFAULT_BERTH_PDF_BODY = [ 'Hi {{client.fullName}},', '', 'Please find attached the spec sheet for berth {{berth.mooringNumber}} at {{port.name}}.', '', 'Happy to set up a call to walk through the details — let me know what works.', '', 'Best,', ].join('\n'); const DEFAULT_BROCHURE_BODY = [ 'Hi {{client.fullName}},', '', 'As discussed, attached is our {{port.name}} brochure with the latest information on availability, amenities, and access.', '', 'Let me know if any specific berths catch your eye and I can pull together more detail.', '', 'Best,', ].join('\n'); // ─── Read accessors ────────────────────────────────────────────────────────── async function readSetting(key: string, portId: string): Promise { const setting = await getSetting(key, portId); if (!setting) return null; return setting.value as T; } function decryptOrNull(value: string | null): string | null { if (!value) return null; try { return decrypt(value); } catch { // If decryption fails (key rotation, corruption, etc.), return null so // the send fails fast rather than mis-authenticating against SMTP. return null; } } export async function getSalesEmailConfig(portId: string): Promise { const [fromAddress, smtpHost, smtpPort, smtpSecure, smtpUser, smtpPassEnc, authMethod] = await Promise.all([ readSetting(SALES_EMAIL_KEYS.fromAddress, portId), readSetting(SALES_EMAIL_KEYS.smtpHost, portId), readSetting(SALES_EMAIL_KEYS.smtpPort, portId), readSetting(SALES_EMAIL_KEYS.smtpSecure, portId), readSetting(SALES_EMAIL_KEYS.smtpUser, portId), readSetting(SALES_EMAIL_KEYS.smtpPassEncrypted, portId), readSetting(SALES_EMAIL_KEYS.authMethod, portId), ]); const smtpPass = decryptOrNull(smtpPassEnc); // For `from`, fall back to the SMTP_FROM env so a brand-new port without // overrides still has a usable identity (for tests + dev). const resolvedFrom = fromAddress ?? env.SMTP_FROM?.replace(/^.*<(.+)>$/, '$1').trim() ?? `sales@${env.SMTP_HOST}`; return { fromAddress: resolvedFrom, smtpHost: smtpHost ?? env.SMTP_HOST ?? null, smtpPort: smtpPort ?? env.SMTP_PORT ?? 587, smtpSecure: smtpSecure ?? false, smtpUser: smtpUser ?? env.SMTP_USER ?? null, smtpPass: smtpPass ?? env.SMTP_PASS ?? null, authMethod: authMethod ?? 'app_password', // "Usable" means we have host + (user, pass) pair OR host + no auth // (some test/local SMTP doesn't auth). For prod-realistic, we require // creds — empty creds means we'll just bounce against the relay. isUsable: Boolean( (smtpHost ?? env.SMTP_HOST) && ((smtpUser && smtpPass) ?? (env.SMTP_USER && env.SMTP_PASS)), ), }; } export async function getSalesImapConfig(portId: string): Promise { const [imapHost, imapPort, imapUser, imapPassEnc] = await Promise.all([ readSetting(SALES_EMAIL_KEYS.imapHost, portId), readSetting(SALES_EMAIL_KEYS.imapPort, portId), readSetting(SALES_EMAIL_KEYS.imapUser, portId), readSetting(SALES_EMAIL_KEYS.imapPassEncrypted, portId), ]); const imapPass = decryptOrNull(imapPassEnc); return { imapHost: imapHost ?? null, imapPort: imapPort ?? 993, imapUser: imapUser ?? null, imapPass, isUsable: Boolean(imapHost && imapUser && imapPass), }; } export async function getSalesContentConfig(portId: string): Promise { const [noreply, berthPdfBody, brochureBody, maxUpload, attachThreshold] = await Promise.all([ readSetting(SALES_EMAIL_KEYS.noreplyFromAddress, portId), readSetting(SALES_EMAIL_KEYS.templateBerthPdfBody, portId), readSetting(SALES_EMAIL_KEYS.templateBrochureBody, portId), readSetting(SALES_EMAIL_KEYS.brochureMaxUploadMb, portId), readSetting(SALES_EMAIL_KEYS.emailAttachThresholdMb, portId), ]); return { noreplyFromAddress: noreply ?? env.SMTP_FROM?.replace(/^.*<(.+)>$/, '$1').trim() ?? `noreply@${env.SMTP_HOST}`, templateBerthPdfBody: berthPdfBody ?? DEFAULT_BERTH_PDF_BODY, templateBrochureBody: brochureBody ?? DEFAULT_BROCHURE_BODY, brochureMaxUploadMb: maxUpload ?? 50, emailAttachThresholdMb: attachThreshold ?? 15, }; } // ─── Write accessors ───────────────────────────────────────────────────────── /** * Plain (unencrypted) input shape for the admin write path. Password fields * are accepted as plaintext and encrypted before storage. * * `null` semantics: * - undefined => leave unchanged (don't touch the row). * - null => clear the value. * - string => set to this value. * * For password fields specifically, the empty string `""` is treated as * "leave unchanged" so the admin form's masked placeholder can round-trip * without forcing the rep to re-enter the password every save. */ export interface SalesEmailConfigUpdate { fromAddress?: string | null; smtpHost?: string | null; smtpPort?: number | null; smtpSecure?: boolean | null; smtpUser?: string | null; /** Plaintext; encrypted before storage. Pass `""` to leave unchanged. */ smtpPass?: string | null; imapHost?: string | null; imapPort?: number | null; imapUser?: string | null; /** Plaintext; encrypted before storage. Pass `""` to leave unchanged. */ imapPass?: string | null; authMethod?: string | null; noreplyFromAddress?: string | null; templateBerthPdfBody?: string | null; templateBrochureBody?: string | null; brochureMaxUploadMb?: number | null; emailAttachThresholdMb?: number | null; } async function writeSetting( key: string, value: T | null | undefined, portId: string, meta: AuditMeta, ): Promise { if (value === undefined) return; await upsertSetting(key, value, portId, meta); } export async function updateSalesEmailConfig( portId: string, update: SalesEmailConfigUpdate, meta: AuditMeta, ): Promise { await writeSetting(SALES_EMAIL_KEYS.fromAddress, update.fromAddress, portId, meta); await writeSetting(SALES_EMAIL_KEYS.smtpHost, update.smtpHost, portId, meta); await writeSetting(SALES_EMAIL_KEYS.smtpPort, update.smtpPort, portId, meta); await writeSetting(SALES_EMAIL_KEYS.smtpSecure, update.smtpSecure, portId, meta); await writeSetting(SALES_EMAIL_KEYS.smtpUser, update.smtpUser, portId, meta); await writeSetting(SALES_EMAIL_KEYS.imapHost, update.imapHost, portId, meta); await writeSetting(SALES_EMAIL_KEYS.imapPort, update.imapPort, portId, meta); await writeSetting(SALES_EMAIL_KEYS.imapUser, update.imapUser, portId, meta); await writeSetting(SALES_EMAIL_KEYS.authMethod, update.authMethod, portId, meta); await writeSetting(SALES_EMAIL_KEYS.noreplyFromAddress, update.noreplyFromAddress, portId, meta); await writeSetting( SALES_EMAIL_KEYS.templateBerthPdfBody, update.templateBerthPdfBody, portId, meta, ); await writeSetting( SALES_EMAIL_KEYS.templateBrochureBody, update.templateBrochureBody, portId, meta, ); await writeSetting( SALES_EMAIL_KEYS.brochureMaxUploadMb, update.brochureMaxUploadMb, portId, meta, ); await writeSetting( SALES_EMAIL_KEYS.emailAttachThresholdMb, update.emailAttachThresholdMb, portId, meta, ); // Password fields: encrypt before write. Empty string = "no change". if (update.smtpPass !== undefined && update.smtpPass !== '') { if (update.smtpPass === null) { await upsertSetting(SALES_EMAIL_KEYS.smtpPassEncrypted, null, portId, meta); } else { await upsertSetting( SALES_EMAIL_KEYS.smtpPassEncrypted, encrypt(update.smtpPass), portId, meta, ); } } if (update.imapPass !== undefined && update.imapPass !== '') { if (update.imapPass === null) { await upsertSetting(SALES_EMAIL_KEYS.imapPassEncrypted, null, portId, meta); } else { await upsertSetting( SALES_EMAIL_KEYS.imapPassEncrypted, encrypt(update.imapPass), portId, meta, ); } } } // ─── Transporter factory ───────────────────────────────────────────────────── export type SenderAccount = 'noreply' | 'sales'; /** * Build a nodemailer transporter for the requested sender account. * * - `'sales'` uses the per-port SALES_* keys (see {@link getSalesEmailConfig}). * - `'noreply'` falls back to the legacy `port-config.ts` resolver (see * `getPortEmailConfig`) which itself drops back to the env defaults. * * Throws when the requested account isn't configured well enough to send; * callers should let this propagate so the document_sends row gets a * `failedAt` + `errorReason`. */ export async function createSalesTransporter(portId: string): Promise<{ transporter: Transporter; fromAddress: string; authedUser: string | null; }> { const cfg = await getSalesEmailConfig(portId); if (!cfg.smtpHost) { throw new ConflictError( 'Sales SMTP not configured for this port. Configure in /admin/email before sending.', ); } const transporter = nodemailer.createTransport({ host: cfg.smtpHost, port: cfg.smtpPort, secure: cfg.smtpSecure, ...(cfg.smtpUser && cfg.smtpPass ? { auth: { user: cfg.smtpUser, pass: cfg.smtpPass } } : {}), }); return { transporter, fromAddress: cfg.fromAddress, authedUser: cfg.smtpUser }; } /** * Public-facing sanitizer — strips encrypted fields and replaces password * fields with a boolean `isSet` marker. Used by the admin GET endpoint so * reps with `manage_settings` can see whether creds are configured without * the API ever returning the ciphertext (much less plaintext). */ export function redactSalesConfigForResponse( cfg: SalesEmailConfig, imap: SalesImapConfig, content: SalesContentConfig, ): { email: Omit & { smtpPassIsSet: boolean }; imap: Omit & { imapPassIsSet: boolean }; content: SalesContentConfig; } { // Spread without the password fields — never reflect the decrypted value // (or its ciphertext) back to the API surface. const email = { fromAddress: cfg.fromAddress, smtpHost: cfg.smtpHost, smtpPort: cfg.smtpPort, smtpSecure: cfg.smtpSecure, smtpUser: cfg.smtpUser, authMethod: cfg.authMethod, isUsable: cfg.isUsable, smtpPassIsSet: Boolean(cfg.smtpPass), }; const imapRedacted = { imapHost: imap.imapHost, imapPort: imap.imapPort, imapUser: imap.imapUser, isUsable: imap.isUsable, imapPassIsSet: Boolean(imap.imapPass), }; return { email, imap: imapRedacted, content }; }