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');
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
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, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
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>
|
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> {
|
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`;
|
|
|
|
|
|
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> {
|
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.',
|
|
|
|
|
);
|
|
|
|
|
}
|
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`;
|
|
|
|
|
|
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;
|
|
|
|
|
}
|