Files
pn-new-crm/src/lib/services/document-sends.service.ts

579 lines
21 KiB
TypeScript
Raw Normal View History

feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
/**
* 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,
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred items (deferring the Documenso Phases 2-7 build and items needing design decisions or live external instances). DB schema: - Migration 0046 converts 5 composite (port_id, archived_at) indexes to partial WHERE archived_at IS NULL — clients, interests, yachts, and both residential tables. Smaller, faster planner choice for the dominant list-query shape. Multi-tenant isolation: - document_sends now verifies recipient.interestId belongs to the port before landing on the audit row (the surrounding clientId check was already port-scoped; interestId pollution was the gap). Routes / API: - /api/v1/custom-fields/[entityId] requires entityType query param and gates on the matching resource permission (clients/interests/berths/ yachts/companies). Fixes the cross-resource gap where a user with clients.view could read company custom-field values. - Admin user list trash button wrapped in PermissionGate (edit was already gated; remove was not). Service polish: - berth-recommender accepts string-shaped JSONB booleans ('true'/'false') so admin UIs that wrap values as strings don't silently fall through to defaults. - expense-pdf renderReceiptHeader anchors all text positions to a captured baseY rather than reading mutating doc.y after rect+stroke. Headers no longer drift on the first receipt page after a soft page break. - berth-pdf apply: collect non-finite numeric coercion drops + warn-log them so partial silent drops are observable (was invisible because the no-fields-supplied check only fires when ALL drop). - Storage cache fingerprint comment documenting the encrypted-secret invariant + the explicit invalidation hook. UI polish: - invoice-detail typed: replaced two `any` casts with a proper InvoiceDetailData / LineItem / LinkedExpense interface set. - YachtForm now accepts initialOwner prop. Wired through: - client-yachts-tab passes { type: 'client', id: clientId } - interest-form passes { type: 'client', id: selectedClientId } - Interest-form yacht picker now includes company-owned yachts where the selected client is a member (fetches client.companies and feeds YachtPicker an array filter). Plus an inline "Add new" button that opens YachtForm pre-bound to the client. - YachtPicker accepts ownerFilter as single OR array for "match any" semantics. BACKLOG.md updated with what landed vs what's still deferred (and why each deferred item is genuinely larger than this push warrants). Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
interests,
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
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<Record<string, string>> {
const values: Record<string, string> = {};
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<string> {
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;
}
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred items (deferring the Documenso Phases 2-7 build and items needing design decisions or live external instances). DB schema: - Migration 0046 converts 5 composite (port_id, archived_at) indexes to partial WHERE archived_at IS NULL — clients, interests, yachts, and both residential tables. Smaller, faster planner choice for the dominant list-query shape. Multi-tenant isolation: - document_sends now verifies recipient.interestId belongs to the port before landing on the audit row (the surrounding clientId check was already port-scoped; interestId pollution was the gap). Routes / API: - /api/v1/custom-fields/[entityId] requires entityType query param and gates on the matching resource permission (clients/interests/berths/ yachts/companies). Fixes the cross-resource gap where a user with clients.view could read company custom-field values. - Admin user list trash button wrapped in PermissionGate (edit was already gated; remove was not). Service polish: - berth-recommender accepts string-shaped JSONB booleans ('true'/'false') so admin UIs that wrap values as strings don't silently fall through to defaults. - expense-pdf renderReceiptHeader anchors all text positions to a captured baseY rather than reading mutating doc.y after rect+stroke. Headers no longer drift on the first receipt page after a soft page break. - berth-pdf apply: collect non-finite numeric coercion drops + warn-log them so partial silent drops are observable (was invisible because the no-fields-supplied check only fires when ALL drop). - Storage cache fingerprint comment documenting the encrypted-secret invariant + the explicit invalidation hook. UI polish: - invoice-detail typed: replaced two `any` casts with a proper InvoiceDetailData / LineItem / LinkedExpense interface set. - YachtForm now accepts initialOwner prop. Wired through: - client-yachts-tab passes { type: 'client', id: clientId } - interest-form passes { type: 'client', id: selectedClientId } - Interest-form yacht picker now includes company-owned yachts where the selected client is a member (fetches client.companies and feeds YachtPicker an array filter). Plus an inline "Add new" button that opens YachtForm pre-bound to the client. - YachtPicker accepts ownerFilter as single OR array for "match any" semantics. BACKLOG.md updated with what landed vs what's still deferred (and why each deferred item is genuinely larger than this push warrants). Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
/**
* 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<void> {
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');
}
fix(audit-final): pre-merge hardening + expense receipt UI Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:11:26 +02:00
async function checkSendRateLimit(portId: string, userId: string): Promise<void> {
// 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}`, {
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
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,
});
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
// HTML-escape the filename: brochure filenames are admin-supplied and
// could in theory carry markup (e.g. `"><script>...`). Even a benign
// ampersand or angle bracket would break the rendered link otherwise.
const safeFileName = attachment.fileName
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
const html = `<p>The file is large enough that we're sending it as a download link rather than an attachment:</p>
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
<p><a href="${url}" target="_blank" rel="noopener noreferrer">Download ${safeFileName}</a> (link expires in 24 hours)</p>`;
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
return { deliveredAsAttachment: false, bodySuffixHtml: html };
}
async function performSend(args: {
portId: string;
recipientEmail: string;
subject: string;
bodyHtml: string;
attachment: ResolvedAttachment;
recordSeed: Omit<typeof documentSends.$inferInsert, 'id' | 'sentAt' | 'createdAt'>;
}): Promise<SendResult> {
// 1. Build attachment vs link preamble.
const delivery = await streamAttachmentOrLink(args.portId, args.attachment);
const finalHtml = delivery.bodySuffixHtml
? `${args.bodyHtml}\n${delivery.bodySuffixHtml}`
: args.bodyHtml;
// 2. Create the transporter (per-port sales account).
let transporter, fromAddress;
try {
({ transporter, fromAddress } = await createSalesTransporter(args.portId));
} catch (configErr) {
const msg = configErr instanceof Error ? configErr.message : String(configErr);
const [row] = await db
.insert(documentSends)
.values({
...args.recordSeed,
fromAddress: args.recordSeed.fromAddress || 'unknown',
bodyMarkdown: args.recordSeed.bodyMarkdown ?? null,
failedAt: new Date(),
errorReason: msg,
})
.returning();
return {
send: row!,
deliveredAsAttachment: false,
error: msg,
};
}
// 3. Send.
try {
const info: SentMessageInfo = await transporter.sendMail({
from: fromAddress,
to: args.recipientEmail,
subject: args.subject,
html: finalHtml,
...(delivery.attachments ? { attachments: delivery.attachments } : {}),
});
const [row] = await db
.insert(documentSends)
.values({
...args.recordSeed,
fromAddress,
messageId: info.messageId ?? null,
fallbackToLinkReason: delivery.deliveredAsAttachment ? null : 'size_above_threshold',
})
.returning();
return { send: row!, deliveredAsAttachment: delivery.deliveredAsAttachment };
} catch (sendErr) {
const msg = sendErr instanceof Error ? sendErr.message : String(sendErr);
logger.error({ err: sendErr, portId: args.portId }, 'Sales send failed');
const [row] = await db
.insert(documentSends)
.values({
...args.recordSeed,
fromAddress,
failedAt: new Date(),
errorReason: msg,
})
.returning();
return { send: row!, deliveredAsAttachment: false, error: msg };
}
}
// ─── Public sender: berth PDF ────────────────────────────────────────────────
export async function sendBerthPdf(input: SendBerthPdfInput): Promise<SendResult> {
fix(audit-final): pre-merge hardening + expense receipt UI Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:11:26 +02:00
// Rate-limit AFTER validation so a typo'd recipient or missing-PDF rep
// doesn't burn a slot on a send that would have failed anyway.
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
const recipientEmail = await resolveRecipientEmail(input.portId, input.recipient);
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred items (deferring the Documenso Phases 2-7 build and items needing design decisions or live external instances). DB schema: - Migration 0046 converts 5 composite (port_id, archived_at) indexes to partial WHERE archived_at IS NULL — clients, interests, yachts, and both residential tables. Smaller, faster planner choice for the dominant list-query shape. Multi-tenant isolation: - document_sends now verifies recipient.interestId belongs to the port before landing on the audit row (the surrounding clientId check was already port-scoped; interestId pollution was the gap). Routes / API: - /api/v1/custom-fields/[entityId] requires entityType query param and gates on the matching resource permission (clients/interests/berths/ yachts/companies). Fixes the cross-resource gap where a user with clients.view could read company custom-field values. - Admin user list trash button wrapped in PermissionGate (edit was already gated; remove was not). Service polish: - berth-recommender accepts string-shaped JSONB booleans ('true'/'false') so admin UIs that wrap values as strings don't silently fall through to defaults. - expense-pdf renderReceiptHeader anchors all text positions to a captured baseY rather than reading mutating doc.y after rect+stroke. Headers no longer drift on the first receipt page after a soft page break. - berth-pdf apply: collect non-finite numeric coercion drops + warn-log them so partial silent drops are observable (was invisible because the no-fields-supplied check only fires when ALL drop). - Storage cache fingerprint comment documenting the encrypted-secret invariant + the explicit invalidation hook. UI polish: - invoice-detail typed: replaced two `any` casts with a proper InvoiceDetailData / LineItem / LinkedExpense interface set. - YachtForm now accepts initialOwner prop. Wired through: - client-yachts-tab passes { type: 'client', id: clientId } - interest-form passes { type: 'client', id: selectedClientId } - Interest-form yacht picker now includes company-owned yachts where the selected client is a member (fetches client.companies and feeds YachtPicker an array filter). Plus an inline "Add new" button that opens YachtForm pre-bound to the client. - YachtPicker accepts ownerFilter as single OR array for "match any" semantics. BACKLOG.md updated with what landed vs what's still deferred (and why each deferred item is genuinely larger than this push warrants). Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
if (input.recipient.interestId) {
await assertInterestInPort(input.portId, input.recipient.interestId);
}
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
// Resolve berth + active version.
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, input.berthId), eq(berths.portId, input.portId)),
});
if (!berth) throw new NotFoundError('Berth');
if (!berth.currentPdfVersionId) {
throw new ValidationError(
'No PDF uploaded for this berth yet. Upload one in the berth detail page first.',
);
}
const version = await db.query.berthPdfVersions.findFirst({
where: eq(berthPdfVersions.id, berth.currentPdfVersionId),
});
if (!version) throw new NotFoundError('Berth PDF version');
// Build body.
const content = await getSalesContentConfig(input.portId);
const template = input.customBodyMarkdown?.trim()?.length
? input.customBodyMarkdown
: content.templateBerthPdfBody;
if (Buffer.byteLength(template, 'utf8') > EMAIL_BODY_MAX_BYTES) {
throw new ValidationError('Email body exceeds maximum length');
}
const values = await buildMergeValues(input.portId, input.recipient, { berthId: berth.id });
const unresolved = findUnresolvedTokens(template, values);
if (unresolved.length > 0 && !input.allowUnresolved) {
throw new ValidationError(`Unresolved merge tokens: ${unresolved.join(', ')}`);
}
const expanded = expandMergeTokens(template, values);
const bodyHtml = renderEmailBody(expanded);
// Subject pulls in the mooring number for inbox triage.
const subject = `Berth ${berth.mooringNumber} — spec sheet`;
fix(audit-final): pre-merge hardening + expense receipt UI Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:11:26 +02:00
await checkSendRateLimit(input.portId, input.sentBy);
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
return performSend({
portId: input.portId,
recipientEmail,
subject,
bodyHtml,
attachment: {
storageKey: version.storageKey,
fileName: version.fileName,
fileSizeBytes: version.fileSizeBytes,
},
recordSeed: {
portId: input.portId,
clientId: input.recipient.clientId ?? null,
interestId: input.recipient.interestId ?? null,
recipientEmail,
documentKind: 'berth_pdf',
berthId: berth.id,
berthPdfVersionId: version.id,
brochureId: null,
brochureVersionId: null,
bodyMarkdown: expanded,
sentByUserId: input.sentBy,
fromAddress: '',
},
});
}
// ─── Public sender: brochure ─────────────────────────────────────────────────
export async function sendBrochure(input: SendBrochureInput): Promise<SendResult> {
fix(audit-final): pre-merge hardening + expense receipt UI Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:11:26 +02:00
// Rate-limit AFTER validation (audit finding); typos shouldn't burn slots.
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
const recipientEmail = await resolveRecipientEmail(input.portId, input.recipient);
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred items (deferring the Documenso Phases 2-7 build and items needing design decisions or live external instances). DB schema: - Migration 0046 converts 5 composite (port_id, archived_at) indexes to partial WHERE archived_at IS NULL — clients, interests, yachts, and both residential tables. Smaller, faster planner choice for the dominant list-query shape. Multi-tenant isolation: - document_sends now verifies recipient.interestId belongs to the port before landing on the audit row (the surrounding clientId check was already port-scoped; interestId pollution was the gap). Routes / API: - /api/v1/custom-fields/[entityId] requires entityType query param and gates on the matching resource permission (clients/interests/berths/ yachts/companies). Fixes the cross-resource gap where a user with clients.view could read company custom-field values. - Admin user list trash button wrapped in PermissionGate (edit was already gated; remove was not). Service polish: - berth-recommender accepts string-shaped JSONB booleans ('true'/'false') so admin UIs that wrap values as strings don't silently fall through to defaults. - expense-pdf renderReceiptHeader anchors all text positions to a captured baseY rather than reading mutating doc.y after rect+stroke. Headers no longer drift on the first receipt page after a soft page break. - berth-pdf apply: collect non-finite numeric coercion drops + warn-log them so partial silent drops are observable (was invisible because the no-fields-supplied check only fires when ALL drop). - Storage cache fingerprint comment documenting the encrypted-secret invariant + the explicit invalidation hook. UI polish: - invoice-detail typed: replaced two `any` casts with a proper InvoiceDetailData / LineItem / LinkedExpense interface set. - YachtForm now accepts initialOwner prop. Wired through: - client-yachts-tab passes { type: 'client', id: clientId } - interest-form passes { type: 'client', id: selectedClientId } - Interest-form yacht picker now includes company-owned yachts where the selected client is a member (fetches client.companies and feeds YachtPicker an array filter). Plus an inline "Add new" button that opens YachtForm pre-bound to the client. - YachtPicker accepts ownerFilter as single OR array for "match any" semantics. BACKLOG.md updated with what landed vs what's still deferred (and why each deferred item is genuinely larger than this push warrants). Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
if (input.recipient.interestId) {
await assertInterestInPort(input.portId, input.recipient.interestId);
}
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
// Resolve brochure + most-recent version.
let brochureRow;
if (input.brochureId) {
brochureRow = await db.query.brochures.findFirst({
where: and(eq(brochures.id, input.brochureId), eq(brochures.portId, input.portId)),
});
if (!brochureRow) throw new NotFoundError('Brochure');
if (brochureRow.archivedAt) {
throw new ValidationError('Brochure is archived');
}
} else {
const def = await getDefaultBrochure(input.portId);
if (!def || !def.currentVersion) {
throw new ValidationError(
'No default brochure configured for this port. Upload one in /admin/brochures.',
);
}
fix(audit-final): pre-merge hardening + expense receipt UI Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:11:26 +02:00
// The partial unique index on `is_default` only enforces uniqueness when
// archived_at IS NULL — an archived row can still carry is_default=true
// and would silently be returned here without this guard.
if (def.archivedAt) {
throw new ValidationError(
'Default brochure is archived. Choose a non-archived brochure as the default first.',
);
}
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
brochureRow = def;
}
const versions = await db.query.brochureVersions.findMany({
where: eq(brochureVersions.brochureId, brochureRow.id),
orderBy: [desc(brochureVersions.uploadedAt)],
limit: 1,
});
const version = versions[0];
if (!version) {
throw new ValidationError('Brochure has no uploaded version yet');
}
// Build body.
const content = await getSalesContentConfig(input.portId);
const template = input.customBodyMarkdown?.trim()?.length
? input.customBodyMarkdown
: content.templateBrochureBody;
if (Buffer.byteLength(template, 'utf8') > EMAIL_BODY_MAX_BYTES) {
throw new ValidationError('Email body exceeds maximum length');
}
const values = await buildMergeValues(input.portId, input.recipient, {
brochureLabel: brochureRow.label,
});
const unresolved = findUnresolvedTokens(template, values);
if (unresolved.length > 0 && !input.allowUnresolved) {
throw new ValidationError(`Unresolved merge tokens: ${unresolved.join(', ')}`);
}
const expanded = expandMergeTokens(template, values);
const bodyHtml = renderEmailBody(expanded);
const subject = `${brochureRow.label} — brochure`;
fix(audit-final): pre-merge hardening + expense receipt UI Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:11:26 +02:00
await checkSendRateLimit(input.portId, input.sentBy);
feat(emails): sales send-out flows + brochures + email-from settings Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7, §5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP config + body templates, and the supporting admin UI. Migration: 0031_brochures_and_document_sends.sql Schema additions: - brochures (port-wide, with isDefault marker + archive) - brochure_versions (versioned uploads, storageKey per §4.7a) - document_sends (audit log of every rep-initiated send; failures captured with failedAt + errorReason). berthPdfVersionId is a plain text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions so the two phases stay independent. §14.7 critical mitigations: - Body XSS: rep-authored markdown goes through renderEmailBody() (HTML-escape first, then a tight allowlist of bold/italic/code/link rules). https:// + mailto: only — javascript:/data: URLs stripped. Tested against script/img/iframe/svg/onerror polyglots. - Recipient typo: strict email regex + two-step confirm modal that shows the exact recipient before send. - Unresolved merge fields: pre-send dry-run /preview endpoint blocks submission until findUnresolvedTokens() returns empty. - SMTP failure: every transport rejection writes a document_sends row with failedAt + errorReason; UI surfaces the message. - Hourly per-user rate limit: 50 sends/user/hour via existing checkRateLimit(). - Size threshold fallback (§11.1): files above email_attach_threshold_mb (default 15) ship as a 24h signed-URL download link in the body instead of an attachment. Storage stream flows directly to nodemailer to avoid buffering 20MB+. §14.10 critical mitigation: - SMTP/IMAP passwords encrypted at rest via the existing EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/ sales-config GET endpoint never returns the decrypted value — only a *PassIsSet boolean. PATCH treats empty string as "leave unchanged" and explicit null as "clear", so the masked-placeholder UI round- trips without forcing re-entry on every save. system_settings keys (per-port unless noted): - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted} - sales_imap_{host,port,user,pass_encrypted} - sales_auth_method (default app_password) - noreply_from_address - email_template_send_berth_pdf_body, email_template_send_brochure_body - brochure_max_upload_mb (default 50) - email_attach_threshold_mb (default 15) UI surfaces (per §5.7, §5.8, §5.9): - <SendDocumentDialog> shared 2-step compose+confirm flow. - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton> wrappers per detail page. - /[portSlug]/admin/brochures: list, upload (direct-to-storage presigned PUT for the 20MB+ files per §11.1), default toggle, archive. - /[portSlug]/admin/email extended with <SalesEmailConfigCard>: SMTP + IMAP creds, body templates, threshold/max settings. Storage: every upload + download goes through getStorageBackend() — no direct minio imports, per Phase 6a contract. Tests: 1145 vitest passing (+ 50 new in markdown-email-sanitization.test.ts, document-sends-validators.test.ts, sales-email-config-validators.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
return performSend({
portId: input.portId,
recipientEmail,
subject,
bodyHtml,
attachment: {
storageKey: version.storageKey,
fileName: version.fileName,
fileSizeBytes: version.fileSizeBytes,
},
recordSeed: {
portId: input.portId,
clientId: input.recipient.clientId ?? null,
interestId: input.recipient.interestId ?? null,
recipientEmail,
documentKind: 'brochure',
berthId: null,
berthPdfVersionId: null,
brochureId: brochureRow.id,
brochureVersionId: version.id,
bodyMarkdown: expanded,
sentByUserId: input.sentBy,
fromAddress: '',
},
});
}
// ─── Audit query ─────────────────────────────────────────────────────────────
export interface ListSendsFilters {
portId: string;
clientId?: string;
interestId?: string;
berthId?: string;
limit?: number;
}
export async function listSends(filters: ListSendsFilters): Promise<DocumentSend[]> {
const conds = [eq(documentSends.portId, filters.portId)];
if (filters.clientId) conds.push(eq(documentSends.clientId, filters.clientId));
if (filters.interestId) conds.push(eq(documentSends.interestId, filters.interestId));
if (filters.berthId) conds.push(eq(documentSends.berthId, filters.berthId));
const rows = await db
.select()
.from(documentSends)
.where(and(...conds))
.orderBy(desc(documentSends.sentAt))
.limit(filters.limit ?? 100);
return rows;
}