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,277 @@
'use client';
/**
* Shared send-document dialog (Phase 7).
*
* Used by:
* - {@link SendBerthPdfDialog} (berths/) — single berth, recipient picker.
* - {@link SendBrochureDialog} (clients/, interests/) — brochure picker.
* - The interest "send from interest" pattern reuses both via a wrapper.
*
* §14.7 mitigations enforced client-side:
* - Recipient email is shown verbatim in the confirm step (no quick-send).
* - Pre-send dry-run calls /preview first; the Send button is disabled
* until the unresolved-tokens list is empty.
* - Body length capped at 50KB; char count visible.
*/
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { apiFetch } from '@/lib/api/client';
const BODY_MAX = 50_000;
export type DocumentKind = 'berth_pdf' | 'brochure';
interface SendDocumentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
documentKind: DocumentKind;
/** Pre-filled recipient. Leave both blank to let the rep type one. */
recipient: { clientId?: string; email?: string; interestId?: string };
/** Either a berthId (for berth_pdf) or brochureId (for brochure). */
context: { berthId?: string; brochureId?: string };
/** Title displayed in the dialog header. */
title: string;
/** Short context line under the title (e.g. "Berth A1 — primary version"). */
subtitle?: string;
onSent?: () => void;
}
interface PreviewResponse {
data: { html: string; markdown: string; unresolved: string[] };
}
export function SendDocumentDialog({
open,
onOpenChange,
documentKind,
recipient,
context,
title,
subtitle,
onSent,
}: SendDocumentDialogProps) {
const [step, setStep] = useState<'compose' | 'confirm'>('compose');
const [emailOverride, setEmailOverride] = useState(recipient.email ?? '');
const [customBody, setCustomBody] = useState('');
useEffect(() => {
if (open) {
setStep('compose');
setEmailOverride(recipient.email ?? '');
setCustomBody('');
}
}, [open, recipient.email]);
const recipientForApi = useMemo(
() => ({
clientId: recipient.clientId,
email: emailOverride || recipient.email,
interestId: recipient.interestId,
}),
[recipient.clientId, recipient.email, recipient.interestId, emailOverride],
);
// Live preview via /api/v1/document-sends/preview. Re-runs whenever the
// body text or recipient changes (debounce-by-react-query for free).
const previewQuery = useQuery<PreviewResponse>({
queryKey: [
'document-sends-preview',
documentKind,
context.berthId ?? null,
context.brochureId ?? null,
recipientForApi.clientId ?? null,
recipientForApi.email ?? null,
customBody,
],
queryFn: () =>
apiFetch('/api/v1/document-sends/preview', {
method: 'POST',
body: {
documentKind,
recipient: recipientForApi,
berthId: context.berthId,
brochureId: context.brochureId,
customBodyMarkdown: customBody.trim() ? customBody : undefined,
},
}),
enabled: open && Boolean(recipientForApi.clientId || recipientForApi.email),
});
type SendResp = { data: { error?: string; deliveredAsAttachment: boolean } };
const sendMutation = useMutation<SendResp, Error, void>({
mutationFn: async (): Promise<SendResp> => {
const endpoint =
documentKind === 'berth_pdf'
? '/api/v1/document-sends/berth-pdf'
: '/api/v1/document-sends/brochure';
const body =
documentKind === 'berth_pdf'
? {
berthId: context.berthId,
recipient: recipientForApi,
customBodyMarkdown: customBody.trim() ? customBody : undefined,
}
: {
brochureId: context.brochureId,
recipient: recipientForApi,
customBodyMarkdown: customBody.trim() ? customBody : undefined,
};
return (await apiFetch<SendResp>(endpoint, { method: 'POST', body })) as SendResp;
},
onSuccess: (resp) => {
if (resp.data.error) {
toast.error(`Send failed: ${resp.data.error}`);
} else {
toast.success(
resp.data.deliveredAsAttachment
? 'Sent as attachment'
: 'Sent (large file delivered as download link)',
);
onSent?.();
onOpenChange(false);
}
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Send failed');
},
});
const unresolved = previewQuery.data?.data.unresolved ?? [];
const previewHtml = previewQuery.data?.data.html ?? '';
const recipientResolved = Boolean(recipientForApi.clientId || recipientForApi.email);
const canPreview = recipientResolved;
const canSend = step === 'confirm' && unresolved.length === 0 && !sendMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{subtitle && <DialogDescription>{subtitle}</DialogDescription>}
</DialogHeader>
{step === 'compose' ? (
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="ds-email">Recipient email</Label>
<Input
id="ds-email"
type="email"
value={emailOverride}
onChange={(e) => setEmailOverride(e.target.value)}
placeholder={recipient.email ? '' : 'recipient@example.com'}
/>
<p className="text-xs text-muted-foreground">
{recipient.clientId
? 'Defaults to the client primary email; override here if needed.'
: 'Type the address you want to send to.'}
</p>
</div>
<div className="space-y-1">
<Label htmlFor="ds-body">Message body</Label>
<Textarea
id="ds-body"
rows={10}
value={customBody}
onChange={(e) => setCustomBody(e.target.value.slice(0, BODY_MAX))}
placeholder="Leave blank to use the port template…"
className="font-mono text-sm"
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Markdown supported. Tokens like {'{{client.fullName}}'} are expanded.</span>
<span>
{customBody.length} / {BODY_MAX}
</span>
</div>
</div>
{canPreview && previewQuery.isSuccess && (
<div className="rounded border bg-muted/30 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Preview
</p>
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
{unresolved.length > 0 && (
<p className="mt-3 rounded border border-amber-300 bg-amber-50 p-2 text-xs text-amber-900">
Unresolved merge fields: {unresolved.join(', ')}. Replace these before sending.
</p>
)}
</div>
)}
{previewQuery.isLoading && canPreview && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Rendering preview
</div>
)}
</div>
) : (
<div className="space-y-3">
<div className="rounded border bg-muted/30 p-3 text-sm">
<p>
Sending to: <span className="font-mono">{recipientForApi.email}</span>
</p>
{context.berthId && <p>Document: berth PDF</p>}
{context.brochureId !== undefined && <p>Document: brochure</p>}
</div>
<div
className="prose prose-sm max-w-none rounded border p-3"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
<p className="text-xs text-muted-foreground">
The PDF file is added by the system after the body your text won&rsquo;t override
it.
</p>
</div>
)}
<DialogFooter className="gap-2">
{step === 'compose' ? (
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => setStep('confirm')}
disabled={!recipientResolved || unresolved.length > 0 || previewQuery.isLoading}
>
Review & send
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => setStep('compose')}>
Back
</Button>
<Button onClick={() => sendMutation.mutate()} disabled={!canSend}>
{sendMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm and send
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}