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:
277
src/components/shared/send-document-dialog.tsx
Normal file
277
src/components/shared/send-document-dialog.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user