Mock server workflow - first draft
This commit is contained in:
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user