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:
168
src/lib/utils/markdown-email.ts
Normal file
168
src/lib/utils/markdown-email.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (`&`, `'` etc.) is the safe
|
||||
// representation for attribute values.
|
||||
if (!isSafeHref(href.replace(/&/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 === '';
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user