1701 lines
83 KiB
TypeScript
1701 lines
83 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
BarChart3,
|
|
Check,
|
|
ChevronDown,
|
|
FlaskConical,
|
|
LockKeyhole,
|
|
PackageCheck,
|
|
Send,
|
|
ShieldCheck,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import type { ApiSettings } from "../../types";
|
|
import {
|
|
appendSent,
|
|
buildVersion,
|
|
getCampaignJobs,
|
|
getCampaignJobDetail,
|
|
getCampaignSummary,
|
|
mockSendCampaign,
|
|
sendCampaignNow,
|
|
updateCampaignReviewState,
|
|
validateVersion,
|
|
type CampaignJobsResponse,
|
|
type CampaignSummary,
|
|
type CampaignVersionDetail,
|
|
} from "../../api/campaigns";
|
|
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
|
import { Button, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
|
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
|
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
|
import { Dialog } from "@govoplan/core-webui";
|
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
|
import { LoadingFrame } from "@govoplan/core-webui";
|
|
import { PageTitle } from "@govoplan/core-webui";
|
|
import { StatusBadge } from "@govoplan/core-webui";
|
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
|
import { InlineHelp } from "@govoplan/core-webui";
|
|
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
|
import VersionLine from "./components/VersionLine";
|
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
import {
|
|
asArray,
|
|
asRecord,
|
|
formatDateTime,
|
|
getCampaignJson,
|
|
getDeliverySection,
|
|
humanize,
|
|
isFinalLockedVersion,
|
|
isHistoricalCampaignVersion,
|
|
isUserLockedVersion,
|
|
isVersionReadyForDelivery,
|
|
stringifyPreview,
|
|
} from "./utils/campaignView";
|
|
import { getBool, getText } from "./utils/draftEditor";
|
|
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders";
|
|
|
|
type FlowState =
|
|
| "complete"
|
|
| "warning"
|
|
| "danger"
|
|
| "active"
|
|
| "locked"
|
|
| "running"
|
|
| "partial"
|
|
| "stale"
|
|
| "pending";
|
|
|
|
type FlowStageDefinition = {
|
|
id: string;
|
|
title: string;
|
|
shortTitle: string;
|
|
description: string;
|
|
icon: LucideIcon;
|
|
state: FlowState;
|
|
stateLabel: string;
|
|
lockReason?: string;
|
|
};
|
|
|
|
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "imap" | "";
|
|
|
|
const stateColors: Record<FlowState, string> = {
|
|
complete: "var(--green)",
|
|
warning: "var(--amber)",
|
|
danger: "var(--red)",
|
|
active: "var(--blue)",
|
|
locked: "var(--line-dark)",
|
|
running: "var(--blue)",
|
|
partial: "#9b86c7",
|
|
stale: "#9b86c7",
|
|
pending: "var(--muted)",
|
|
};
|
|
|
|
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
|
|
{ value: "ready", label: "Ready" },
|
|
{ value: "warning", label: "Warning" },
|
|
{ value: "needs_review", label: "Needs review" },
|
|
{ value: "blocked", label: "Blocked" },
|
|
{ value: "excluded", label: "Excluded" },
|
|
];
|
|
|
|
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
|
|
|
|
export default function ReviewSendPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
|
const navigate = useNavigate();
|
|
const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox");
|
|
const mockWorkflowAvailable = devMailboxCapability?.enabled === true;
|
|
const [mockVerificationRequired, setMockVerificationRequired] = useState(true);
|
|
const [mockMailboxPreviewEnabled, setMockMailboxPreviewEnabled] = useState(true);
|
|
const mockWorkflowRequired = mockWorkflowAvailable && mockVerificationRequired;
|
|
const mockMailboxPreviewActive = mockWorkflowAvailable && mockMailboxPreviewEnabled;
|
|
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(
|
|
() => asArray(asRecord(campaignJson.entries).inline).map(asRecord),
|
|
[campaignJson],
|
|
);
|
|
const validation = asRecord(version?.validation_summary);
|
|
const build = asRecord(version?.build_summary);
|
|
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);
|
|
|
|
const [busy, setBusy] = useState<WorkflowBusy>("");
|
|
const [message, setMessage] = useState("");
|
|
const [messageReviewComplete, setMessageReviewComplete] = useState(false);
|
|
const [builtReviewRows, setBuiltReviewRows] = useState<Record<string, unknown>[]>([]);
|
|
const [reviewJobs, setReviewJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
|
const [reviewPage, setReviewPage] = useState(1);
|
|
const [showAllReviewJobs, setShowAllReviewJobs] = useState(false);
|
|
const [jobsLoadedKey, setJobsLoadedKey] = useState("");
|
|
const [reviewedMessageKeys, setReviewedMessageKeys] = useState<Set<string>>(() => new Set());
|
|
const [newlyReviewedRequiredKeys, setNewlyReviewedRequiredKeys] = useState<Set<string>>(() => new Set());
|
|
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
|
|
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
|
|
const [mockClearFirst, setMockClearFirst] = useState(true);
|
|
const [mockAppendSent, setMockAppendSent] = useState(true);
|
|
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
|
|
const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false);
|
|
const [dryRun, setDryRun] = useState(false);
|
|
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
|
|
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
|
|
const [imapAppendResult, setImapAppendResult] = useState<Record<string, unknown> | null>(null);
|
|
const [imapDiagnostics, setImapDiagnostics] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
|
const [selectedDeliveryJobDetail, setSelectedDeliveryJobDetail] = useState<Record<string, unknown> | null>(null);
|
|
const persistedReview = storedMessageReviewState(version);
|
|
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}`;
|
|
|
|
useEffect(() => {
|
|
setBuiltReviewRows([]);
|
|
setReviewJobs(emptyCampaignJobsResponse());
|
|
setReviewPage(1);
|
|
setJobsLoadedKey("");
|
|
setNewlyReviewedRequiredKeys(new Set());
|
|
setSelectedBuiltIndex(null);
|
|
setMockResult(null);
|
|
setSelectedMockMessage(null);
|
|
setSendResult(null);
|
|
setImapAppendResult(null);
|
|
setImapDiagnostics(emptyCampaignJobsResponse());
|
|
setSelectedDeliveryJobDetail(null);
|
|
setSendConfirmOpen(false);
|
|
setLiveSummary(null);
|
|
}, [version?.id]);
|
|
|
|
useEffect(() => {
|
|
setMessageReviewComplete(persistedReview.inspectionComplete);
|
|
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
|
|
}, [version?.id, persistedReviewKey]);
|
|
|
|
useEffect(() => {
|
|
if (!mockMailboxPreviewActive) setSelectedMockMessage(null);
|
|
}, [mockMailboxPreviewActive]);
|
|
|
|
const validationPresent = Object.keys(validation).length > 0;
|
|
const validationOk = validation.ok === true;
|
|
const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]);
|
|
const validationWarnings = numberFrom(validation, ["warning_count", "warnings"]);
|
|
const validationIssues = asArray(validation.issues).map(asRecord);
|
|
const visibleValidationIssues = validationIssues.slice(0, 10);
|
|
const readyForDelivery = isVersionReadyForDelivery(version);
|
|
const validationStale = validationOk && !readyForDelivery;
|
|
|
|
const buildPresent = Object.keys(build).length > 0;
|
|
const builtCount = numberFrom(build, ["built_count", "ready_count", "built", "messages_built"]);
|
|
const buildBlocked = numberFrom(build, ["blocked_count", "blocked"]);
|
|
const buildNeedsReview = numberFrom(build, ["needs_review_count", "needs_review"]);
|
|
const buildWarnings = numberFrom(build, ["warning_count", "warnings"]);
|
|
const hasBuild = buildPresent && (builtCount > 0 || version?.workflow_state === "built");
|
|
|
|
useEffect(() => {
|
|
if (!version?.id || !hasBuild) return;
|
|
const expectedKey = `${version.id}:${reviewPage}:${showAllReviewJobs ? "all" : "issues"}`;
|
|
if (jobsLoadedKey === expectedKey) return;
|
|
void loadBuiltMessages(true, reviewPage);
|
|
}, [version?.id, hasBuild, reviewPage, showAllReviewJobs, jobsLoadedKey]);
|
|
|
|
const statusCounts = asRecord(summary?.status_counts);
|
|
const sendStatusCounts = asRecord(statusCounts.send);
|
|
const imapStatusCounts = asRecord(statusCounts.imap);
|
|
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 imapPending = numberFrom(imapStatusCounts, ["pending"]);
|
|
const imapSkipped = numberFrom(imapStatusCounts, ["skipped"]);
|
|
const deliveryHasTerminalOutcome = sentCount + failedCount + outcomeUnknownCount + cancelledCount > 0;
|
|
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
|
|
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 || deliverySending) || loading || queueStatusLoading || busy === "send") return;
|
|
const handle = window.setTimeout(() => { void refreshQueueStatus(true); }, 3000);
|
|
return () => window.clearTimeout(handle);
|
|
}, [deliveryQueued, deliverySending, loading, queueStatusLoading, busy, refreshQueueStatus]);
|
|
|
|
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
|
|
const reviewMetadata = reviewJobs.review ?? {};
|
|
const blockingReviewCount = Number(reviewMetadata.blocking_count ?? 0);
|
|
const explicitReviewCount = Number(reviewMetadata.required_count ?? 0);
|
|
const bulkAcceptableCount = Number(reviewMetadata.bulk_acceptable_count ?? 0);
|
|
const reviewedExplicitCount = Math.min(
|
|
explicitReviewCount,
|
|
Number(reviewMetadata.reviewed_required_count ?? 0) + newlyReviewedRequiredKeys.size,
|
|
);
|
|
const reviewRequiredCount = explicitReviewCount + bulkAcceptableCount;
|
|
const reviewedRequiredCount = reviewedExplicitCount;
|
|
const automaticInspectionComplete = reviewJobs.total_unfiltered > 0
|
|
&& blockingReviewCount === 0
|
|
&& reviewRequiredCount === 0;
|
|
|
|
const mockSend = asRecord(mockResult?.send);
|
|
const mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]);
|
|
const mockFailed = numberFrom(mockSend, ["failed_count"]);
|
|
const mockSkipped = numberFrom(mockSend, ["skipped_count"]);
|
|
const mockComplete = Boolean(mockResult) && mockSent > 0 && mockFailed === 0 && mockSkipped === 0;
|
|
const mockPartial = Boolean(mockResult) && mockSent > 0 && (mockFailed > 0 || mockSkipped > 0);
|
|
const mockRows = asArray(mockSend.results).map(asRecord);
|
|
const mockMailbox = asRecord(mockResult?.mailbox);
|
|
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
|
|
const sendResultRows = asArray(sendResult?.results).map(asRecord);
|
|
const imapAppendResultRows = asArray(imapAppendResult?.results).map(asRecord);
|
|
const imapDiagnosticRows = imapDiagnostics.jobs.map(asRecord);
|
|
const imapDiagnosticsPending = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "pending").length;
|
|
const imapDiagnosticsFailed = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "failed").length;
|
|
const imapPendingForDisplay = Math.max(imapPending, imapDiagnosticsPending);
|
|
const imapFailedForDisplay = Math.max(imapFailed, imapDiagnosticsFailed);
|
|
const canAppendPendingImap = Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && !historicalVersion && !userLockedVersion;
|
|
|
|
const validationReviewState: FlowState = busy === "validate"
|
|
? "running"
|
|
: validationStale
|
|
? "stale"
|
|
: validationPresent && !validationOk
|
|
? "danger"
|
|
: readyForDelivery && (validationWarnings > 0 || (cards?.needs_attention ?? 0) > 0)
|
|
? "warning"
|
|
: readyForDelivery
|
|
? "complete"
|
|
: "active";
|
|
|
|
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
|
|
const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
|
|
|
|
const buildReviewState: FlowState = !readyForDelivery
|
|
? "locked"
|
|
: busy === "build" || busy === "inspect"
|
|
? "running"
|
|
: hasBuild && buildBlocked > 0
|
|
? "danger"
|
|
: hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied)
|
|
? "warning"
|
|
: hasBuild && inspectionSatisfied
|
|
? "complete"
|
|
: "active";
|
|
|
|
const mockState: FlowState = !mockWorkflowAvailable
|
|
? "locked"
|
|
: !inspectionSatisfied
|
|
? "locked"
|
|
: busy === "mock" || busy === "mailbox"
|
|
? "running"
|
|
: mockPartial
|
|
? "partial"
|
|
: mockFailed > 0
|
|
? "danger"
|
|
: mockComplete
|
|
? "complete"
|
|
: downstreamDeliveryActivity
|
|
? "warning"
|
|
: "active";
|
|
|
|
const mockStateDisplayLabel = !mockWorkflowAvailable ? "Unavailable" : !mockVerificationRequired ? "Optional" : stateLabel(mockState);
|
|
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || downstreamDeliveryActivity);
|
|
const sendLockReason = !inspectionSatisfied
|
|
? "Build and complete the required message review first."
|
|
: mockWorkflowRequired
|
|
? "Complete a successful mock delivery first."
|
|
: "Build the exact queue first.";
|
|
const sendState: FlowState = !mockGateSatisfied
|
|
? "locked"
|
|
: busy === "send" || deliverySending
|
|
? "running"
|
|
: queuedWithoutWorker
|
|
? "warning"
|
|
: deliveryQueued
|
|
? "running"
|
|
: deliveryPartial
|
|
? "partial"
|
|
: deliveryComplete || ["sent", "completed"].includes(currentWorkflowState)
|
|
? "complete"
|
|
: deliveryDanger
|
|
? "danger"
|
|
: "active";
|
|
|
|
const resultState: FlowState = !deliveryStarted
|
|
? "locked"
|
|
: deliverySending
|
|
? "running"
|
|
: deliveryPartial
|
|
? "partial"
|
|
: deliveryComplete || ["sent", "completed"].includes(currentWorkflowState)
|
|
? "complete"
|
|
: deliveryDanger
|
|
? "danger"
|
|
: "active";
|
|
|
|
const stages: FlowStageDefinition[] = useMemo(() => [
|
|
{
|
|
id: "workflow-validate-review",
|
|
title: "Validate and inspect",
|
|
shortTitle: "Validate",
|
|
description: "Lock and validate the campaign, then inspect blocking errors, warnings and attachment exceptions in the same step.",
|
|
icon: ShieldCheck,
|
|
state: validationReviewState,
|
|
stateLabel: stateLabel(validationReviewState),
|
|
},
|
|
{
|
|
id: "workflow-build-review",
|
|
title: "Build and review",
|
|
shortTitle: "Build",
|
|
description: "Build the exact queue, resolve recipient values and managed files, then review only messages that need attention.",
|
|
icon: PackageCheck,
|
|
state: buildReviewState,
|
|
stateLabel: stateLabel(buildReviewState),
|
|
lockReason: validationErrors > 0
|
|
? `Resolve ${validationErrors} blocking validation issue${validationErrors === 1 ? "" : "s"} first.`
|
|
: "Lock and validate the current working version first.",
|
|
},
|
|
{
|
|
id: "workflow-mock-verify",
|
|
title: "Mock send and verify",
|
|
shortTitle: "Mock send",
|
|
description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers. This dev workflow can be optional before real sending.",
|
|
icon: FlaskConical,
|
|
state: mockState,
|
|
stateLabel: mockStateDisplayLabel,
|
|
lockReason: mockWorkflowAvailable ? "Build and complete the required message review first." : "Enable the Mail dev mailbox capability to run mock delivery.",
|
|
},
|
|
{
|
|
id: "workflow-send",
|
|
title: "Confirm and send",
|
|
shortTitle: "Send",
|
|
description: "Review the final execution summary, optionally run a dry run, and send the exact built queue.",
|
|
icon: Send,
|
|
state: sendState,
|
|
stateLabel: stateLabel(sendState),
|
|
lockReason: sendLockReason,
|
|
},
|
|
{
|
|
id: "workflow-results",
|
|
title: "Delivery results",
|
|
shortTitle: "Results",
|
|
description: "Review SMTP outcomes, IMAP append results, partial delivery and failures.",
|
|
icon: BarChart3,
|
|
state: resultState,
|
|
stateLabel: stateLabel(resultState),
|
|
lockReason: "Delivery results become available after the real send starts.",
|
|
},
|
|
], [
|
|
validationReviewState,
|
|
buildReviewState,
|
|
mockState,
|
|
sendState,
|
|
resultState,
|
|
validationErrors,
|
|
mockStateDisplayLabel,
|
|
mockWorkflowAvailable,
|
|
sendLockReason,
|
|
]);
|
|
|
|
async function runValidation() {
|
|
if (!version || busy || readOnlyVersion || readyForDelivery) return;
|
|
setBusy("validate");
|
|
setMessage("Validating the campaign, including managed attachment matches…");
|
|
setError("");
|
|
try {
|
|
const result = await validateVersion(settings, version.id, true);
|
|
setMessage(result.ok ? "Validation passed." : "Validation finished with issues. Review the exceptions below.");
|
|
resetDownstreamReview();
|
|
await reload();
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function runBuild() {
|
|
if (!version || busy || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted) return;
|
|
setBusy("build");
|
|
setMessage("Building exact messages and resolving managed attachment versions…");
|
|
setError("");
|
|
try {
|
|
const result = await buildVersion(settings, version.id, true);
|
|
const review = await getCampaignJobs(settings, campaignId, {
|
|
versionId: version.id,
|
|
page: 1,
|
|
pageSize: 50,
|
|
validationStatus: showAllReviewJobs ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES,
|
|
});
|
|
setReviewPage(1);
|
|
setReviewJobs(review);
|
|
setBuiltReviewRows(review.jobs.map(asRecord));
|
|
setJobsLoadedKey(`${version.id}:1:${showAllReviewJobs ? "all" : "issues"}`);
|
|
setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s). The message review is ready below.`);
|
|
setMessageReviewComplete(false);
|
|
setReviewedMessageKeys(new Set());
|
|
setMockResult(null);
|
|
setSelectedMockMessage(null);
|
|
await reload();
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function loadBuiltMessages(silent = false, targetPage = reviewPage) {
|
|
if (!version || busy || !hasBuild) return;
|
|
setBusy("inspect");
|
|
if (!silent) setMessage("Loading the built-message review…");
|
|
setError("");
|
|
try {
|
|
const result = await getCampaignJobs(settings, campaignId, {
|
|
versionId: version.id,
|
|
page: targetPage,
|
|
pageSize: 50,
|
|
validationStatus: showAllReviewJobs ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES,
|
|
});
|
|
const jobs = result.jobs.map(asRecord);
|
|
setReviewJobs(result);
|
|
setBuiltReviewRows(jobs);
|
|
setJobsLoadedKey(`${version.id}:${targetPage}:${showAllReviewJobs ? "all" : "issues"}`);
|
|
if (result.pages > 0 && targetPage > result.pages) setReviewPage(result.pages);
|
|
if (!silent) setMessage(`Loaded ${jobs.length} message(s) on page ${result.page} of ${result.pages || 1}.`);
|
|
} catch (err) {
|
|
if (!silent) setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function runMockSend() {
|
|
if (!version || busy || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
|
|
setBusy("mock");
|
|
setMessage("Running the complete mock-delivery flow…");
|
|
setError("");
|
|
setSelectedMockMessage(null);
|
|
try {
|
|
const response = await mockSendCampaign(settings, campaignId, {
|
|
version_id: version.id,
|
|
send: true,
|
|
include_warnings: true,
|
|
include_needs_review: false,
|
|
append_sent: mockAppendSent,
|
|
clear_mailbox: mockClearFirst,
|
|
check_files: true,
|
|
});
|
|
const result = asRecord(response.result ?? response);
|
|
setMockResult(result);
|
|
const sendResult = asRecord(result.send);
|
|
setMessage(`Mock delivery finished. Captured ${String(sendResult.sent_count ?? 0)} message(s), failed ${String(sendResult.failed_count ?? 0)}, skipped ${String(sendResult.skipped_count ?? 0)}.`);
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function runSendNow() {
|
|
if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || (deliveryQueued && !directQueuedSendAllowed) || deliveryStarted) return;
|
|
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
|
|
setBusy("send");
|
|
setMessage(effectiveDryRun
|
|
? "Checking the built queue without sending…"
|
|
: directQueuedSendAllowed
|
|
? "Sending the queued jobs synchronously…"
|
|
: "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: effectiveDryRun,
|
|
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;
|
|
const unknown = result.outcome_unknown_count ?? 0;
|
|
setMessage(
|
|
effectiveDryRun
|
|
? "Dry run finished. No message was sent."
|
|
: `Send finished. SMTP accepted ${String(sent)} message(s), failed ${String(failed)}, outcome unknown ${String(unknown)}.`,
|
|
);
|
|
setSendConfirmOpen(false);
|
|
await reload();
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function runAppendSent() {
|
|
if (!version || busy || !canAppendPendingImap) return;
|
|
const runInline = !backgroundWorkersEnabled;
|
|
setBusy("imap");
|
|
setError("");
|
|
setMessage(runInline ? "Appending pending Sent copies via IMAP..." : "Queueing pending IMAP append jobs...");
|
|
try {
|
|
const response = await appendSent(settings, campaignId, {
|
|
enqueue_celery: backgroundWorkersEnabled,
|
|
run_inline: runInline,
|
|
dry_run: false,
|
|
});
|
|
const result = asRecord(response.result ?? response);
|
|
setImapAppendResult(result);
|
|
const appended = numberFrom(result, ["appended_count"]);
|
|
const failed = numberFrom(result, ["failed_count"]);
|
|
const enqueued = numberFrom(result, ["enqueued_count"]);
|
|
const pending = numberFrom(result, ["pending_count"]);
|
|
setMessage(runInline
|
|
? `IMAP append processed ${pending} pending job(s): appended ${appended}, failed ${failed}.`
|
|
: `Queued ${enqueued} pending IMAP append job(s).`);
|
|
await refreshQueueStatus(true);
|
|
await loadImapDiagnostics(true);
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function loadImapDiagnostics(silent = false) {
|
|
if (!version?.id) return;
|
|
setBusy("inspect");
|
|
if (!silent) setMessage("Loading IMAP diagnostics...");
|
|
setError("");
|
|
try {
|
|
const result = await getCampaignJobs(settings, campaignId, {
|
|
versionId: version.id,
|
|
page: 1,
|
|
pageSize: 50,
|
|
imapStatus: ["pending", "failed"],
|
|
});
|
|
setImapDiagnostics(result);
|
|
if (!silent) setMessage(`Loaded ${result.total} pending/failed IMAP job(s).`);
|
|
} catch (err) {
|
|
if (!silent) setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function openDeliveryJobDetail(jobId: string) {
|
|
if (!jobId || busy) return;
|
|
setBusy("inspect");
|
|
setError("");
|
|
try {
|
|
const detail = await getCampaignJobDetail(settings, campaignId, jobId);
|
|
setSelectedDeliveryJobDetail(detail as unknown as Record<string, unknown>);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function completeInspection(_acceptBulk = false) {
|
|
if (!version || busy || readOnlyVersion || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
|
|
setBusy("inspect");
|
|
setError("");
|
|
setMessage("Recording the completed message review…");
|
|
try {
|
|
const reviewed = new Set(reviewedMessageKeys);
|
|
await updateCampaignReviewState(settings, campaignId, version.id, {
|
|
inspection_complete: true,
|
|
reviewed_message_keys: [...reviewed],
|
|
});
|
|
setReviewedMessageKeys(reviewed);
|
|
setNewlyReviewedRequiredKeys(new Set());
|
|
setMessageReviewComplete(true);
|
|
setReviewJobs((current) => ({
|
|
...current,
|
|
review: {
|
|
...current.review,
|
|
inspection_complete: true,
|
|
reviewed_required_count: explicitReviewCount,
|
|
},
|
|
}));
|
|
setReviewConfirmOpen(false);
|
|
setMessage("Message review completed and recorded for this build.");
|
|
await reload();
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function openMockMessage(id: string) {
|
|
if (!id || busy === "mailbox" || !mockMailboxPreviewActive) return;
|
|
setBusy("mailbox");
|
|
setError("");
|
|
try {
|
|
const response = await getMockMailboxMessage(settings, id);
|
|
setSelectedMockMessage(response.message);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function openBuiltMessage(index: number) {
|
|
const row = builtReviewRows[index];
|
|
if (!row) return;
|
|
const jobId = String(row.id ?? "");
|
|
const reviewKey = String(row.review_key ?? builtMessageKey(row, index));
|
|
setReviewedMessageKeys((current) => {
|
|
const next = new Set(current);
|
|
next.add(reviewKey);
|
|
return next;
|
|
});
|
|
if (messageNeedsExplicitReview(row) && row.reviewed !== true) {
|
|
setNewlyReviewedRequiredKeys((current) => new Set(current).add(reviewKey));
|
|
}
|
|
if (!jobId || row.resolved_recipients || row.attachments || row.issues) {
|
|
setSelectedBuiltIndex(index);
|
|
return;
|
|
}
|
|
setBusy("inspect");
|
|
setError("");
|
|
try {
|
|
const detail = await getCampaignJobDetail(settings, campaignId, jobId);
|
|
setBuiltReviewRows((current) => current.map((item, itemIndex) => itemIndex === index
|
|
? { ...item, ...detail.job, review_key: item.review_key, reviewed: item.reviewed, attempts: detail.attempts }
|
|
: item));
|
|
setSelectedBuiltIndex(index);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
function resetDownstreamReview() {
|
|
setMessageReviewComplete(false);
|
|
setBuiltReviewRows([]);
|
|
setReviewJobs(emptyCampaignJobsResponse());
|
|
setReviewPage(1);
|
|
setJobsLoadedKey("");
|
|
setReviewedMessageKeys(new Set());
|
|
setNewlyReviewedRequiredKeys(new Set());
|
|
setSelectedBuiltIndex(null);
|
|
setMockResult(null);
|
|
setSelectedMockMessage(null);
|
|
}
|
|
|
|
function scrollToStage(id: string) {
|
|
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
|
|
const matchedAttachments = numberFrom(attachmentSummary, ["total_matched_files"]);
|
|
const missingAttachments = numberFrom(attachmentSummary, ["missing_configs"]);
|
|
const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]);
|
|
const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
|
|
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
|
|
const canCompleteInspection = blockingReviewCount === 0
|
|
&& reviewRequiredCount > 0
|
|
&& reviewedExplicitCount === explicitReviewCount;
|
|
|
|
return (
|
|
<div className="content-pad workspace-data-page review-send-page">
|
|
<div className="page-heading split workspace-heading">
|
|
<div>
|
|
<PageTitle loading={loading || Boolean(busy)}>Review & Send</PageTitle>
|
|
<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>
|
|
|
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
{message && <DismissibleAlert tone="info" resetKey={message} floating>{message}</DismissibleAlert>}
|
|
|
|
|
|
{version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) && (
|
|
<LockedVersionNotice
|
|
settings={settings}
|
|
campaignId={campaignId}
|
|
version={version}
|
|
currentVersionId={data.campaign?.current_version_id}
|
|
reload={reload}
|
|
message="This workflow is read-only for the selected version."
|
|
/>
|
|
)}
|
|
|
|
<LoadingFrame loading={loading} label="Loading workflow state…">
|
|
<WorkflowNavigation stages={stages} onSelect={scrollToStage} />
|
|
|
|
<div className="review-flow-timeline" aria-label="Campaign review and sending workflow">
|
|
<WorkflowStage stage={stages[0]} nextState={stages[1].state}>
|
|
<div className="review-flow-fact-grid">
|
|
<WorkflowFact label="Status" value={validationPresent ? (validationOk ? "Passed" : "Needs attention") : "Not run"} />
|
|
<WorkflowFact label="Blocking" value={validationPresent ? validationErrors : "—"} />
|
|
<WorkflowFact label="Warnings" value={validationPresent ? validationWarnings : "—"} />
|
|
<WorkflowFact label="Jobs needing attention" value={cards?.needs_attention ?? "—"} />
|
|
</div>
|
|
{validationStale && <p className="review-flow-inline-note is-stale">The stored validation result is no longer an active delivery lock. Run validation again.</p>}
|
|
{validationPresent && validationErrors === 0 && (
|
|
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> No blocking validation exceptions remain.</p>
|
|
)}
|
|
{validationErrors > 0 && (
|
|
<p className="review-flow-inline-note is-danger">Resolve the blocking entries, then validate again.</p>
|
|
)}
|
|
{visibleValidationIssues.length > 0 && (
|
|
<div className="review-flow-data-section">
|
|
<h3>Validation details</h3>
|
|
<dl className="detail-list">
|
|
{visibleValidationIssues.map((issue, index) => (
|
|
<div key={`${String(issue.code ?? "issue")}:${index}`}>
|
|
<dt>{humanize(String(issue.severity ?? "issue"))}</dt>
|
|
<dd>
|
|
<strong>{String(issue.message ?? issue.code ?? "Validation issue")}</strong>
|
|
{issue.path ? <span className="muted"> · {String(issue.path)}</span> : null}
|
|
{issue.code ? <span className="muted"> · {String(issue.code)}</span> : null}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
{validationIssues.length > visibleValidationIssues.length && <p className="muted small-note">Showing {visibleValidationIssues.length} of {validationIssues.length} validation issue(s).</p>}
|
|
</div>
|
|
)}
|
|
<div className="button-row compact-actions review-flow-stage-actions">
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => void runValidation()}
|
|
disabled={!version || Boolean(busy) || readOnlyVersion || readyForDelivery}
|
|
>
|
|
{busy === "validate"
|
|
? "Validating…"
|
|
: readyForDelivery
|
|
? "Locked and validated"
|
|
: "Lock and validate"}
|
|
</Button>
|
|
<Button onClick={() => navigate("../files")}>Review attachment rules</Button>
|
|
</div>
|
|
</WorkflowStage>
|
|
|
|
<WorkflowStage stage={stages[1]} nextState={stages[2].state}>
|
|
<div className="review-flow-fact-grid">
|
|
<WorkflowFact label="Built" value={hasBuild ? builtCount : "—"} />
|
|
<WorkflowFact label="Blocked" value={hasBuild ? buildBlocked : "—"} />
|
|
<WorkflowFact label="Need review" value={hasBuild ? buildNeedsReview : "—"} />
|
|
<WorkflowFact label="Reviewed" value={hasBuild ? reviewedRequiredCount : "—"} />
|
|
<WorkflowFact label="Review candidates" value={reviewJobs.total || "—"} />
|
|
<WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} />
|
|
</div>
|
|
<p className="muted">Building freezes the current recipients, rendered content and exact managed-file versions into the queue reviewed below.</p>
|
|
<div className="button-row compact-actions review-flow-stage-actions">
|
|
<Button variant="primary" onClick={() => void runBuild()} disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted}>
|
|
{busy === "build" ? "Building…" : hasBuild ? "Build again" : "Build exact messages"}
|
|
</Button>
|
|
<Button onClick={() => void loadBuiltMessages(false, reviewPage)} disabled={!hasBuild || Boolean(busy)}>
|
|
{busy === "inspect" ? "Loading messages…" : builtReviewRows.length > 0 ? "Reload page" : "Load review"}
|
|
</Button>
|
|
<Button onClick={() => navigate("../template")}>Open template editor</Button>
|
|
{reviewRequiredCount > 0 && (
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => bulkAcceptableCount > 0 ? setReviewConfirmOpen(true) : void completeInspection(false)}
|
|
disabled={readOnlyVersion || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)}
|
|
>
|
|
{messageReviewComplete ? "Review completed" : "Complete review"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{automaticInspectionComplete && (
|
|
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> All built messages are ready. No manual review acknowledgement is required.</p>
|
|
)}
|
|
{blockingReviewCount > 0 && (
|
|
<p className="review-flow-inline-note is-danger">{blockingReviewCount} blocked or failed message(s) must be resolved before review can be completed.</p>
|
|
)}
|
|
{hasBuild && (
|
|
<div className="review-flow-data-section">
|
|
<div className="page-heading split">
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={() => { setShowAllReviewJobs((value) => !value); setReviewPage(1); setJobsLoadedKey(""); }} disabled={busy === "inspect"}>
|
|
{showAllReviewJobs ? "Show review candidates only" : "Show all messages"}
|
|
</Button>
|
|
</div>
|
|
<span className="muted">{reviewJobs.total} matching of {reviewJobs.total_unfiltered} built job(s)</span>
|
|
</div>
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-workflow-built-messages`}
|
|
rows={builtReviewRows}
|
|
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
|
|
getRowKey={builtMessageKey}
|
|
emptyText="No built messages are available."
|
|
filteredEmptyText="No messages match the active filters."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={() => { setReviewPage((value) => Math.max(1, value - 1)); setJobsLoadedKey(""); }} disabled={reviewPage <= 1 || busy === "inspect"}>Previous</Button>
|
|
<span>Page {reviewJobs.pages === 0 ? 0 : reviewJobs.page} of {reviewJobs.pages}</span>
|
|
<Button onClick={() => { setReviewPage((value) => Math.min(reviewJobs.pages || 1, value + 1)); setJobsLoadedKey(""); }} disabled={reviewPage >= reviewJobs.pages || busy === "inspect"}>Next</Button>
|
|
</div>
|
|
<p className="muted small-note">Ready messages are hidden initially. Messages marked Needs review require an explicit open/review step. Warnings and exclusions are acceptable conditions and can be acknowledged together when completing review.</p>
|
|
</div>
|
|
)}
|
|
</WorkflowStage>
|
|
|
|
<WorkflowStage stage={stages[2]} nextState={stages[3].state}>
|
|
<div className="review-flow-fact-grid">
|
|
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
|
|
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
|
|
<WorkflowFact label="Skipped" value={mockResult ? mockSkipped : "—"} />
|
|
<WorkflowFact label="Captured messages" value={!mockWorkflowAvailable ? "Unavailable" : mockResult ? mockMailboxMessages.length : "—"} />
|
|
</div>
|
|
<div className="button-row compact-actions review-flow-stage-actions">
|
|
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
|
|
{busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"}
|
|
</Button>
|
|
<Button onClick={() => navigate("../mail-settings")}>Review server settings</Button>
|
|
</div>
|
|
{!mockWorkflowAvailable && (
|
|
<p className="review-flow-inline-note is-stale">Mock delivery uses the Mail module development mailbox API. Enable that capability to run and inspect captured messages.</p>
|
|
)}
|
|
<div className="toggle-row mock-send-options">
|
|
<ToggleSwitch label="Require mock before real send" checked={mockVerificationRequired} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockVerificationRequired} />
|
|
<ToggleSwitch label="Show captured mock mailbox" checked={mockMailboxPreviewEnabled && mockWorkflowAvailable} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockMailboxPreviewEnabled} />
|
|
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockClearFirst} />
|
|
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockAppendSent} />
|
|
</div>
|
|
{mockResult && (
|
|
<div className="review-flow-data-stack">
|
|
<section className="review-flow-data-section" aria-labelledby="workflow-mock-results-title">
|
|
<h3 id="workflow-mock-results-title">Recipient outcomes</h3>
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-workflow-mock-results`}
|
|
rows={mockRows}
|
|
columns={mockSendResultColumns()}
|
|
getRowKey={(row, index) => String(row.entry_id ?? row.entry_index ?? index)}
|
|
emptyText="No mock delivery results were returned."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
</section>
|
|
{mockMailboxPreviewActive ? (
|
|
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
|
|
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-workflow-mock-mailbox`}
|
|
rows={mockMailboxMessages}
|
|
columns={mockMailboxColumns(openMockMessage)}
|
|
getRowKey={(row, index) => String(row.id ?? index)}
|
|
emptyText="No mock messages were captured in this run."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
</section>
|
|
) : (
|
|
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
|
|
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
|
|
<p className="muted small-note">{mockWorkflowAvailable ? "Captured mailbox preview is disabled for this run. Recipient outcomes remain available." : "Captured mailbox preview requires the Mail development mailbox API."}</p>
|
|
</section>
|
|
)}
|
|
</div>
|
|
)}
|
|
</WorkflowStage>
|
|
|
|
<WorkflowStage stage={stages[3]} nextState={stages[4].state}>
|
|
<div className="review-flow-execution-summary">
|
|
<div><span>Recipients</span><strong>{jobsTotal || "—"}</strong></div>
|
|
<div><span>Messages to send</span><strong>{builtCount || jobsTotal || "—"}</strong></div>
|
|
<div><span>Matched attachments</span><strong>{matchedAttachments || "—"}</strong></div>
|
|
<div><span>Missing / ambiguous</span><strong>{missingAttachments} / {ambiguousAttachments}</strong></div>
|
|
<div><span>Rate limit</span><strong>{messagesPerMinute > 0 ? `${messagesPerMinute}/min` : "Not set"}</strong></div>
|
|
<div><span>Minimum duration</span><strong>{estimatedMinutes ? `about ${estimatedMinutes} min` : "—"}</strong></div>
|
|
<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"
|
|
checked={dryRun}
|
|
disabled={Boolean(busy) || deliveryQueued || deliveryStarted}
|
|
onChange={setDryRun}
|
|
/>
|
|
<span className="muted small-note">A dry run checks the frozen queue and delivery configuration without contacting SMTP or IMAP.</span>
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
|
|
disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || (deliveryQueued && !directQueuedSendAllowed) || deliveryStarted}
|
|
>
|
|
{busy === "send"
|
|
? (selectedDryRun ? "Running dry run…" : "Sending…")
|
|
: directQueuedSendAllowed
|
|
? "Send queued now"
|
|
: selectedDryRun
|
|
? "Run dry run"
|
|
: "Send now"}
|
|
</Button>
|
|
</div>
|
|
{sendResult && (
|
|
<div className="review-flow-data-section">
|
|
<p className="muted small-note">Attempted {String(sendResult.attempted_count ?? "—")}, SMTP accepted {String(sendResult.sent_count ?? "—")}, failed {String(sendResult.failed_count ?? "—")}, outcome unknown {String(sendResult.outcome_unknown_count ?? 0)}, skipped {String(sendResult.skipped_count ?? "—")}.</p>
|
|
{sendResultRows.length > 0 && (
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-workflow-send-results`}
|
|
rows={sendResultRows}
|
|
columns={sendResultColumns()}
|
|
getRowKey={(_row, index) => `send-result-${index}`}
|
|
emptyText="No send results returned."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</WorkflowStage>
|
|
|
|
<WorkflowStage stage={stages[4]}>
|
|
<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 pending" value={imapPendingForDisplay} />
|
|
<WorkflowFact label="IMAP failed" value={imapFailedForDisplay} />
|
|
<WorkflowFact label="IMAP skipped" value={imapSkipped} />
|
|
</div>
|
|
<div className="review-flow-result-line">
|
|
<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>
|
|
{Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && (
|
|
<p className="review-flow-inline-note is-stale">IMAP Sent append is still pending for {imapPendingForDisplay} job(s). Pending with no IMAP attempt usually means the append worker was not queued or is not running.</p>
|
|
)}
|
|
{Boolean(imapAppend.enabled) && imapFailedForDisplay > 0 && (
|
|
<p className="review-flow-inline-note is-danger">{imapFailedForDisplay} IMAP append job(s) failed. Load diagnostics to inspect the last error per job.</p>
|
|
)}
|
|
{Boolean(imapAppend.enabled) && (imapPendingForDisplay > 0 || imapFailedForDisplay > 0) && (
|
|
<div className="button-row compact-actions review-flow-stage-actions">
|
|
<Button onClick={() => void loadImapDiagnostics(false)} disabled={Boolean(busy)}>Load IMAP diagnostics</Button>
|
|
{imapPendingForDisplay > 0 && (
|
|
<Button variant="primary" onClick={() => void runAppendSent()} disabled={Boolean(busy) || !canAppendPendingImap}>
|
|
{busy === "imap" ? "Appending IMAP..." : backgroundWorkersEnabled ? "Queue pending IMAP append" : "Append pending IMAP now"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{imapAppendResult && (
|
|
<div className="review-flow-data-section">
|
|
<h3>IMAP append result</h3>
|
|
<p className="muted small-note">Pending {String(imapAppendResult.pending_count ?? "0")}, enqueued {String(imapAppendResult.enqueued_count ?? "0")}, processed {String(imapAppendResult.processed_count ?? "0")}, appended {String(imapAppendResult.appended_count ?? "0")}, failed {String(imapAppendResult.failed_count ?? "0")}.</p>
|
|
{imapAppendResultRows.length > 0 && (
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-workflow-imap-append-results`}
|
|
rows={imapAppendResultRows}
|
|
columns={imapAppendResultColumns()}
|
|
getRowKey={(row, index) => String(row.job_id ?? index)}
|
|
emptyText="No IMAP append result rows returned."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{imapDiagnostics.total > 0 && (
|
|
<div className="review-flow-data-section">
|
|
<h3>IMAP diagnostics</h3>
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-workflow-imap-diagnostics`}
|
|
rows={imapDiagnosticRows}
|
|
columns={imapDiagnosticColumns(openDeliveryJobDetail)}
|
|
getRowKey={(row, index) => String(row.id ?? index)}
|
|
emptyText="No pending or failed IMAP jobs found."
|
|
className="data-table-wrap data-table compact-table"
|
|
/>
|
|
</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>
|
|
</div>
|
|
</WorkflowStage>
|
|
</div>
|
|
</LoadingFrame>
|
|
|
|
{selectedBuiltMessage && selectedBuiltIndex !== null && (
|
|
<BuiltMessagePreview
|
|
campaignJson={campaignJson}
|
|
entries={inlineEntries}
|
|
rows={builtReviewRows}
|
|
index={selectedBuiltIndex}
|
|
onSelect={openBuiltMessage}
|
|
onClose={() => setSelectedBuiltIndex(null)}
|
|
/>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={reviewConfirmOpen}
|
|
title="Accept non-blocking review conditions?"
|
|
message={`${bulkAcceptableCount} message(s) contain warnings or exclusions but no blocking condition. Completing review will acknowledge these conditions together without opening each message.`}
|
|
confirmLabel="Accept and complete review"
|
|
cancelLabel="Cancel"
|
|
onConfirm={() => void completeInspection(true)}
|
|
onCancel={() => setReviewConfirmOpen(false)}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
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={directQueuedSendAllowed ? "Send queued now" : "Send now"}
|
|
tone="danger"
|
|
busy={busy === "send"}
|
|
onCancel={() => setSendConfirmOpen(false)}
|
|
onConfirm={() => void runSendNow()}
|
|
/>
|
|
|
|
{selectedDeliveryJobDetail && (
|
|
<DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} />
|
|
)}
|
|
|
|
{selectedMockMessage && mockMailboxPreviewActive && (
|
|
<CampaignMessagePreviewOverlay
|
|
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 ? formatDateTime(selectedMockMessage.created_at) : undefined}
|
|
metaItems={mockMessageMetaItems(selectedMockMessage)}
|
|
attachments={mockMessageAttachments(selectedMockMessage)}
|
|
raw={selectedMockMessage.raw_eml}
|
|
rawLabel="Raw MIME"
|
|
onClose={() => setSelectedMockMessage(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkflowNavigation({ stages, onSelect }: { stages: FlowStageDefinition[]; onSelect: (id: string) => void }) {
|
|
return (
|
|
<nav className="review-flow-navigation" aria-label="Review and send workflow steps">
|
|
<div className="review-flow-navigation-track">
|
|
{stages.map((stage, index) => {
|
|
const Icon = stage.icon;
|
|
const nextStage = stages[index + 1];
|
|
const style = {
|
|
"--review-nav-color": stateColors[stage.state],
|
|
"--review-nav-next-color": stateColors[nextStage?.state ?? stage.state],
|
|
} as CSSProperties;
|
|
const showSecondaryState = !["active", "locked"].includes(stage.state);
|
|
return (
|
|
<div className="review-flow-navigation-group" key={stage.id} style={style}>
|
|
<button
|
|
type="button"
|
|
className="review-flow-navigation-item"
|
|
data-state={stage.state}
|
|
onClick={() => onSelect(stage.id)}
|
|
title={`${stage.title}: ${stage.stateLabel}`}
|
|
>
|
|
<span className="review-flow-navigation-icon"><Icon size={17} strokeWidth={1.8} aria-hidden="true" /></span>
|
|
<span className="review-flow-navigation-copy">
|
|
<strong>
|
|
<span>{stage.shortTitle}</span>
|
|
{stage.state === "locked" && <LockKeyhole size={12} aria-label="Locked" />}
|
|
</strong>
|
|
{showSecondaryState && <small>{stage.stateLabel}</small>}
|
|
</span>
|
|
</button>
|
|
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function WorkflowStage({
|
|
stage,
|
|
nextState,
|
|
children,
|
|
}: {
|
|
stage: FlowStageDefinition;
|
|
nextState?: FlowState;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const Icon = stage.icon;
|
|
const locked = stage.state === "locked";
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const style = {
|
|
"--review-stage-color": stateColors[stage.state],
|
|
"--review-next-stage-color": stateColors[nextState ?? stage.state],
|
|
} as CSSProperties;
|
|
|
|
return (
|
|
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
|
|
<div className="review-flow-stage-marker" aria-hidden="true">
|
|
<div className="review-flow-stage-node"><Icon size={20} strokeWidth={1.8} /></div>
|
|
{nextState && <div className="review-flow-stage-line" />}
|
|
</div>
|
|
<article className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`} aria-disabled={locked || undefined}>
|
|
<header className="card-header review-flow-stage-header">
|
|
<h2>
|
|
<span>{stage.title}</span>
|
|
{locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="Locked" />}
|
|
{!locked && (
|
|
<span
|
|
className="review-flow-state-badge"
|
|
data-state={stage.state}
|
|
aria-label={stage.stateLabel}
|
|
title={stage.stateLabel}
|
|
>
|
|
{stage.stateLabel}
|
|
</span>
|
|
)}
|
|
<InlineHelp>{stage.description}</InlineHelp>
|
|
</h2>
|
|
<div className="review-flow-stage-header-actions">
|
|
<button
|
|
type="button"
|
|
className="card-collapse-toggle"
|
|
aria-expanded={!collapsed}
|
|
aria-label={collapsed ? `Expand ${stage.title}` : `Collapse ${stage.title}`}
|
|
title={collapsed ? "Show content" : "Show header only"}
|
|
onClick={() => setCollapsed((value) => !value)}
|
|
>
|
|
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
{!collapsed && (
|
|
<>
|
|
<div className="card-body review-flow-stage-content">{children}</div>
|
|
{locked && (
|
|
<div className="review-flow-lock-message">
|
|
<span className="review-flow-lock-icon"><LockKeyhole size={20} aria-hidden="true" /></span>
|
|
<span>{stage.lockReason}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</article>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function BuiltMessagePreview({
|
|
campaignJson,
|
|
entries,
|
|
rows,
|
|
index,
|
|
onSelect,
|
|
onClose,
|
|
}: {
|
|
campaignJson: Record<string, unknown>;
|
|
entries: Record<string, unknown>[];
|
|
rows: Record<string, unknown>[];
|
|
index: number;
|
|
onSelect: (index: number) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const row = rows[index] ?? {};
|
|
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
|
|
const entry = entries[entryIndex] ?? {};
|
|
const template = asRecord(campaignJson.template);
|
|
const context = buildTemplatePreviewContext(campaignJson, entry);
|
|
const ignoreEmptyFields = getBool(asRecord(campaignJson.validation_policy), "ignore_empty_fields", false);
|
|
const html = renderTemplatePreviewText(getText(template, "html"), context, ignoreEmptyFields);
|
|
const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields);
|
|
const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "No subject");
|
|
const issues = asArray(row.issues).map(asRecord);
|
|
const resolvedRecipients = asRecord(row.resolved_recipients);
|
|
|
|
return (
|
|
<CampaignMessagePreviewOverlay
|
|
title="Built message review"
|
|
subject={subject}
|
|
bodyMode={html.trim() ? "html" : "text"}
|
|
text={text}
|
|
html={html}
|
|
recipientLabel={formatAddressList(resolvedRecipients.to) || String(row.recipient_email ?? `Message ${index + 1}`)}
|
|
recipientNote={issues.length > 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "Issue")).join(" · ")}` : "Built without reported issues."}
|
|
metaItems={builtMessageMetaItems(row)}
|
|
attachments={builtMessageAttachments(row)}
|
|
navigation={{
|
|
index,
|
|
total: rows.length,
|
|
onFirst: () => onSelect(0),
|
|
onPrevious: () => onSelect(Math.max(0, index - 1)),
|
|
onNext: () => onSelect(Math.min(rows.length - 1, index + 1)),
|
|
onLast: () => onSelect(rows.length - 1),
|
|
}}
|
|
onClose={onClose}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DeliveryJobDetailOverlay({ detail, onClose }: { detail: Record<string, unknown>; onClose: () => void }) {
|
|
const job = asRecord(detail.job);
|
|
const attempts = asRecord(detail.attempts);
|
|
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
|
|
const imapAttempts = asArray(attempts.imap).map(asRecord);
|
|
const issues = asArray(job.issues).map(asRecord);
|
|
|
|
return (
|
|
<Dialog
|
|
open
|
|
title="Delivery job details"
|
|
className="template-preview-modal"
|
|
onClose={onClose}
|
|
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
|
|
>
|
|
<dl className="detail-list">
|
|
<div><dt>Recipient</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
|
|
<div><dt>Subject</dt><dd>{String(job.subject ?? "-")}</dd></div>
|
|
<div><dt>SMTP status</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
|
|
<div><dt>IMAP status</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
|
|
{job.last_error ? <div><dt>Last error</dt><dd>{String(job.last_error)}</dd></div> : null}
|
|
</dl>
|
|
{issues.length > 0 && <AttemptList title="Message issues" rows={issues} />}
|
|
<AttemptList title="SMTP attempts" rows={smtpAttempts} emptyText="No SMTP attempts were recorded." />
|
|
<AttemptList title="IMAP attempts" rows={imapAttempts} emptyText="No IMAP append attempts were recorded. If status is pending, append has not run yet." />
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function AttemptList({ title, rows, emptyText = "No rows." }: { title: string; rows: Record<string, unknown>[]; emptyText?: string }) {
|
|
return (
|
|
<section className="review-flow-data-section">
|
|
<h3>{title}</h3>
|
|
{rows.length === 0 ? <p className="muted small-note">{emptyText}</p> : (
|
|
<dl className="detail-list">
|
|
{rows.map((row, index) => (
|
|
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
|
|
<dt>{String(row.status ?? row.severity ?? row.code ?? `#${index + 1}`)}</dt>
|
|
<dd>
|
|
<strong>{String(row.message ?? row.error_message ?? row.smtp_response ?? row.folder ?? row.path ?? "-")}</strong>
|
|
{row.started_at || row.created_at ? <span className="muted"> · {formatDateTime(String(row.started_at ?? row.created_at))}</span> : null}
|
|
{row.finished_at || row.updated_at ? <span className="muted"> → {formatDateTime(String(row.finished_at ?? row.updated_at))}</span> : null}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function WorkflowFact({ label, value }: { label: string; value: React.ReactNode }) {
|
|
return (
|
|
<div className="review-flow-fact">
|
|
<span>{label}</span>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function builtMessageColumns(
|
|
openMessage: (index: number) => void,
|
|
reviewedKeys: Set<string>,
|
|
): DataGridColumn<Record<string, unknown>>[] {
|
|
return [
|
|
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
|
|
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") },
|
|
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
|
{
|
|
id: "validation",
|
|
header: "Validation",
|
|
width: 145,
|
|
sortable: true,
|
|
filterable: true,
|
|
columnType: "from-list",
|
|
list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
|
|
value: (row) => String(row.validation_status ?? "unknown"),
|
|
},
|
|
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
|
|
{ id: "reviewed", header: "Reviewed", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "Reviewed" }, { value: "no", label: "Not reviewed" }] }, render: (row, index) => (row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index)))) ? <Check size={17} aria-label="Reviewed" /> : <span className="muted">—</span>, value: (row, index) => (row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index)))) ? "yes" : "no" },
|
|
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (_row, index) => <Button onClick={() => openMessage(index)}>Review</Button> },
|
|
];
|
|
}
|
|
|
|
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
|
const options: DataGridListOption[] = [
|
|
{ value: "sent", label: "Sent" },
|
|
{ value: "failed", label: "Failed" },
|
|
{ value: "skipped", label: "Skipped" },
|
|
{ value: "ready", label: "Ready" },
|
|
];
|
|
return [
|
|
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
|
|
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.status ?? "info") },
|
|
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).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)", resizable: true, filterable: true, value: (row) => String(row.message ?? "—") },
|
|
];
|
|
}
|
|
|
|
function sendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
|
const statuses: DataGridListOption[] = [
|
|
{ value: "smtp_accepted", label: "SMTP accepted" },
|
|
{ value: "already_accepted", label: "Already accepted" },
|
|
{ value: "outcome_unknown", label: "Outcome unknown" },
|
|
{ value: "failed", label: "Failed" },
|
|
{ value: "cancelled", label: "Cancelled" },
|
|
{ value: "not_claimed", label: "Not claimed" },
|
|
{ value: "dry_run", label: "Dry run" },
|
|
];
|
|
return [
|
|
{ id: "status", header: "Status", width: 160, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options: statuses, display: "pill" }, 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)", resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) },
|
|
];
|
|
}
|
|
|
|
function imapAppendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
|
return [
|
|
{ id: "status", header: "Status", width: 150, sticky: "start", sortable: true, filterable: true, 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 ?? "-") },
|
|
{ id: "folder", header: "Folder", width: 190, sortable: true, filterable: true, value: (row) => String(row.folder ?? "-") },
|
|
{ id: "message", header: "Message", width: "minmax(300px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) },
|
|
];
|
|
}
|
|
|
|
function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
|
|
return [
|
|
{ id: "recipient", header: "Recipient", width: 240, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "-") },
|
|
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
|
|
{ id: "send", header: "SMTP", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
|
|
{ id: "imap", header: "IMAP", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
|
|
{ id: "error", header: "Last error", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
|
|
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>Details</Button> },
|
|
];
|
|
}
|
|
|
|
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
|
|
const options: DataGridListOption[] = [
|
|
{ value: "smtp", label: "SMTP" },
|
|
{ value: "imap_append", label: "IMAP append" },
|
|
];
|
|
return [
|
|
{ id: "kind", header: "Kind", width: 125, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.kind ?? "mock") },
|
|
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
|
{ id: "envelope", header: "Envelope / folder", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")} → ${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
|
|
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
|
|
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openMessage(String(row.id ?? ""))}>Review</Button> },
|
|
];
|
|
}
|
|
|
|
function builtMessageMetaItems(row: Record<string, unknown>) {
|
|
const recipients = asRecord(row.resolved_recipients);
|
|
return [
|
|
{ label: "From", value: formatSingleAddress(recipients.from) || "—" },
|
|
{ label: "To", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
|
|
{ label: "CC", value: formatAddressList(recipients.cc) || null },
|
|
{ label: "BCC", value: formatAddressList(recipients.bcc) || null },
|
|
{ label: "Validation", value: String(row.validation_status ?? "—") },
|
|
{ label: "MIME size", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" },
|
|
];
|
|
}
|
|
|
|
function builtMessageAttachments(row: Record<string, unknown>): CampaignMessagePreviewAttachment[] {
|
|
return asArray(row.attachments).flatMap((value, index) => {
|
|
const attachment = asRecord(value);
|
|
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
|
|
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
|
if (managedMatches.length > 0) {
|
|
return managedMatches.map((match, matchIndex) => ({
|
|
filename: String(match.filename ?? match.display_path ?? `Attachment ${index + 1}.${matchIndex + 1}`),
|
|
detail: String(match.display_path ?? match.relative_path ?? attachment.label ?? ""),
|
|
contentType: stringOrUndefined(match.content_type),
|
|
sizeBytes: numberOrUndefined(match.size_bytes),
|
|
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
|
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
|
protected: zipProtection.protected,
|
|
protectionNote: zipProtection.note,
|
|
}));
|
|
}
|
|
const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim()));
|
|
if (matches.length > 0) {
|
|
return matches.map((match) => ({
|
|
filename: match.split(/[\\/]/).pop() || String(attachment.label ?? `Attachment ${index + 1}`),
|
|
detail: match,
|
|
contentType: stringOrUndefined(attachment.content_type),
|
|
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
|
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
|
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
|
protected: zipProtection.protected,
|
|
protectionNote: zipProtection.note,
|
|
}));
|
|
}
|
|
return [{
|
|
filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? attachment.label ?? `Attachment ${index + 1}`),
|
|
detail: String(attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? ""),
|
|
contentType: stringOrUndefined(attachment.content_type),
|
|
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
|
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
|
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
|
protected: zipProtection.protected,
|
|
protectionNote: zipProtection.note,
|
|
}];
|
|
});
|
|
}
|
|
|
|
function zipProtectionFromBuiltAttachment(attachment: Record<string, unknown>): { protected: boolean; note: string | null } {
|
|
const zipFilename = stringOrUndefined(attachment.zip_filename);
|
|
if (!zipFilename) return { protected: false, note: null };
|
|
const legacyMode = String(attachment.password_mode ?? attachment.zip_password_mode ?? "").trim();
|
|
const protectedArchive = getBool(attachment, "password_enabled", getBool(attachment, "zip_password_protected", getBool(attachment, "zip_protected", ["direct", "field", "template"].includes(legacyMode))));
|
|
if (!protectedArchive) return { protected: false, note: null };
|
|
const field = stringOrUndefined(attachment.password_field) ?? stringOrUndefined(attachment.zip_password_field);
|
|
const rawScope = String(attachment.password_scope ?? attachment.zip_password_scope ?? "local");
|
|
const scope = rawScope === "global" ? "global" : "local";
|
|
const method = String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" ? "ZipCrypto" : "AES";
|
|
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
|
|
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
|
|
}
|
|
|
|
function humanizeScope(scope: string): string {
|
|
return scope === "global" ? "Global" : "Local";
|
|
}
|
|
|
|
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: "Cc", value: message.cc_header || null },
|
|
{ label: "Bcc", value: message.bcc_header || null },
|
|
{ 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): CampaignMessagePreviewAttachment[] {
|
|
return (message.attachments ?? []).map((attachment, index) => ({
|
|
filename: attachment.filename || `Attachment ${index + 1}`,
|
|
contentType: attachment.content_type || undefined,
|
|
sizeBytes: attachment.size_bytes ?? undefined,
|
|
}));
|
|
}
|
|
|
|
function countResolvedAttachments(value: unknown): number {
|
|
const archives = new Set<string>();
|
|
let directCount = 0;
|
|
for (const item of asArray(value)) {
|
|
const attachment = asRecord(item);
|
|
const zipFilename = String(attachment.zip_filename ?? "").trim();
|
|
if (zipFilename) {
|
|
archives.add(zipFilename);
|
|
continue;
|
|
}
|
|
const managedCount = asArray(attachment.managed_matches).length;
|
|
if (managedCount > 0) {
|
|
directCount += managedCount;
|
|
continue;
|
|
}
|
|
const matchCount = asArray(attachment.matches).length;
|
|
directCount += matchCount > 0 ? matchCount : 1;
|
|
}
|
|
return directCount + archives.size;
|
|
}
|
|
|
|
function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
|
|
return String(row.validation_status ?? "").toLowerCase() === "needs_review";
|
|
}
|
|
|
|
function emptyCampaignJobsResponse(): CampaignJobsResponse {
|
|
return {
|
|
jobs: [],
|
|
page: 1,
|
|
page_size: 50,
|
|
total: 0,
|
|
total_unfiltered: 0,
|
|
pages: 0,
|
|
counts: {},
|
|
filtered_counts: {},
|
|
review: {},
|
|
};
|
|
}
|
|
|
|
function storedMessageReviewState(version: CampaignVersionDetail | null): {
|
|
buildToken: string;
|
|
inspectionComplete: boolean;
|
|
reviewedMessageKeys: string[];
|
|
} {
|
|
const build = asRecord(version?.build_summary);
|
|
const buildToken = String(build.build_token ?? build.built_at ?? "");
|
|
const review = asRecord(asRecord(version?.editor_state).review_send);
|
|
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
|
|
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
|
|
}
|
|
return {
|
|
buildToken,
|
|
inspectionComplete: review.inspection_complete === true,
|
|
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter((value): value is string => typeof value === "string"),
|
|
};
|
|
}
|
|
|
|
function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
|
return String(row.entry_id ?? row.entry_index ?? index);
|
|
}
|
|
|
|
function formatAddressList(value: unknown): string {
|
|
return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", ");
|
|
}
|
|
|
|
function formatSingleAddress(value: unknown): string {
|
|
const address = asRecord(value);
|
|
const email = String(address.email ?? "").trim();
|
|
const name = String(address.name ?? "").trim();
|
|
if (name && email) return `${name} <${email}>`;
|
|
return email || name;
|
|
}
|
|
|
|
function numberFrom(record: Record<string, unknown>, keys: string[]): number {
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function numberOrUndefined(value: unknown): number | undefined {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
|
return undefined;
|
|
}
|
|
|
|
function stringOrUndefined(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
return value.trim() || undefined;
|
|
}
|
|
|
|
function stateLabel(state: FlowState): string {
|
|
switch (state) {
|
|
case "complete": return "Passed";
|
|
case "warning": return "Warnings";
|
|
case "danger": return "Blocked";
|
|
case "active": return "Available";
|
|
case "locked": return "Locked";
|
|
case "running": return "Running";
|
|
case "partial": return "Partial";
|
|
case "stale": return "Stale";
|
|
default: return humanize(state);
|
|
}
|
|
}
|