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:
36
src/lib/validators/brochures.ts
Normal file
36
src/lib/validators/brochures.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createBrochureSchema = z.object({
|
||||
label: z.string().trim().min(1).max(120),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateBrochureSchema = z.object({
|
||||
label: z.string().trim().min(1).max(120).optional(),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const registerBrochureVersionSchema = z.object({
|
||||
storageKey: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(500)
|
||||
// Mirrors the `validateStorageKey` regex in `src/lib/storage/filesystem.ts`
|
||||
// — defense-in-depth against path-traversal payloads from the client.
|
||||
.regex(/^[a-zA-Z0-9/_.-]+$/, 'Invalid storage key format')
|
||||
.refine((s) => !s.includes('..'), 'Storage key may not contain ".."')
|
||||
.refine((s) => !s.startsWith('/'), 'Storage key may not be absolute'),
|
||||
fileName: z.string().min(1).max(255),
|
||||
fileSizeBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(100 * 1024 * 1024), // 100MB hard ceiling
|
||||
contentSha256: z.string().regex(/^[0-9a-f]{64}$/, 'sha256 must be 64-char hex'),
|
||||
});
|
||||
|
||||
export type CreateBrochureInput = z.infer<typeof createBrochureSchema>;
|
||||
export type UpdateBrochureInput = z.infer<typeof updateBrochureSchema>;
|
||||
export type RegisterBrochureVersionInput = z.infer<typeof registerBrochureVersionSchema>;
|
||||
43
src/lib/validators/document-sends.ts
Normal file
43
src/lib/validators/document-sends.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const recipientSchema = z
|
||||
.object({
|
||||
clientId: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
interestId: z.string().min(1).optional(),
|
||||
})
|
||||
.refine((v) => v.clientId !== undefined || v.email !== undefined, {
|
||||
message: 'recipient.clientId or recipient.email is required',
|
||||
});
|
||||
|
||||
export const sendBerthPdfSchema = z.object({
|
||||
berthId: z.string().min(1),
|
||||
recipient: recipientSchema,
|
||||
customBodyMarkdown: z.string().max(50_000).optional(),
|
||||
});
|
||||
|
||||
export const sendBrochureSchema = z.object({
|
||||
brochureId: z.string().min(1).optional(),
|
||||
recipient: recipientSchema,
|
||||
customBodyMarkdown: z.string().max(50_000).optional(),
|
||||
});
|
||||
|
||||
export const previewBodySchema = z.object({
|
||||
documentKind: z.enum(['berth_pdf', 'brochure']),
|
||||
recipient: recipientSchema,
|
||||
berthId: z.string().min(1).optional(),
|
||||
brochureId: z.string().min(1).optional(),
|
||||
customBodyMarkdown: z.string().max(50_000).optional(),
|
||||
});
|
||||
|
||||
export const listSendsQuerySchema = z.object({
|
||||
clientId: z.string().min(1).optional(),
|
||||
interestId: z.string().min(1).optional(),
|
||||
berthId: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
});
|
||||
|
||||
export type SendBerthPdfInput = z.infer<typeof sendBerthPdfSchema>;
|
||||
export type SendBrochureInput = z.infer<typeof sendBrochureSchema>;
|
||||
export type PreviewBodyInput = z.infer<typeof previewBodySchema>;
|
||||
export type ListSendsQuery = z.infer<typeof listSendsQuerySchema>;
|
||||
31
src/lib/validators/sales-email-config.ts
Normal file
31
src/lib/validators/sales-email-config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Per-port sales-email config update payload (Phase 7).
|
||||
*
|
||||
* Password fields accept:
|
||||
* - undefined / omitted => leave unchanged
|
||||
* - empty string "" => leave unchanged (UI placeholder round-trip)
|
||||
* - explicit null => clear the value
|
||||
* - non-empty string => set to this value (encrypted before storage)
|
||||
*/
|
||||
export const updateSalesEmailConfigSchema = z.object({
|
||||
fromAddress: z.string().email().optional().nullable(),
|
||||
smtpHost: z.string().min(1).max(255).optional().nullable(),
|
||||
smtpPort: z.number().int().min(1).max(65535).optional().nullable(),
|
||||
smtpSecure: z.boolean().optional().nullable(),
|
||||
smtpUser: z.string().max(255).optional().nullable(),
|
||||
smtpPass: z.string().max(255).optional().nullable(),
|
||||
imapHost: z.string().min(1).max(255).optional().nullable(),
|
||||
imapPort: z.number().int().min(1).max(65535).optional().nullable(),
|
||||
imapUser: z.string().max(255).optional().nullable(),
|
||||
imapPass: z.string().max(255).optional().nullable(),
|
||||
authMethod: z.enum(['app_password', 'oauth_google', 'oauth_microsoft']).optional().nullable(),
|
||||
noreplyFromAddress: z.string().email().optional().nullable(),
|
||||
templateBerthPdfBody: z.string().max(50_000).optional().nullable(),
|
||||
templateBrochureBody: z.string().max(50_000).optional().nullable(),
|
||||
brochureMaxUploadMb: z.number().int().min(1).max(500).optional().nullable(),
|
||||
emailAttachThresholdMb: z.number().int().min(1).max(50).optional().nullable(),
|
||||
});
|
||||
|
||||
export type UpdateSalesEmailConfigInput = z.infer<typeof updateSalesEmailConfigSchema>;
|
||||
Reference in New Issue
Block a user