feat(campaign): make delivery modes explicit

This commit is contained in:
2026-07-22 08:26:52 +02:00
parent 7e1660344d
commit 60efd1cb5d
7 changed files with 274 additions and 13 deletions

View File

@@ -492,6 +492,8 @@ class QueueCampaignResponse(BaseModel):
skipped_count: int skipped_count: int
blocked_count: int blocked_count: int
enqueued_count: int enqueued_count: int
delivery_mode: str = "worker_queue"
worker_queue_available: bool = False
dry_run: bool = False dry_run: bool = False

View File

@@ -90,6 +90,8 @@ class QueueCampaignResult:
skipped_count: int skipped_count: int
blocked_count: int blocked_count: int
enqueued_count: int enqueued_count: int
delivery_mode: str = "worker_queue"
worker_queue_available: bool = False
dry_run: bool = False dry_run: bool = False
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
@@ -100,6 +102,8 @@ class QueueCampaignResult:
"skipped_count": self.skipped_count, "skipped_count": self.skipped_count,
"blocked_count": self.blocked_count, "blocked_count": self.blocked_count,
"enqueued_count": self.enqueued_count, "enqueued_count": self.enqueued_count,
"delivery_mode": self.delivery_mode,
"worker_queue_available": self.worker_queue_available,
"dry_run": self.dry_run, "dry_run": self.dry_run,
} }
@@ -676,6 +680,8 @@ def queue_campaign_jobs(
skipped_count=skipped_count, skipped_count=skipped_count,
blocked_count=blocked_count, blocked_count=blocked_count,
enqueued_count=enqueued_count, enqueued_count=enqueued_count,
delivery_mode="worker_queue" if enqueue_celery else "database_queue",
worker_queue_available=_celery_enabled(),
dry_run=dry_run, dry_run=dry_run,
) )

View File

@@ -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: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: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: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": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"

View File

@@ -271,6 +271,24 @@ export type CampaignQueuePayload = {
dry_run?: boolean; 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 = { export type CampaignSendNowPayload = {
version_id?: string | null; version_id?: string | null;
include_warnings?: boolean; include_warnings?: boolean;
@@ -928,6 +946,15 @@ payload: CampaignQueuePayload = {})
}); });
} }
export async function getCampaignDeliveryOptions(
settings: ApiSettings,
campaignId: string,
versionId?: string | null)
: Promise<CampaignDeliveryOptions> {
const query = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
return apiFetch<CampaignDeliveryOptions>(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`);
}
export async function sendCampaignNow( export async function sendCampaignNow(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,

View File

@@ -95,7 +95,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="settings" />} /> <Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="settings" />} />
<Route path="policies" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="policy" />} /> <Route path="policies" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="policy" />} />
<Route path="policy" element={<Navigate to="../policies" replace />} /> <Route path="policy" element={<Navigate to="../policies" replace />} />
<Route path="review" element={<ReviewSendPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="send" element={<Navigate to="../review" replace />} /> <Route path="send" element={<Navigate to="../review" replace />} />
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="reports" element={<Navigate to="../report" replace />} /> <Route path="reports" element={<Navigate to="../report" replace />} />

View File

@@ -17,26 +17,33 @@ import type { ApiSettings } from "../../types";
import { import {
appendSent, appendSent,
buildVersion, buildVersion,
cancelCampaign,
getCampaignDeliveryOptions,
getCampaignJobs, getCampaignJobs,
getCampaignJobsDelta, getCampaignJobsDelta,
getCampaignJobDetail, getCampaignJobDetail,
getCampaignSummary, getCampaignSummary,
linkCampaignAttachmentMatches, linkCampaignAttachmentMatches,
mockSendCampaign, mockSendCampaign,
pauseCampaign,
previewCampaignAttachments, previewCampaignAttachments,
queueCampaign,
resumeCampaign,
retryCampaignJobs,
sendCampaignJob, sendCampaignJob,
sendCampaignNow, sendCampaignNow,
updateCampaignReviewState, updateCampaignReviewState,
validateVersion, validateVersion,
type CampaignAttachmentPreviewFile, type CampaignAttachmentPreviewFile,
type CampaignAttachmentPreviewResponse, type CampaignAttachmentPreviewResponse,
type CampaignDeliveryOptions,
type CampaignJobsQuery, type CampaignJobsQuery,
type CampaignJobsResponse, type CampaignJobsResponse,
type CampaignSummary, type CampaignSummary,
type CampaignVersionDetail } from type CampaignVersionDetail } from
"../../api/campaigns"; "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; 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 { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
@@ -97,7 +104,7 @@ type DeliverabilityPreflightItem = {
state: "ready" | "warning" | "blocked" | "info"; 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<FlowState, string> = { const stateColors: Record<FlowState, string> = {
complete: "var(--green)", complete: "var(--green)",
@@ -121,7 +128,7 @@ const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"]; 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 navigate = useGuardedNavigate();
const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox"); const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox");
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
@@ -134,6 +141,12 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
const [liveSummary, setLiveSummary] = useState<CampaignSummary | null>(null); const [liveSummary, setLiveSummary] = useState<CampaignSummary | null>(null);
const [queueStatusLoading, setQueueStatusLoading] = useState(false); const [queueStatusLoading, setQueueStatusLoading] = useState(false);
const version = data.currentVersion; 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<CampaignDeliveryOptions | null>(null);
const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false);
const campaignJson = useMemo(() => getCampaignJson(version), [version]); const campaignJson = useMemo(() => getCampaignJson(version), [version]);
const server = asRecord(campaignJson.server); const server = asRecord(campaignJson.server);
const selectedMailProfileId = getText(server, "mail_profile_id"); 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 [reviewConfirmOpen, setReviewConfirmOpen] = useState(false);
const [dryRun, setDryRun] = useState(false); const [dryRun, setDryRun] = useState(false);
const [sendConfirmOpen, setSendConfirmOpen] = 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 [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 [imapAppendResult, setImapAppendResult] = useState<Record<string, unknown> | null>(null);
const [imapDiagnostics, setImapDiagnostics] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse()); const [imapDiagnostics, setImapDiagnostics] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const imapDiagnosticsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse()); const imapDiagnosticsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
@@ -200,11 +216,15 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
setAttachmentPreviewError(""); setAttachmentPreviewError("");
setAttachmentLockConfirmOpen(false); setAttachmentLockConfirmOpen(false);
setSendResult(null); setSendResult(null);
setQueueResult(null);
setImapAppendResult(null); setImapAppendResult(null);
setImapDiagnostics(emptyCampaignJobsResponse()); setImapDiagnostics(emptyCampaignJobsResponse());
imapDiagnosticsRef.current = emptyCampaignJobsResponse(); imapDiagnosticsRef.current = emptyCampaignJobsResponse();
setSelectedDeliveryJobDetail(null); setSelectedDeliveryJobDetail(null);
setSendConfirmOpen(false); setSendConfirmOpen(false);
setQueueConfirmOpen(false);
setCancelDeliveryConfirmOpen(false);
setDeliveryOptions(null);
setLiveSummary(null); setLiveSummary(null);
resetDeltaWatermark(); resetDeltaWatermark();
}, [version?.id, resetDeltaWatermark]); }, [version?.id, resetDeltaWatermark]);
@@ -234,6 +254,28 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
const buildWarnings = numberFrom(build, ["warning_count", "warnings"]); const buildWarnings = numberFrom(build, ["warning_count", "warnings"]);
const hasBuild = buildPresent && (builtCount > 0 || version?.workflow_state === "built"); 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(() => { useEffect(() => {
if (!version?.id || !hasBuild) return; if (!version?.id || !hasBuild) return;
const expectedKey = reviewJobsLoadKey(version.id, showAllReviewJobs); 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]); }, [version?.id, hasBuild, showAllReviewJobs, jobsLoadedKey, campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const statusCounts = asRecord(summary?.status_counts); const statusCounts = asRecord(summary?.status_counts);
const queueStatusCounts = asRecord(statusCounts.queue);
const sendStatusCounts = asRecord(statusCounts.send); const sendStatusCounts = asRecord(statusCounts.send);
const imapStatusCounts = asRecord(statusCounts.imap); const imapStatusCounts = asRecord(statusCounts.imap);
const attempts = asRecord(summary?.attempts); const attempts = asRecord(summary?.attempts);
const summaryDelivery = asRecord(summary?.delivery); const summaryDelivery = asRecord(summary?.delivery);
const queuedSendCount = numberFrom(sendStatusCounts, ["queued"]); const queuedSendCount = numberFrom(sendStatusCounts, ["queued"]);
const pausedQueueCount = numberFrom(queueStatusCounts, ["paused"]);
const claimedSendCount = numberFrom(sendStatusCounts, ["claimed"]); const claimedSendCount = numberFrom(sendStatusCounts, ["claimed"]);
const sendingSendCount = numberFrom(sendStatusCounts, ["sending"]); const sendingSendCount = numberFrom(sendStatusCounts, ["sending"]);
const activeSendCount = claimedSendCount + sendingSendCount; const activeSendCount = claimedSendCount + sendingSendCount;
@@ -255,8 +299,8 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
const cancelledCount = cards?.cancelled ?? numberFrom(sendStatusCounts, ["cancelled"]); const cancelledCount = cards?.cancelled ?? numberFrom(sendStatusCounts, ["cancelled"]);
const outcomeUnknownCount = cards?.outcome_unknown ?? numberFrom(sendStatusCounts, ["outcome_unknown"]); const outcomeUnknownCount = cards?.outcome_unknown ?? numberFrom(sendStatusCounts, ["outcome_unknown"]);
const sendAttemptCount = numberFrom(attempts, ["send_attempts"]); const sendAttemptCount = numberFrom(attempts, ["send_attempts"]);
const backgroundWorkersEnabled = summaryDelivery.background_workers_enabled === true || summaryDelivery.celery_enabled === true; const backgroundWorkersEnabled = deliveryOptions?.worker_queue_available === true || summaryDelivery.background_workers_enabled === true || summaryDelivery.celery_enabled === true;
const backgroundWorkersDisabled = summaryDelivery.background_workers_enabled === false || summaryDelivery.celery_enabled === false; 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 recentFailures = asArray(summary?.recent_failures).map(asRecord).slice(0, 5);
const jobsTotal = cards?.jobs_total ?? inlineEntries.filter((entry) => entry.active !== false).length; const jobsTotal = cards?.jobs_total ?? inlineEntries.filter((entry) => entry.active !== false).length;
const sentCount = cards?.sent ?? 0; 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 deliveryStarted = deliverySending || deliveryHasTerminalOutcome || ["sent", "completed", "partially_completed", "outcome_unknown", "failed"].includes(currentWorkflowState);
const queuedWithoutWorker = backgroundWorkersDisabled && queuedSendCount > 0 && activeSendCount === 0; const queuedWithoutWorker = backgroundWorkersDisabled && queuedSendCount > 0 && activeSendCount === 0;
const directQueuedSendAllowed = queuedWithoutWorker && !deliveryStarted; 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 selectedDryRun = dryRun && !directQueuedSendAllowed;
const deliveryPartial = cards?.partially_completed === true || ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState); const deliveryPartial = cards?.partially_completed === true || ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState);
const deliveryComplete = queuedOrActiveCount === 0 && const deliveryComplete = queuedOrActiveCount === 0 &&
@@ -325,8 +375,9 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
}, [campaignId, setError, settings, version?.id]); }, [campaignId, setError, settings, version?.id]);
useEffect(() => { useEffect(() => {
if (!(deliveryQueued || deliverySending) || loading || queueStatusLoading || busy === "send") return; const commandInProgress = ["send", "queue", "control", "retry"].includes(busy);
const handle = window.setTimeout(() => {void refreshQueueStatus(true);}, 3000); if (!(deliveryQueued || deliverySending || commandInProgress) || loading || queueStatusLoading) return;
const handle = window.setTimeout(() => {void refreshQueueStatus(true);}, commandInProgress ? 1000 : 3000);
return () => window.clearTimeout(handle); return () => window.clearTimeout(handle);
}, [deliveryQueued, deliverySending, loading, queueStatusLoading, busy, refreshQueueStatus]); }, [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"; "i18n:govoplan-campaign.build_the_exact_queue_first.98d7ce1b";
const sendState: FlowState = !mockGateSatisfied ? const sendState: FlowState = !mockGateSatisfied ?
"locked" : "locked" :
busy === "send" || deliverySending ? ["send", "queue", "control", "retry"].includes(busy) || deliverySending ?
"running" : "running" :
queuedWithoutWorker ? queuedWithoutWorker ?
"warning" : "warning" :
@@ -764,7 +815,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
} }
async function runSendNow() { 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; const effectiveDryRun = dryRun && !directQueuedSendAllowed;
setBusy("send"); setBusy("send");
setMessage(effectiveDryRun ? setMessage(effectiveDryRun ?
@@ -797,6 +848,81 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
); );
setSendConfirmOpen(false); setSendConfirmOpen(false);
await reload(); 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) { } catch (err) {
setMessage(""); setMessage("");
setError(err instanceof Error ? err.message : String(err)); 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.", 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" 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", 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", 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
<div><span>i18n:govoplan-campaign.rate_limit.d08e55f5</span><strong>{messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"}</strong></div> <div><span>i18n:govoplan-campaign.rate_limit.d08e55f5</span><strong>{messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"}</strong></div>
<div><span>i18n:govoplan-campaign.minimum_duration.91a71a6d</span><strong>{estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"}</strong></div> <div><span>i18n:govoplan-campaign.minimum_duration.91a71a6d</span><strong>{estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"}</strong></div>
<div><span>i18n:govoplan-campaign.imap_append.8c0d9e96</span><strong>{Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"}</strong></div> <div><span>i18n:govoplan-campaign.imap_append.8c0d9e96</span><strong>{Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"}</strong></div>
<div><span>Synchronous limit</span><strong>{deliveryOptionsLoading ? "…" : synchronousSendLimit}</strong></div>
<div><span>Limit source</span><strong>{humanize(String(synchronousSendPolicy.source ?? "not available"))}</strong></div>
<div><span>Worker queue</span><strong>{workerQueueAvailable ? "Available" : "Not configured"}</strong></div>
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div> <div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div> </div>
<DeliverabilityPreflight items={deliverabilityPreflightItems} /> <DeliverabilityPreflight items={deliverabilityPreflightItems} />
@@ -1327,8 +1467,13 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button <Button
variant="primary" variant="primary"
onClick={() => setQueueConfirmOpen(true)}
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
{busy === "queue" ? "Queueing for workers…" : "Queue for workers"}
</Button>
<Button
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)} onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}> disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
{busy === "send" ? {busy === "send" ?
selectedDryRun ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" : selectedDryRun ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" :
@@ -1339,6 +1484,15 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
"i18n:govoplan-campaign.send_now.dae33010"} "i18n:govoplan-campaign.send_now.dae33010"}
</Button> </Button>
</div> </div>
<p className="muted small-note">
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.
</p>
{!synchronousSendAllowed && canSendSynchronously &&
<p className="review-flow-inline-note is-warning">Send now is unavailable: {synchronousSendReason(synchronousSendOption)}.</p>
}
{queueResult &&
<p className="review-flow-inline-note is-stale">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.</p>
}
{sendResult && {sendResult &&
<div className="review-flow-data-section"> <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">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>
@@ -1361,6 +1515,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
<WorkflowFact label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} /> <WorkflowFact label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
<WorkflowFact label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} /> <WorkflowFact label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
<WorkflowFact label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} /> <WorkflowFact label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
<WorkflowFact label="Paused" value={pausedQueueCount} />
<WorkflowFact label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} /> <WorkflowFact label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
<WorkflowFact label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} /> <WorkflowFact label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
<WorkflowFact label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} /> <WorkflowFact label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
@@ -1371,6 +1526,28 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
<StatusBadge status={deliveryDisplayStatus} /> <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> <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> </div>
<div className="button-row compact-actions review-flow-stage-actions">
<Button
onClick={() => void runDeliveryControl("pause")}
disabled={Boolean(busy) || !canControlDelivery || queuedSendCount <= 0}>
Pause queued work
</Button>
<Button
onClick={() => void runDeliveryControl("resume")}
disabled={Boolean(busy) || !canControlDelivery || pausedQueueCount <= 0 || !workerQueueAvailable}>
Resume with workers
</Button>
<Button
onClick={() => setCancelDeliveryConfirmOpen(true)}
disabled={Boolean(busy) || !canControlDelivery || queuedSendCount + pausedQueueCount <= 0}>
Cancel unsent work
</Button>
<Button
onClick={() => void runRetryFailed()}
disabled={Boolean(busy) || !canRetryDelivery || failedCount <= 0 || !workerQueueAvailable}>
Retry failed with workers
</Button>
</div>
{Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && {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> <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>
} }
@@ -1476,16 +1653,37 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
onCancel={() => setReviewConfirmOpen(false)} /> onCancel={() => setReviewConfirmOpen(false)} />
<ConfirmDialog
open={queueConfirmOpen}
title="Queue this version for background workers?"
message={`This commits ${synchronousEligibleCount} eligible message(s) from version ${String(version?.version_number ?? "—")} to durable worker jobs. You may leave this page and return to the same progress and recovery state.`}
confirmLabel="Queue for workers"
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
busy={busy === "queue"}
onCancel={() => setQueueConfirmOpen(false)}
onConfirm={() => void runQueueForWorkers()} />
<ConfirmDialog <ConfirmDialog
open={sendConfirmOpen} open={sendConfirmOpen}
title="i18n:govoplan-campaign.send_this_version_now.10a0ca56" title="i18n:govoplan-campaign.send_this_version_now.10a0ca56"
message={i18nMessage("i18n:govoplan-campaign.this_sends_the_frozen_execution_snapshot_for_ver.1f7c53cb", { value0: String(version?.version_number ?? "—"), value1: jobsTotal, value2: builtCount, value3: buildBlocked, value4: String(attachmentSummary.total_matched_files ?? 0), value5: String(rateLimit.messages_per_minute ?? "i18n:govoplan-campaign.not_set.ef374c57"), value6: imapAppend.enabled === true ? "enabled" : "disabled", value7: version?.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: version.execution_snapshot_hash.slice(0, 12) }) : "missing" })} message={`${i18nMessage("i18n:govoplan-campaign.this_sends_the_frozen_execution_snapshot_for_ver.1f7c53cb", { value0: String(version?.version_number ?? "—"), value1: jobsTotal, value2: builtCount, value3: buildBlocked, value4: String(attachmentSummary.total_matched_files ?? 0), value5: String(rateLimit.messages_per_minute ?? "i18n:govoplan-campaign.not_set.ef374c57"), value6: imapAppend.enabled === true ? "enabled" : "disabled", value7: version?.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: version.execution_snapshot_hash.slice(0, 12) }) : "missing" })} This is a synchronous request for ${synchronousEligibleCount} eligible message(s), within the effective limit of ${synchronousSendLimit}. All messages are preflighted before SMTP is contacted.`}
confirmLabel={directQueuedSendAllowed ? "i18n:govoplan-campaign.send_queued_now.bbace803" : "i18n:govoplan-campaign.send_now.dae33010"} confirmLabel={directQueuedSendAllowed ? "i18n:govoplan-campaign.send_queued_now.bbace803" : "i18n:govoplan-campaign.send_now.dae33010"}
tone="danger" tone="danger"
busy={busy === "send"} busy={busy === "send"}
onCancel={() => setSendConfirmOpen(false)} onCancel={() => setSendConfirmOpen(false)}
onConfirm={() => void runSendNow()} /> onConfirm={() => void runSendNow()} />
<ConfirmDialog
open={cancelDeliveryConfirmOpen}
title="Cancel all unsent delivery work?"
message={`This cancels queued or paused messages that have not crossed the SMTP boundary. Accepted, active, or outcome-unknown messages remain protected for audit and reconciliation.`}
confirmLabel="Cancel unsent work"
cancelLabel="Keep delivery work"
tone="danger"
busy={busy === "control"}
onCancel={() => setCancelDeliveryConfirmOpen(false)}
onConfirm={() => void runDeliveryControl("cancel")} />
<ConfirmDialog <ConfirmDialog
open={singleSendConfirmRow !== null} open={singleSendConfirmRow !== null}
title="Send this one message now?" title="Send this one message now?"
@@ -2348,6 +2546,18 @@ function formatSingleAddress(value: unknown): string {
return email || name; return email || name;
} }
function synchronousSendReason(option: Record<string, unknown>): 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<string, unknown>, keys: string[]): number { function numberFrom(record: Record<string, unknown>, keys: string[]): number {
for (const key of keys) { for (const key of keys) {
const value = record[key]; const value = record[key];

View File

@@ -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, /<ConfirmDialog[\s\S]*open=\{queueConfirmOpen\}/);
assert.match(source, /<ConfirmDialog[\s\S]*open=\{cancelDeliveryConfirmOpen\}/);
assert.doesNotMatch(source, /window\.alert\s*\(/);