campaign sending prototype
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
buildVersion,
|
||||
getCampaignJobs,
|
||||
getCampaignJobDetail,
|
||||
getCampaignSummary,
|
||||
mockSendCampaign,
|
||||
sendCampaignNow,
|
||||
updateCampaignReviewState,
|
||||
validateVersion,
|
||||
type CampaignJobsResponse,
|
||||
type CampaignSummary,
|
||||
type CampaignVersionDetail,
|
||||
} from "../../api/campaigns";
|
||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||
@@ -40,6 +42,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import {
|
||||
asArray,
|
||||
asRecord,
|
||||
formatDateTime,
|
||||
getCampaignJson,
|
||||
getDeliverySection,
|
||||
humanize,
|
||||
@@ -101,6 +104,8 @@ const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "ex
|
||||
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 [liveSummary, setLiveSummary] = useState<CampaignSummary | null>(null);
|
||||
const [queueStatusLoading, setQueueStatusLoading] = useState(false);
|
||||
const version = data.currentVersion;
|
||||
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
|
||||
const inlineEntries = useMemo(
|
||||
@@ -109,8 +114,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
);
|
||||
const validation = asRecord(version?.validation_summary);
|
||||
const build = asRecord(version?.build_summary);
|
||||
const cards = data.summary?.cards;
|
||||
const attachmentSummary = asRecord(data.summary?.attachments);
|
||||
const summary = liveSummary ?? data.summary;
|
||||
const cards = summary?.cards;
|
||||
const attachmentSummary = asRecord(summary?.attachments);
|
||||
const delivery = getDeliverySection(version);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||
@@ -148,6 +154,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
setSelectedMockMessage(null);
|
||||
setSendResult(null);
|
||||
setSendConfirmOpen(false);
|
||||
setLiveSummary(null);
|
||||
}, [version?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -176,24 +183,91 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
void loadBuiltMessages(true, reviewPage);
|
||||
}, [version?.id, hasBuild, reviewPage, showAllReviewJobs, jobsLoadedKey]);
|
||||
|
||||
const statusCounts = asRecord(summary?.status_counts);
|
||||
const sendStatusCounts = asRecord(statusCounts.send);
|
||||
const attempts = asRecord(summary?.attempts);
|
||||
const summaryDelivery = asRecord(summary?.delivery);
|
||||
const queuedSendCount = numberFrom(sendStatusCounts, ["queued"]);
|
||||
const claimedSendCount = numberFrom(sendStatusCounts, ["claimed"]);
|
||||
const sendingSendCount = numberFrom(sendStatusCounts, ["sending"]);
|
||||
const activeSendCount = claimedSendCount + sendingSendCount;
|
||||
const queuedOrActiveCount = cards?.queued_or_active ?? queuedSendCount + activeSendCount;
|
||||
const notQueuedCount = cards?.not_attempted ?? numberFrom(sendStatusCounts, ["not_queued"]);
|
||||
const cancelledCount = cards?.cancelled ?? numberFrom(sendStatusCounts, ["cancelled"]);
|
||||
const outcomeUnknownCount = cards?.outcome_unknown ?? numberFrom(sendStatusCounts, ["outcome_unknown"]);
|
||||
const sendAttemptCount = numberFrom(attempts, ["send_attempts"]);
|
||||
const backgroundWorkersEnabled = summaryDelivery.background_workers_enabled === true || summaryDelivery.celery_enabled === true;
|
||||
const backgroundWorkersDisabled = summaryDelivery.background_workers_enabled === false || summaryDelivery.celery_enabled === false;
|
||||
const recentFailures = asArray(summary?.recent_failures).map(asRecord).slice(0, 5);
|
||||
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 deliveryHasTerminalOutcome = sentCount + failedCount + outcomeUnknownCount + cancelledCount > 0;
|
||||
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
|
||||
const deliveryQueued = currentWorkflowState === "queued";
|
||||
const deliveryStarted = ["sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed"].includes(currentWorkflowState);
|
||||
const deliverySending = activeSendCount > 0 || (currentWorkflowState === "sending" && queuedOrActiveCount > 0);
|
||||
const deliveryQueued = queuedOrActiveCount > 0 || (currentWorkflowState === "queued" && !deliveryHasTerminalOutcome);
|
||||
const deliveryStarted = deliverySending || deliveryHasTerminalOutcome || ["sent", "completed", "partially_completed", "outcome_unknown", "failed"].includes(currentWorkflowState);
|
||||
const queuedWithoutWorker = backgroundWorkersDisabled && queuedSendCount > 0 && activeSendCount === 0;
|
||||
const directQueuedSendAllowed = queuedWithoutWorker && !deliveryStarted;
|
||||
const selectedDryRun = dryRun && !directQueuedSendAllowed;
|
||||
const deliveryPartial = cards?.partially_completed === true || ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState);
|
||||
const deliveryComplete = queuedOrActiveCount === 0
|
||||
&& sentCount > 0
|
||||
&& failedCount === 0
|
||||
&& outcomeUnknownCount === 0
|
||||
&& cancelledCount === 0
|
||||
&& cards?.partially_completed !== true;
|
||||
const deliveryDanger = failedCount > 0 || outcomeUnknownCount > 0 || ["outcome_unknown", "failed"].includes(currentWorkflowState);
|
||||
const deliveryDisplayStatus = deliverySending
|
||||
? "sending"
|
||||
: deliveryQueued
|
||||
? "queued"
|
||||
: deliveryPartial
|
||||
? "partially_completed"
|
||||
: deliveryComplete
|
||||
? "completed"
|
||||
: deliveryDanger
|
||||
? failedCount > 0 ? "failed" : "outcome_unknown"
|
||||
: data.campaign?.status ?? version?.workflow_state ?? "not_started";
|
||||
const queueStatusNote = queuedWithoutWorker
|
||||
? "Queued jobs are waiting in the database, but background delivery workers are disabled on this server. Use Send queued now for a small dev run, or start Redis/Celery with CELERY_ENABLED=true."
|
||||
: deliverySending
|
||||
? "Delivery is being processed. This page refreshes queue counters without reloading the whole campaign workspace."
|
||||
: deliveryQueued && backgroundWorkersEnabled
|
||||
? "Queued jobs are waiting for a background delivery worker. If the counts do not change, check the worker process and queue logs."
|
||||
: deliveryQueued
|
||||
? "Queued jobs are waiting. If the counts do not change, check whether background delivery workers are enabled and running."
|
||||
: "";
|
||||
const queueStatusTone = queuedWithoutWorker ? "is-warning" : deliveryQueued || deliverySending ? "is-stale" : "";
|
||||
const historicalVersion = isHistoricalCampaignVersion(version, data.campaign?.current_version_id);
|
||||
const finalVersion = isFinalLockedVersion(version);
|
||||
const userLockedVersion = isUserLockedVersion(version);
|
||||
const readOnlyVersion = historicalVersion || userLockedVersion || finalVersion;
|
||||
|
||||
const refreshQueueStatus = useCallback(async (silent = true) => {
|
||||
if (!version?.id) return;
|
||||
setQueueStatusLoading(true);
|
||||
if (!silent) setMessage("Refreshing queue status…");
|
||||
setError("");
|
||||
try {
|
||||
const result = await getCampaignSummary(settings, campaignId, version.id);
|
||||
setLiveSummary(result);
|
||||
if (!silent) setMessage("Queue status refreshed.");
|
||||
} catch (err) {
|
||||
if (!silent) setMessage("");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setQueueStatusLoading(false);
|
||||
}
|
||||
}, [campaignId, setError, settings, version?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(deliveryQueued || currentWorkflowState === "sending") || loading || busy === "send") return;
|
||||
const handle = window.setTimeout(() => { void reload(); }, 3000);
|
||||
if (!(deliveryQueued || deliverySending) || loading || queueStatusLoading || busy === "send") return;
|
||||
const handle = window.setTimeout(() => { void refreshQueueStatus(true); }, 3000);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [deliveryQueued, currentWorkflowState, loading, busy, reload]);
|
||||
}, [deliveryQueued, deliverySending, loading, queueStatusLoading, busy, refreshQueueStatus]);
|
||||
|
||||
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
|
||||
const reviewMetadata = reviewJobs.review ?? {};
|
||||
@@ -265,25 +339,31 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
const mockGateSatisfied = mockComplete || downstreamDeliveryActivity;
|
||||
const sendState: FlowState = !mockGateSatisfied
|
||||
? "locked"
|
||||
: busy === "send" || deliveryQueued || currentWorkflowState === "sending"
|
||||
: busy === "send" || deliverySending
|
||||
? "running"
|
||||
: ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState)
|
||||
? "partial"
|
||||
: ["sent", "completed"].includes(currentWorkflowState)
|
||||
? "complete"
|
||||
: ["outcome_unknown", "failed"].includes(currentWorkflowState)
|
||||
? "danger"
|
||||
: "active";
|
||||
: queuedWithoutWorker
|
||||
? "warning"
|
||||
: deliveryQueued
|
||||
? "running"
|
||||
: deliveryPartial
|
||||
? "partial"
|
||||
: deliveryComplete || ["sent", "completed"].includes(currentWorkflowState)
|
||||
? "complete"
|
||||
: deliveryDanger
|
||||
? "danger"
|
||||
: "active";
|
||||
|
||||
const resultState: FlowState = !deliveryStarted
|
||||
? "locked"
|
||||
: currentWorkflowState === "sending"
|
||||
: deliverySending
|
||||
? "running"
|
||||
: ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState)
|
||||
: deliveryPartial
|
||||
? "partial"
|
||||
: ["sent", "completed"].includes(currentWorkflowState)
|
||||
: deliveryComplete || ["sent", "completed"].includes(currentWorkflowState)
|
||||
? "complete"
|
||||
: "danger";
|
||||
: deliveryDanger
|
||||
? "danger"
|
||||
: "active";
|
||||
|
||||
const stages: FlowStageDefinition[] = useMemo(() => [
|
||||
{
|
||||
@@ -450,9 +530,14 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
}
|
||||
|
||||
async function runSendNow() {
|
||||
if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted) return;
|
||||
if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || (deliveryQueued && !directQueuedSendAllowed) || deliveryStarted) return;
|
||||
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
|
||||
setBusy("send");
|
||||
setMessage(dryRun ? "Checking the built queue without sending…" : "Sending the locked campaign version…");
|
||||
setMessage(effectiveDryRun
|
||||
? "Checking the built queue without sending…"
|
||||
: directQueuedSendAllowed
|
||||
? "Sending the queued jobs synchronously…"
|
||||
: "Sending the locked campaign version…");
|
||||
setSendResult(null);
|
||||
setError("");
|
||||
try {
|
||||
@@ -462,7 +547,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
check_files: false,
|
||||
validate_before_send: false,
|
||||
build_before_send: false,
|
||||
dry_run: dryRun,
|
||||
dry_run: effectiveDryRun,
|
||||
use_rate_limit: true,
|
||||
enqueue_imap_task: false,
|
||||
});
|
||||
@@ -472,7 +557,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
const failed = result.failed_count ?? 0;
|
||||
const unknown = result.outcome_unknown_count ?? 0;
|
||||
setMessage(
|
||||
dryRun
|
||||
effectiveDryRun
|
||||
? "Dry run finished. No message was sent."
|
||||
: `Send finished. SMTP accepted ${String(sent)} message(s), failed ${String(failed)}, outcome unknown ${String(unknown)}.`,
|
||||
);
|
||||
@@ -599,6 +684,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void refreshQueueStatus(false)} disabled={!version || queueStatusLoading || Boolean(busy)}>
|
||||
{queueStatusLoading ? "Refreshing status…" : "Refresh status"}
|
||||
</Button>
|
||||
<Button onClick={reload} disabled={loading || Boolean(busy)}>Reload</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -771,6 +859,15 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
<div><span>IMAP append</span><strong>{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}</strong></div>
|
||||
<div><span>Version</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
|
||||
</div>
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact label="Queued" value={queuedSendCount} />
|
||||
<WorkflowFact label="Claimed / sending" value={activeSendCount} />
|
||||
<WorkflowFact label="Not queued" value={notQueuedCount} />
|
||||
<WorkflowFact label="Send attempts" value={sendAttemptCount} />
|
||||
</div>
|
||||
{queueStatusNote && (
|
||||
<p className={`review-flow-inline-note ${queueStatusTone}`.trim()}>{queueStatusNote}</p>
|
||||
)}
|
||||
<div className="review-send-controls">
|
||||
<ToggleSwitch
|
||||
label="Dry run"
|
||||
@@ -783,10 +880,16 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
<div className="button-row compact-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => dryRun ? void runSendNow() : setSendConfirmOpen(true)}
|
||||
disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted}
|
||||
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
|
||||
disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || (deliveryQueued && !directQueuedSendAllowed) || deliveryStarted}
|
||||
>
|
||||
{busy === "send" ? (dryRun ? "Running dry run…" : "Sending…") : dryRun ? "Run dry run" : "Send now"}
|
||||
{busy === "send"
|
||||
? (selectedDryRun ? "Running dry run…" : "Sending…")
|
||||
: directQueuedSendAllowed
|
||||
? "Send queued now"
|
||||
: selectedDryRun
|
||||
? "Run dry run"
|
||||
: "Send now"}
|
||||
</Button>
|
||||
</div>
|
||||
{sendResult && (
|
||||
@@ -810,13 +913,32 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact label="SMTP accepted" value={sentCount} />
|
||||
<WorkflowFact label="SMTP failed" value={failedCount} />
|
||||
<WorkflowFact label="Queued / active" value={queuedOrActiveCount} />
|
||||
<WorkflowFact label="Outcome unknown" value={outcomeUnknownCount} />
|
||||
<WorkflowFact label="IMAP appended" value={imapAppended} />
|
||||
<WorkflowFact label="IMAP failed" value={imapFailed} />
|
||||
</div>
|
||||
<div className="review-flow-result-line">
|
||||
<StatusBadge status={data.campaign?.status ?? version?.workflow_state ?? "not_started"} />
|
||||
<StatusBadge status={deliveryDisplayStatus} />
|
||||
<span>{deliveryStarted ? "Delivery activity is available in the report and audit views." : "No real delivery has started for this campaign version."}</span>
|
||||
</div>
|
||||
{recentFailures.length > 0 && (
|
||||
<div className="review-flow-data-section">
|
||||
<h3>Recent delivery failures</h3>
|
||||
<dl className="detail-list">
|
||||
{recentFailures.map((failure, index) => (
|
||||
<div key={String(failure.job_id ?? index)}>
|
||||
<dt>{String(failure.recipient_email ?? failure.entry_id ?? `#${String(failure.entry_index ?? index + 1)}`)}</dt>
|
||||
<dd>
|
||||
<strong>{humanize(String(failure.send_status ?? failure.imap_status ?? "failed"))}</strong>
|
||||
{failure.last_error ? `: ${String(failure.last_error)}` : ""}
|
||||
{failure.updated_at ? <span className="muted"> · {formatDateTime(String(failure.updated_at))}</span> : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => navigate("../report")}>Open report</Button>
|
||||
<Button onClick={() => navigate("../audit")}>Open audit log</Button>
|
||||
@@ -850,7 +972,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
open={sendConfirmOpen}
|
||||
title="Send this version now?"
|
||||
message={`This sends the frozen execution snapshot for version ${String(version?.version_number ?? "—")}: ${jobsTotal} planned job(s), ${builtCount} built, ${buildBlocked} blocked, ${String(attachmentSummary.total_matched_files ?? 0)} resolved file(s), rate limit ${String(rateLimit.messages_per_minute ?? "not set")}/minute, IMAP append ${imapAppend.enabled === true ? "enabled" : "disabled"}, snapshot ${version?.execution_snapshot_hash ? `${version.execution_snapshot_hash.slice(0, 12)}…` : "missing"}. SMTP-accepted or uncertain jobs will never be resent automatically.`}
|
||||
confirmLabel="Send now"
|
||||
confirmLabel={directQueuedSendAllowed ? "Send queued now" : "Send now"}
|
||||
tone="danger"
|
||||
busy={busy === "send"}
|
||||
onCancel={() => setSendConfirmOpen(false)}
|
||||
@@ -864,7 +986,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
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}
|
||||
recipientNote={selectedMockMessage.created_at ? formatDateTime(selectedMockMessage.created_at) : undefined}
|
||||
metaItems={mockMessageMetaItems(selectedMockMessage)}
|
||||
attachments={mockMessageAttachments(selectedMockMessage)}
|
||||
raw={selectedMockMessage.raw_eml}
|
||||
|
||||
Reference in New Issue
Block a user