Mock server workflow - first draft

This commit is contained in:
2026-06-11 11:27:14 +02:00
parent 8791de0959
commit 03c3f5f5c3
15 changed files with 1111 additions and 100 deletions

View File

@@ -10,7 +10,9 @@ import VersionLine from "./components/VersionLine";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import StatusBadge from "../../components/StatusBadge";
import { sendCampaignNow } from "../../api/campaigns";
import ToggleSwitch from "../../components/ToggleSwitch";
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";
@@ -27,6 +29,12 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
const [sendMessage, setSendMessage] = useState("");
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
const [mockBusy, setMockBusy] = useState(false);
const [mockMessage, setMockMessage] = useState("");
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
const [mockAppendSent, setMockAppendSent] = useState(true);
const [mockClearFirst, setMockClearFirst] = useState(true);
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
const readyForDelivery = isVersionReadyForDelivery(version);
const hasBuild = Boolean(version?.build_summary);
@@ -61,6 +69,51 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
}
}
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(() => {
@@ -70,22 +123,30 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
}, [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 (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading || sendBusy}>Send</PageTitle>
<PageTitle loading={loading || sendBusy || mockBusy}>Send</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading || sendBusy}>Reload</Button>
<Button onClick={reload} disabled={loading || sendBusy || mockBusy}>Reload</Button>
<Link to="../wizard/send"><Button variant="primary">Open Send Wizard</Button></Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{sendMessage && <div className="alert info">{sendMessage}</div>}
{mockMessage && <div className="alert info">{mockMessage}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Send snapshot. Copy to edit." />}
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
@@ -96,6 +157,123 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Mock delivery test" collapsible actions={<span className="muted small-note">Temporary sandbox. It never uses the real SMTP/IMAP server and never marks this version sent.</span>}>
<div className="button-row compact-actions">
<Button onClick={() => void runMockFlow(false, false)} disabled={!version || loading || mockBusy}>{mockBusy ? "Working…" : "Review mock steps"}</Button>
<Button variant="primary" onClick={() => void runMockFlow(true, false)} disabled={!version || loading || mockBusy}>Mock-send queueable</Button>
<Button onClick={() => void runMockFlow(true, true)} disabled={!version || loading || mockBusy}>Mock-send incl. review</Button>
</div>
<div className="toggle-row mock-send-options">
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={mockBusy} onChange={setMockClearFirst} />
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={mockBusy} onChange={setMockAppendSent} />
</div>
{mockResult && (
<div className="mock-flow-panel">
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead><tr><th>Step</th><th>Status</th><th>Summary</th></tr></thead>
<tbody>
{mockSteps.map((step) => (
<tr key={String(step.key)}>
<td>{String(step.label ?? step.key)}</td>
<td><StatusBadge status={String(step.status ?? "info")} /></td>
<td>{stringifyPreview(asRecord(step.summary), 240)}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3>Built messages</h3>
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead><tr><th>#</th><th>Recipient</th><th>Subject</th><th>Validation</th><th>Attachments</th><th>Issues</th></tr></thead>
<tbody>
{mockMessages.map((message) => {
const to = asArray(message.to).map(asRecord);
const issues = asArray(message.issues).map(asRecord);
return (
<tr key={String(message.entry_index ?? message.entry_id)}>
<td>{String(message.entry_index ?? "—")}</td>
<td>{to.map((item) => String(item.email ?? "")).filter(Boolean).join(", ") || "—"}</td>
<td>{String(message.subject ?? "—")}</td>
<td><StatusBadge status={String(message.validation_status ?? "unknown")} /></td>
<td>{String(message.attachment_count ?? 0)}</td>
<td>{issues.length === 0 ? "—" : issues.map((issue) => String(issue.message ?? issue.code ?? "issue")).join(" · ")}</td>
</tr>
);
})}
{mockMessages.length === 0 && <tr><td colSpan={6}>No messages were built.</td></tr>}
</tbody>
</table>
</div>
{mockRows.length > 0 && (
<>
<h3>Mock send results</h3>
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead><tr><th>#</th><th>Status</th><th>Recipient</th><th>SMTP</th><th>IMAP</th><th>Message</th></tr></thead>
<tbody>
{mockRows.map((row, index) => {
const to = asArray(row.to).map(asRecord);
return (
<tr key={index}>
<td>{String(row.entry_index ?? index + 1)}</td>
<td><StatusBadge status={String(row.status ?? "info")} /></td>
<td>{to.map((item) => String(item.email ?? "")).filter(Boolean).join(", ") || "—"}</td>
<td>{String(row.smtp_message_id ?? row.status ?? "—")}</td>
<td>{String(row.imap_message_id ?? row.imap_status ?? "—")}</td>
<td>{String(row.message ?? "—")}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
<h3>Mock mailbox</h3>
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead><tr><th>Kind</th><th>Subject</th><th>Envelope / folder</th><th>Attachments</th><th>Actions</th></tr></thead>
<tbody>
{mockMailboxMessages.map((message) => (
<tr key={String(message.id)}>
<td><span className="status-badge neutral">{String(message.kind ?? "mock")}</span></td>
<td>{String(message.subject ?? "—")}</td>
<td>{String(message.envelope_from ?? message.folder ?? "—")} {asArray(message.envelope_recipients).join(", ") || String(message.folder ?? "—")}</td>
<td>{String(message.attachment_count ?? 0)}</td>
<td><Button onClick={() => void openMockMessage(String(message.id))}>Open</Button></td>
</tr>
))}
{mockMailboxMessages.length === 0 && <tr><td colSpan={5}>No mock messages captured in this run.</td></tr>}
</tbody>
</table>
</div>
{selectedMockMessage && (
<div className="mock-message-detail">
<div className="subsection-heading split">
<h3>{selectedMockMessage.subject || "Mock message"}</h3>
<Button onClick={() => setSelectedMockMessage(null)}>Close details</Button>
</div>
<div className="detail-grid">
<div><span className="muted small-note">From</span><strong>{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}</strong></div>
<div><span className="muted small-note">To</span><strong>{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}</strong></div>
<div><span className="muted small-note">Size</span><strong>{selectedMockMessage.size_bytes || 0} bytes</strong></div>
<div><span className="muted small-note">Folder</span><strong>{selectedMockMessage.folder || "—"}</strong></div>
</div>
{selectedMockMessage.body_preview && <pre className="mock-message-preview">{selectedMockMessage.body_preview}</pre>}
{selectedMockMessage.raw_eml && <details><summary>Raw MIME</summary><pre className="mock-message-raw">{selectedMockMessage.raw_eml}</pre></details>}
</div>
)}
</div>
)}
</Card>
<Card title="Send campaign" actions={<span className="muted small-note">Requires a validated, locked and built version. Sending makes it final.</span>}>
{!readyForDelivery && <div className="alert warning compact-alert">Validate and lock this version in Review before dry-run or sending.</div>}
{readyForDelivery && !hasBuild && <div className="alert warning compact-alert">Build the queue in Review before dry-run or sending.</div>}