/** * 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, interests, ports, } from '@/lib/db/schema'; 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; } } return values; } /** * 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(); const { url } = await storage.presignDownload(attachment.storageKey, { expirySeconds: 24 * 60 * 60, filename: attachment.fileName, }); // HTML-escape the filename: brochure filenames are admin-supplied and // could in theory carry markup (e.g. `">