395 lines
21 KiB
TypeScript
395 lines
21 KiB
TypeScript
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<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);
|
|
|
|
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 (
|
|
<div className="content-pad workspace-data-page">
|
|
<div className="page-heading split workspace-heading">
|
|
<div>
|
|
<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 || mockBusy}>Reload</Button>
|
|
<Link to="../wizard/send"><Button variant="primary">Open Send Wizard</Button></Link>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
{sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage}>{sendMessage}</DismissibleAlert>}
|
|
{mockMessage && <DismissibleAlert tone="info" resetKey={mockMessage}>{mockMessage}</DismissibleAlert>}
|
|
{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…"}>
|
|
<div className="metric-grid">
|
|
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
|
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
|
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
|
|
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
|
</div>
|
|
|
|
<ReviewWorkflowCards
|
|
settings={settings}
|
|
version={version}
|
|
summary={data.summary}
|
|
loading={loading}
|
|
reload={reload}
|
|
setError={setError}
|
|
/>
|
|
|
|
<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">
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-mock-steps`}
|
|
rows={mockSteps}
|
|
columns={mockStepColumns()}
|
|
getRowKey={(step, index) => String(step.key ?? index)}
|
|
emptyText="No mock steps returned."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
|
|
<h3>Built messages</h3>
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-mock-built-messages`}
|
|
rows={mockMessages}
|
|
columns={mockBuiltMessageColumns()}
|
|
getRowKey={(message, index) => String(message.entry_index ?? message.entry_id ?? index)}
|
|
emptyText="No messages were built."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
|
|
{mockRows.length > 0 && (
|
|
<>
|
|
<h3>Mock send results</h3>
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-mock-send-results`}
|
|
rows={mockRows}
|
|
columns={mockSendResultColumns()}
|
|
getRowKey={(_row, index) => `mock-send-${index}`}
|
|
emptyText="No mock send results returned."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<h3>Mock mailbox</h3>
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-mock-mailbox`}
|
|
rows={mockMailboxMessages}
|
|
columns={mockMailboxColumns(openMockMessage)}
|
|
getRowKey={(message, index) => String(message.id ?? index)}
|
|
emptyText="No mock messages captured in this run."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
|
|
|
|
|
|
</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 && <DismissibleAlert tone="warning" compact>Validate and lock this version in Review before dry-run or sending.</DismissibleAlert>}
|
|
{readyForDelivery && !hasBuild && <DismissibleAlert tone="warning" compact>Build the queue in Review before dry-run or sending.</DismissibleAlert>}
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={() => void runSendNow(true)} disabled={!version || loading || sendBusy || !readyForDelivery || !hasBuild}>Dry run</Button>
|
|
<Button variant="primary" onClick={() => setSendConfirmOpen(true)} disabled={!version || loading || sendBusy || !readyForDelivery || !hasBuild}>
|
|
{sendBusy ? "Sending…" : "Send now"}
|
|
</Button>
|
|
</div>
|
|
<dl className="detail-list compact-detail-list">
|
|
<div><dt>Campaign status</dt><dd><StatusBadge status={data.campaign?.status ?? "unknown"} /></dd></div>
|
|
<div><dt>Version state</dt><dd>{version?.workflow_state ?? "—"}</dd></div>
|
|
<div><dt>Version lock</dt><dd>{versionLockReason(version)}</dd></div>
|
|
<div><dt>Validation/build</dt><dd>{readyForDelivery ? "Validated and locked" : "Not ready"} · {hasBuild ? "Build available" : "Not built"}</dd></div>
|
|
</dl>
|
|
{sendResult && (
|
|
<div className="send-result-panel">
|
|
<strong>Last send result</strong>
|
|
<p className="muted small-note">
|
|
Attempted {String(sendResult.attempted_count ?? "—")}, sent {String(sendResult.sent_count ?? "—")}, failed {String(sendResult.failed_count ?? "—")}, skipped {String(sendResult.skipped_count ?? "—")}.
|
|
</p>
|
|
{resultRows.length > 0 && (
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-send-results`}
|
|
rows={resultRows.slice(0, 10)}
|
|
columns={sendResultColumns()}
|
|
getRowKey={(_row, index) => `send-result-${index}`}
|
|
emptyText="No send results returned."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
<div className="dashboard-grid">
|
|
<Card title="Delivery rate limit">
|
|
<dl className="detail-list">
|
|
<div><dt>Messages/minute</dt><dd>{String(rateLimit.messages_per_minute ?? "—")}</dd></div>
|
|
<div><dt>Concurrency</dt><dd>{String(rateLimit.concurrency ?? "—")}</dd></div>
|
|
<div><dt>Max attempts</dt><dd>{String(retry.max_attempts ?? "—")}</dd></div>
|
|
<div><dt>Backoff</dt><dd>{getNestedString(delivery, ["retry", "backoff_seconds"])}</dd></div>
|
|
</dl>
|
|
</Card>
|
|
|
|
<Card title="Sent-folder append">
|
|
<dl className="detail-list">
|
|
<div><dt>Enabled</dt><dd>{String(Boolean(imapAppend.enabled))}</dd></div>
|
|
<div><dt>Folder</dt><dd>{String(imapAppend.folder || "auto")}</dd></div>
|
|
<div><dt>Appended</dt><dd>{cards?.imap_appended ?? "—"}</dd></div>
|
|
<div><dt>Append failed</dt><dd>{cards?.imap_failed ?? "—"}</dd></div>
|
|
</dl>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card title="Sending rule">
|
|
<p className="muted">
|
|
SMTP sending and IMAP append-to-Sent remain separate states. A successful SMTP send is still successful even if appending to Sent fails.
|
|
</p>
|
|
</Card>
|
|
</LoadingFrame>
|
|
{selectedMockMessage && (
|
|
<MessagePreviewOverlay
|
|
title="Captured mock mail"
|
|
subject={selectedMockMessage.subject || "Mock message"}
|
|
bodyMode="text"
|
|
text={selectedMockMessage.body_preview || ""}
|
|
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
|
|
recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined}
|
|
metaItems={mockMessageMetaItems(selectedMockMessage)}
|
|
attachments={mockMessageAttachments(selectedMockMessage)}
|
|
raw={selectedMockMessage.raw_eml}
|
|
rawLabel="Raw MIME"
|
|
onClose={() => setSelectedMockMessage(null)}
|
|
/>
|
|
)}
|
|
<ConfirmDialog
|
|
open={sendConfirmOpen}
|
|
title="Send this version now?"
|
|
message="This sends the built queue and keeps this version as the final audit snapshot. Further changes require a new editable copy."
|
|
confirmLabel="Send now"
|
|
tone="danger"
|
|
busy={sendBusy}
|
|
onCancel={() => setSendConfirmOpen(false)}
|
|
onConfirm={() => {
|
|
setSendConfirmOpen(false);
|
|
void runSendNow(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function mockStepColumns(): DataGridColumn<Record<string, unknown>>[] {
|
|
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) => <StatusBadge status={String(step.status ?? "info")} />, 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<Record<string, unknown>>[] {
|
|
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) => <StatusBadge status={String(message.validation_status ?? "unknown")} />, 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<Record<string, unknown>>[] {
|
|
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) => <StatusBadge status={String(row.status ?? "info")} />, 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<void>): DataGridColumn<Record<string, unknown>>[] {
|
|
return [
|
|
{ id: "kind", header: "Kind", width: 130, sortable: true, filterable: true, sticky: "start", render: (message) => <span className="status-badge neutral">{String(message.kind ?? "mock")}</span>, 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) => <Button onClick={() => void openMockMessage(String(message.id))}>Open</Button> }
|
|
];
|
|
}
|
|
|
|
function sendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
|
return [
|
|
{ id: "status", header: "Status", width: 140, sortable: true, filterable: true, sticky: "start", render: (row) => <StatusBadge status={String(row.status ?? "info")} />, 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
|
|
}));
|
|
}
|