import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useNavigate } from "react-router-dom"; import { BarChart3, Check, ChevronDown, FlaskConical, LockKeyhole, PackageCheck, Send, ShieldCheck, type LucideIcon, } from "lucide-react"; import type { ApiSettings } from "../../types"; import { buildVersion, getCampaignJobs, getCampaignJobDetail, mockSendCampaign, sendCampaignNow, updateCampaignReviewState, validateVersion, type CampaignJobsResponse, type CampaignVersionDetail, } from "../../api/campaigns"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import Button from "../../components/Button"; import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid"; import DismissibleAlert from "../../components/DismissibleAlert"; import ConfirmDialog from "../../components/ConfirmDialog"; import LoadingFrame from "../../components/LoadingFrame"; import PageTitle from "../../components/PageTitle"; import StatusBadge from "../../components/StatusBadge"; import ToggleSwitch from "../../components/ToggleSwitch"; import InlineHelp from "../../components/help/InlineHelp"; import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asArray, asRecord, getCampaignJson, getDeliverySection, humanize, isFinalLockedVersion, isHistoricalCampaignVersion, isUserLockedVersion, isVersionReadyForDelivery, stringifyPreview, } from "./utils/campaignView"; import { getBool, getText } from "./utils/draftEditor"; import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders"; type FlowState = | "complete" | "warning" | "danger" | "active" | "locked" | "running" | "partial" | "stale" | "pending"; type FlowStageDefinition = { id: string; title: string; shortTitle: string; description: string; icon: LucideIcon; state: FlowState; stateLabel: string; lockReason?: string; }; type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | ""; const stateColors: Record = { complete: "var(--green)", warning: "var(--amber)", danger: "var(--red)", active: "var(--blue)", locked: "var(--line-dark)", running: "var(--blue)", partial: "#9b86c7", stale: "#9b86c7", pending: "var(--muted)", }; const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [ { value: "ready", label: "Ready" }, { value: "warning", label: "Warning" }, { value: "needs_review", label: "Needs review" }, { value: "blocked", label: "Blocked" }, { value: "excluded", label: "Excluded" }, ]; const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"]; export default function ReviewSendPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const navigate = useNavigate(); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const version = data.currentVersion; const campaignJson = useMemo(() => getCampaignJson(version), [version]); const inlineEntries = useMemo( () => asArray(asRecord(campaignJson.entries).inline).map(asRecord), [campaignJson], ); const validation = asRecord(version?.validation_summary); const build = asRecord(version?.build_summary); const cards = data.summary?.cards; const attachmentSummary = asRecord(data.summary?.attachments); const delivery = getDeliverySection(version); const rateLimit = asRecord(delivery.rate_limit); const imapAppend = asRecord(delivery.imap_append_sent); const [busy, setBusy] = useState(""); const [message, setMessage] = useState(""); const [messageReviewComplete, setMessageReviewComplete] = useState(false); const [builtReviewRows, setBuiltReviewRows] = useState[]>([]); const [reviewJobs, setReviewJobs] = useState(() => emptyCampaignJobsResponse()); const [reviewPage, setReviewPage] = useState(1); const [showAllReviewJobs, setShowAllReviewJobs] = useState(false); const [jobsLoadedKey, setJobsLoadedKey] = useState(""); const [reviewedMessageKeys, setReviewedMessageKeys] = useState>(() => new Set()); const [newlyReviewedRequiredKeys, setNewlyReviewedRequiredKeys] = useState>(() => new Set()); const [selectedBuiltIndex, setSelectedBuiltIndex] = useState(null); const [mockResult, setMockResult] = useState | null>(null); const [mockClearFirst, setMockClearFirst] = useState(true); const [mockAppendSent, setMockAppendSent] = useState(true); const [selectedMockMessage, setSelectedMockMessage] = useState(null); const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false); const [dryRun, setDryRun] = useState(false); const [sendConfirmOpen, setSendConfirmOpen] = useState(false); const [sendResult, setSendResult] = useState | null>(null); const persistedReview = storedMessageReviewState(version); const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}`; useEffect(() => { setBuiltReviewRows([]); setReviewJobs(emptyCampaignJobsResponse()); setReviewPage(1); setJobsLoadedKey(""); setNewlyReviewedRequiredKeys(new Set()); setSelectedBuiltIndex(null); setMockResult(null); setSelectedMockMessage(null); setSendResult(null); setSendConfirmOpen(false); }, [version?.id]); useEffect(() => { setMessageReviewComplete(persistedReview.inspectionComplete); setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys)); }, [version?.id, persistedReviewKey]); const validationPresent = Object.keys(validation).length > 0; const validationOk = validation.ok === true; const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]); const validationWarnings = numberFrom(validation, ["warning_count", "warnings"]); const readyForDelivery = isVersionReadyForDelivery(version); const validationStale = validationOk && !readyForDelivery; const buildPresent = Object.keys(build).length > 0; const builtCount = numberFrom(build, ["built_count", "ready_count", "built", "messages_built"]); const buildBlocked = numberFrom(build, ["blocked_count", "blocked"]); const buildNeedsReview = numberFrom(build, ["needs_review_count", "needs_review"]); const buildWarnings = numberFrom(build, ["warning_count", "warnings"]); const hasBuild = buildPresent && (builtCount > 0 || version?.workflow_state === "built"); useEffect(() => { if (!version?.id || !hasBuild) return; const expectedKey = `${version.id}:${reviewPage}:${showAllReviewJobs ? "all" : "issues"}`; if (jobsLoadedKey === expectedKey) return; void loadBuiltMessages(true, reviewPage); }, [version?.id, hasBuild, reviewPage, showAllReviewJobs, jobsLoadedKey]); const jobsTotal = cards?.jobs_total ?? inlineEntries.filter((entry) => entry.active !== false).length; const sentCount = cards?.sent ?? 0; const failedCount = cards?.failed ?? 0; const imapAppended = cards?.imap_appended ?? 0; const imapFailed = cards?.imap_failed ?? 0; const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase(); const deliveryQueued = currentWorkflowState === "queued"; const deliveryStarted = ["sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed"].includes(currentWorkflowState); const historicalVersion = isHistoricalCampaignVersion(version, data.campaign?.current_version_id); const finalVersion = isFinalLockedVersion(version); const userLockedVersion = isUserLockedVersion(version); const readOnlyVersion = historicalVersion || userLockedVersion || finalVersion; useEffect(() => { if (!(deliveryQueued || currentWorkflowState === "sending") || loading || busy === "send") return; const handle = window.setTimeout(() => { void reload(); }, 3000); return () => window.clearTimeout(handle); }, [deliveryQueued, currentWorkflowState, loading, busy, reload]); const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null; const reviewMetadata = reviewJobs.review ?? {}; const blockingReviewCount = Number(reviewMetadata.blocking_count ?? 0); const explicitReviewCount = Number(reviewMetadata.required_count ?? 0); const bulkAcceptableCount = Number(reviewMetadata.bulk_acceptable_count ?? 0); const reviewedExplicitCount = Math.min( explicitReviewCount, Number(reviewMetadata.reviewed_required_count ?? 0) + newlyReviewedRequiredKeys.size, ); const reviewRequiredCount = explicitReviewCount + bulkAcceptableCount; const reviewedRequiredCount = reviewedExplicitCount; const automaticInspectionComplete = reviewJobs.total_unfiltered > 0 && blockingReviewCount === 0 && reviewRequiredCount === 0; const mockSend = asRecord(mockResult?.send); const mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]); const mockFailed = numberFrom(mockSend, ["failed_count"]); const mockSkipped = numberFrom(mockSend, ["skipped_count"]); const mockComplete = Boolean(mockResult) && mockSent > 0 && mockFailed === 0 && mockSkipped === 0; const mockPartial = Boolean(mockResult) && mockSent > 0 && (mockFailed > 0 || mockSkipped > 0); const mockRows = asArray(mockSend.results).map(asRecord); const mockMailbox = asRecord(mockResult?.mailbox); const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord); const sendResultRows = asArray(sendResult?.results).map(asRecord); const validationReviewState: FlowState = busy === "validate" ? "running" : validationStale ? "stale" : validationPresent && !validationOk ? "danger" : readyForDelivery && (validationWarnings > 0 || (cards?.needs_attention ?? 0) > 0) ? "warning" : readyForDelivery ? "complete" : "active"; const downstreamDeliveryActivity = deliveryQueued || deliveryStarted; const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity; const buildReviewState: FlowState = !readyForDelivery ? "locked" : busy === "build" || busy === "inspect" ? "running" : hasBuild && buildBlocked > 0 ? "danger" : hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied) ? "warning" : hasBuild && inspectionSatisfied ? "complete" : "active"; const mockState: FlowState = !inspectionSatisfied ? "locked" : busy === "mock" || busy === "mailbox" ? "running" : mockPartial ? "partial" : mockFailed > 0 ? "danger" : mockComplete ? "complete" : downstreamDeliveryActivity ? "warning" : "active"; const mockGateSatisfied = mockComplete || downstreamDeliveryActivity; const sendState: FlowState = !mockGateSatisfied ? "locked" : busy === "send" || deliveryQueued || currentWorkflowState === "sending" ? "running" : ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState) ? "partial" : ["sent", "completed"].includes(currentWorkflowState) ? "complete" : ["outcome_unknown", "failed"].includes(currentWorkflowState) ? "danger" : "active"; const resultState: FlowState = !deliveryStarted ? "locked" : currentWorkflowState === "sending" ? "running" : ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState) ? "partial" : ["sent", "completed"].includes(currentWorkflowState) ? "complete" : "danger"; const stages: FlowStageDefinition[] = useMemo(() => [ { id: "workflow-validate-review", title: "Validate and inspect", shortTitle: "Validate", description: "Lock and validate the campaign, then inspect blocking errors, warnings and attachment exceptions in the same step.", icon: ShieldCheck, state: validationReviewState, stateLabel: stateLabel(validationReviewState), }, { id: "workflow-build-review", title: "Build and review", shortTitle: "Build", description: "Build the exact queue, resolve recipient values and managed files, then review only messages that need attention.", icon: PackageCheck, state: buildReviewState, stateLabel: stateLabel(buildReviewState), lockReason: validationErrors > 0 ? `Resolve ${validationErrors} blocking validation issue${validationErrors === 1 ? "" : "s"} first.` : "Lock and validate the current working version first.", }, { id: "workflow-mock-verify", title: "Mock send and verify", shortTitle: "Mock send", description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers.", icon: FlaskConical, state: mockState, stateLabel: stateLabel(mockState), lockReason: "Build and complete the required message review first.", }, { id: "workflow-send", title: "Confirm and send", shortTitle: "Send", description: "Review the final execution summary, optionally run a dry run, and send the exact built queue.", icon: Send, state: sendState, stateLabel: stateLabel(sendState), lockReason: "Complete a successful mock delivery first.", }, { id: "workflow-results", title: "Delivery results", shortTitle: "Results", description: "Review SMTP outcomes, IMAP append results, partial delivery and failures.", icon: BarChart3, state: resultState, stateLabel: stateLabel(resultState), lockReason: "Delivery results become available after the real send starts.", }, ], [ validationReviewState, buildReviewState, mockState, sendState, resultState, validationErrors, ]); async function runValidation() { if (!version || busy || readOnlyVersion || readyForDelivery) return; setBusy("validate"); setMessage("Validating the campaign, including managed attachment matches…"); setError(""); try { const result = await validateVersion(settings, version.id, true); setMessage(result.ok ? "Validation passed." : "Validation finished with issues. Review the exceptions below."); resetDownstreamReview(); await reload(); } catch (err) { setMessage(""); setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(""); } } async function runBuild() { if (!version || busy || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted) return; setBusy("build"); setMessage("Building exact messages and resolving managed attachment versions…"); setError(""); try { const result = await buildVersion(settings, version.id, true); const review = await getCampaignJobs(settings, campaignId, { versionId: version.id, page: 1, pageSize: 50, validationStatus: showAllReviewJobs ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES, }); setReviewPage(1); setReviewJobs(review); setBuiltReviewRows(review.jobs.map(asRecord)); setJobsLoadedKey(`${version.id}:1:${showAllReviewJobs ? "all" : "issues"}`); setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s). The message review is ready below.`); setMessageReviewComplete(false); setReviewedMessageKeys(new Set()); setMockResult(null); setSelectedMockMessage(null); await reload(); } catch (err) { setMessage(""); setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(""); } } async function loadBuiltMessages(silent = false, targetPage = reviewPage) { if (!version || busy || !hasBuild) return; setBusy("inspect"); if (!silent) setMessage("Loading the built-message review…"); setError(""); try { const result = await getCampaignJobs(settings, campaignId, { versionId: version.id, page: targetPage, pageSize: 50, validationStatus: showAllReviewJobs ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES, }); const jobs = result.jobs.map(asRecord); setReviewJobs(result); setBuiltReviewRows(jobs); setJobsLoadedKey(`${version.id}:${targetPage}:${showAllReviewJobs ? "all" : "issues"}`); if (result.pages > 0 && targetPage > result.pages) setReviewPage(result.pages); if (!silent) setMessage(`Loaded ${jobs.length} message(s) on page ${result.page} of ${result.pages || 1}.`); } catch (err) { if (!silent) setMessage(""); setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(""); } } async function runMockSend() { if (!version || busy || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return; setBusy("mock"); setMessage("Running the complete mock-delivery flow…"); setError(""); setSelectedMockMessage(null); try { const response = await mockSendCampaign(settings, campaignId, { version_id: version.id, send: true, include_warnings: true, include_needs_review: false, append_sent: mockAppendSent, clear_mailbox: mockClearFirst, check_files: true, }); const result = asRecord(response.result ?? response); setMockResult(result); const sendResult = asRecord(result.send); setMessage(`Mock delivery finished. Captured ${String(sendResult.sent_count ?? 0)} message(s), failed ${String(sendResult.failed_count ?? 0)}, skipped ${String(sendResult.skipped_count ?? 0)}.`); } catch (err) { setMessage(""); setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(""); } } async function runSendNow() { if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted) return; setBusy("send"); setMessage(dryRun ? "Checking the built queue without sending…" : "Sending the locked campaign version…"); setSendResult(null); setError(""); try { const response = await sendCampaignNow(settings, campaignId, { version_id: version.id, include_warnings: true, check_files: false, validate_before_send: false, build_before_send: false, dry_run: dryRun, use_rate_limit: true, enqueue_imap_task: false, }); const result = asRecord(response.result ?? response); setSendResult(result); const sent = result.sent_count ?? 0; const failed = result.failed_count ?? 0; const unknown = result.outcome_unknown_count ?? 0; setMessage( dryRun ? "Dry run finished. No message was sent." : `Send finished. SMTP accepted ${String(sent)} message(s), failed ${String(failed)}, outcome unknown ${String(unknown)}.`, ); setSendConfirmOpen(false); await reload(); } catch (err) { setMessage(""); setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(""); } } async function completeInspection(_acceptBulk = false) { if (!version || busy || readOnlyVersion || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return; setBusy("inspect"); setError(""); setMessage("Recording the completed message review…"); try { const reviewed = new Set(reviewedMessageKeys); await updateCampaignReviewState(settings, campaignId, version.id, { inspection_complete: true, reviewed_message_keys: [...reviewed], }); setReviewedMessageKeys(reviewed); setNewlyReviewedRequiredKeys(new Set()); setMessageReviewComplete(true); setReviewJobs((current) => ({ ...current, review: { ...current.review, inspection_complete: true, reviewed_required_count: explicitReviewCount, }, })); setReviewConfirmOpen(false); setMessage("Message review completed and recorded for this build."); await reload(); } catch (err) { setMessage(""); setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(""); } } async function openMockMessage(id: string) { if (!id || busy === "mailbox") return; setBusy("mailbox"); setError(""); try { const response = await getMockMailboxMessage(settings, id); setSelectedMockMessage(response.message); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(""); } } async function openBuiltMessage(index: number) { const row = builtReviewRows[index]; if (!row) return; const jobId = String(row.id ?? ""); const reviewKey = String(row.review_key ?? builtMessageKey(row, index)); setReviewedMessageKeys((current) => { const next = new Set(current); next.add(reviewKey); return next; }); if (messageNeedsExplicitReview(row) && row.reviewed !== true) { setNewlyReviewedRequiredKeys((current) => new Set(current).add(reviewKey)); } if (!jobId || row.resolved_recipients || row.attachments || row.issues) { setSelectedBuiltIndex(index); return; } setBusy("inspect"); setError(""); try { const detail = await getCampaignJobDetail(settings, campaignId, jobId); setBuiltReviewRows((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, ...detail.job, review_key: item.review_key, reviewed: item.reviewed, attempts: detail.attempts } : item)); setSelectedBuiltIndex(index); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(""); } } function resetDownstreamReview() { setMessageReviewComplete(false); setBuiltReviewRows([]); setReviewJobs(emptyCampaignJobsResponse()); setReviewPage(1); setJobsLoadedKey(""); setReviewedMessageKeys(new Set()); setNewlyReviewedRequiredKeys(new Set()); setSelectedBuiltIndex(null); setMockResult(null); setSelectedMockMessage(null); } function scrollToStage(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); } const matchedAttachments = numberFrom(attachmentSummary, ["total_matched_files"]); const missingAttachments = numberFrom(attachmentSummary, ["missing_configs"]); const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]); const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]); const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null; const canCompleteInspection = blockingReviewCount === 0 && reviewRequiredCount > 0 && reviewedExplicitCount === explicitReviewCount; return (
Review & Send
{error && {error}} {message && {message}} {version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) && ( )}
{validationStale &&

The stored validation result is no longer an active delivery lock. Run validation again.

} {validationPresent && validationErrors === 0 && (

)} {validationErrors > 0 && (

Resolve the blocking entries, then validate again.

)}

Building freezes the current recipients, rendered content and exact managed-file versions into the queue reviewed below.

{reviewRequiredCount > 0 && ( )}
{automaticInspectionComplete && (

)} {blockingReviewCount > 0 && (

{blockingReviewCount} blocked or failed message(s) must be resolved before review can be completed.

)} {hasBuild && (
{reviewJobs.total} matching of {reviewJobs.total_unfiltered} built job(s)
Page {reviewJobs.pages === 0 ? 0 : reviewJobs.page} of {reviewJobs.pages}

Ready messages are hidden initially. Messages marked Needs review require an explicit open/review step. Warnings and exclusions are acceptable conditions and can be acknowledged together when completing review.

)}
{mockResult && (

Recipient outcomes

String(row.entry_id ?? row.entry_index ?? index)} emptyText="No mock delivery results were returned." className="data-table-wrap data-table compact-table" />

Captured mock messages

String(row.id ?? index)} emptyText="No mock messages were captured in this run." className="data-table-wrap data-table compact-table" />
)}
Recipients{jobsTotal || "—"}
Messages to send{builtCount || jobsTotal || "—"}
Matched attachments{matchedAttachments || "—"}
Missing / ambiguous{missingAttachments} / {ambiguousAttachments}
Rate limit{messagesPerMinute > 0 ? `${messagesPerMinute}/min` : "Not set"}
Minimum duration{estimatedMinutes ? `about ${estimatedMinutes} min` : "—"}
IMAP append{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}
Version{version ? `v${version.version_number}` : "—"}
A dry run checks the frozen queue and delivery configuration without contacting SMTP or IMAP.
{sendResult && (

Attempted {String(sendResult.attempted_count ?? "—")}, SMTP accepted {String(sendResult.sent_count ?? "—")}, failed {String(sendResult.failed_count ?? "—")}, outcome unknown {String(sendResult.outcome_unknown_count ?? 0)}, skipped {String(sendResult.skipped_count ?? "—")}.

{sendResultRows.length > 0 && ( `send-result-${index}`} emptyText="No send results returned." className="data-table-wrap data-table compact-table" /> )}
)}
{deliveryStarted ? "Delivery activity is available in the report and audit views." : "No real delivery has started for this campaign version."}
{selectedBuiltMessage && selectedBuiltIndex !== null && ( setSelectedBuiltIndex(null)} /> )} void completeInspection(true)} onCancel={() => setReviewConfirmOpen(false)} /> setSendConfirmOpen(false)} onConfirm={() => void runSendNow()} /> {selectedMockMessage && ( setSelectedMockMessage(null)} /> )}
); } function WorkflowNavigation({ stages, onSelect }: { stages: FlowStageDefinition[]; onSelect: (id: string) => void }) { return ( ); } function WorkflowStage({ stage, nextState, children, }: { stage: FlowStageDefinition; nextState?: FlowState; children: React.ReactNode; }) { const Icon = stage.icon; const locked = stage.state === "locked"; const [collapsed, setCollapsed] = useState(false); const style = { "--review-stage-color": stateColors[stage.state], "--review-next-stage-color": stateColors[nextState ?? stage.state], } as CSSProperties; return (
); } function BuiltMessagePreview({ campaignJson, entries, rows, index, onSelect, onClose, }: { campaignJson: Record; entries: Record[]; rows: Record[]; index: number; onSelect: (index: number) => void; onClose: () => void; }) { const row = rows[index] ?? {}; const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1); const entry = entries[entryIndex] ?? {}; const template = asRecord(campaignJson.template); const context = buildTemplatePreviewContext(campaignJson, entry); const ignoreEmptyFields = getBool(asRecord(campaignJson.validation_policy), "ignore_empty_fields", false); const html = renderTemplatePreviewText(getText(template, "html"), context, ignoreEmptyFields); const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields); const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "No subject"); const issues = asArray(row.issues).map(asRecord); const resolvedRecipients = asRecord(row.resolved_recipients); return ( 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "Issue")).join(" · ")}` : "Built without reported issues."} metaItems={builtMessageMetaItems(row)} attachments={builtMessageAttachments(row)} navigation={{ index, total: rows.length, onFirst: () => onSelect(0), onPrevious: () => onSelect(Math.max(0, index - 1)), onNext: () => onSelect(Math.min(rows.length - 1, index + 1)), onLast: () => onSelect(rows.length - 1), }} onClose={onClose} /> ); } function WorkflowFact({ label, value }: { label: string; value: React.ReactNode }) { return (
{label} {value}
); } function builtMessageColumns( openMessage: (index: number) => void, reviewedKeys: Set, ): DataGridColumn>[] { return [ { id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) }, { id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") }, { id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, columnType: "from-list", list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" }, value: (row) => String(row.validation_status ?? "unknown"), }, { id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) }, { id: "reviewed", header: "Reviewed", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "Reviewed" }, { value: "no", label: "Not reviewed" }] }, render: (row, index) => (row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index)))) ? : , value: (row, index) => (row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index)))) ? "yes" : "no" }, { id: "actions", header: "Actions", width: 110, sticky: "end", render: (_row, index) => }, ]; } function mockSendResultColumns(): DataGridColumn>[] { const options: DataGridListOption[] = [ { value: "sent", label: "Sent" }, { value: "failed", label: "Failed" }, { value: "skipped", label: "Skipped" }, { value: "ready", label: "Ready" }, ]; return [ { id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) }, { id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.status ?? "info") }, { id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).join(", ") || "—" }, { id: "smtp", header: "SMTP", width: 190, sortable: true, filterable: true, value: (row) => String(row.smtp_message_id ?? row.status ?? "—") }, { id: "imap", header: "IMAP", width: 190, sortable: true, filterable: true, value: (row) => String(row.imap_message_id ?? row.imap_status ?? "—") }, { id: "message", header: "Message", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? "—") }, ]; } function sendResultColumns(): DataGridColumn>[] { const statuses: DataGridListOption[] = [ { value: "smtp_accepted", label: "SMTP accepted" }, { value: "already_accepted", label: "Already accepted" }, { value: "outcome_unknown", label: "Outcome unknown" }, { value: "failed", label: "Failed" }, { value: "cancelled", label: "Cancelled" }, { value: "not_claimed", label: "Not claimed" }, { value: "dry_run", label: "Dry run" }, ]; return [ { id: "status", header: "Status", width: 160, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options: statuses, display: "pill" }, render: (row) => , value: (row) => String(row.status ?? "info") }, { id: "job", header: "Job", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? row.version_id ?? "—") }, { id: "message", header: "Message", width: "minmax(320px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) }, ]; } function mockMailboxColumns(openMessage: (id: string) => Promise): DataGridColumn>[] { const options: DataGridListOption[] = [ { value: "smtp", label: "SMTP" }, { value: "imap_append", label: "IMAP append" }, ]; return [ { id: "kind", header: "Kind", width: 125, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.kind ?? "mock") }, { id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { id: "envelope", header: "Envelope / folder", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")} → ${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` }, { id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) }, { id: "actions", header: "Actions", width: 110, sticky: "end", render: (row) => }, ]; } function builtMessageMetaItems(row: Record) { const recipients = asRecord(row.resolved_recipients); return [ { label: "From", value: formatSingleAddress(recipients.from) || "—" }, { label: "To", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") }, { label: "CC", value: formatAddressList(recipients.cc) || "—" }, { label: "BCC", value: formatAddressList(recipients.bcc) || "—" }, { label: "Validation", value: String(row.validation_status ?? "—") }, { label: "MIME size", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" }, ]; } function builtMessageAttachments(row: Record): MessagePreviewAttachment[] { return asArray(row.attachments).flatMap((value, index) => { const attachment = asRecord(value); const managedMatches = asArray(attachment.managed_matches).map(asRecord); if (managedMatches.length > 0) { return managedMatches.map((match, matchIndex) => ({ filename: String(match.filename ?? match.display_path ?? `Attachment ${index + 1}.${matchIndex + 1}`), detail: String(match.display_path ?? match.relative_path ?? attachment.label ?? ""), contentType: stringOrUndefined(match.content_type), sizeBytes: numberOrUndefined(match.size_bytes), archiveGroup: stringOrUndefined(attachment.zip_filename), archiveLabel: stringOrUndefined(attachment.zip_filename), })); } const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim())); if (matches.length > 0) { return matches.map((match) => ({ filename: match.split(/[\\/]/).pop() || String(attachment.label ?? `Attachment ${index + 1}`), detail: match, contentType: stringOrUndefined(attachment.content_type), sizeBytes: numberOrUndefined(attachment.size_bytes), archiveGroup: stringOrUndefined(attachment.zip_filename), archiveLabel: stringOrUndefined(attachment.zip_filename), })); } return [{ filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? attachment.label ?? `Attachment ${index + 1}`), detail: String(attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? ""), contentType: stringOrUndefined(attachment.content_type), sizeBytes: numberOrUndefined(attachment.size_bytes), archiveGroup: stringOrUndefined(attachment.zip_filename), archiveLabel: stringOrUndefined(attachment.zip_filename), }]; }); } function mockMessageMetaItems(message: MockMailboxMessage) { return [ { label: "From", value: message.from_header || message.envelope_from || "—" }, { label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" }, { label: "Kind", value: message.kind || "—" }, { label: "Folder", value: message.folder || "—" }, { label: "Message-ID", value: message.message_id || "—" }, { label: "Size", value: `${message.size_bytes || 0} bytes` }, ]; } function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] { return (message.attachments ?? []).map((attachment, index) => ({ filename: attachment.filename || `Attachment ${index + 1}`, contentType: attachment.content_type || undefined, sizeBytes: attachment.size_bytes ?? undefined, })); } function countResolvedAttachments(value: unknown): number { const archives = new Set(); let directCount = 0; for (const item of asArray(value)) { const attachment = asRecord(item); const zipFilename = String(attachment.zip_filename ?? "").trim(); if (zipFilename) { archives.add(zipFilename); continue; } const managedCount = asArray(attachment.managed_matches).length; if (managedCount > 0) { directCount += managedCount; continue; } const matchCount = asArray(attachment.matches).length; directCount += matchCount > 0 ? matchCount : 1; } return directCount + archives.size; } function messageNeedsExplicitReview(row: Record): boolean { return String(row.validation_status ?? "").toLowerCase() === "needs_review"; } function emptyCampaignJobsResponse(): CampaignJobsResponse { return { jobs: [], page: 1, page_size: 50, total: 0, total_unfiltered: 0, pages: 0, counts: {}, filtered_counts: {}, review: {}, }; } function storedMessageReviewState(version: CampaignVersionDetail | null): { buildToken: string; inspectionComplete: boolean; reviewedMessageKeys: string[]; } { const build = asRecord(version?.build_summary); const buildToken = String(build.build_token ?? build.built_at ?? ""); const review = asRecord(asRecord(version?.editor_state).review_send); if (!buildToken || String(review.build_token ?? "") !== buildToken) { return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] }; } return { buildToken, inspectionComplete: review.inspection_complete === true, reviewedMessageKeys: asArray(review.reviewed_message_keys).filter((value): value is string => typeof value === "string"), }; } function builtMessageKey(row: Record, index: number): string { return String(row.entry_id ?? row.entry_index ?? index); } function formatAddressList(value: unknown): string { return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", "); } function formatSingleAddress(value: unknown): string { const address = asRecord(value); const email = String(address.email ?? "").trim(); const name = String(address.name ?? "").trim(); if (name && email) return `${name} <${email}>`; return email || name; } function numberFrom(record: Record, keys: string[]): number { for (const key of keys) { const value = record[key]; if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); } return 0; } function numberOrUndefined(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); return undefined; } function stringOrUndefined(value: unknown): string | undefined { if (typeof value !== "string") return undefined; return value.trim() || undefined; } function stateLabel(state: FlowState): string { switch (state) { case "complete": return "Passed"; case "warning": return "Warnings"; case "danger": return "Blocked"; case "active": return "Available"; case "locked": return "Locked"; case "running": return "Running"; case "partial": return "Partial"; case "stale": return "Stale"; default: return humanize(state); } }