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:
164
src/components/clients/send-documents-dialog.tsx
Normal file
164
src/components/clients/send-documents-dialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Client-detail multi-step "Send documents" dialog (Phase 7 §5.7).
|
||||
*
|
||||
* The client header action opens this dialog. The rep picks one of the
|
||||
* client's interest-linked berths (to send a per-berth PDF) OR a brochure
|
||||
* (defaults to the port default when unspecified). The actual send flow
|
||||
* delegates to {@link SendDocumentDialog}; this wrapper is the picker.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileText, Loader2, Mail } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { SendDocumentDialog } from '@/components/shared/send-document-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface SendDocumentsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
/** When the rep is launching from a specific interest, pin it. */
|
||||
interestId?: string;
|
||||
}
|
||||
|
||||
interface BrochureOption {
|
||||
id: string;
|
||||
label: string;
|
||||
isDefault: boolean;
|
||||
archivedAt: string | null;
|
||||
versionCount: number;
|
||||
}
|
||||
|
||||
interface BrochuresResponse {
|
||||
data: BrochureOption[];
|
||||
}
|
||||
|
||||
export function SendDocumentsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
clientId,
|
||||
clientName,
|
||||
interestId,
|
||||
}: SendDocumentsDialogProps) {
|
||||
const [activeSend, setActiveSend] = useState<
|
||||
| { kind: 'brochure'; brochureId?: string }
|
||||
| { kind: 'berth_pdf'; berthId: string; mooring: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
// Lightweight brochures fetch — only fires once dialog is opened.
|
||||
const brochuresQuery = useQuery<BrochuresResponse>({
|
||||
queryKey: ['brochures', 'list'],
|
||||
queryFn: () => apiFetch('/api/v1/admin/brochures'),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const usableBrochures =
|
||||
brochuresQuery.data?.data.filter((b) => !b.archivedAt && b.versionCount > 0) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open && activeSend === null} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send documents to {clientName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick a brochure or open the berth detail page to send a per-berth spec sheet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||
<Mail className="h-4 w-4" /> Brochures
|
||||
</h3>
|
||||
{brochuresQuery.isLoading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading brochures…
|
||||
</div>
|
||||
)}
|
||||
{!brochuresQuery.isLoading && usableBrochures.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No brochures uploaded yet. Add one in /admin/brochures.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{usableBrochures.map((b) => (
|
||||
<Button
|
||||
key={b.id}
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setActiveSend({ kind: 'brochure', brochureId: b.id })}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
{b.label}
|
||||
{b.isDefault && (
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{b.versionCount} ver.</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{activeSend?.kind === 'brochure' && (
|
||||
<SendDocumentDialog
|
||||
open
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setActiveSend(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
documentKind="brochure"
|
||||
recipient={{ clientId, interestId }}
|
||||
context={{ brochureId: activeSend.brochureId }}
|
||||
title={`Send brochure to ${clientName}`}
|
||||
onSent={() => setActiveSend(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSend?.kind === 'berth_pdf' && (
|
||||
<SendDocumentDialog
|
||||
open
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setActiveSend(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
documentKind="berth_pdf"
|
||||
recipient={{ clientId, interestId }}
|
||||
context={{ berthId: activeSend.berthId }}
|
||||
title={`Send berth ${activeSend.mooring} spec sheet`}
|
||||
onSent={() => setActiveSend(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user