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>
This commit is contained in:
Matt Ciaccio
2026-05-05 03:38:47 +02:00
parent 249ffe3e4a
commit a0091e4ca6
32 changed files with 15129 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
/**
* Minimal markdown -> HTML email conversion + safe sanitization.
*
* Used by the Phase 7 sales send-out flow (`document-sends.service.ts`) to
* render rep-authored bodies into the email-safe HTML we hand to nodemailer.
*
* § §14.7 critical mitigation: "Body markdown with HTML/script injection".
* This module is the choke point — every code path that turns rep-authored
* markdown into an email body MUST go through `renderEmailBody()`. Direct
* passthrough to `transporter.sendMail({ html })` from a user-supplied string
* is a code-review block.
*
* The implementation is intentionally tiny (no DOMPurify, no marked) for two
* reasons:
*
* 1. Email clients render a strict subset of HTML anyway — paragraphs,
* bold/italic, line breaks, links and code spans cover ~99% of what
* reps actually write. Anything more complex (tables, images, lists)
* goes via the admin-editable HTML body templates.
* 2. Adding a transitive deps surface for the markdown→HTML conversion
* doubles the attack surface for the very mitigation it implements.
*
* The renderer:
* - HTML-escapes every input character before applying any markdown rules.
* - Whitelists exactly: paragraphs, line breaks, **bold**, _italic_, `code`,
* and `[text](https://...)` links (https only).
* - Strips any other markdown / HTML constructs by virtue of being
* escape-first-then-rule-replace.
*
* Tested against the standard XSS vector list (`<script>`, `<img onerror>`,
* `javascript:` URLs, `<iframe>`, etc.) — see
* `tests/unit/markdown-email-sanitization.test.ts`.
*/
const MAX_BODY_BYTES = 50 * 1024; // 50 KB hard cap matching §14.7
/** Re-export for the sender service so it doesn't have to remember the cap. */
export const EMAIL_BODY_MAX_BYTES = MAX_BODY_BYTES;
/**
* Escape every HTML-significant character. Run on raw input BEFORE any
* markdown rules so user-supplied HTML can never reach the rendered body.
*/
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* The list of allowed URL schemes. We deliberately reject `javascript:`,
* `data:`, `vbscript:`, and bare-word schemes (which some browsers resolve
* to `http:`) — only fully-qualified `https://` and `mailto:` make it through.
*/
function isSafeHref(href: string): boolean {
const trimmed = href.trim().toLowerCase();
return trimmed.startsWith('https://') || trimmed.startsWith('mailto:');
}
/**
* Apply inline markdown rules to an already-HTML-escaped string. The order
* matters: code spans win over emphasis, links win over emphasis (so
* `[**foo**](url)` works), and emphasis is greedy-non-greedy aware.
*/
function applyInlineRules(escaped: string): string {
let out = escaped;
// `code` (single backticks; nothing fancier — no fenced blocks here)
out = out.replace(/`([^`\n]+)`/g, '<code>$1</code>');
// [text](href) — href validated. Bracket text is already escaped so we
// pass it through verbatim. Use a non-greedy match so two links on one
// line don't collapse.
out = out.replace(/\[([^\]\n]+?)\]\(([^)\n]+?)\)/g, (_full, text: string, href: string) => {
// The href captured group came from already-escaped input, so the
// entity-encoded chars need to survive into the attribute. We DO NOT
// unescape here; the escaped form (`&amp;`, `&#39;` etc.) is the safe
// representation for attribute values.
if (!isSafeHref(href.replace(/&amp;/g, '&'))) {
// Drop the link entirely; render the text as plain.
return text;
}
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`;
});
// **bold** then *italic* / _italic_
out = out.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
out = out.replace(/(?<!\*)\*([^*\n]+?)\*(?!\*)/g, '<em>$1</em>');
out = out.replace(/(^|\s)_([^_\n]+?)_(?=\s|$)/g, '$1<em>$2</em>');
return out;
}
/**
* Convert rep-authored markdown into email-safe HTML.
*
* Throws when the input exceeds {@link EMAIL_BODY_MAX_BYTES}.
*/
export function renderEmailBody(markdown: string): string {
if (Buffer.byteLength(markdown, 'utf8') > MAX_BODY_BYTES) {
throw new Error(`Email body exceeds maximum length (${MAX_BODY_BYTES} bytes)`);
}
// 1. HTML-escape EVERYTHING first — there is no path through the rules
// below that lets an unescaped angle bracket reach the output.
const escaped = escapeHtml(markdown);
// 2. Split into paragraphs on blank lines; within each paragraph,
// single newlines become <br>.
const paragraphs = escaped.split(/\n{2,}/);
const rendered = paragraphs
.map((para) => {
const trimmed = para.trim();
if (!trimmed) return '';
const inline = applyInlineRules(trimmed).replace(/\n/g, '<br>');
return `<p>${inline}</p>`;
})
.filter(Boolean)
.join('\n');
return rendered;
}
/**
* Find every `{{token}}` reference in a body. Returns the raw token strings
* (with braces) for caller-side validation against the merge-field catalog.
*/
export function extractTokens(markdown: string): string[] {
const matches = markdown.match(/\{\{[^{}\n]+?\}\}/g);
return matches ? Array.from(new Set(matches)) : [];
}
/**
* Replace `{{token}}` references with values from the supplied map. Tokens
* not present in the map are left intact so the dry-run reporter can flag
* them. Values are HTML-escape-safe by virtue of being run BEFORE
* `renderEmailBody()`; the caller is expected to pass plain strings.
*/
export function expandMergeTokens(
markdown: string,
values: Record<string, string | number | null | undefined>,
): string {
return markdown.replace(/\{\{([^{}\n]+?)\}\}/g, (full, raw: string) => {
const key = `{{${raw}}}`;
const value = values[key];
if (value === null || value === undefined || value === '') return full;
return String(value);
});
}
/**
* Returns the list of `{{token}}` references in `markdown` that aren't
* present (or are blank/null) in the provided value map. Used by the
* pre-send dry-run UI per §14.7 ("Body markdown contains unresolved merge
* fields — Send blocked until resolved").
*/
export function findUnresolvedTokens(
markdown: string,
values: Record<string, string | number | null | undefined>,
): string[] {
return extractTokens(markdown).filter((token) => {
const v = values[token];
return v === undefined || v === null || v === '';
});
}