Files
govoplan-campaign/webui/src/features/campaigns/ReviewSendPage.tsx

1856 lines
97 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import {
BarChart3,
Check,
ChevronDown,
FlaskConical,
LockKeyhole,
PackageCheck,
Send,
ShieldCheck,
type LucideIcon } from
"lucide-react";
import type { ApiSettings } from "../../types";
import {
appendSent,
buildVersion,
getCampaignJobsDelta,
getCampaignJobDetail,
getCampaignSummary,
mockSendCampaign,
sendCampaignNow,
updateCampaignReviewState,
validateVersion,
type CampaignJobsQuery,
type CampaignJobsResponse,
type CampaignSummary,
type CampaignVersionDetail } from
"../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, useDeltaWatermarks, useGuardedNavigate, 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, i18nMessage } 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 { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
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 DeliverabilityPreflightItem = {
label: string;
detail: string;
state: "ready" | "warning" | "blocked" | "info";
};
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: "i18n:govoplan-campaign.ready.20c7c552" },
{ value: "warning", label: "i18n:govoplan-campaign.warning.e9c45563" },
{ value: "needs_review", label: "i18n:govoplan-campaign.needs_review.33a506cf" },
{ value: "blocked", label: "i18n:govoplan-campaign.blocked.99613c74" },
{ value: "excluded", label: "i18n:govoplan-campaign.excluded.9804952b" }];
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
export default function ReviewSendPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const navigate = useGuardedNavigate();
const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox");
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
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 server = asRecord(campaignJson.server);
const smtpServer = asRecord(server.smtp);
const imapServer = asRecord(server.imap);
const serverCredentials = asRecord(server.credentials);
const smtpCredentials = asRecord(serverCredentials.smtp);
const imapCredentials = asRecord(serverCredentials.imap);
const selectedMailProfileId = getText(server, "mail_profile_id", getText(server, "profile_id"));
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 reviewJobsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
const jobPageCursorsRef = useRef<Record<string, Record<number, string | null>>>({});
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 imapDiagnosticsRef = useRef<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());
reviewJobsRef.current = emptyCampaignJobsResponse();
setReviewPage(1);
setJobsLoadedKey("");
setNewlyReviewedRequiredKeys(new Set());
setSelectedBuiltIndex(null);
setMockResult(null);
setSelectedMockMessage(null);
setSendResult(null);
setImapAppendResult(null);
setImapDiagnostics(emptyCampaignJobsResponse());
imapDiagnosticsRef.current = emptyCampaignJobsResponse();
setSelectedDeliveryJobDetail(null);
setSendConfirmOpen(false);
setLiveSummary(null);
resetDeltaWatermark();
}, [version?.id, resetDeltaWatermark]);
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 = reviewJobsDeltaKey(version.id, reviewPage, showAllReviewJobs);
if (jobsLoadedKey === expectedKey) return;
void loadBuiltMessages(true, reviewPage);
}, [version?.id, hasBuild, reviewPage, showAllReviewJobs, jobsLoadedKey, campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
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 ?
"i18n:govoplan-campaign.queued_jobs_are_waiting_in_the_database_but_back.5b1fadfd" :
deliverySending ?
"i18n:govoplan-campaign.delivery_is_being_processed_this_page_refreshes_.fe83676e" :
deliveryQueued && backgroundWorkersEnabled ?
"i18n:govoplan-campaign.queued_jobs_are_waiting_for_a_background_deliver.cc703afb" :
deliveryQueued ?
"i18n:govoplan-campaign.queued_jobs_are_waiting_if_the_counts_do_not_cha.7d2164d6" :
"";
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("i18n:govoplan-campaign.refreshing_queue_status.2a7dea57");
setError("");
try {
const result = await getCampaignSummary(settings, campaignId, version.id);
setLiveSummary(result);
if (!silent) setMessage("i18n:govoplan-campaign.queue_status_refreshed.253e2e5b");
} 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 ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : !mockVerificationRequired ? "i18n:govoplan-campaign.optional.0c6c4102" : stateLabel(mockState);
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || downstreamDeliveryActivity);
const sendLockReason = !inspectionSatisfied ?
"i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" :
mockWorkflowRequired ?
"i18n:govoplan-campaign.complete_a_successful_mock_delivery_first.bc2a587d" :
"i18n:govoplan-campaign.build_the_exact_queue_first.98d7ce1b";
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: "i18n:govoplan-campaign.validate_and_inspect.b617b9b2",
shortTitle: "i18n:govoplan-campaign.validate.6752f198",
description: "i18n:govoplan-campaign.lock_and_validate_the_campaign_then_inspect_bloc.e22b9266",
icon: ShieldCheck,
state: validationReviewState,
stateLabel: stateLabel(validationReviewState)
},
{
id: "workflow-build-review",
title: "i18n:govoplan-campaign.build_and_review.635177f0",
shortTitle: "i18n:govoplan-campaign.build.bbd80cf7",
description: "i18n:govoplan-campaign.build_the_exact_queue_resolve_recipient_values_a.d86446cb",
icon: PackageCheck,
state: buildReviewState,
stateLabel: stateLabel(buildReviewState),
lockReason: validationErrors > 0 ?
`Resolve ${validationErrors} blocking validation issue${validationErrors === 1 ? "" : "s"} first.` :
"i18n:govoplan-campaign.lock_and_validate_the_current_working_version_fi.e824704f"
},
{
id: "workflow-mock-verify",
title: "i18n:govoplan-campaign.mock_send_and_verify.03ec38d0",
shortTitle: "i18n:govoplan-campaign.mock_send.37a5f500",
description: "i18n:govoplan-campaign.exercise_the_delivery_path_and_verify_recipient_.12ed1928",
icon: FlaskConical,
state: mockState,
stateLabel: mockStateDisplayLabel,
lockReason: mockWorkflowAvailable ? "i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" : "i18n:govoplan-campaign.enable_the_mail_dev_mailbox_capability_to_run_mo.4f9a506d"
},
{
id: "workflow-send",
title: "i18n:govoplan-campaign.confirm_and_send.fe43b726",
shortTitle: "i18n:govoplan-campaign.send.9bc2575c",
description: "i18n:govoplan-campaign.review_the_final_execution_summary_optionally_ru.6b32b00a",
icon: Send,
state: sendState,
stateLabel: stateLabel(sendState),
lockReason: sendLockReason
},
{
id: "workflow-results",
title: "i18n:govoplan-campaign.delivery_results.b5d7d1cb",
shortTitle: "i18n:govoplan-campaign.results.612e12d2",
description: "i18n:govoplan-campaign.review_smtp_outcomes_imap_append_results_partial.a0d4e83e",
icon: BarChart3,
state: resultState,
stateLabel: stateLabel(resultState),
lockReason: "i18n:govoplan-campaign.delivery_results_become_available_after_the_real.4a86ab77"
}],
[
validationReviewState,
buildReviewState,
mockState,
sendState,
resultState,
validationErrors,
mockStateDisplayLabel,
mockWorkflowAvailable,
sendLockReason]
);
function reviewJobsDeltaKey(versionId: string, targetPage: number, includeAll: boolean): string {
return JSON.stringify({
scope: "review-jobs",
campaignId,
versionId,
page: targetPage,
pageSize: 50,
includeAll,
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
});
}
function reviewJobsCursorScopeKey(versionId: string, includeAll: boolean): string {
return JSON.stringify({
scope: "review-jobs",
campaignId,
versionId,
pageSize: 50,
includeAll,
validationStatus: includeAll ? [] : MESSAGE_REVIEW_ISSUE_STATUSES
});
}
function imapDiagnosticsDeltaKey(versionId: string): string {
return JSON.stringify({
scope: "imap-diagnostics",
campaignId,
versionId,
page: 1,
pageSize: 50,
imapStatus: ["pending", "failed"],
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
});
}
async function fetchJobsDelta(key: string, current: CampaignJobsResponse, options: CampaignJobsQuery, cursorScopeKey?: string): Promise<CampaignJobsResponse> {
let nextWatermark = getDeltaWatermark(key);
let merged = current;
let hasMore = false;
const page = options.page ?? 1;
const cursorMap = cursorScopeKey ? jobPageCursorsRef.current[cursorScopeKey] ?? { 1: null } : undefined;
const pageCursor = cursorMap ? page === 1 ? null : cursorMap[page] : undefined;
do {
const response = await getCampaignJobsDelta(settings, campaignId, {
...options,
cursor: pageCursor,
since: nextWatermark
});
merged = mergeCampaignJobsDelta(merged, response);
if (cursorScopeKey) {
const nextCursorMap = jobPageCursorsRef.current[cursorScopeKey] ?? { 1: null };
if (response.cursor !== undefined) nextCursorMap[page] = response.cursor ?? null;
if (response.next_cursor !== undefined) {
if (response.next_cursor) nextCursorMap[page + 1] = response.next_cursor;
else delete nextCursorMap[page + 1];
}
jobPageCursorsRef.current[cursorScopeKey] = nextCursorMap;
}
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(key, nextWatermark);
return merged;
}
async function runValidation() {
if (!version || busy || readOnlyVersion || readyForDelivery) return;
setBusy("validate");
setMessage("i18n:govoplan-campaign.validating_the_campaign_including_managed_attach.b133cfd9");
setError("");
try {
const result = await validateVersion(settings, version.id, true);
setMessage(result.ok ? "i18n:govoplan-campaign.validation_passed.c3e25768" : "i18n:govoplan-campaign.validation_finished_with_issues_review_the_excep.be65d2a1");
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("i18n:govoplan-campaign.building_exact_messages_and_resolving_managed_at.7f24ee41");
setError("");
try {
const result = await buildVersion(settings, version.id, true);
const reviewKey = reviewJobsDeltaKey(version.id, 1, showAllReviewJobs);
const reviewCursorKey = reviewJobsCursorScopeKey(version.id, showAllReviewJobs);
resetDeltaWatermark(reviewKey);
jobPageCursorsRef.current[reviewCursorKey] = { 1: null };
reviewJobsRef.current = emptyCampaignJobsResponse();
const review = await fetchJobsDelta(reviewKey, reviewJobsRef.current, {
versionId: version.id,
page: 1,
pageSize: 50,
validationStatus: showAllReviewJobs ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES
}, reviewCursorKey);
setReviewPage(1);
reviewJobsRef.current = review;
setReviewJobs(review);
setBuiltReviewRows(review.jobs.map(asRecord));
setJobsLoadedKey(reviewKey);
setMessage(i18nMessage("i18n:govoplan-campaign.build_finished_built_value_message_s_the_message.e1ec0296", { value0: String(result.built_count ?? result.ready_count ?? "—") }));
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("i18n:govoplan-campaign.loading_the_built_message_review.a5339588");
setError("");
try {
const reviewKey = reviewJobsDeltaKey(version.id, targetPage, showAllReviewJobs);
const reviewCursorKey = reviewJobsCursorScopeKey(version.id, showAllReviewJobs);
const current = jobsLoadedKey === reviewKey ? reviewJobsRef.current : emptyCampaignJobsResponse();
if (jobsLoadedKey !== reviewKey) resetDeltaWatermark(reviewKey);
const result = await fetchJobsDelta(reviewKey, current, {
versionId: version.id,
page: targetPage,
pageSize: 50,
validationStatus: showAllReviewJobs ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES
}, reviewCursorKey);
const jobs = result.jobs.map(asRecord);
reviewJobsRef.current = result;
setReviewJobs(result);
setBuiltReviewRows(jobs);
setJobsLoadedKey(reviewKey);
if (result.pages > 0 && targetPage > result.pages) setReviewPage(result.pages);
if (!silent) setMessage(i18nMessage("i18n:govoplan-campaign.loaded_value_message_s_on_page_value_of_value.febbeb1e", { value0: jobs.length, value1: result.page, value2: 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("i18n:govoplan-campaign.running_the_complete_mock_delivery_flow.3d0f6cd8");
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(i18nMessage("i18n:govoplan-campaign.mock_delivery_finished_captured_value_message_s_.ac23dedf", { value0: String(sendResult.sent_count ?? 0), value1: String(sendResult.failed_count ?? 0), value2: 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 ?
"i18n:govoplan-campaign.checking_the_built_queue_without_sending.e15bb876" :
directQueuedSendAllowed ?
"i18n:govoplan-campaign.sending_the_queued_jobs_synchronously.6162f750" :
"i18n:govoplan-campaign.sending_the_locked_campaign_version.061ee1bb");
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 ?
"i18n:govoplan-campaign.dry_run_finished_no_message_was_sent.c026c6cd" :
`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 ? "i18n:govoplan-campaign.appending_pending_sent_copies_via_imap.3f0daf68" : "i18n:govoplan-campaign.queueing_pending_imap_append_jobs.31dd388e");
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("i18n:govoplan-campaign.loading_imap_diagnostics.a5b00c0e");
setError("");
try {
const diagnosticsKey = imapDiagnosticsDeltaKey(version.id);
const result = await fetchJobsDelta(diagnosticsKey, imapDiagnosticsRef.current, {
versionId: version.id,
page: 1,
pageSize: 50,
imapStatus: ["pending", "failed"]
});
imapDiagnosticsRef.current = result;
setImapDiagnostics(result);
if (!silent) setMessage(i18nMessage("i18n:govoplan-campaign.loaded_value_pending_failed_imap_job_s.39b1b268", { value0: result.total }));
} 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("i18n:govoplan-campaign.recording_the_completed_message_review.16f58b81");
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) => {
const updated = {
...current,
review: {
...current.review,
inspection_complete: true,
reviewed_required_count: explicitReviewCount
}
};
reviewJobsRef.current = updated;
return updated;
});
setReviewConfirmOpen(false);
setMessage("i18n:govoplan-campaign.message_review_completed_and_recorded_for_this_b.452d87de");
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());
reviewJobsRef.current = emptyCampaignJobsResponse();
setReviewPage(1);
setJobsLoadedKey("");
setReviewedMessageKeys(new Set());
setNewlyReviewedRequiredKeys(new Set());
setSelectedBuiltIndex(null);
setMockResult(null);
setSelectedMockMessage(null);
resetDeltaWatermark();
}
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 smtpConfigured = Boolean(selectedMailProfileId || getText(smtpServer, "host") || getText(smtpCredentials, "username"));
const imapConfigured = Boolean(selectedMailProfileId || getText(imapServer, "host") || getText(imapCredentials, "username"));
const deliverabilityPreflightItems: DeliverabilityPreflightItem[] = [
{
label: "Transport",
detail: smtpConfigured ? selectedMailProfileId ? "Reusable mail profile selected." : "Inline SMTP settings are present." : "Select a reusable mail profile or configure SMTP before live delivery.",
state: smtpConfigured ? "ready" : "blocked"
},
{
label: "Policy",
detail: validationPresent ? validationErrors > 0 ? "Validation still reports blocking policy or data issues." : "Latest validation has no blocking issue." : "Run validation to evaluate effective mail and attachment policy.",
state: validationPresent ? validationErrors > 0 ? "blocked" : validationWarnings > 0 ? "warning" : "ready" : "warning"
},
{
label: "Messages",
detail: hasBuild ? inspectionSatisfied ? "Built messages are reviewed or did not require manual review." : "Built messages still need review before live delivery." : "Build exact messages before sending.",
state: hasBuild ? inspectionSatisfied ? "ready" : "warning" : "blocked"
},
{
label: "Attachments",
detail: missingAttachments + ambiguousAttachments === 0 ? "No missing or ambiguous attachment matches in the current summary." : `${missingAttachments} missing and ${ambiguousAttachments} ambiguous attachment match(es).`,
state: missingAttachments + ambiguousAttachments === 0 ? "ready" : "blocked"
},
{
label: "Rate limit",
detail: messagesPerMinute > 0 ? `${messagesPerMinute} message(s) per minute; estimated minimum duration ${estimatedMinutes ?? "unknown"} minute(s).` : "No explicit rate limit is configured for this execution.",
state: messagesPerMinute > 0 ? "ready" : "warning"
},
{
label: "Sent copy",
detail: Boolean(imapAppend.enabled) ? imapConfigured ? `IMAP append enabled for ${String(imapAppend.folder ?? "auto")}.` : "IMAP append is enabled, but no IMAP server/profile is visible." : "IMAP append is disabled for this campaign.",
state: Boolean(imapAppend.enabled) ? imapConfigured ? "ready" : "blocked" : "info"
}];
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)}>i18n:govoplan-campaign.review_send.1627617d</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 ? "i18n:govoplan-campaign.refreshing_status.d8965739" : "i18n:govoplan-campaign.refresh_status.ade15a52"}
</Button>
<Button onClick={reload} disabled={loading || Boolean(busy)}>i18n:govoplan-campaign.reload.cce71553</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="i18n:govoplan-campaign.this_workflow_is_read_only_for_the_selected_vers.6626b342" />
}
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_workflow_state.5319aae1">
<WorkflowNavigation stages={stages} onSelect={scrollToStage} />
<div className="review-flow-timeline" aria-label="i18n:govoplan-campaign.campaign_review_and_sending_workflow.e784ea7b">
<WorkflowStage stage={stages[0]} nextState={stages[1].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.status.bae7d5be" value={validationPresent ? validationOk ? "i18n:govoplan-campaign.passed.271d60f4" : "i18n:govoplan-campaign.needs_attention.a126722e" : "i18n:govoplan-campaign.not_run.9e019cd5"} />
<WorkflowFact label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} />
</div>
{validationStale && <p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.the_stored_validation_result_is_no_longer_an_act.45d2dd14</p>}
{validationPresent && validationErrors === 0 &&
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p>
}
{validationErrors > 0 &&
<p className="review-flow-inline-note is-danger">i18n:govoplan-campaign.resolve_the_blocking_entries_then_validate_again.e282ae0f</p>
}
{visibleValidationIssues.length > 0 &&
<div className="review-flow-data-section">
<h3>i18n:govoplan-campaign.validation_details.aa503267</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 ?? "i18n:govoplan-campaign.validation_issue.800cc3d0")}</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">i18n:govoplan-campaign.showing.163d8174 {visibleValidationIssues.length} of {validationIssues.length} i18n:govoplan-campaign.validation_issue_s.c6a8b911</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" ?
"i18n:govoplan-campaign.validating.c07434c9" :
readyForDelivery ?
"i18n:govoplan-campaign.locked_and_validated.4688d836" :
"i18n:govoplan-campaign.lock_and_validate.982552e9"}
</Button>
<Button onClick={() => navigate("../files")}>i18n:govoplan-campaign.review_attachment_rules.552c2116</Button>
</div>
</WorkflowStage>
<WorkflowStage stage={stages[1]} nextState={stages[2].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? reviewedRequiredCount : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} />
<WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
</div>
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</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" ? "i18n:govoplan-campaign.building.7cc766ce" : hasBuild ? "i18n:govoplan-campaign.build_again.bd018b93" : "i18n:govoplan-campaign.build_exact_messages.bc53f55e"}
</Button>
<Button onClick={() => void loadBuiltMessages(false, reviewPage)} disabled={!hasBuild || Boolean(busy)}>
{busy === "inspect" ? "i18n:govoplan-campaign.loading_messages.a863c67f" : builtReviewRows.length > 0 ? "i18n:govoplan-campaign.reload_page.37614e96" : "i18n:govoplan-campaign.load_review.19af85af"}
</Button>
<Button onClick={() => navigate("../template")}>i18n:govoplan-campaign.open_template_editor.1739b545</Button>
{reviewRequiredCount > 0 &&
<Button
variant="primary"
onClick={() => bulkAcceptableCount > 0 ? setReviewConfirmOpen(true) : void completeInspection(false)}
disabled={readOnlyVersion || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)}>
{messageReviewComplete ? "i18n:govoplan-campaign.review_completed.6387eb08" : "i18n:govoplan-campaign.complete_review.4c2ed8c8"}
</Button>
}
</div>
{automaticInspectionComplete &&
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p>
}
{blockingReviewCount > 0 &&
<p className="review-flow-inline-note is-danger">{blockingReviewCount} i18n:govoplan-campaign.blocked_or_failed_message_s_must_be_resolved_bef.5c6b5140</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 ? "i18n:govoplan-campaign.show_review_candidates_only.f49df60b" : "i18n:govoplan-campaign.show_all_messages.1c2107a1"}
</Button>
</div>
<span className="muted">{reviewJobs.total} i18n:govoplan-campaign.matching_of.66a3778e {reviewJobs.total_unfiltered} i18n:govoplan-campaign.built_job_s.82d49ade</span>
</div>
<DataGrid
id={`campaign-${campaignId}-workflow-built-messages`}
rows={builtReviewRows}
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
getRowKey={builtMessageKey}
emptyText="i18n:govoplan-campaign.no_built_messages_are_available.cc1216ad"
filteredEmptyText="i18n:govoplan-campaign.no_messages_match_the_active_filters.14811cc8"
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"}>i18n:govoplan-campaign.previous.50f94286</Button>
<span>i18n:govoplan-campaign.page.fb06270f {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"}>i18n:govoplan-campaign.next.bc981983</Button>
</div>
<p className="muted small-note">i18n:govoplan-campaign.ready_messages_are_hidden_initially_messages_mar.1cb9770d</p>
</div>
}
</WorkflowStage>
<WorkflowStage stage={stages[2]} nextState={stages[3].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.captured_smtp.78f2e2ca" value={mockResult ? mockSent : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.mock_failures.9475ce2a" value={mockResult ? mockFailed : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.skipped.5a000ad7" value={mockResult ? mockSkipped : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.captured_messages.a833d293" value={!mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : 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" ? "i18n:govoplan-campaign.running_mock_delivery.06c6a3bf" : mockResult ? "i18n:govoplan-campaign.run_mock_delivery_again.77fa416e" : "i18n:govoplan-campaign.run_mock_delivery.b74ff33d"}
</Button>
<Button onClick={() => navigate("../mail-settings")}>i18n:govoplan-campaign.review_server_settings.65a32dd1</Button>
</div>
{!mockWorkflowAvailable &&
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.mock_delivery_uses_the_mail_module_development_m.9d645a5f</p>
}
<div className="toggle-row mock-send-options">
<ToggleSwitch label="i18n:govoplan-campaign.require_mock_before_real_send.a1ccc330" checked={mockVerificationRequired} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockVerificationRequired} />
<ToggleSwitch label="i18n:govoplan-campaign.show_captured_mock_mailbox.019e1e79" checked={mockMailboxPreviewEnabled && mockWorkflowAvailable} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockMailboxPreviewEnabled} />
<ToggleSwitch label="i18n:govoplan-campaign.clear_mock_mailbox_first.7627dcad" checked={mockClearFirst} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockClearFirst} />
<ToggleSwitch label="i18n:govoplan-campaign.append_mock_sent_copy.49c2cf08" 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">i18n:govoplan-campaign.recipient_outcomes.d88abb50</h3>
<DataGrid
id={`campaign-${campaignId}-workflow-mock-results`}
rows={mockRows}
columns={mockSendResultColumns()}
getRowKey={(row, index) => String(row.entry_id ?? row.entry_index ?? index)}
emptyText="i18n:govoplan-campaign.no_mock_delivery_results_were_returned.96b6736d"
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">i18n:govoplan-campaign.captured_mock_messages.5839fae1</h3>
<DataGrid
id={`campaign-${campaignId}-workflow-mock-mailbox`}
rows={mockMailboxMessages}
columns={mockMailboxColumns(openMockMessage)}
getRowKey={(row, index) => String(row.id ?? index)}
emptyText="i18n:govoplan-campaign.no_mock_messages_were_captured_in_this_run.2b6e90f0"
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">i18n:govoplan-campaign.captured_mock_messages.5839fae1</h3>
<p className="muted small-note">{mockWorkflowAvailable ? "i18n:govoplan-campaign.captured_mailbox_preview_is_disabled_for_this_ru.6559458d" : "i18n:govoplan-campaign.captured_mailbox_preview_requires_the_mail_devel.9b3d9f0d"}</p>
</section>
}
</div>
}
</WorkflowStage>
<WorkflowStage stage={stages[3]} nextState={stages[4].state}>
<div className="review-flow-execution-summary">
<div><span>i18n:govoplan-campaign.recipients.78cbf8eb</span><strong>{jobsTotal || "—"}</strong></div>
<div><span>i18n:govoplan-campaign.messages_to_send.f7763bf9</span><strong>{builtCount || jobsTotal || "—"}</strong></div>
<div><span>i18n:govoplan-campaign.matched_attachments.ead1eeb1</span><strong>{matchedAttachments || "—"}</strong></div>
<div><span>i18n:govoplan-campaign.missing_ambiguous.fffdd3f5</span><strong>{missingAttachments} / {ambiguousAttachments}</strong></div>
<div><span>i18n:govoplan-campaign.rate_limit.d08e55f5</span><strong>{messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"}</strong></div>
<div><span>i18n:govoplan-campaign.minimum_duration.91a71a6d</span><strong>{estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"}</strong></div>
<div><span>i18n:govoplan-campaign.imap_append.8c0d9e96</span><strong>{Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"}</strong></div>
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div>
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
<WorkflowFact label="i18n:govoplan-campaign.not_queued.b7f41e1e" value={notQueuedCount} />
<WorkflowFact label="i18n:govoplan-campaign.send_attempts.2cdb19e8" value={sendAttemptCount} />
</div>
{queueStatusNote &&
<p className={`review-flow-inline-note ${queueStatusTone}`.trim()}>{queueStatusNote}</p>
}
<div className="review-send-controls">
<ToggleSwitch
label="i18n:govoplan-campaign.dry_run.485a3d15"
checked={dryRun}
disabled={Boolean(busy) || deliveryQueued || deliveryStarted}
onChange={setDryRun} />
<span className="muted small-note">i18n:govoplan-campaign.a_dry_run_checks_the_frozen_queue_and_delivery_c.c42924bb</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 ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" :
directQueuedSendAllowed ?
"i18n:govoplan-campaign.send_queued_now.bbace803" :
selectedDryRun ?
"i18n:govoplan-campaign.run_dry_run.26db1eeb" :
"i18n:govoplan-campaign.send_now.dae33010"}
</Button>
</div>
{sendResult &&
<div className="review-flow-data-section">
<p className="muted small-note">i18n:govoplan-campaign.attempted.a9eb9c90 {String(sendResult.attempted_count ?? "—")}i18n:govoplan-campaign.smtp_accepted.a5d0dccc {String(sendResult.sent_count ?? "—")}i18n:govoplan-campaign.failed.fac9f871 {String(sendResult.failed_count ?? "—")}i18n:govoplan-campaign.outcome_unknown.4383023a {String(sendResult.outcome_unknown_count ?? 0)}i18n:govoplan-campaign.skipped.6b98496c {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="i18n:govoplan-campaign.no_send_results_returned.d1ec3e23"
className="data-table-wrap data-table compact-table" />
}
</div>
}
</WorkflowStage>
<WorkflowStage stage={stages[4]}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
<WorkflowFact label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
<WorkflowFact label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
<WorkflowFact label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
<WorkflowFact label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
<WorkflowFact label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
<WorkflowFact label="i18n:govoplan-campaign.imap_failed.50dbca55" value={imapFailedForDisplay} />
<WorkflowFact label="i18n:govoplan-campaign.imap_skipped.5a97b542" value={imapSkipped} />
</div>
<div className="review-flow-result-line">
<StatusBadge status={deliveryDisplayStatus} />
<span>{deliveryStarted ? "i18n:govoplan-campaign.delivery_activity_is_available_in_the_report_and.cb163d1d" : "i18n:govoplan-campaign.no_real_delivery_has_started_for_this_campaign_v.3b9235ff"}</span>
</div>
{Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 &&
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.imap_sent_append_is_still_pending_for.475700e4 {imapPendingForDisplay} i18n:govoplan-campaign.job_s_pending_with_no_imap_attempt_usually_means.0776d29f</p>
}
{Boolean(imapAppend.enabled) && imapFailedForDisplay > 0 &&
<p className="review-flow-inline-note is-danger">{imapFailedForDisplay} i18n:govoplan-campaign.imap_append_job_s_failed_load_diagnostics_to_ins.9de2b9cd</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)}>i18n:govoplan-campaign.load_imap_diagnostics.8ebb1891</Button>
{imapPendingForDisplay > 0 &&
<Button variant="primary" onClick={() => void runAppendSent()} disabled={Boolean(busy) || !canAppendPendingImap}>
{busy === "imap" ? "i18n:govoplan-campaign.appending_imap.637be745" : backgroundWorkersEnabled ? "i18n:govoplan-campaign.queue_pending_imap_append.8b2b09ca" : "i18n:govoplan-campaign.append_pending_imap_now.d3ccedfd"}
</Button>
}
</div>
}
{imapAppendResult &&
<div className="review-flow-data-section">
<h3>i18n:govoplan-campaign.imap_append_result.544fc7ed</h3>
<p className="muted small-note">i18n:govoplan-campaign.pending.96f608c1 {String(imapAppendResult.pending_count ?? "0")}i18n:govoplan-campaign.enqueued.d93063b1 {String(imapAppendResult.enqueued_count ?? "0")}i18n:govoplan-campaign.processed.90e0ee45 {String(imapAppendResult.processed_count ?? "0")}i18n:govoplan-campaign.appended.f15bf444 {String(imapAppendResult.appended_count ?? "0")}i18n:govoplan-campaign.failed.fac9f871 {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="i18n:govoplan-campaign.no_imap_append_result_rows_returned.13a62b2e"
className="data-table-wrap data-table compact-table" />
}
</div>
}
{imapDiagnostics.total > 0 &&
<div className="review-flow-data-section">
<h3>i18n:govoplan-campaign.imap_diagnostics.f3666fc4</h3>
<DataGrid
id={`campaign-${campaignId}-workflow-imap-diagnostics`}
rows={imapDiagnosticRows}
columns={imapDiagnosticColumns(openDeliveryJobDetail)}
getRowKey={(row, index) => String(row.id ?? index)}
emptyText="i18n:govoplan-campaign.no_pending_or_failed_imap_jobs_found.eb8adfdc"
className="data-table-wrap data-table compact-table" />
</div>
}
{recentFailures.length > 0 &&
<div className="review-flow-data-section">
<h3>i18n:govoplan-campaign.recent_delivery_failures.282285fb</h3>
<dl className="detail-list">
{recentFailures.map((failure, index) =>
<div key={String(failure.job_id ?? index)}>
<dt>{String(failure.recipient_email ?? failure.entry_id ?? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: 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")}>i18n:govoplan-campaign.open_report.44f83158</Button>
<Button onClick={() => navigate("../audit")}>i18n:govoplan-campaign.open_audit_log.f387069c</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="i18n:govoplan-campaign.accept_non_blocking_review_conditions.47895387"
message={i18nMessage("i18n:govoplan-campaign.value_message_s_contain_warnings_or_exclusions_b.e7d13991", { value0: bulkAcceptableCount })}
confirmLabel="i18n:govoplan-campaign.accept_and_complete_review.35831c1d"
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
onConfirm={() => void completeInspection(true)}
onCancel={() => setReviewConfirmOpen(false)} />
<ConfirmDialog
open={sendConfirmOpen}
title="i18n:govoplan-campaign.send_this_version_now.10a0ca56"
message={i18nMessage("i18n:govoplan-campaign.this_sends_the_frozen_execution_snapshot_for_ver.1f7c53cb", { value0: String(version?.version_number ?? "—"), value1: jobsTotal, value2: builtCount, value3: buildBlocked, value4: String(attachmentSummary.total_matched_files ?? 0), value5: String(rateLimit.messages_per_minute ?? "i18n:govoplan-campaign.not_set.ef374c57"), value6: imapAppend.enabled === true ? "enabled" : "disabled", value7: version?.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: version.execution_snapshot_hash.slice(0, 12) }) : "missing" })}
confirmLabel={directQueuedSendAllowed ? "i18n:govoplan-campaign.send_queued_now.bbace803" : "i18n:govoplan-campaign.send_now.dae33010"}
tone="danger"
busy={busy === "send"}
onCancel={() => setSendConfirmOpen(false)}
onConfirm={() => void runSendNow()} />
{selectedDeliveryJobDetail &&
<DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} />
}
{selectedMockMessage && mockMailboxPreviewActive &&
<CampaignMessagePreviewOverlay
title="i18n:govoplan-campaign.captured_mock_mail.2fce35f3"
subject={selectedMockMessage.subject || "i18n:govoplan-campaign.mock_message.27222ca1"}
bodyMode="text"
text={selectedMockMessage.body_preview || ""}
recipientLabel={selectedMockMessage.kind === "imap_append" ? "i18n:govoplan-campaign.mock_imap_append.9ee5eb70" : "i18n:govoplan-campaign.mock_smtp_delivery.c6e2364d"}
recipientNote={selectedMockMessage.created_at ? formatDateTime(selectedMockMessage.created_at) : undefined}
metaItems={mockMessageMetaItems(selectedMockMessage)}
attachments={mockMessageAttachments(selectedMockMessage)}
raw={selectedMockMessage.raw_eml}
rawLabel="i18n:govoplan-campaign.raw_mime.82c612d4"
onClose={() => setSelectedMockMessage(null)} />
}
</div>);
}
function WorkflowNavigation({ stages, onSelect }: {stages: FlowStageDefinition[];onSelect: (id: string) => void;}) {
return (
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2">
<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="i18n:govoplan-campaign.locked.a798882f" />}
</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="i18n:govoplan-campaign.locked.a798882f" />}
{!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 ? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: stage.title }) : i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: stage.title })}
title={collapsed ? "i18n:govoplan-campaign.show_content.0528d8d2" : "i18n:govoplan-campaign.show_header_only.24afefca"}
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 DeliverabilityPreflight({ items }: {items: DeliverabilityPreflightItem[];}) {
return (
<section className="deliverability-preflight" aria-label="Deliverability preflight">
<div className="deliverability-preflight-header">
<h3>Deliverability preflight</h3>
<span className="muted small-note">Operator checks before the first live send.</span>
</div>
<div className="deliverability-preflight-grid">
{items.map((item) =>
<div key={item.label} className="deliverability-preflight-item" data-state={item.state}>
<div>
<span>{item.label}</span>
<strong>{item.detail}</strong>
</div>
<StatusBadge status={item.state === "blocked" ? "failed" : item.state === "warning" ? "warning" : item.state === "ready" ? "ready" : "info"} label={humanize(item.state)} />
</div>
)}
</div>
</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) || "i18n:govoplan-campaign.no_subject.7b4e8035");
const issues = asArray(row.issues).map(asRecord);
const resolvedRecipients = asRecord(row.resolved_recipients);
return (
<CampaignMessagePreviewOverlay
title="i18n:govoplan-campaign.built_message_review.c7a594f9"
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 ?? "i18n:govoplan-campaign.issue.73781a12")).join(" · ")}` : "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"}
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="i18n:govoplan-campaign.delivery_job_details.0d9c5b1f"
className="template-preview-modal"
onClose={onClose}
footer={<Button variant="primary" onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(job.subject ?? "-")}</dd></div>
<div><dt>i18n:govoplan-campaign.smtp_status.6211cb2b</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
<div><dt>i18n:govoplan-campaign.imap_status.dbc2e430</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
{job.last_error ? <div><dt>i18n:govoplan-campaign.last_error.5e4df866</dt><dd>{String(job.last_error)}</dd></div> : null}
</dl>
{issues.length > 0 && <AttemptList title="i18n:govoplan-campaign.message_issues.9092a7db" rows={issues} />}
<AttemptList title="i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" rows={smtpAttempts} emptyText="i18n:govoplan-campaign.no_smtp_attempts_were_recorded.ff5b4c5d" />
<AttemptList title="i18n:govoplan-campaign.imap_attempts.e815f0c2" rows={imapAttempts} emptyText="i18n:govoplan-campaign.no_imap_append_attempts_were_recorded_if_status_.12279f7e" />
</Dialog>);
}
function AttemptList({ title, rows, emptyText = "i18n:govoplan-campaign.no_rows.fdbeab75" }: {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 ?? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: 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: "i18n:govoplan-campaign.recipient.90343260", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") },
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{
id: "validation",
header: "i18n:govoplan-campaign.validation.dd74d182",
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: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (_row, index) => <Button onClick={() => openMessage(index)}>i18n:govoplan-campaign.review.e29a79fe</Button> }];
}
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" },
{ value: "failed", label: "i18n:govoplan-campaign.failed.09fef5d8" },
{ value: "skipped", label: "i18n:govoplan-campaign.skipped.5a000ad7" },
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" }];
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.status ?? "info") },
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).join(", ") || "—" },
{ id: "smtp", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 190, sortable: true, filterable: true, value: (row) => String(row.smtp_message_id ?? row.status ?? "—") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 190, sortable: true, filterable: true, value: (row) => String(row.imap_message_id ?? row.imap_status ?? "—") },
{ id: "message", header: "i18n:govoplan-campaign.message.68f4145f", 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: "i18n:govoplan-campaign.smtp_accepted.e3aa7603" },
{ value: "already_accepted", label: "i18n:govoplan-campaign.already_accepted.826e68f2" },
{ value: "outcome_unknown", label: "i18n:govoplan-campaign.outcome_unknown.6e929fca" },
{ value: "failed", label: "i18n:govoplan-campaign.failed.09fef5d8" },
{ value: "cancelled", label: "i18n:govoplan-campaign.cancelled.a1bf92ef" },
{ value: "not_claimed", label: "i18n:govoplan-campaign.not_claimed.aa712c1b" },
{ value: "dry_run", label: "i18n:govoplan-campaign.dry_run.485a3d15" }];
return [
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", 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: "i18n:govoplan-campaign.job.30c8cb83", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? row.version_id ?? "—") },
{ id: "message", header: "i18n:govoplan-campaign.message.68f4145f", 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: "i18n:govoplan-campaign.status.bae7d5be", 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: "i18n:govoplan-campaign.job.30c8cb83", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? "-") },
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 190, sortable: true, filterable: true, value: (row) => String(row.folder ?? "-") },
{ id: "message", header: "i18n:govoplan-campaign.message.68f4145f", 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: "i18n:govoplan-campaign.recipient.90343260", 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: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>i18n:govoplan-campaign.details.dc3decbb</Button> }];
}
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "smtp", label: "i18n:govoplan-campaign.smtp.efff9cca" },
{ value: "imap_append", label: "i18n:govoplan-campaign.imap_append.8c0d9e96" }];
return [
{ id: "kind", header: "i18n:govoplan-campaign.kind.e00ac23f", width: 125, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.kind ?? "mock") },
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", 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: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openMessage(String(row.id ?? ""))}>i18n:govoplan-campaign.review.e29a79fe</Button> }];
}
function builtMessageMetaItems(row: Record<string, unknown>) {
const recipients = asRecord(row.resolved_recipients);
return [
{ label: "i18n:govoplan-campaign.from.3f66052a", value: formatSingleAddress(recipients.from) || "—" },
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
{ label: "i18n:govoplan-campaign.cc.c5a976de", value: formatAddressList(recipients.cc) || null },
{ label: "i18n:govoplan-campaign.bcc.4c0145a3", value: formatAddressList(recipients.bcc) || null },
{ label: "i18n:govoplan-campaign.validation.dd74d182", value: String(row.validation_status ?? "—") },
{ label: "i18n:govoplan-campaign.mime_size.c8b9d519", 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" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
return {
protected: true,
note: source ?
i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) :
i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
};
}
function humanizeScope(scope: string): string {
return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d";
}
function mockMessageMetaItems(message: MockMailboxMessage) {
return [
{ label: "i18n:govoplan-campaign.from.3f66052a", value: message.from_header || message.envelope_from || "—" },
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
{ label: "i18n:govoplan-campaign.cc.1fd6a880", value: message.cc_header || null },
{ label: "i18n:govoplan-campaign.bcc.8431acad", value: message.bcc_header || null },
{ label: "i18n:govoplan-campaign.kind.e00ac23f", value: message.kind || "—" },
{ label: "i18n:govoplan-campaign.folder.30baa249", value: message.folder || "—" },
{ label: "i18n:govoplan-campaign.message_id.fa0e4fdd", value: message.message_id || "—" },
{ label: "i18n:govoplan-campaign.size.b7152342", 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 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 "i18n:govoplan-campaign.passed.271d60f4";
case "warning":return "i18n:govoplan-campaign.warnings.1430f976";
case "danger":return "i18n:govoplan-campaign.blocked.99613c74";
case "active":return "i18n:govoplan-campaign.available.7c62a142";
case "locked":return "i18n:govoplan-campaign.locked.a798882f";
case "running":return "i18n:govoplan-campaign.running.73989d9c";
case "partial":return "i18n:govoplan-campaign.partial.65de2e2a";
case "stale":return "i18n:govoplan-campaign.stale.189cc40c";
default:return humanize(state);
}
}