2189 lines
114 KiB
TypeScript
2189 lines
114 KiB
TypeScript
import { DescriptionList } from "@govoplan/core-webui";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
BarChart3,
|
|
Check,
|
|
FlaskConical,
|
|
PackageCheck,
|
|
Send,
|
|
ShieldCheck } from
|
|
"lucide-react";
|
|
import type { ApiSettings } from "../../types";
|
|
import {
|
|
appendSent,
|
|
buildVersion,
|
|
cancelCampaign,
|
|
downloadCampaignPrintArtifact,
|
|
getCampaignDeliveryOptions,
|
|
getCampaignJobs,
|
|
getCampaignJobsDelta,
|
|
getCampaignJobDetail,
|
|
getCampaignSummary,
|
|
linkCampaignAttachmentMatches,
|
|
mockSendCampaign,
|
|
pauseCampaign,
|
|
previewCampaignAttachments,
|
|
queueCampaign,
|
|
requestCampaignApproval,
|
|
resumeCampaign,
|
|
retryCampaignJobs,
|
|
sendCampaignJob,
|
|
sendCampaignNow,
|
|
updateCampaignReviewState,
|
|
validateVersion,
|
|
type CampaignAttachmentPreviewResponse,
|
|
type CampaignDeliveryOptions,
|
|
type CampaignJobsQuery,
|
|
type CampaignJobsResponse,
|
|
type CampaignSummary } from
|
|
"../../api/campaigns";
|
|
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
|
import { FormGrid, Button, Dialog, FormField, MetricCard, MetricGrid, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
|
import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
|
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
|
import { LoadingFrame } from "@govoplan/core-webui";
|
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
|
import { StatusBadge } from "@govoplan/core-webui";
|
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
|
import { i18nMessage } from "@govoplan/core-webui";
|
|
import CampaignMessagePreviewOverlay 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 } from
|
|
"./utils/campaignView";
|
|
import { deliveryModeLabel } from "./utils/deliveryMode";
|
|
import { getText } from "./utils/draftEditor";
|
|
import { attachmentPreviewLinkableFiles } from "./utils/attachmentPreview";
|
|
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
|
import AttachmentLinkingPreview from "./review/AttachmentLinkingPreview";
|
|
import DeliverabilityPreflight, {
|
|
type DeliverabilityPreflightItem
|
|
} from "./review/DeliverabilityPreflight";
|
|
import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay";
|
|
import BuiltMessagePreview from "./review/BuiltMessagePreview";
|
|
import {
|
|
BuiltMessageReviewProgress,
|
|
BuiltMessageWorkflowGuidance,
|
|
ValidationWorkflowGuidance
|
|
} from "./review/ReviewWorkflowGuidance";
|
|
import { calculateBuildReviewProgress } from "./review/reviewProgress";
|
|
import {
|
|
WorkflowNavigation,
|
|
WorkflowStage,
|
|
stageConnectorState,
|
|
stateLabel,
|
|
type FlowStageDefinition,
|
|
type FlowState
|
|
} from "./review/WorkflowNavigation";
|
|
import {
|
|
builtMessageKey,
|
|
filterAndSortBuiltMessageRows,
|
|
findBuiltMessageIndex,
|
|
messageNeedsExplicitReview,
|
|
reviewQueryEquals,
|
|
sameBuiltMessage,
|
|
storedMessageReviewState
|
|
} from "./review/builtMessageQuery";
|
|
import {
|
|
formatAddressList,
|
|
numberFrom
|
|
} from "./review/reviewFormatters";
|
|
import {
|
|
builtMessageColumns,
|
|
deliveryControlProgressMessage,
|
|
deliveryPolicySourceLabel,
|
|
imapAppendResultColumns,
|
|
imapDiagnosticColumns,
|
|
mockMailboxColumns,
|
|
mockMessageAttachments,
|
|
mockMessageMetaItems,
|
|
mockSendResultColumns,
|
|
sendResultColumns,
|
|
synchronousSendReason
|
|
} from "./review/reviewPresentation";
|
|
|
|
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "approval" | "send" | "queue" | "control" | "retry" | "imap" | "";
|
|
|
|
|
|
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
|
|
|
|
export default function ReviewSendPage({
|
|
settings,
|
|
auth,
|
|
campaignId,
|
|
initialStageId
|
|
}: {
|
|
settings: ApiSettings;
|
|
auth: AuthInfo;
|
|
campaignId: string;
|
|
initialStageId?: 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 canSendSynchronously = hasScope(auth, "campaigns:campaign:send");
|
|
const canQueueForWorkers = hasScope(auth, "campaigns:campaign:queue");
|
|
const canControlDelivery = hasScope(auth, "campaigns:campaign:control");
|
|
const canRetryDelivery = hasScope(auth, "campaigns:campaign:retry");
|
|
const canRequestApproval = hasScope(auth, "campaigns:campaign:review");
|
|
const [deliveryOptions, setDeliveryOptions] = useState<CampaignDeliveryOptions | null>(null);
|
|
const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false);
|
|
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
|
|
const server = asRecord(campaignJson.server);
|
|
const selectedMailProfileId = getText(server, "mail_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 attachmentReuse = asRecord(build.attachment_reuse);
|
|
const attachmentReusePolicy = asRecord(attachmentReuse.policy);
|
|
const attachmentReuseFindings = asArray(attachmentReuse.findings).map(asRecord);
|
|
const residualFileDisposition = asRecord(build.residual_file_disposition);
|
|
const residualFileRecipient = asRecord(residualFileDisposition.recipient);
|
|
const printOutput = asRecord(build.print_output);
|
|
const printArtifact = asRecord(printOutput.artifact);
|
|
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 [reviewPage, setReviewPage] = useState(1);
|
|
const [reviewPageSize, setReviewPageSize] = useState(50);
|
|
const [reviewQuery, setReviewQuery] = useState<DataGridQueryState>({ sort: null, filters: {} });
|
|
const [showAllReviewJobs, setShowAllReviewJobs] = useState(false);
|
|
const [jobsLoadedKey, setJobsLoadedKey] = useState("");
|
|
const [reviewedMessageKeys, setReviewedMessageKeys] = useState<Set<string>>(() => new Set());
|
|
const [reviewIssueDecisions, setReviewIssueDecisions] = useState<
|
|
Record<string, string>
|
|
>({});
|
|
const [newlyReviewedRequiredKeys, setNewlyReviewedRequiredKeys] = useState<Set<string>>(() => new Set());
|
|
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
|
|
const [singleSendConfirmIndex, setSingleSendConfirmIndex] = useState<number | null>(null);
|
|
const [singleMessageActionKind, setSingleMessageActionKind] = useState<"test" | "single_send" | "single_resend">("single_send");
|
|
const [singleMessageResendReason, setSingleMessageResendReason] = useState("");
|
|
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 [attachmentPreview, setAttachmentPreview] = useState<CampaignAttachmentPreviewResponse | null>(null);
|
|
const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false);
|
|
const [attachmentPreviewError, setAttachmentPreviewError] = useState("");
|
|
const [attachmentLinking, setAttachmentLinking] = useState(false);
|
|
const [attachmentLockConfirmOpen, setAttachmentLockConfirmOpen] = useState(false);
|
|
const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false);
|
|
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
|
|
const [approvalSelectorKind, setApprovalSelectorKind] = useState<"account" | "group" | "role" | "function_assignment" | "any_account">("role");
|
|
const [approvalSelectorValue, setApprovalSelectorValue] = useState("");
|
|
const [approvalRequiredCount, setApprovalRequiredCount] = useState(1);
|
|
const [approvalSignatureRequired, setApprovalSignatureRequired] = useState(false);
|
|
const [approvalExcludedRoles, setApprovalExcludedRoles] = useState<Set<"author" | "owner" | "validator" | "builder" | "reviewer">>(() => new Set());
|
|
const [dryRun, setDryRun] = useState(false);
|
|
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
|
|
const [queueConfirmOpen, setQueueConfirmOpen] = useState(false);
|
|
const [cancelDeliveryConfirmOpen, setCancelDeliveryConfirmOpen] = useState(false);
|
|
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
|
|
const [queueResult, setQueueResult] = 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(",")}|${JSON.stringify(persistedReview.issueDecisions)}`;
|
|
const initialStageScrollKey = useRef("");
|
|
|
|
useEffect(() => {
|
|
setBuiltReviewRows([]);
|
|
setReviewJobs(emptyCampaignJobsResponse());
|
|
reviewJobsRef.current = emptyCampaignJobsResponse();
|
|
setReviewPage(1);
|
|
setReviewQuery({ sort: null, filters: {} });
|
|
setJobsLoadedKey("");
|
|
setNewlyReviewedRequiredKeys(new Set());
|
|
setReviewIssueDecisions({});
|
|
setSelectedBuiltIndex(null);
|
|
setMockResult(null);
|
|
setSelectedMockMessage(null);
|
|
setAttachmentPreview(null);
|
|
setAttachmentPreviewError("");
|
|
setAttachmentLockConfirmOpen(false);
|
|
setApprovalDialogOpen(false);
|
|
setSendResult(null);
|
|
setQueueResult(null);
|
|
setImapAppendResult(null);
|
|
setImapDiagnostics(emptyCampaignJobsResponse());
|
|
imapDiagnosticsRef.current = emptyCampaignJobsResponse();
|
|
setSelectedDeliveryJobDetail(null);
|
|
setSendConfirmOpen(false);
|
|
setQueueConfirmOpen(false);
|
|
setCancelDeliveryConfirmOpen(false);
|
|
setDeliveryOptions(null);
|
|
setLiveSummary(null);
|
|
resetDeltaWatermark();
|
|
}, [version?.id, resetDeltaWatermark]);
|
|
|
|
useEffect(() => {
|
|
if (!initialStageId || loading || !version?.id) return;
|
|
const key = `${version.id}:${initialStageId}`;
|
|
if (initialStageScrollKey.current === key) return;
|
|
const frame = window.requestAnimationFrame(() => {
|
|
const target = document.getElementById(initialStageId);
|
|
if (!target) return;
|
|
initialStageScrollKey.current = key;
|
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
target.focus({ preventScroll: true });
|
|
});
|
|
return () => window.cancelAnimationFrame(frame);
|
|
}, [initialStageId, loading, version?.id]);
|
|
|
|
useEffect(() => {
|
|
setMessageReviewComplete(persistedReview.inspectionComplete);
|
|
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
|
|
setReviewIssueDecisions(Object.fromEntries(
|
|
persistedReview.issueDecisions.flatMap((item) => {
|
|
const jobId = String(item.job_id ?? "").trim();
|
|
return jobId ? [[jobId, String(item.reason ?? "")]] : [];
|
|
})
|
|
));
|
|
}, [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");
|
|
|
|
const refreshDeliveryOptions = useCallback(async (silent = true) => {
|
|
if (!version?.id || !(canSendSynchronously || canQueueForWorkers)) {
|
|
setDeliveryOptions(null);
|
|
return;
|
|
}
|
|
setDeliveryOptionsLoading(true);
|
|
try {
|
|
const result = await getCampaignDeliveryOptions(settings, campaignId, version.id);
|
|
setDeliveryOptions(result);
|
|
} catch (err) {
|
|
setDeliveryOptions(null);
|
|
if (!silent) setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setDeliveryOptionsLoading(false);
|
|
}
|
|
}, [campaignId, canQueueForWorkers, canSendSynchronously, setError, settings, version?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!version?.id || !hasBuild) return;
|
|
void refreshDeliveryOptions(true);
|
|
}, [hasBuild, refreshDeliveryOptions, version?.id, version?.updated_at]);
|
|
|
|
useEffect(() => {
|
|
if (!version?.id || !hasBuild) return;
|
|
const expectedKey = reviewJobsLoadKey(version.id, showAllReviewJobs);
|
|
if (jobsLoadedKey === expectedKey) return;
|
|
void loadBuiltMessages(true);
|
|
}, [version?.id, hasBuild, showAllReviewJobs, jobsLoadedKey, campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
const statusCounts = asRecord(summary?.status_counts);
|
|
const queueStatusCounts = asRecord(statusCounts.queue);
|
|
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 pausedQueueCount = numberFrom(queueStatusCounts, ["paused"]);
|
|
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 = deliveryOptions?.worker_queue_available === true || summaryDelivery.background_workers_enabled === true || summaryDelivery.celery_enabled === true;
|
|
const backgroundWorkersDisabled = deliveryOptions?.worker_queue_available === false || 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 retryableCount = cards?.retryable ?? numberFrom(sendStatusCounts, ["failed_temporary"]);
|
|
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 synchronousSendOption = asRecord(deliveryOptions?.synchronous_send);
|
|
const synchronousSendPolicy = asRecord(synchronousSendOption.policy);
|
|
const synchronousSendLimit = numberFrom(synchronousSendPolicy, ["max_recipient_jobs"]);
|
|
const synchronousEligibleCount = numberFrom(synchronousSendOption, ["eligible_recipient_job_count"]);
|
|
const synchronousSendAllowed = synchronousSendOption.allowed === true;
|
|
const workerQueueAvailable = deliveryOptions?.worker_queue_available === true;
|
|
const approvalGate = asRecord(deliveryOptions?.approval_gate);
|
|
const approvalGateConfigured = approvalGate.configured === true;
|
|
const approvalGateAvailable = approvalGate.available === true;
|
|
const approvalGateApproved = approvalGate.approved === true;
|
|
const approvalGateBlocksLiveDelivery = approvalGateConfigured && !approvalGateApproved;
|
|
const persistedDeliveryMode = version?.delivery_mode ?? null;
|
|
const persistedDeliveryModeSelectedAt = version?.delivery_mode_selected_at ?? null;
|
|
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(() => {
|
|
const commandInProgress = ["send", "queue", "control", "retry"].includes(busy);
|
|
if (!(deliveryQueued || deliverySending || commandInProgress) || loading || queueStatusLoading) return;
|
|
const handle = window.setTimeout(() => {void refreshQueueStatus(true);}, commandInProgress ? 1000 : 3000);
|
|
return () => window.clearTimeout(handle);
|
|
}, [deliveryQueued, deliverySending, loading, queueStatusLoading, busy, refreshQueueStatus]);
|
|
|
|
const reloadAttachmentPreview = useCallback(async (silent = true) => {
|
|
if (!version?.id) return;
|
|
setAttachmentPreviewLoading(true);
|
|
setAttachmentPreviewError("");
|
|
if (!silent) setMessage("i18n:govoplan-campaign.resolving_attachment_patterns.87d7d21b");
|
|
try {
|
|
const response = await previewCampaignAttachments(settings, campaignId, version.id, {
|
|
include_unmatched: true,
|
|
include_unlinked_candidates: true
|
|
});
|
|
setAttachmentPreview(response);
|
|
if (!silent) setMessage("i18n:govoplan-campaign.attachment_preview_refreshed.50d5b50d");
|
|
} catch (err) {
|
|
setAttachmentPreview(null);
|
|
setAttachmentPreviewError(err instanceof Error ? err.message : String(err));
|
|
if (!silent) setMessage("");
|
|
} finally {
|
|
setAttachmentPreviewLoading(false);
|
|
}
|
|
}, [campaignId, settings, version?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!version?.id) return;
|
|
void reloadAttachmentPreview(true);
|
|
}, [reloadAttachmentPreview, version?.id, version?.updated_at]);
|
|
|
|
const filteredBuiltReviewRows = useMemo(
|
|
() => filterAndSortBuiltMessageRows(builtReviewRows, reviewQuery, reviewedMessageKeys),
|
|
[builtReviewRows, reviewQuery, reviewedMessageKeys]
|
|
);
|
|
const reviewColumns = builtMessageColumns(openBuiltMessage, reviewedMessageKeys);
|
|
const reviewPageCount = Math.max(1, Math.ceil(filteredBuiltReviewRows.length / reviewPageSize));
|
|
const effectiveReviewPage = Math.min(reviewPageCount, Math.max(1, reviewPage));
|
|
const pagedBuiltReviewRows = filteredBuiltReviewRows.slice((effectiveReviewPage - 1) * reviewPageSize, effectiveReviewPage * reviewPageSize);
|
|
const selectedBuiltMessage = selectedBuiltIndex === null ? null : filteredBuiltReviewRows[selectedBuiltIndex] ?? null;
|
|
const singleSendConfirmRow = singleSendConfirmIndex === null ? null : filteredBuiltReviewRows[singleSendConfirmIndex] ?? 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 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) =>
|
|
["failed", "outcome_unknown"].includes(String(job.imap_status ?? "").toLowerCase()),
|
|
).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 buildReviewProgress = calculateBuildReviewProgress({
|
|
blocking: Math.max(blockingReviewCount, buildBlocked),
|
|
individualRequired: explicitReviewCount,
|
|
individualReviewed: reviewedExplicitCount,
|
|
groupRequired: bulkAcceptableCount,
|
|
reviewComplete: messageReviewComplete || downstreamDeliveryActivity
|
|
});
|
|
|
|
const buildReviewState: FlowState = !readyForDelivery ?
|
|
"locked" :
|
|
busy === "build" || busy === "inspect" ?
|
|
"running" :
|
|
hasBuild && buildBlocked > 0 ?
|
|
"danger" :
|
|
hasBuild && !inspectionSatisfied && (buildNeedsReview > 0 || buildWarnings > 0 || buildReviewProgress.remaining > 0) ?
|
|
"warning" :
|
|
hasBuild && inspectionSatisfied ?
|
|
"complete" :
|
|
"active";
|
|
|
|
const optionalMockSkipped = mockWorkflowAvailable && !mockWorkflowRequired && inspectionSatisfied && !mockResult && busy !== "mock" && busy !== "mailbox";
|
|
const mockState: FlowState = !mockWorkflowAvailable ?
|
|
"locked" :
|
|
!inspectionSatisfied ?
|
|
"locked" :
|
|
busy === "mock" || busy === "mailbox" ?
|
|
"running" :
|
|
mockPartial ?
|
|
"partial" :
|
|
mockFailed > 0 ?
|
|
"danger" :
|
|
mockComplete ?
|
|
"complete" :
|
|
optionalMockSkipped ?
|
|
"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 || mockPartial || downstreamDeliveryActivity);
|
|
const singleMessageSendWorkflowBlocked = ["archived", "cancelled"].includes(currentWorkflowState);
|
|
const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !approvalGateBlocksLiveDelivery && !singleMessageSendWorkflowBlocked);
|
|
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" :
|
|
["send", "queue", "control", "retry"].includes(busy) || 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,
|
|
connectorState: !mockWorkflowAvailable && inspectionSatisfied ? sendState : 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,
|
|
inspectionSatisfied,
|
|
sendLockReason]
|
|
);
|
|
|
|
function reviewJobsLoadKey(versionId: string, includeAll: boolean): string {
|
|
return JSON.stringify({
|
|
scope: "review-jobs",
|
|
campaignId,
|
|
versionId,
|
|
pageSize: "all",
|
|
includeAll,
|
|
apiBaseUrl: settings.apiBaseUrl,
|
|
apiKey: settings.apiKey,
|
|
accessToken: settings.accessToken
|
|
});
|
|
}
|
|
|
|
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): Promise<CampaignJobsResponse> {
|
|
let nextWatermark = getDeltaWatermark(key);
|
|
let merged = current;
|
|
let hasMore = false;
|
|
do {
|
|
const response = await getCampaignJobsDelta(settings, campaignId, {
|
|
...options,
|
|
since: nextWatermark
|
|
});
|
|
merged = mergeCampaignJobsDelta(merged, response);
|
|
nextWatermark = response.watermark ?? null;
|
|
hasMore = response.has_more;
|
|
} while (hasMore);
|
|
setDeltaWatermark(key, nextWatermark);
|
|
return merged;
|
|
}
|
|
|
|
async function linkMatchedAttachmentFiles() {
|
|
if (!version || busy || readOnlyVersion || readyForDelivery || attachmentLinking) return;
|
|
const pending = attachmentPreviewLinkableFiles(attachmentPreview);
|
|
if (pending.length === 0) {
|
|
await reloadAttachmentPreview(false);
|
|
return;
|
|
}
|
|
setAttachmentLinking(true);
|
|
setMessage("i18n:govoplan-campaign.linking_matched_attachment_files.92f38088");
|
|
setError("");
|
|
try {
|
|
const result = await linkCampaignAttachmentMatches(settings, campaignId, version.id, { dry_run: false });
|
|
setMessage(i18nMessage("i18n:govoplan-campaign.linked_value_attachment_file_s_to_this_campaign.02e5ecf7", { value0: result.linked_file_count }));
|
|
await reloadAttachmentPreview(true);
|
|
await reload();
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setAttachmentLinking(false);
|
|
}
|
|
}
|
|
|
|
async function runValidation(linkUnsharedMatches = false) {
|
|
if (!version || busy || readOnlyVersion || readyForDelivery) return;
|
|
const pending = attachmentPreviewLinkableFiles(attachmentPreview);
|
|
if (!linkUnsharedMatches && pending.length > 0) {
|
|
setAttachmentLockConfirmOpen(true);
|
|
return;
|
|
}
|
|
setAttachmentLockConfirmOpen(false);
|
|
setBusy("validate");
|
|
setMessage(linkUnsharedMatches ?
|
|
"i18n:govoplan-campaign.linking_matched_files_then_validating_the_campa.0d48a1d0" :
|
|
"i18n:govoplan-campaign.validating_the_campaign_including_managed_attach.b133cfd9");
|
|
setError("");
|
|
try {
|
|
const result = await validateVersion(settings, version.id, true, linkUnsharedMatches);
|
|
setMessage(result.ok ? "i18n:govoplan-campaign.validation_passed.c3e25768" : "i18n:govoplan-campaign.validation_finished_with_issues_review_the_excep.be65d2a1");
|
|
resetDownstreamReview();
|
|
await reloadAttachmentPreview(true);
|
|
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);
|
|
applyLoadedReviewJobs(await loadAllReviewJobs(version.id, showAllReviewJobs), true);
|
|
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) {
|
|
if (!version || busy || !hasBuild) return;
|
|
setBusy("inspect");
|
|
if (!silent) setMessage("i18n:govoplan-campaign.loading_the_built_message_review.a5339588");
|
|
setError("");
|
|
try {
|
|
const result = await loadAllReviewJobs(version.id, showAllReviewJobs);
|
|
applyLoadedReviewJobs(result, silent);
|
|
} catch (err) {
|
|
if (!silent) setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
function applyLoadedReviewJobs(result: CampaignJobsResponse, silent: boolean) {
|
|
if (!version) return;
|
|
const reviewKey = reviewJobsLoadKey(version.id, showAllReviewJobs);
|
|
const jobs = result.jobs.map(asRecord);
|
|
const mergedResult = {
|
|
...result,
|
|
jobs,
|
|
page: 1,
|
|
page_size: jobs.length || result.page_size,
|
|
pages: jobs.length > 0 ? 1 : 0
|
|
};
|
|
reviewJobsRef.current = mergedResult;
|
|
setReviewJobs(mergedResult);
|
|
setBuiltReviewRows(jobs);
|
|
setReviewedMessageKeys(new Set(
|
|
jobs
|
|
.filter((row) => row.reviewed === true)
|
|
.map((row, index) => builtMessageKey(row, index))
|
|
));
|
|
setMessageReviewComplete(result.review?.inspection_complete === true);
|
|
setReviewPage(1);
|
|
setJobsLoadedKey(reviewKey);
|
|
if (!silent) {
|
|
setMessage(i18nMessage("i18n:govoplan-campaign.loaded_value_message_s_on_page_value_of_value.febbeb1e", { value0: jobs.length, value1: 1, value2: 1 }));
|
|
}
|
|
}
|
|
|
|
async function loadAllReviewJobs(versionId: string, includeAll: boolean): Promise<CampaignJobsResponse> {
|
|
const pageSize = 200;
|
|
const first = await getCampaignJobs(settings, campaignId, {
|
|
versionId,
|
|
page: 1,
|
|
pageSize,
|
|
validationStatus: includeAll ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES
|
|
});
|
|
const allJobs = [...first.jobs];
|
|
for (let page = 2; page <= Math.max(1, first.pages || 1); page += 1) {
|
|
const next = await getCampaignJobs(settings, campaignId, {
|
|
versionId,
|
|
page,
|
|
pageSize,
|
|
validationStatus: includeAll ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES
|
|
});
|
|
allJobs.push(...next.jobs);
|
|
}
|
|
return {
|
|
...first,
|
|
jobs: allJobs,
|
|
page: 1,
|
|
page_size: pageSize,
|
|
pages: first.pages
|
|
};
|
|
}
|
|
|
|
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 createApprovalGate() {
|
|
if (!version || busy || !canRequestApproval || !approvalGateAvailable || approvalGateConfigured || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim()) return;
|
|
setBusy("approval");
|
|
setError("");
|
|
setMessage("Requesting approval for the exact built execution...");
|
|
try {
|
|
await requestCampaignApproval(settings, campaignId, version.id, {
|
|
title: `Approve delivery of ${data.campaign?.name ?? "Campaign"}`,
|
|
description: "Approval is bound to this version's immutable execution snapshot.",
|
|
steps: [{
|
|
key: "release",
|
|
label: "Approve delivery",
|
|
selectors: [{ kind: approvalSelectorKind, value: approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue.trim() }],
|
|
required_approvals: approvalRequiredCount,
|
|
rejection_policy: "fail_fast",
|
|
signature_required: approvalSignatureRequired,
|
|
forbidden_evidence_roles: [...approvalExcludedRoles]
|
|
}],
|
|
unique_actors_across_steps: true,
|
|
policy_refs: [],
|
|
idempotency_key: crypto.randomUUID()
|
|
});
|
|
setApprovalDialogOpen(false);
|
|
setMessage("Approval requested. Real delivery remains unavailable until the request is approved.");
|
|
await reload();
|
|
await refreshDeliveryOptions(false);
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function runSendNow() {
|
|
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
|
|
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted || !effectiveDryRun && approvalGateBlocksLiveDelivery) return;
|
|
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;
|
|
const paused = result.paused_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)}, paused ${String(paused)}.`
|
|
);
|
|
setSendConfirmOpen(false);
|
|
await reload();
|
|
await refreshDeliveryOptions(true);
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function runQueueForWorkers() {
|
|
if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return;
|
|
setBusy("queue");
|
|
setMessage("i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2");
|
|
setQueueResult(null);
|
|
setError("");
|
|
try {
|
|
const response = await queueCampaign(settings, campaignId, {
|
|
version_id: version.id,
|
|
include_warnings: true,
|
|
enqueue_celery: true,
|
|
dry_run: false
|
|
});
|
|
const result = asRecord(response);
|
|
setQueueResult(result);
|
|
setMessage(i18nMessage("i18n:govoplan-campaign.queued_value0_message_s_value1_worker_task_s_pub.18d67ec8", {
|
|
value0: String(result.queued_count ?? 0),
|
|
value1: String(result.enqueued_count ?? 0)
|
|
}));
|
|
setQueueConfirmOpen(false);
|
|
await reload();
|
|
await refreshDeliveryOptions(true);
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function runDeliveryControl(action: "pause" | "resume" | "cancel") {
|
|
if (busy || !canControlDelivery) return;
|
|
setBusy("control");
|
|
setMessage(deliveryControlProgressMessage(action));
|
|
setError("");
|
|
try {
|
|
const response = action === "pause" ?
|
|
await pauseCampaign(settings, campaignId) :
|
|
action === "resume" ?
|
|
await resumeCampaign(settings, campaignId) :
|
|
await cancelCampaign(settings, campaignId);
|
|
const result = asRecord(response.result ?? response);
|
|
setMessage(
|
|
action === "pause" ? i18nMessage("i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2", {
|
|
value0: String(result.paused_count ?? 0)
|
|
}) :
|
|
action === "resume" ? i18nMessage("i18n:govoplan-campaign.resumed_value0_message_s_value1_worker_task_s_pu.0f59afb4", {
|
|
value0: String(result.resumed_count ?? 0),
|
|
value1: String(result.enqueued_count ?? 0)
|
|
}) :
|
|
i18nMessage("i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24", {
|
|
value0: String(result.cancelled_count ?? 0),
|
|
value1: String(result.protected_count ?? 0),
|
|
value2: String(result.skipped_count ?? 0)
|
|
})
|
|
);
|
|
if (action === "cancel") setCancelDeliveryConfirmOpen(false);
|
|
await reload();
|
|
await refreshDeliveryOptions(true);
|
|
} catch (err) {
|
|
setMessage("");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function runRetryFailed() {
|
|
if (busy || !canRetryDelivery || retryableCount <= 0 || !workerQueueAvailable) return;
|
|
setBusy("retry");
|
|
setMessage("i18n:govoplan-campaign.queueing_retryable_failed_messages_for_backgroun.4bc80cd9");
|
|
setError("");
|
|
try {
|
|
const response = await retryCampaignJobs(settings, campaignId, { enqueue_celery: true });
|
|
const result = asRecord(response.result ?? response);
|
|
setMessage(i18nMessage("i18n:govoplan-campaign.queued_value0_retryable_message_s_value1_worker_.f4795bdb", {
|
|
value0: String(result.selected_count ?? 0),
|
|
value1: String(result.enqueued_count ?? 0)
|
|
}));
|
|
await reload();
|
|
await refreshDeliveryOptions(true);
|
|
} 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<string>(reviewedMessageKeys);
|
|
await updateCampaignReviewState(settings, campaignId, version.id, {
|
|
inspection_complete: true,
|
|
reviewed_message_keys: [...reviewed],
|
|
issue_decisions: Object.entries(reviewIssueDecisions).map(
|
|
([jobId, reason]) => ({
|
|
job_id: jobId,
|
|
decision: "accept" as const,
|
|
reason: reason.trim() || null
|
|
})
|
|
)
|
|
});
|
|
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 openBuiltMessageAtIndex(index: number) {
|
|
const row = filteredBuiltReviewRows[index];
|
|
if (!row) return;
|
|
await openBuiltMessage(row, index);
|
|
}
|
|
|
|
async function openBuiltMessage(row: Record<string, unknown>, requestedIndex?: number) {
|
|
const index = requestedIndex ?? findBuiltMessageIndex(filteredBuiltReviewRows, row);
|
|
if (index < 0) return;
|
|
const reviewRow = filteredBuiltReviewRows[index] ?? row;
|
|
const jobId = String(reviewRow.id ?? "");
|
|
if (!jobId || reviewRow.resolved_recipients || reviewRow.attachments || reviewRow.issues) {
|
|
setSelectedBuiltIndex(index);
|
|
return;
|
|
}
|
|
setBusy("inspect");
|
|
setError("");
|
|
try {
|
|
const detail = await getCampaignJobDetail(settings, campaignId, jobId);
|
|
setBuiltReviewRows((current) => current.map((item, itemIndex) => sameBuiltMessage(item, itemIndex, reviewRow, 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 acceptBuiltMessageReview(
|
|
row: Record<string, unknown>,
|
|
index: number,
|
|
reasonRequired: boolean
|
|
) {
|
|
const jobId = String(row.id ?? "").trim();
|
|
const reviewKey = String(
|
|
row.review_key ?? builtMessageKey(row, index)
|
|
);
|
|
const reason = reviewIssueDecisions[jobId] ?? "";
|
|
if (!jobId) {
|
|
setError("The built message has no delivery job id.");
|
|
return;
|
|
}
|
|
if (reasonRequired && !reason.trim()) {
|
|
setError("Enter a reason for accepting the attachment exception.");
|
|
return;
|
|
}
|
|
setReviewedMessageKeys((current) => new Set(current).add(reviewKey));
|
|
setNewlyReviewedRequiredKeys((current) =>
|
|
new Set(current).add(reviewKey)
|
|
);
|
|
setReviewIssueDecisions((current) => ({
|
|
...current,
|
|
[jobId]: reason
|
|
}));
|
|
setError("");
|
|
}
|
|
|
|
async function sendSingleBuiltMessage() {
|
|
if (!version || busy || singleSendConfirmIndex === null) return;
|
|
const row = filteredBuiltReviewRows[singleSendConfirmIndex];
|
|
const jobId = String(row?.id ?? "");
|
|
if (!row || !jobId) {
|
|
setSingleSendConfirmIndex(null);
|
|
setError("Selected message has no delivery job id.");
|
|
return;
|
|
}
|
|
setBusy("send");
|
|
setMessage("Sending one selected message.");
|
|
setError("");
|
|
try {
|
|
const response = await sendCampaignJob(settings, campaignId, jobId, {
|
|
kind: singleMessageActionKind,
|
|
idempotency_key: crypto.randomUUID(),
|
|
reason: singleMessageActionKind === "single_resend" ? singleMessageResendReason.trim() : undefined,
|
|
context: { surface: "campaign_review_preview" },
|
|
include_warnings: true,
|
|
use_rate_limit: true,
|
|
enqueue_imap_task: backgroundWorkersEnabled
|
|
});
|
|
const actionResult = asRecord(response.result ?? response);
|
|
const sendResult = asRecord(actionResult.result);
|
|
const status = String(sendResult.status ?? "submitted");
|
|
const attemptCount = Number(sendResult.attempt_number ?? row.attempt_count ?? 0);
|
|
const finalSendStatus = String(actionResult.final_send_status ?? "");
|
|
if (singleMessageActionKind !== "test") setBuiltReviewRows((current) => current.map((item) => String(item.id ?? "") === jobId ?
|
|
{
|
|
...item,
|
|
send_status: finalSendStatus || (status === "already_accepted" ? "smtp_accepted" : status),
|
|
queue_status: ["smtp_accepted", "already_accepted", "accepted", "accepted_with_refusals"].includes(finalSendStatus || status) ? "draft" : item.queue_status,
|
|
imap_status: sendResult.imap_status ?? item.imap_status,
|
|
attempt_count: Number.isFinite(attemptCount) ? attemptCount : item.attempt_count,
|
|
last_error: sendResult.message ?? ""
|
|
} :
|
|
item));
|
|
setMessage(`${humanize(singleMessageActionKind)} finished: ${humanize(String(actionResult.status ?? status))}.`);
|
|
setSingleSendConfirmIndex(null);
|
|
setSingleMessageResendReason("");
|
|
setSelectedBuiltIndex(null);
|
|
await refreshQueueStatus(true);
|
|
await reload();
|
|
} catch (err) {
|
|
setMessage("");
|
|
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);
|
|
setSingleSendConfirmIndex(null);
|
|
setSingleMessageResendReason("");
|
|
setMockResult(null);
|
|
setSelectedMockMessage(null);
|
|
resetDeltaWatermark();
|
|
}
|
|
|
|
function scrollToStage(id: string) {
|
|
revealReviewElement(id);
|
|
}
|
|
|
|
function revealReviewElement(id: string) {
|
|
const target = document.getElementById(id);
|
|
if (!target) return;
|
|
const stage = target.classList.contains("review-flow-stage")
|
|
? target
|
|
: target.closest<HTMLElement>(".review-flow-stage");
|
|
stage?.querySelector<HTMLButtonElement>('.card-collapse-toggle[aria-expanded="false"]')?.click();
|
|
window.requestAnimationFrame(() => {
|
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
target.focus({ preventScroll: true });
|
|
});
|
|
}
|
|
|
|
function showBuiltDetails(filters: Record<string, string> = {}, includeAll = false) {
|
|
setShowAllReviewJobs(includeAll);
|
|
setReviewQuery({ sort: null, filters });
|
|
setReviewPage(1);
|
|
window.requestAnimationFrame(() => revealReviewElement("campaign-built-message-details"));
|
|
}
|
|
|
|
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 mailProfileSelected = Boolean(selectedMailProfileId);
|
|
const deliverabilityPreflightItems: DeliverabilityPreflightItem[] = [
|
|
{
|
|
label: "Transport",
|
|
detail: mailProfileSelected ? "i18n:govoplan-campaign.mail_owned_delivery_profile_selected.4f44778e" : "i18n:govoplan-campaign.select_an_authorized_mail_profile_before_live_de.45c80a42",
|
|
state: mailProfileSelected ? "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: "Approval",
|
|
detail: approvalGateConfigured ?
|
|
approvalGateApproved ?
|
|
`Approval ${String(approvalGate.request_id ?? "")} covers this exact execution snapshot.` :
|
|
String(approvalGate.explanation ?? "The configured approval request is not approved yet.") :
|
|
approvalGateAvailable ?
|
|
"No approval gate is configured. Add one when this delivery requires independent release authority." :
|
|
"The optional Approvals module is not active; no Campaign approval gate is configured.",
|
|
state: approvalGateConfigured ? approvalGateApproved ? "ready" : "blocked" : "info"
|
|
},
|
|
{
|
|
label: "i18n:govoplan-campaign.delivery_mode.109ed9d1",
|
|
detail: deliveryOptionsLoading ?
|
|
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011" :
|
|
synchronousSendAllowed ?
|
|
i18nMessage("i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c", {
|
|
value0: synchronousEligibleCount,
|
|
value1: synchronousSendLimit,
|
|
value2: workerQueueAvailable
|
|
? "i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e"
|
|
: "i18n:govoplan-campaign.background_workers_are_not_configured_.efa72396"
|
|
}) :
|
|
workerQueueAvailable ?
|
|
i18nMessage("i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873", {
|
|
value0: synchronousSendReason(synchronousSendOption)
|
|
}) :
|
|
i18nMessage("i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f", {
|
|
value0: synchronousSendReason(synchronousSendOption)
|
|
}),
|
|
state: synchronousSendAllowed || workerQueueAvailable ? "ready" : deliveryOptionsLoading ? "info" : "blocked"
|
|
},
|
|
{
|
|
label: "Sent copy",
|
|
detail: Boolean(imapAppend.enabled) ? mailProfileSelected ? i18nMessage("i18n:govoplan-campaign.imap_append_requested_for_value0_validation_chec.51527a20", { value0: String(imapAppend.folder ?? "auto") }) : "i18n:govoplan-campaign.imap_append_is_enabled_but_no_mail_profile_is_se.cf50419e" : "i18n:govoplan-campaign.imap_append_is_disabled_for_this_campaign.7757f7f1",
|
|
state: Boolean(imapAppend.enabled) ? mailProfileSelected ? "ready" : "blocked" : "info"
|
|
}];
|
|
const canCompleteInspection = blockingReviewCount === 0 &&
|
|
reviewRequiredCount > 0 &&
|
|
reviewedExplicitCount === explicitReviewCount;
|
|
|
|
return (
|
|
<PageLayout
|
|
archetype="workspace"
|
|
mode="workspace"
|
|
className="review-send-page"
|
|
title="i18n:govoplan-campaign.review_send.1627617d"
|
|
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />}
|
|
headerLoading={loading || Boolean(busy)}
|
|
error={error}
|
|
actions={<PageActionBar
|
|
variant="workspace"
|
|
refreshable
|
|
reloadAction={{ onReload: () => void reload({ force: true }), loading: loading || Boolean(busy) }}
|
|
primaryActions={<Button onClick={() => void refreshQueueStatus(false)} disabled={!version || queueStatusLoading || Boolean(busy)}>
|
|
{queueStatusLoading ? "i18n:govoplan-campaign.refreshing_status.d8965739" : "i18n:govoplan-campaign.refresh_status.ade15a52"}
|
|
</Button>}
|
|
/>}
|
|
notices={(message || (version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion))) ? <>
|
|
{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" />}
|
|
</> : undefined}
|
|
>
|
|
|
|
<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} nextConnectorState={stageConnectorState(stages[1])}>
|
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
|
<MetricCard density="compact" surface="subtle" 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"} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} drilldown={validationErrors > 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} drilldown={validationWarnings > 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} drilldown={hasBuild && Number(cards?.needs_attention ?? 0) > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails() } : undefined} />
|
|
</MetricGrid>
|
|
<ValidationWorkflowGuidance
|
|
errors={validationErrors}
|
|
warnings={validationWarnings}
|
|
stale={validationStale}
|
|
/>
|
|
{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>
|
|
}
|
|
<div id="campaign-attachment-preview" tabIndex={-1}>
|
|
<AttachmentLinkingPreview
|
|
preview={attachmentPreview}
|
|
loading={attachmentPreviewLoading}
|
|
error={attachmentPreviewError}
|
|
linking={attachmentLinking}
|
|
disabled={!version || readOnlyVersion || readyForDelivery || Boolean(busy)}
|
|
onRefresh={() => void reloadAttachmentPreview(false)}
|
|
onLink={() => void linkMatchedAttachmentFiles()} />
|
|
</div>
|
|
|
|
{visibleValidationIssues.length > 0 &&
|
|
<div id="campaign-validation-details" className="review-flow-data-section" tabIndex={-1}>
|
|
<h3>i18n:govoplan-campaign.validation_details.aa503267</h3>
|
|
<DescriptionList variant="inline">
|
|
{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>
|
|
)}
|
|
</DescriptionList>
|
|
{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) || attachmentLinking || 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} nextConnectorState={stageConnectorState(stages[2])}>
|
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} drilldown={hasBuild && builtCount > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} drilldown={buildBlocked > 0 ? { label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => showBuiltDetails({ validation: 'list:["blocked"]' }) } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} drilldown={buildNeedsReview > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails({ validation: 'list:["warning","needs_review"]' }) } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} drilldown={buildReviewProgress.reviewed > 0 ? { label: "i18n:govoplan-campaign.reviewed.31ef8593", onActivate: () => showBuiltDetails({ reviewed: 'list:["yes"]' }, true) } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} drilldown={buildReviewProgress.remaining > 0 ? { label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => showBuiltDetails({ reviewed: 'list:["no"]' }) } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} drilldown={reviewJobs.total > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails() } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} drilldown={missingAttachments + ambiguousAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
|
|
</MetricGrid>
|
|
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
|
|
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
|
|
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
|
|
{hasBuild && Object.keys(residualFileDisposition).length > 0 && (
|
|
<div className="review-flow-data-section" data-residual-file-policy>
|
|
<h3>i18n:govoplan-campaign.unassigned_file_policy</h3>
|
|
<DescriptionList variant="inline">
|
|
<div><dt>i18n:govoplan-campaign.action.97c89a4d</dt><dd>{humanize(String(residualFileDisposition.action ?? "review"))}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.validation_policy.57dcc756</dt><dd>{humanize(String(residualFileDisposition.validation_behavior ?? "warn"))}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.unassigned_files_detected</dt><dd>{String(residualFileDisposition.residual_file_count ?? 0)}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.watched_sources</dt><dd>{String(residualFileDisposition.watched_source_count ?? 0)}</dd></div>
|
|
{residualFileRecipient.email ? <div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(residualFileRecipient.email)}</dd></div> : null}
|
|
</DescriptionList>
|
|
</div>
|
|
)}
|
|
{hasBuild && Object.keys(attachmentReuse).length > 0 && (
|
|
<div className="review-flow-data-section" data-attachment-reuse-policy>
|
|
<h3>Attachment reuse policy</h3>
|
|
<DescriptionList variant="inline">
|
|
<div><dt>Action</dt><dd>{humanize(String(attachmentReusePolicy.action ?? "allow"))}</dd></div>
|
|
<div><dt>Allowed boundary</dt><dd>{humanize(String(attachmentReusePolicy.allow_within ?? "none"))}</dd></div>
|
|
<div><dt>Repeated files</dt><dd>{String(attachmentReuse.duplicate_file_count ?? 0)}</dd></div>
|
|
<div><dt>Allowed</dt><dd>{String(attachmentReuse.allowed_file_count ?? 0)}</dd></div>
|
|
<div><dt>Policy findings</dt><dd>{String(attachmentReuse.violation_file_count ?? 0)}</dd></div>
|
|
<div><dt>Affected messages</dt><dd>{String(attachmentReuse.affected_message_count ?? 0)}</dd></div>
|
|
</DescriptionList>
|
|
{attachmentReuseFindings.length > 0 && (
|
|
<ul className="small-note">
|
|
{attachmentReuseFindings.slice(0, 10).map((finding) => (
|
|
<li key={String(finding.file_fingerprint)}>
|
|
{String(finding.file_name ?? "Attachment")} · {String(finding.use_count ?? 0)} uses · {humanize(String(finding.disposition ?? "allowed"))}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{attachmentReuseFindings.length > 10 && <p className="muted small-note">Showing 10 of {attachmentReuseFindings.length} repeated files.</p>}
|
|
</div>
|
|
)}
|
|
{getText(printOutput, "render_id") && (
|
|
<div className="review-flow-data-section">
|
|
<div className="page-heading split">
|
|
<div>
|
|
<h3>Printable output</h3>
|
|
<p className="muted small-note">
|
|
Template revision {String(printOutput.template_revision ?? "—")} · {String(printOutput.item_count ?? 0)} recipient item(s) · {String(printOutput.page_count ?? 0)} page(s) · {String(printOutput.output_size_bytes ?? 0)} B
|
|
</p>
|
|
</div>
|
|
{getText(printArtifact, "download_path") && (
|
|
<Button
|
|
onClick={() => void downloadCampaignPrintArtifact(
|
|
settings,
|
|
getText(printArtifact, "download_path"),
|
|
getText(printArtifact, "filename", "campaign-print-output.html")
|
|
).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)))}
|
|
>
|
|
Download output
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<DescriptionList variant="inline">
|
|
<div><dt>Template hash</dt><dd><code>{getText(printOutput, "template_hash") || "—"}</code></dd></div>
|
|
<div><dt>Input hash</dt><dd><code>{getText(printOutput, "input_hash") || "—"}</code></dd></div>
|
|
<div><dt>Output hash</dt><dd><code>{getText(printOutput, "output_sha256") || "—"}</code></dd></div>
|
|
</DescriptionList>
|
|
</div>
|
|
)}
|
|
<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)} 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>
|
|
}
|
|
{hasBuild &&
|
|
<div id="campaign-built-message-details" className="review-flow-data-section" tabIndex={-1}>
|
|
<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">{filteredBuiltReviewRows.length} 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={pagedBuiltReviewRows}
|
|
columns={reviewColumns}
|
|
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"
|
|
pagination={{
|
|
mode: "server",
|
|
page: effectiveReviewPage,
|
|
pageSize: reviewPageSize,
|
|
totalRows: filteredBuiltReviewRows.length,
|
|
pageSizeOptions: [25, 50, 100, 200],
|
|
disabled: busy === "inspect",
|
|
onPageChange: setReviewPage,
|
|
onPageSizeChange: (pageSize) => {
|
|
setReviewPageSize(pageSize);
|
|
setReviewPage(1);
|
|
}
|
|
}}
|
|
onQueryChange={(query) => {
|
|
if (reviewQueryEquals(reviewQuery, query)) return;
|
|
setReviewQuery(query);
|
|
setReviewPage(1);
|
|
}} />
|
|
<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} nextConnectorState={stageConnectorState(stages[3])}>
|
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.captured_smtp.78f2e2ca" value={mockResult ? mockSent : "—"} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.mock_failures.9475ce2a" value={mockResult ? mockFailed : "—"} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.skipped.5a000ad7" value={mockResult ? mockSkipped : "—"} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.captured_messages.a833d293" value={!mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : mockResult ? mockMailboxMessages.length : "—"} />
|
|
</MetricGrid>
|
|
<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} nextConnectorState={stageConnectorState(stages[4])}>
|
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.recipients.78cbf8eb" value={jobsTotal || "—"} drilldown={hasBuild && jobsTotal > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.messages_to_send.f7763bf9" value={builtCount || jobsTotal || "—"} drilldown={hasBuild && builtCount > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.matched_attachments.ead1eeb1" value={matchedAttachments || "—"} drilldown={matchedAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.missing_ambiguous.fffdd3f5" value={`${missingAttachments} / ${ambiguousAttachments}`} drilldown={missingAttachments + ambiguousAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.rate_limit.d08e55f5" value={messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.minimum_duration.91a71a6d" value={estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_append.8c0d9e96" value={Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.synchronous_limit.f88c0bcd" value={deliveryOptionsLoading ? "…" : synchronousSendLimit} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.limit_source.bd933adb" value={deliveryPolicySourceLabel(String(synchronousSendPolicy.source ?? ""))} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.worker_queue.c911e32c" value={workerQueueAvailable ? "i18n:govoplan-campaign.available.7c62a142" : "i18n:govoplan-campaign.not_configured.811931bb"} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.version.2da600bf" value={version ? `v${version.version_number}` : "—"} />
|
|
</MetricGrid>
|
|
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
|
|
<div className="review-send-controls">
|
|
<span>Approval gate</span>
|
|
<StatusBadge
|
|
status={approvalGateConfigured ? approvalGateApproved ? "approved" : String(approvalGate.state ?? "pending") : "not_required"}
|
|
label={approvalGateConfigured ? approvalGateApproved ? "Approved" : humanize(String(approvalGate.state ?? "pending")) : "Not required"} />
|
|
{!approvalGateConfigured && approvalGateAvailable && canRequestApproval &&
|
|
<Button disabled={Boolean(busy) || readOnlyVersion || !hasBuild} onClick={() => setApprovalDialogOpen(true)}>
|
|
<ShieldCheck size={16} aria-hidden="true" />Request approval
|
|
</Button>}
|
|
{approvalGateConfigured &&
|
|
<Button disabled={Boolean(busy)} onClick={() => navigate("/approvals")}>Open approval</Button>}
|
|
</div>
|
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.not_queued.b7f41e1e" value={notQueuedCount} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.send_attempts.2cdb19e8" value={sendAttemptCount} />
|
|
</MetricGrid>
|
|
{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={() => setQueueConfirmOpen(true)}
|
|
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
|
|
{busy === "queue" ? "i18n:govoplan-campaign.queueing_for_workers_.d24584fe" : "i18n:govoplan-campaign.queue_for_workers.dae9a3e4"}
|
|
</Button>
|
|
<Button
|
|
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
|
|
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || !selectedDryRun && approvalGateBlocksLiveDelivery || 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>
|
|
<p className="muted small-note">
|
|
{i18nMessage("i18n:govoplan-campaign.queue_for_workers_commits_durable_jobs_and_retur.d0dbe81a", {
|
|
value0: synchronousSendLimit || "i18n:govoplan-campaign.the_configured_maximum.eb10006b"
|
|
})}
|
|
</p>
|
|
{!synchronousSendAllowed && canSendSynchronously &&
|
|
<p className="review-flow-inline-note is-warning">
|
|
{i18nMessage("i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29", {
|
|
value0: synchronousSendReason(synchronousSendOption)
|
|
})}
|
|
</p>
|
|
}
|
|
{queueResult &&
|
|
<p className="review-flow-inline-note is-stale">
|
|
{i18nMessage("i18n:govoplan-campaign.worker_queue_committed_value0_message_s_and_publ.24400d3c", {
|
|
value0: String(queueResult.queued_count ?? 0),
|
|
value1: String(queueResult.enqueued_count ?? 0)
|
|
})}
|
|
</p>
|
|
}
|
|
{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>
|
|
<p className="muted small-note">
|
|
SMTP batch: {humanize(String(sendResult.batch_state ?? "not_started"))} · connections {String(sendResult.smtp_connection_count ?? 0)} · reconnects {String(sendResult.smtp_reconnect_count ?? 0)} · paused {String(sendResult.paused_count ?? 0)}.
|
|
</p>
|
|
{sendResult.batch_state === "paused" &&
|
|
<DismissibleAlert tone="warning" resetKey={String(sendResult.batch_pause_reason_code ?? "smtp_systemic_failure")}>
|
|
Remaining messages were paused before SMTP after a systemic transport failure ({String(sendResult.batch_pause_reason_code ?? "smtp_systemic_failure")}). Review the Mail profile, then resume the campaign queue.
|
|
</DismissibleAlert>}
|
|
{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]}>
|
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.paused.c7dfb6f1" value={pausedQueueCount} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.delivery_mode.109ed9d1" value={deliveryModeLabel(persistedDeliveryMode)} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_failed.50dbca55" value={imapFailedForDisplay} />
|
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_skipped.5a97b542" value={imapSkipped} />
|
|
</MetricGrid>
|
|
<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>
|
|
{persistedDeliveryMode &&
|
|
<p className="muted small-note">
|
|
{persistedDeliveryModeSelectedAt
|
|
? i18nMessage("i18n:govoplan-campaign.this_version_last_entered_value0_mode_at_value1_.c087d2f3", {
|
|
value0: deliveryModeLabel(persistedDeliveryMode),
|
|
value1: formatDateTime(persistedDeliveryModeSelectedAt)
|
|
})
|
|
: i18nMessage("i18n:govoplan-campaign.this_version_last_entered_value0_mode_this_recor.ea2f201f", {
|
|
value0: deliveryModeLabel(persistedDeliveryMode)
|
|
})}
|
|
</p>
|
|
}
|
|
<div className="button-row compact-actions review-flow-stage-actions">
|
|
<Button
|
|
onClick={() => void runDeliveryControl("pause")}
|
|
disabled={Boolean(busy) || !canControlDelivery || queuedSendCount <= 0}>
|
|
i18n:govoplan-campaign.pause_queued_work.35ab4a5b
|
|
</Button>
|
|
<Button
|
|
onClick={() => void runDeliveryControl("resume")}
|
|
disabled={Boolean(busy) || !canControlDelivery || pausedQueueCount <= 0 || !workerQueueAvailable}>
|
|
i18n:govoplan-campaign.resume_with_workers.510a2a9a
|
|
</Button>
|
|
<Button
|
|
onClick={() => setCancelDeliveryConfirmOpen(true)}
|
|
disabled={Boolean(busy) || !canControlDelivery || queuedSendCount + pausedQueueCount <= 0}>
|
|
i18n:govoplan-campaign.cancel_unsent_work.66df9f0d
|
|
</Button>
|
|
<Button
|
|
onClick={() => void runRetryFailed()}
|
|
disabled={Boolean(busy) || !canRetryDelivery || retryableCount <= 0 || !workerQueueAvailable}>
|
|
i18n:govoplan-campaign.retry_failed_with_workers.a4b7dcc5
|
|
</Button>
|
|
</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>
|
|
<DescriptionList variant="inline">
|
|
{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>
|
|
)}
|
|
</DescriptionList>
|
|
</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={filteredBuiltReviewRows}
|
|
index={selectedBuiltIndex}
|
|
canStartSingleMessageSend={canStartSingleMessageSend}
|
|
singleMessageSendBusy={busy === "send"}
|
|
reviewed={reviewedMessageKeys.has(String(
|
|
selectedBuiltMessage.review_key
|
|
?? builtMessageKey(selectedBuiltMessage, selectedBuiltIndex)
|
|
))}
|
|
reviewReason={reviewIssueDecisions[
|
|
String(selectedBuiltMessage.id ?? "")
|
|
] ?? ""}
|
|
onReviewReasonChange={(value) => {
|
|
const jobId = String(selectedBuiltMessage.id ?? "").trim();
|
|
if (!jobId) return;
|
|
setReviewIssueDecisions((current) => ({
|
|
...current,
|
|
[jobId]: value
|
|
}));
|
|
}}
|
|
onAcceptReview={(reasonRequired) =>
|
|
acceptBuiltMessageReview(
|
|
selectedBuiltMessage,
|
|
selectedBuiltIndex,
|
|
reasonRequired
|
|
)
|
|
}
|
|
onSelect={openBuiltMessageAtIndex}
|
|
onSendSingle={(targetIndex) => {
|
|
const target = filteredBuiltReviewRows[targetIndex];
|
|
const status = String(target?.send_status ?? "not_queued");
|
|
setSingleMessageActionKind(
|
|
["not_queued", "cancelled", "queued"].includes(status)
|
|
&& Number(target?.attempt_count ?? 0) === 0
|
|
? "single_send"
|
|
: "single_resend"
|
|
);
|
|
setSingleMessageResendReason("");
|
|
setSingleSendConfirmIndex(targetIndex);
|
|
}}
|
|
onClose={() => setSelectedBuiltIndex(null)} />
|
|
|
|
}
|
|
|
|
<Dialog
|
|
open={approvalDialogOpen}
|
|
title="Request Campaign approval"
|
|
portal
|
|
closeDisabled={busy === "approval"}
|
|
onClose={() => setApprovalDialogOpen(false)}
|
|
footer={<>
|
|
<Button disabled={busy === "approval"} onClick={() => setApprovalDialogOpen(false)}>Cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={busy === "approval" || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim() || approvalRequiredCount < 1}
|
|
onClick={() => void createApprovalGate()}>
|
|
{busy === "approval" ? "Requesting..." : "Request approval"}
|
|
</Button>
|
|
</>}>
|
|
<FormGrid columns={1} collapseAt="standard" className="">
|
|
<p className="muted small-note form-grid-wide">The request covers the exact built message, attachment, recipient, policy, and transport snapshot. Rebuilding invalidates and removes this gate.</p>
|
|
<FormField label="Approver type">
|
|
<select value={approvalSelectorKind} disabled={busy === "approval"} onChange={(event) => setApprovalSelectorKind(event.target.value as typeof approvalSelectorKind)}>
|
|
<option value="account">Account</option>
|
|
<option value="group">Group</option>
|
|
<option value="role">Role</option>
|
|
<option value="function_assignment">Function assignment</option>
|
|
<option value="any_account">Any account</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Approver reference">
|
|
<input value={approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue} disabled={busy === "approval" || approvalSelectorKind === "any_account"} onChange={(event) => setApprovalSelectorValue(event.target.value)} />
|
|
</FormField>
|
|
<FormField label="Required approvals">
|
|
<input type="number" min={1} max={500} value={approvalRequiredCount} disabled={busy === "approval"} onChange={(event) => setApprovalRequiredCount(Math.max(1, Number(event.target.value) || 1))} />
|
|
</FormField>
|
|
<ToggleSwitch label="Require signature evidence" checked={approvalSignatureRequired} disabled={busy === "approval"} onChange={setApprovalSignatureRequired} />
|
|
<div className="form-grid-wide">
|
|
<span className="field-label">Actors excluded by four-eyes policy</span>
|
|
<div className="button-row compact-actions">
|
|
{(["author", "owner", "validator", "builder", "reviewer"] as const).map((role) =>
|
|
<ToggleSwitch
|
|
key={role}
|
|
label={humanize(role)}
|
|
checked={approvalExcludedRoles.has(role)}
|
|
disabled={busy === "approval"}
|
|
onChange={(checked) => setApprovalExcludedRoles((current) => {
|
|
const next = new Set(current);
|
|
if (checked) next.add(role); else next.delete(role);
|
|
return next;
|
|
})} />)}
|
|
</div>
|
|
</div>
|
|
</FormGrid>
|
|
</Dialog>
|
|
|
|
<ConfirmDialog
|
|
open={attachmentLockConfirmOpen}
|
|
title="i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653"
|
|
message={i18nMessage("i18n:govoplan-campaign.value_matched_file_s_are_not_yet_linked_to_t.43d6c926", { value0: attachmentPreviewLinkableFiles(attachmentPreview).length })}
|
|
confirmLabel="i18n:govoplan-campaign.link_and_lock.6eac996d"
|
|
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
|
|
busy={busy === "validate"}
|
|
onConfirm={() => void runValidation(true)}
|
|
onCancel={() => setAttachmentLockConfirmOpen(false)} />
|
|
|
|
|
|
<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={queueConfirmOpen}
|
|
title="i18n:govoplan-campaign.queue_this_version_for_background_workers_.943d032c"
|
|
message={i18nMessage("i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222", {
|
|
value0: synchronousEligibleCount,
|
|
value1: String(version?.version_number ?? "—")
|
|
})}
|
|
confirmLabel="i18n:govoplan-campaign.queue_for_workers.dae9a3e4"
|
|
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
|
|
busy={busy === "queue"}
|
|
onCancel={() => setQueueConfirmOpen(false)}
|
|
onConfirm={() => void runQueueForWorkers()} />
|
|
|
|
<ConfirmDialog
|
|
open={sendConfirmOpen}
|
|
title="i18n:govoplan-campaign.send_this_version_now.10a0ca56"
|
|
message={i18nMessage("i18n:govoplan-campaign.value0_this_is_a_synchronous_request_for_value1_.0267bc6b", {
|
|
value0: 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" }),
|
|
value1: synchronousEligibleCount,
|
|
value2: synchronousSendLimit
|
|
})}
|
|
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()} />
|
|
|
|
<ConfirmDialog
|
|
open={cancelDeliveryConfirmOpen}
|
|
title="i18n:govoplan-campaign.cancel_all_unsent_delivery_work_.a2f56cda"
|
|
message="i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc"
|
|
confirmLabel="i18n:govoplan-campaign.cancel_unsent_work.66df9f0d"
|
|
cancelLabel="i18n:govoplan-campaign.keep_delivery_work.10dbcb13"
|
|
tone="danger"
|
|
busy={busy === "control"}
|
|
onCancel={() => setCancelDeliveryConfirmOpen(false)}
|
|
onConfirm={() => void runDeliveryControl("cancel")} />
|
|
|
|
<Dialog
|
|
open={singleSendConfirmRow !== null}
|
|
title="One-message delivery"
|
|
portal
|
|
closeDisabled={busy === "send"}
|
|
onClose={() => {
|
|
setSingleSendConfirmIndex(null);
|
|
setSingleMessageResendReason("");
|
|
}}
|
|
footer={
|
|
<>
|
|
<Button
|
|
disabled={busy === "send"}
|
|
onClick={() => {
|
|
setSingleSendConfirmIndex(null);
|
|
setSingleMessageResendReason("");
|
|
}}
|
|
>
|
|
i18n:govoplan-campaign.cancel.77dfd213
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
disabled={
|
|
busy === "send"
|
|
|| (singleMessageActionKind === "single_resend" && !singleMessageResendReason.trim())
|
|
}
|
|
onClick={() => void sendSingleBuiltMessage()}
|
|
>
|
|
{busy === "send" ? "Sending..." : "Confirm action"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{singleSendConfirmRow && (
|
|
<FormGrid columns={1} collapseAt="standard" className="">
|
|
<SegmentedControl
|
|
value={singleMessageActionKind}
|
|
width="fill"
|
|
size="equal"
|
|
ariaLabel="One-message action"
|
|
options={[
|
|
{ id: "test", label: "Test" },
|
|
{
|
|
id: "single_send",
|
|
label: "Initial send",
|
|
disabled: !(
|
|
["not_queued", "cancelled", "queued"].includes(String(singleSendConfirmRow.send_status ?? "not_queued"))
|
|
&& Number(singleSendConfirmRow.attempt_count ?? 0) === 0
|
|
)
|
|
},
|
|
{
|
|
id: "single_resend",
|
|
label: "Resend",
|
|
disabled: ["claimed", "sending", "outcome_unknown"].includes(String(singleSendConfirmRow.send_status ?? ""))
|
|
}
|
|
]}
|
|
onChange={setSingleMessageActionKind}
|
|
/>
|
|
<p className="muted small-note">
|
|
{singleMessageActionKind === "test"
|
|
? "Transports this exact rendered message once without changing its official delivery state."
|
|
: singleMessageActionKind === "single_resend"
|
|
? "Transports this exact message again. A successful resend can complete it; a failed resend preserves an earlier success."
|
|
: "Makes the first official delivery attempt and changes only this message after SMTP acceptance."}
|
|
</p>
|
|
<dl className="detail-grid">
|
|
<div>
|
|
<dt>Recipient</dt>
|
|
<dd>{formatAddressList(asRecord(singleSendConfirmRow.resolved_recipients).to) || String(singleSendConfirmRow.recipient_email ?? "—")}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Version</dt>
|
|
<dd>{String(version?.version_number ?? "—")}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Message</dt>
|
|
<dd><code>{String(singleSendConfirmRow.eml_sha256 ?? "—").slice(0, 16)}</code></dd>
|
|
</div>
|
|
<div>
|
|
<dt>State</dt>
|
|
<dd>{humanize(String(singleSendConfirmRow.send_status ?? "not_queued"))}</dd>
|
|
</div>
|
|
</dl>
|
|
{singleMessageActionKind === "single_resend" && (
|
|
<label>
|
|
Reason
|
|
<textarea
|
|
value={singleMessageResendReason}
|
|
maxLength={2000}
|
|
rows={3}
|
|
onChange={(event) => setSingleMessageResendReason(event.target.value)}
|
|
/>
|
|
</label>
|
|
)}
|
|
</FormGrid>
|
|
)}
|
|
</Dialog>
|
|
|
|
{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)} />
|
|
|
|
}
|
|
</PageLayout>);
|
|
|
|
}
|