/** * Sales send-out flow (Phase 7 — see plan §4.8 / §11.1 / §14.7). * * Sends per-berth PDFs and brochures to a client recipient, attaching the * file when it's at-or-below the configured threshold or falling back to a * 24h signed-URL link when it's larger. Every send writes one row to * `document_sends` (success OR failure) so the rep can see the outcome in * the timeline. * * §14.7 critical mitigations implemented here: * * - **Body XSS** — bodies go through `renderEmailBody()` (HTML-escape + * allowlist of markdown rules) before reaching nodemailer. * - **Recipient typo** — recipient email validated against a strict regex * before the SMTP transaction. * - **Unresolved merge fields** — `findUnresolvedTokens()` is exported * for the dry-run UI; the service blocks sends with unresolved tokens * unless `allowUnresolved: true` is explicitly passed (test-only). * - **SMTP failure** — every transport rejection writes a `failedAt` row * with `errorReason` and surfaces a typed error to the API. * - **Hourly rate limit** — 50 sends/user/hour individual. * - **Size threshold fallback** — files larger than the per-port * `email_attach_threshold_mb` go as a signed-URL link in the body * instead of an attachment (§11.1). */ import { Readable } from 'node:stream'; import { and, desc, eq } from 'drizzle-orm'; import type { SentMessageInfo } from 'nodemailer'; import { db } from '@/lib/db'; import { brochures, brochureVersions, documentSends, berths, berthPdfVersions, clients, clientContacts, customFieldDefinitions, customFieldValues, interests, ports, } from '@/lib/db/schema'; import { inArray } from 'drizzle-orm'; import type { DocumentSend } from '@/lib/db/schema'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { checkRateLimit } from '@/lib/rate-limit'; import { getStorageBackend } from '@/lib/storage'; import { EMAIL_BODY_MAX_BYTES, expandMergeTokens, findUnresolvedTokens, renderEmailBody, } from '@/lib/utils/markdown-email'; import { getDefaultBrochure } from '@/lib/services/brochures.service'; import { createSalesTransporter, getSalesContentConfig, } from '@/lib/services/sales-email-config.service'; // ─── Public types ──────────────────────────────────────────────────────────── export interface SendRecipientInput { /** Existing client ID (resolves the primary email automatically). */ clientId?: string; /** Optional explicit address override (for cases where a client has multiple). */ email?: string; /** Optional interest pin so the audit row links into the interest timeline. */ interestId?: string; } export interface SendBerthPdfInput { portId: string; berthId: string; recipient: SendRecipientInput; /** When provided, replaces the per-port template. Still passes through * merge expansion + sanitization. */ customBodyMarkdown?: string; sentBy: string; ipAddress: string; userAgent: string; /** Test-only: skip the unresolved-merge-field block. */ allowUnresolved?: boolean; } export interface SendBrochureInput { portId: string; /** Defaults to the port's default brochure when omitted. */ brochureId?: string; recipient: SendRecipientInput; customBodyMarkdown?: string; sentBy: string; ipAddress: string; userAgent: string; allowUnresolved?: boolean; } export interface SendResult { send: DocumentSend; /** True when the file was attached; false when a signed-URL link was used. */ deliveredAsAttachment: boolean; /** Set when the transport rejected — the row carries `failedAt`. */ error?: string; } // ─── Public dry-run / preview helpers (used by the modal) ──────────────────── /** * Compute the merge-value bag for a given send context. The same map is used * by the dry-run preview AND the actual send so the rep sees exactly what * gets posted. */ export async function buildMergeValues( portId: string, recipient: SendRecipientInput, context: { berthId?: string; brochureLabel?: string } = {}, ): Promise> { const values: Record = {}; values['{{date.today}}'] = new Date().toISOString().slice(0, 10); values['{{date.year}}'] = String(new Date().getFullYear()); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); if (port) { values['{{port.name}}'] = port.name; if (port.defaultCurrency) values['{{port.defaultCurrency}}'] = port.defaultCurrency; } if (recipient.clientId) { const client = await db.query.clients.findFirst({ where: and(eq(clients.id, recipient.clientId), eq(clients.portId, portId)), }); if (client) { if (client.fullName) values['{{client.fullName}}'] = client.fullName; if (client.nationalityIso) values['{{client.nationality}}'] = client.nationalityIso; if (client.source) values['{{client.source}}'] = client.source; const contacts = await db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, client.id), }); const primaryEmail = contacts.find((c) => c.channel === 'email' && c.isPrimary)?.value ?? contacts.find((c) => c.channel === 'email')?.value; const primaryPhone = contacts.find((c) => c.channel === 'phone' && c.isPrimary)?.value ?? contacts.find((c) => c.channel === 'phone')?.value; if (primaryEmail) values['{{client.email}}'] = primaryEmail; if (primaryPhone) values['{{client.phone}}'] = primaryPhone; } } if (context.berthId) { const berth = await db.query.berths.findFirst({ where: and(eq(berths.id, context.berthId), eq(berths.portId, portId)), }); if (berth) { values['{{berth.mooringNumber}}'] = berth.mooringNumber; if (berth.area) values['{{berth.area}}'] = berth.area; if (berth.status) values['{{berth.status}}'] = berth.status; if (berth.lengthFt) values['{{berth.lengthFt}}'] = String(berth.lengthFt); if (berth.widthFt) values['{{berth.widthFt}}'] = String(berth.widthFt); if (berth.price) values['{{berth.price}}'] = String(berth.price); if (berth.priceCurrency) values['{{berth.priceCurrency}}'] = berth.priceCurrency; } } // Custom-field tokens (`{{custom.}}`). The validator allows // any matching shape; the resolver here looks up real values per-port, // per-entity and substitutes them. Unknown field names stay // unresolved — `findUnresolvedTokens` flags them at preview time so // the rep can edit the template before sending. await mergeCustomFieldValues(values, portId, recipient, context); return values; } interface CustomMergeContext { berthId?: string; brochureLabel?: string; } /** * Resolve `{{custom.}}` tokens. Reads every per-port custom * field definition for the entity types currently in scope (client, * interest, berth) and joins to the actual stored value for each entity * id we have on hand. Boolean values render as 'true' / 'false', dates * as ISO yyyy-mm-dd, numbers as plain numerics, selects/text verbatim. */ async function mergeCustomFieldValues( values: Record, portId: string, recipient: SendRecipientInput, context: CustomMergeContext, ): Promise { // Build the (entityType → entityId) map for the current send context. const entityIdsByType = new Map(); if (recipient.clientId) entityIdsByType.set('client', recipient.clientId); if (recipient.interestId) entityIdsByType.set('interest', recipient.interestId); if (context.berthId) entityIdsByType.set('berth', context.berthId); if (entityIdsByType.size === 0) return; const definitions = await db .select() .from(customFieldDefinitions) .where( and( eq(customFieldDefinitions.portId, portId), inArray(customFieldDefinitions.entityType, Array.from(entityIdsByType.keys())), ), ); if (definitions.length === 0) return; const fieldIds = definitions.map((d) => d.id); const entityIds = Array.from(entityIdsByType.values()); const valueRows = await db .select() .from(customFieldValues) .where( and( inArray(customFieldValues.fieldId, fieldIds), inArray(customFieldValues.entityId, entityIds), ), ); const valueByFieldEntity = new Map(); for (const row of valueRows) { valueByFieldEntity.set(`${row.fieldId}|${row.entityId}`, row.value); } for (const def of definitions) { const entityId = entityIdsByType.get(def.entityType); if (!entityId) continue; const raw = valueByFieldEntity.get(`${def.id}|${entityId}`); if (raw === undefined || raw === null) continue; const token = `{{custom.${def.fieldName}}}`; values[token] = stringifyCustomValue(raw, def.fieldType); } } function stringifyCustomValue(raw: unknown, fieldType: string): string { if (raw === null || raw === undefined) return ''; switch (fieldType) { case 'boolean': return raw ? 'true' : 'false'; case 'date': return typeof raw === 'string' ? raw.slice(0, 10) : String(raw); case 'number': return String(raw); default: return typeof raw === 'string' ? raw : JSON.stringify(raw); } } /** * Render a body for the dry-run UI. Returns `{ html, unresolved }`. The UI * uses `unresolved` to populate the warning chip; the rep can't submit * until the list is empty. */ export async function previewBody( portId: string, documentKind: 'berth_pdf' | 'brochure', recipient: SendRecipientInput, customBody: string | null, ctx: { berthId?: string; brochureLabel?: string } = {}, ): Promise<{ html: string; markdown: string; unresolved: string[] }> { const content = await getSalesContentConfig(portId); const template = customBody?.trim()?.length ? customBody : documentKind === 'berth_pdf' ? content.templateBerthPdfBody : content.templateBrochureBody; const values = await buildMergeValues(portId, recipient, ctx); const expanded = expandMergeTokens(template, values); const unresolved = findUnresolvedTokens(template, values); const html = renderEmailBody(expanded); return { html, markdown: expanded, unresolved }; } // ─── Internal helpers ──────────────────────────────────────────────────────── const RFC5322_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function assertEmailValid(email: string): void { if (!email || email.length > 254 || !RFC5322_EMAIL.test(email)) { throw new ValidationError(`Invalid recipient email: ${email}`); } } async function resolveRecipientEmail( portId: string, recipient: SendRecipientInput, ): Promise { if (recipient.email) { assertEmailValid(recipient.email); return recipient.email; } if (!recipient.clientId) { throw new ValidationError('Recipient must include either clientId or email'); } const client = await db.query.clients.findFirst({ where: and(eq(clients.id, recipient.clientId), eq(clients.portId, portId)), }); if (!client) throw new NotFoundError('Client'); const contacts = await db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, client.id), }); const emails = contacts.filter((c) => c.channel === 'email'); const primary = emails.find((c) => c.isPrimary) ?? emails[0]; if (!primary) throw new ValidationError('Client has no email on file'); assertEmailValid(primary.value); return primary.value; } /** * Verify a caller-supplied `interestId` belongs to the authenticated port * before it lands on the `document_sends` audit row. Without this, an * attacker who knows a foreign-port interest UUID can pollute another * tenant's audit history (the surrounding `clientId` lookup is already * port-scoped, so data isn't exposed — but the audit trail would be). */ async function assertInterestInPort(portId: string, interestId: string): Promise { const row = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), columns: { id: true }, }); if (!row) throw new NotFoundError('Interest'); } async function checkSendRateLimit(portId: string, userId: string): Promise { // Per-(port, user) so a multi-port rep can't be DoS'd by another tenant // burning their global cap. Audit caught this — the original // single-key version locked a user out across every port they touched. const result = await checkRateLimit(`${portId}:${userId}`, { windowMs: 60 * 60 * 1000, max: 50, keyPrefix: 'docsend', }); if (!result.allowed) { throw new ForbiddenError( `Hit hourly send limit (${result.limit}). Retry after ${new Date( result.resetAt, ).toISOString()}.`, ); } } interface ResolvedAttachment { /** Object key in the active storage backend. */ storageKey: string; fileName: string; fileSizeBytes: number; } async function streamAttachmentOrLink( portId: string, attachment: ResolvedAttachment, ): Promise<{ attachments?: Array<{ filename: string; content: Readable }>; bodySuffixHtml?: string; deliveredAsAttachment: boolean; }> { const content = await getSalesContentConfig(portId); const thresholdBytes = content.emailAttachThresholdMb * 1024 * 1024; if (attachment.fileSizeBytes <= thresholdBytes) { // Stream from storage directly into nodemailer to avoid buffering 20MB+. const storage = await getStorageBackend(); const stream = await storage.get(attachment.storageKey); // The storage abstraction returns NodeJS.ReadableStream; nodemailer's // Attachment.content type wants `Readable`. The two are compatible — // both stream backends expose a Readable. Cast to keep types tight. const readable = stream as unknown as Readable; return { deliveredAsAttachment: true, attachments: [{ filename: attachment.fileName, content: readable }], }; } // Above threshold: generate a 24h signed download URL and append a link // to the body. Per §11.1 the size decision is made BEFORE the SMTP relay, // so we never produce duplicate sends. const storage = await getStorageBackend(); // Bind the proxy token to the issuing port slug. The storage key is // already structured `${portSlug}/...` via generateStorageKey() — this // closes the loop so a buggy future call site that hands us a key from // a different port can't mint a valid 24h URL for it. const portRow = await db.query.ports.findFirst({ where: eq(ports.id, portId), columns: { slug: true }, }); const { url } = await storage.presignDownload(attachment.storageKey, { expirySeconds: 24 * 60 * 60, filename: attachment.fileName, portSlug: portRow?.slug, }); // HTML-escape the filename: brochure filenames are admin-supplied and // could in theory carry markup (e.g. `">