From 60efd1cb5de15602badeda37fdfe8e604e957dcf Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 08:26:52 +0200 Subject: [PATCH] feat(campaign): make delivery modes explicit --- src/govoplan_campaign/backend/schemas.py | 2 + src/govoplan_campaign/backend/sending/jobs.py | 6 + webui/package.json | 2 +- webui/src/api/campaigns.ts | 27 ++ .../features/campaigns/CampaignWorkspace.tsx | 2 +- .../src/features/campaigns/ReviewSendPage.tsx | 232 +++++++++++++++++- .../tests/delivery-mode-ui-structure.test.mjs | 16 ++ 7 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 webui/tests/delivery-mode-ui-structure.test.mjs diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index a838b98..1301f8a 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -492,6 +492,8 @@ class QueueCampaignResponse(BaseModel): skipped_count: int blocked_count: int enqueued_count: int + delivery_mode: str = "worker_queue" + worker_queue_available: bool = False dry_run: bool = False diff --git a/src/govoplan_campaign/backend/sending/jobs.py b/src/govoplan_campaign/backend/sending/jobs.py index 2e62952..71bf4e2 100644 --- a/src/govoplan_campaign/backend/sending/jobs.py +++ b/src/govoplan_campaign/backend/sending/jobs.py @@ -90,6 +90,8 @@ class QueueCampaignResult: skipped_count: int blocked_count: int enqueued_count: int + delivery_mode: str = "worker_queue" + worker_queue_available: bool = False dry_run: bool = False def as_dict(self) -> dict[str, Any]: @@ -100,6 +102,8 @@ class QueueCampaignResult: "skipped_count": self.skipped_count, "blocked_count": self.blocked_count, "enqueued_count": self.enqueued_count, + "delivery_mode": self.delivery_mode, + "worker_queue_available": self.worker_queue_available, "dry_run": self.dry_run, } @@ -676,6 +680,8 @@ def queue_campaign_jobs( skipped_count=skipped_count, blocked_count=blocked_count, enqueued_count=enqueued_count, + delivery_mode="worker_queue" if enqueue_celery else "database_queue", + worker_queue_available=_celery_enabled(), dry_run=dry_run, ) diff --git a/webui/package.json b/webui/package.json index 9ef9f89..3853476 100644 --- a/webui/package.json +++ b/webui/package.json @@ -27,7 +27,7 @@ "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js", "test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js", "test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js", - "test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js" + "test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 72a840e..3849b63 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -271,6 +271,24 @@ export type CampaignQueuePayload = { dry_run?: boolean; }; +export type CampaignDeliveryOptions = { + campaign_id: string; + version_id: string; + worker_queue_available: boolean; + synchronous_send: { + allowed?: boolean; + reason?: string | null; + message?: string | null; + eligible_recipient_job_count?: number; + policy?: { + max_recipient_jobs?: number; + source?: string; + deployment_max_recipient_jobs?: number; + tenant_max_recipient_jobs?: number | null; + }; + }; +}; + export type CampaignSendNowPayload = { version_id?: string | null; include_warnings?: boolean; @@ -928,6 +946,15 @@ payload: CampaignQueuePayload = {}) }); } +export async function getCampaignDeliveryOptions( +settings: ApiSettings, +campaignId: string, +versionId?: string | null) +: Promise { + const query = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`); +} + export async function sendCampaignNow( settings: ApiSettings, campaignId: string, diff --git a/webui/src/features/campaigns/CampaignWorkspace.tsx b/webui/src/features/campaigns/CampaignWorkspace.tsx index 0a6d6fa..8cc1b29 100644 --- a/webui/src/features/campaigns/CampaignWorkspace.tsx +++ b/webui/src/features/campaigns/CampaignWorkspace.tsx @@ -95,7 +95,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx index ae3a87c..32f5180 100644 --- a/webui/src/features/campaigns/ReviewSendPage.tsx +++ b/webui/src/features/campaigns/ReviewSendPage.tsx @@ -17,26 +17,33 @@ import type { ApiSettings } from "../../types"; import { appendSent, buildVersion, + cancelCampaign, + getCampaignDeliveryOptions, getCampaignJobs, getCampaignJobsDelta, getCampaignJobDetail, getCampaignSummary, linkCampaignAttachmentMatches, mockSendCampaign, + pauseCampaign, previewCampaignAttachments, + queueCampaign, + resumeCampaign, + retryCampaignJobs, sendCampaignJob, sendCampaignNow, updateCampaignReviewState, validateVersion, type CampaignAttachmentPreviewFile, type CampaignAttachmentPreviewResponse, + type CampaignDeliveryOptions, type CampaignJobsQuery, type CampaignJobsResponse, type CampaignSummary, type CampaignVersionDetail } from "../../api/campaigns"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; -import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; +import { Button, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; @@ -97,7 +104,7 @@ type DeliverabilityPreflightItem = { state: "ready" | "warning" | "blocked" | "info"; }; -type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "imap" | ""; +type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | ""; const stateColors: Record = { complete: "var(--green)", @@ -121,7 +128,7 @@ const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [ const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"]; -export default function ReviewSendPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { +export default function ReviewSendPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) { const navigate = useGuardedNavigate(); const devMailboxCapability = usePlatformUiCapability("mail.devMailbox"); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); @@ -134,6 +141,12 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS const [liveSummary, setLiveSummary] = useState(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 [deliveryOptions, setDeliveryOptions] = useState(null); + const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false); const campaignJson = useMemo(() => getCampaignJson(version), [version]); const server = asRecord(campaignJson.server); const selectedMailProfileId = getText(server, "mail_profile_id"); @@ -177,7 +190,10 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false); const [dryRun, setDryRun] = useState(false); const [sendConfirmOpen, setSendConfirmOpen] = useState(false); + const [queueConfirmOpen, setQueueConfirmOpen] = useState(false); + const [cancelDeliveryConfirmOpen, setCancelDeliveryConfirmOpen] = useState(false); const [sendResult, setSendResult] = useState | null>(null); + const [queueResult, setQueueResult] = useState | null>(null); const [imapAppendResult, setImapAppendResult] = useState | null>(null); const [imapDiagnostics, setImapDiagnostics] = useState(() => emptyCampaignJobsResponse()); const imapDiagnosticsRef = useRef(emptyCampaignJobsResponse()); @@ -200,11 +216,15 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS setAttachmentPreviewError(""); setAttachmentLockConfirmOpen(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]); @@ -234,6 +254,28 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS 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); @@ -242,11 +284,13 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS }, [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; @@ -255,8 +299,8 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS const cancelledCount = cards?.cancelled ?? numberFrom(sendStatusCounts, ["cancelled"]); const outcomeUnknownCount = cards?.outcome_unknown ?? numberFrom(sendStatusCounts, ["outcome_unknown"]); const sendAttemptCount = numberFrom(attempts, ["send_attempts"]); - const backgroundWorkersEnabled = summaryDelivery.background_workers_enabled === true || summaryDelivery.celery_enabled === true; - const backgroundWorkersDisabled = summaryDelivery.background_workers_enabled === false || summaryDelivery.celery_enabled === false; + const 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; @@ -272,6 +316,12 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS 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 selectedDryRun = dryRun && !directQueuedSendAllowed; const deliveryPartial = cards?.partially_completed === true || ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState); const deliveryComplete = queuedOrActiveCount === 0 && @@ -325,8 +375,9 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS }, [campaignId, setError, settings, version?.id]); useEffect(() => { - if (!(deliveryQueued || deliverySending) || loading || queueStatusLoading || busy === "send") return; - const handle = window.setTimeout(() => {void refreshQueueStatus(true);}, 3000); + 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]); @@ -457,7 +508,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS "i18n:govoplan-campaign.build_the_exact_queue_first.98d7ce1b"; const sendState: FlowState = !mockGateSatisfied ? "locked" : - busy === "send" || deliverySending ? + ["send", "queue", "control", "retry"].includes(busy) || deliverySending ? "running" : queuedWithoutWorker ? "warning" : @@ -764,7 +815,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS } async function runSendNow() { - if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted) return; + if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted) return; const effectiveDryRun = dryRun && !directQueuedSendAllowed; setBusy("send"); setMessage(effectiveDryRun ? @@ -797,6 +848,81 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS ); 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 || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return; + setBusy("queue"); + setMessage("Committing the reviewed execution to the background-worker queue…"); + 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(`Queued ${String(result.queued_count ?? 0)} message(s); ${String(result.enqueued_count ?? 0)} worker task(s) published.`); + 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(`${action === "pause" ? "Pausing" : action === "resume" ? "Resuming" : "Cancelling"} eligible delivery jobs…`); + 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" ? `Paused ${String(result.paused_count ?? 0)} queued message(s).` : + action === "resume" ? `Resumed ${String(result.resumed_count ?? 0)} message(s); ${String(result.enqueued_count ?? 0)} worker task(s) published.` : + `Cancelled ${String(result.cancelled_count ?? 0)} unsent message(s); ${String(result.protected_count ?? 0)} protected outcome(s) were retained.` + ); + 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 || failedCount <= 0 || !workerQueueAvailable) return; + setBusy("retry"); + setMessage("Queueing retryable failed messages for background workers…"); + setError(""); + try { + const response = await retryCampaignJobs(settings, campaignId, { enqueue_celery: true }); + const result = asRecord(response.result ?? response); + setMessage(`Queued ${String(result.queued_count ?? 0)} retryable message(s); ${String(result.enqueued_count ?? 0)} worker task(s) published.`); + await reload(); + await refreshDeliveryOptions(true); } catch (err) { setMessage(""); setError(err instanceof Error ? err.message : String(err)); @@ -1062,6 +1188,17 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS 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: "Delivery mode", + detail: deliveryOptionsLoading ? + "Loading the effective delivery policy…" : + synchronousSendAllowed ? + `Send now is available for ${synchronousEligibleCount} eligible message(s), within the effective limit of ${synchronousSendLimit}. ${workerQueueAvailable ? "The worker queue is also available." : "Background workers are not configured."}` : + workerQueueAvailable ? + `Send now is unavailable (${synchronousSendReason(synchronousSendOption)}). Queue for workers remains available.` : + `No real delivery mode is currently available (${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", @@ -1303,6 +1440,9 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
i18n:govoplan-campaign.rate_limit.d08e55f5{messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"}
i18n:govoplan-campaign.minimum_duration.91a71a6d{estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"}
i18n:govoplan-campaign.imap_append.8c0d9e96{Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"}
+
Synchronous limit{deliveryOptionsLoading ? "…" : synchronousSendLimit}
+
Limit source{humanize(String(synchronousSendPolicy.source ?? "not available"))}
+
Worker queue{workerQueueAvailable ? "Available" : "Not configured"}
i18n:govoplan-campaign.version.2da600bf{version ? `v${version.version_number}` : "—"}
@@ -1327,8 +1467,13 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
+
+

+ Queue for workers commits durable jobs and returns immediately; progress remains visible here after you leave. Send now keeps this request open and is limited to {synchronousSendLimit || "the configured maximum"} eligible recipient jobs. +

+ {!synchronousSendAllowed && canSendSynchronously && +

Send now is unavailable: {synchronousSendReason(synchronousSendOption)}.

+ } + {queueResult && +

Worker queue committed {String(queueResult.queued_count ?? 0)} message(s) and published {String(queueResult.enqueued_count ?? 0)} task(s). Refreshing progress does not requeue them.

+ } {sendResult &&

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 ?? "—")}.

@@ -1361,6 +1515,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS + @@ -1371,6 +1526,28 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS {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"}
+
+ + + + +
{Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 &&

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

} @@ -1476,15 +1653,36 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS onCancel={() => setReviewConfirmOpen(false)} /> + setQueueConfirmOpen(false)} + onConfirm={() => void runQueueForWorkers()} /> + setSendConfirmOpen(false)} onConfirm={() => void runSendNow()} /> + + setCancelDeliveryConfirmOpen(false)} + onConfirm={() => void runDeliveryControl("cancel")} /> ): string { + const configuredMessage = String(option.message ?? "").trim(); + if (configuredMessage) return configuredMessage; + switch (String(option.reason ?? "")) { + case "recipient_limit_exceeded":return "the exact built run exceeds the effective recipient-job limit"; + case "no_eligible_recipient_jobs":return "the built run has no eligible message"; + case "version_not_ready":return "validate, lock, build, and review the current version first"; + case "policy_configuration_invalid":return "the delivery policy configuration is invalid"; + default:return "delivery options are still being evaluated"; + } +} + function numberFrom(record: Record, keys: string[]): number { for (const key of keys) { const value = record[key]; diff --git a/webui/tests/delivery-mode-ui-structure.test.mjs b/webui/tests/delivery-mode-ui-structure.test.mjs new file mode 100644 index 0000000..972879a --- /dev/null +++ b/webui/tests/delivery-mode-ui-structure.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8"); + +assert.match(source, /getCampaignDeliveryOptions/); +assert.match(source, /Queue for workers/); +assert.match(source, /Send now keeps this request open/); +assert.match(source, /synchronousSendAllowed/); +assert.match(source, /commandInProgress[\s\S]*refreshQueueStatus/); +assert.match(source, /