import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import ConfirmDialog from "../../components/ConfirmDialog"; import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import ReviewWorkflowCards from "./components/ReviewWorkflowCards"; import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import Card from "../../components/Card"; import MetricCard from "../../components/MetricCard"; import StatusBadge from "../../components/StatusBadge"; import ToggleSwitch from "../../components/ToggleSwitch"; import DismissibleAlert from "../../components/DismissibleAlert"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import { mockSendCampaign, sendCampaignNow } from "../../api/campaigns"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asArray, asRecord, getDeliverySection, getNestedString, isAuditLockedVersion, isVersionReadyForDelivery, stringifyPreview, versionLockReason } from "./utils/campaignView"; export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const version = data.currentVersion; const locked = isAuditLockedVersion(version); const cards = data.summary?.cards; const delivery = getDeliverySection(version); const rateLimit = asRecord(delivery.rate_limit); const imapAppend = asRecord(delivery.imap_append_sent); const retry = asRecord(delivery.retry); const [sendBusy, setSendBusy] = useState(false); const [sendMessage, setSendMessage] = useState(""); const [sendResult, setSendResult] = useState | null>(null); const [sendConfirmOpen, setSendConfirmOpen] = useState(false); const [mockBusy, setMockBusy] = useState(false); const [mockMessage, setMockMessage] = useState(""); const [mockResult, setMockResult] = useState | null>(null); const [mockAppendSent, setMockAppendSent] = useState(true); const [mockClearFirst, setMockClearFirst] = useState(true); const [selectedMockMessage, setSelectedMockMessage] = useState(null); const readyForDelivery = isVersionReadyForDelivery(version); const hasBuild = Boolean(version?.build_summary); async function runSendNow(dryRun = false) { if (!version || sendBusy || !readyForDelivery || !hasBuild) return; setSendBusy(true); setSendMessage(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; setSendMessage(dryRun ? "Dry run finished." : `Send finished. Sent ${String(sent)} message(s), failed ${String(failed)}.`); await reload(); } catch (err) { setSendMessage(""); setError(err instanceof Error ? err.message : String(err)); } finally { setSendBusy(false); } } async function runMockFlow(send: boolean, includeNeedsReview = false) { if (!version || mockBusy) return; setMockBusy(true); setMockMessage(send ? "Running mock delivery…" : "Validating and building mock delivery plan…"); setMockResult(null); setSelectedMockMessage(null); setError(""); try { const response = await mockSendCampaign(settings, campaignId, { version_id: version.id, send, include_warnings: true, include_needs_review: includeNeedsReview, append_sent: mockAppendSent, clear_mailbox: send && mockClearFirst, check_files: false }); const result = asRecord(response.result ?? response); const sendSection = asRecord(result.send); setMockResult(result); setMockMessage( send ? `Mock send finished. Captured ${String(sendSection.sent_count ?? 0)} SMTP message(s), failed ${String(sendSection.failed_count ?? 0)}, skipped ${String(sendSection.skipped_count ?? 0)}.` : "Mock review finished. Inspect the steps and job details below before sending." ); } catch (err) { setMockMessage(""); setError(err instanceof Error ? err.message : String(err)); } finally { setMockBusy(false); } } async function openMockMessage(id: string) { setMockBusy(true); try { const response = await getMockMailboxMessage(settings, id); setSelectedMockMessage(response.message); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setMockBusy(false); } } const queuedOrSending = ["queued", "sending"].includes(data.campaign?.status ?? "") || ["queued", "sending"].includes(version?.workflow_state ?? ""); useEffect(() => { if (!queuedOrSending || loading || sendBusy) return; const handle = window.setTimeout(() => { void reload(); }, 3000); return () => window.clearTimeout(handle); }, [queuedOrSending, loading, reload, sendBusy]); const resultRows = asArray(sendResult?.results).map(asRecord); const mockSteps = asArray(mockResult?.steps).map(asRecord); const mockBuild = asRecord(mockResult?.build); const mockMessages = asArray(mockBuild.messages).map(asRecord); const mockSend = asRecord(mockResult?.send); const mockRows = asArray(mockSend.results).map(asRecord); const mockMailbox = asRecord(mockResult?.mailbox); const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord); return (
Send
{error && {error}} {sendMessage && {sendMessage}} {mockMessage && {mockMessage}} {locked && }
Temporary sandbox. It never uses the real SMTP/IMAP server and never marks this version sent.}>
{mockResult && (
String(step.key ?? index)} emptyText="No mock steps returned." className="data-table-wrap data-table compact-table" />

Built messages

String(message.entry_index ?? message.entry_id ?? index)} emptyText="No messages were built." className="data-table-wrap data-table compact-table" /> {mockRows.length > 0 && ( <>

Mock send results

`mock-send-${index}`} emptyText="No mock send results returned." className="data-table-wrap data-table compact-table" /> )}

Mock mailbox

String(message.id ?? index)} emptyText="No mock messages captured in this run." className="data-table-wrap data-table compact-table" />
)}
Requires a validated, locked and built version. Sending makes it final.}> {!readyForDelivery && Validate and lock this version in Review before dry-run or sending.} {readyForDelivery && !hasBuild && Build the queue in Review before dry-run or sending.}
Campaign status
Version state
{version?.workflow_state ?? "—"}
Version lock
{versionLockReason(version)}
Validation/build
{readyForDelivery ? "Validated and locked" : "Not ready"} · {hasBuild ? "Build available" : "Not built"}
{sendResult && (
Last send result

Attempted {String(sendResult.attempted_count ?? "—")}, sent {String(sendResult.sent_count ?? "—")}, failed {String(sendResult.failed_count ?? "—")}, skipped {String(sendResult.skipped_count ?? "—")}.

{resultRows.length > 0 && ( `send-result-${index}`} emptyText="No send results returned." className="data-table-wrap data-table compact-table" /> )}
)}
Messages/minute
{String(rateLimit.messages_per_minute ?? "—")}
Concurrency
{String(rateLimit.concurrency ?? "—")}
Max attempts
{String(retry.max_attempts ?? "—")}
Backoff
{getNestedString(delivery, ["retry", "backoff_seconds"])}
Enabled
{String(Boolean(imapAppend.enabled))}
Folder
{String(imapAppend.folder || "auto")}
Appended
{cards?.imap_appended ?? "—"}
Append failed
{cards?.imap_failed ?? "—"}

SMTP sending and IMAP append-to-Sent remain separate states. A successful SMTP send is still successful even if appending to Sent fails.

{selectedMockMessage && ( setSelectedMockMessage(null)} /> )} setSendConfirmOpen(false)} onConfirm={() => { setSendConfirmOpen(false); void runSendNow(false); }} />
); } function mockStepColumns(): DataGridColumn>[] { return [ { id: "step", header: "Step", width: 190, sortable: true, filterable: true, sticky: "start", value: (step) => String(step.label ?? step.key ?? "") }, { id: "status", header: "Status", width: 140, sortable: true, filterable: true, render: (step) => , value: (step) => String(step.status ?? "info") }, { id: "summary", header: "Summary", width: "minmax(320px, 1fr)", filterable: true, render: (step) => stringifyPreview(asRecord(step.summary), 240), value: (step) => stringifyPreview(asRecord(step.summary), 240) } ]; } function mockBuiltMessageColumns(): DataGridColumn>[] { return [ { id: "number", header: "#", width: 80, sortable: true, sticky: "start", value: (message) => String(message.entry_index ?? "—") }, { id: "recipient", header: "Recipient", width: 240, resizable: true, sortable: true, filterable: true, value: (message) => asArray(message.to).map(asRecord).map((item) => String(item.email ?? "")).filter(Boolean).join(", ") || "—" }, { id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (message) => String(message.subject ?? "—") }, { id: "validation", header: "Validation", width: 150, sortable: true, filterable: true, render: (message) => , value: (message) => String(message.validation_status ?? "unknown") }, { id: "attachments", header: "Attachments", width: 130, sortable: true, filterable: true, value: (message) => String(message.attachment_count ?? 0) }, { id: "issues", header: "Issues", width: 260, resizable: true, filterable: true, value: (message) => { const issues = asArray(message.issues).map(asRecord); return issues.length === 0 ? "—" : issues.map((issue) => String(issue.message ?? issue.code ?? "issue")).join(" · "); } } ]; } function mockSendResultColumns(): DataGridColumn>[] { return [ { id: "number", header: "#", width: 80, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) }, { id: "status", header: "Status", width: 140, sortable: true, filterable: true, render: (row) => , value: (row) => String(row.status ?? "info") }, { id: "recipient", header: "Recipient", width: 240, resizable: true, sortable: true, filterable: true, value: (row) => asArray(row.to).map(asRecord).map((item) => String(item.email ?? "")).filter(Boolean).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)", filterable: true, value: (row) => String(row.message ?? "—") } ]; } function mockMailboxColumns(openMockMessage: (id: string) => Promise): DataGridColumn>[] { return [ { id: "kind", header: "Kind", width: 130, sortable: true, filterable: true, sticky: "start", render: (message) => {String(message.kind ?? "mock")}, value: (message) => String(message.kind ?? "mock") }, { id: "subject", header: "Subject", width: "minmax(240px, 1fr)", sortable: true, filterable: true, value: (message) => String(message.subject ?? "—") }, { id: "envelope", header: "Envelope / folder", width: 300, resizable: true, filterable: true, value: (message) => `${String(message.envelope_from ?? message.folder ?? "—")} → ${asArray(message.envelope_recipients).join(", ") || String(message.folder ?? "—")}` }, { id: "attachments", header: "Attachments", width: 130, sortable: true, filterable: true, value: (message) => String(message.attachment_count ?? 0) }, { id: "actions", header: "Actions", width: 110, sticky: "end", render: (message) => } ]; } function sendResultColumns(): DataGridColumn>[] { return [ { id: "status", header: "Status", width: 140, sortable: true, filterable: true, sticky: "start", 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)", filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) } ]; } 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 })); }