campaign sending prototype

This commit is contained in:
2026-06-24 16:20:49 +02:00
parent 841d0b4024
commit a147ad27ec
13 changed files with 274 additions and 198 deletions

View File

@@ -114,7 +114,7 @@ manifest = ModuleManifest(
route_factory=_campaigns_router,
role_templates=ROLE_TEMPLATES,
nav_items=(
NavItem(path="/campaigns", label="Campaigns", icon="mail", required_any=("campaigns:campaign:read",), order=20),
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20),
NavItem(
path="/operator",
label="Operator Queue",
@@ -134,7 +134,7 @@ manifest = ModuleManifest(
module_id="campaigns",
package_name="@govoplan/campaign-webui",
nav_items=(
NavItem(path="/campaigns", label="Campaigns", icon="mail", required_any=("campaigns:campaign:read",), order=20),
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20),
NavItem(
path="/operator",
label="Operator Queue",

View File

@@ -9,6 +9,8 @@ from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
@@ -89,6 +91,7 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
"imap_config_fingerprint": None,
"estimated_remaining_send_seconds": None,
"estimated_remaining_send_human": None,
"background_workers_enabled": bool(core_settings.celery_enabled),
}
if not version or not isinstance(version.execution_snapshot, dict):
default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery."
@@ -131,6 +134,7 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
"imap_config_fingerprint": snapshot.imap_config_fingerprint,
"estimated_remaining_send_seconds": estimated_seconds,
"estimated_remaining_send_human": _human_duration(estimated_seconds),
"background_workers_enabled": bool(core_settings.celery_enabled),
}

View File

@@ -54,7 +54,7 @@ from govoplan_files.backend.storage.files import current_version_and_blob
from govoplan_files.backend.storage.campaign_attachments import managed_match_payloads, prepared_campaign_snapshot
from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
from govoplan_campaign.backend.time import utc_now
from govoplan_core.security.time import utc_now
from govoplan_campaign.backend.persistence.versions import (
LockedCampaignVersionError,
create_minimal_campaign,

View File

@@ -12,6 +12,7 @@ from uuid import uuid4
from sqlalchemy.orm import Session
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
@@ -197,6 +198,14 @@ def _get_current_version(session: Session, campaign: Campaign, version_id: str |
return version
def _celery_enabled() -> bool:
return bool(core_settings.celery_enabled)
def _should_enqueue_celery(enqueue_celery: bool) -> bool:
return bool(enqueue_celery and _celery_enabled())
def _celery_enqueue_send_job(job_id: str) -> None:
from govoplan_core.celery_app import celery
@@ -292,7 +301,7 @@ def queue_campaign_jobs(
session.commit()
enqueued_count = 0
if enqueue_celery and not dry_run:
if _should_enqueue_celery(enqueue_celery) and not dry_run:
for job in queued:
_celery_enqueue_send_job(job.id)
enqueued_count += 1
@@ -402,6 +411,8 @@ def send_campaign_now(
def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int:
if not _celery_enabled():
return 0
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
jobs = (
session.query(CampaignJob)
@@ -459,7 +470,7 @@ def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str,
session.commit()
enqueued_count = 0
if enqueue_celery:
if _should_enqueue_celery(enqueue_celery):
for job in jobs:
_celery_enqueue_send_job(job.id)
enqueued_count += 1
@@ -577,7 +588,7 @@ def queue_failed_jobs_for_retry(
session.commit()
enqueued = 0
if enqueue_celery and not dry_run:
if _should_enqueue_celery(enqueue_celery) and not dry_run:
for job in selected:
_celery_enqueue_send_job(job.id)
enqueued += 1
@@ -645,7 +656,7 @@ def queue_unattempted_jobs(
session.add(version)
session.commit()
enqueued = 0
if enqueue_celery and not dry_run:
if _should_enqueue_celery(enqueue_celery) and not dry_run:
for job in selected:
_celery_enqueue_send_job(job.id)
enqueued += 1
@@ -1092,7 +1103,7 @@ def send_campaign_job(
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
session.commit()
if enqueue_imap_task and job.imap_status == JobImapStatus.PENDING.value:
if enqueue_imap_task and _celery_enabled() and job.imap_status == JobImapStatus.PENDING.value:
_celery_enqueue_append_sent_job(job.id)
return SendJobResult(
job_id=job.id,
@@ -1265,13 +1276,14 @@ def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_i
.order_by(CampaignJob.entry_index.asc())
.all()
)
if not dry_run and enqueue_celery:
should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run
if should_enqueue:
for job in jobs:
_celery_enqueue_append_sent_job(job.id)
return {
"campaign_id": campaign.id,
"pending_count": len(jobs),
"enqueued_count": 0 if dry_run or not enqueue_celery else len(jobs),
"enqueued_count": len(jobs) if should_enqueue else 0,
"dry_run": dry_run,
}

View File

@@ -1,21 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware_utc(value: datetime | None) -> datetime | None:
"""Return a timezone-aware UTC datetime.
SQLite and some DB drivers may return naive datetimes even when SQLAlchemy
columns are declared as DateTime(timezone=True). Treat naive values as UTC
so comparisons with aware `datetime.now(timezone.utc)` do not crash.
"""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate } from "@govoplan/core-webui";
import { Link, useNavigate } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
@@ -167,25 +168,9 @@ function shortId(value: string): string {
}
function formatDateTime(value?: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
return formatPlatformDateTime(value);
}
function formatLoadedAt(value: Date): string {
return value.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
return formatDateTimeFromDate(value, { second: "2-digit" });
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { MailServerSettingsPanel, formatDateTime as formatPlatformDateTime, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
@@ -8,7 +9,6 @@ import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import ToggleSwitch from "../../components/ToggleSwitch";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { MailProfilePolicyEditor } from "../mail/MailProfileManagement";
@@ -26,8 +26,6 @@ import {
testMailProfileSmtp,
testSmtpSettings,
updateMockMailboxFailures,
type MailConnectionTestResponse,
type MailImapFolderListResponse,
type MailProfilePolicy,
type MailSecurity,
type MailServerProfile,
@@ -42,9 +40,9 @@ const securityOptions = ["plain", "tls", "starttls"];
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [smtpTestResult, setSmtpTestResult] = useState<MailConnectionTestResponse | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailConnectionTestResponse | null>(null);
const [folderResult, setFolderResult] = useState<MailImapFolderListResponse | null>(null);
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null);
const [mockMessages, setMockMessages] = useState<MockMailboxMessage[]>([]);
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
@@ -164,6 +162,25 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
}
function patchSmtpSettings(patchValue: Partial<MailServerSmtpSettings>) {
if (patchValue.host !== undefined) patch(["server", "smtp", "host"], String(patchValue.host ?? ""));
if (patchValue.port !== undefined) patch(["server", "smtp", "port"], Number(patchValue.port || 0));
if (patchValue.username !== undefined) patch(["server", "smtp", "username"], String(patchValue.username ?? ""));
if (patchValue.password !== undefined) patch(["server", "smtp", "password"], String(patchValue.password ?? ""));
if (patchValue.security !== undefined) patch(["server", "smtp", "security"], String(patchValue.security || "starttls"));
if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], Number(patchValue.timeout_seconds || 0));
}
function patchImapSettings(patchValue: Partial<MailServerImapSettings>) {
if (patchValue.host !== undefined) patch(["server", "imap", "host"], String(patchValue.host ?? ""));
if (patchValue.port !== undefined) patch(["server", "imap", "port"], Number(patchValue.port || 0));
if (patchValue.username !== undefined) patch(["server", "imap", "username"], String(patchValue.username ?? ""));
if (patchValue.password !== undefined) patch(["server", "imap", "password"], String(patchValue.password ?? ""));
if (patchValue.security !== undefined) patch(["server", "imap", "security"], String(patchValue.security || "tls"));
if (patchValue.sent_folder !== undefined) patch(["server", "imap", "sent_folder"], String(patchValue.sent_folder ?? ""));
if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], Number(patchValue.timeout_seconds || 0));
}
function profileScopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "system") return "system";
if (profile.scope_type === "tenant") return "tenant";
@@ -451,54 +468,57 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
</Card>
<Card title="Mail server settings">
<div className="mail-server-settings-grid">
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>SMTP login</h3>
<ToggleSwitch label="Mock server settings" checked={mockSandboxEnabled} disabled={locked || usingMailProfile} onChange={toggleMockMailSandbox} />
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Host"><input value={getText(smtp, "host")} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(smtp, "port", 587)} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(smtp, "username")} disabled={smtpCredentialDisabled} onChange={(event) => patch(["server", "smtp", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(smtp, "password")} disabled={smtpCredentialDisabled} onChange={(event) => patch(["server", "smtp", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(smtp, "security", "starttls")} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Timeout seconds"><input type="number" value={getNumber(smtp, "timeout_seconds", 30)} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "timeout_seconds"], Number(event.target.value || 0))} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runSmtpTest} disabled={locked || mailActionState === "smtp"}>{mailActionState === "smtp" ? "Testing…" : (usingMailProfile ? "Test profile SMTP" : "Test SMTP login")}</Button>
</div>
<MailActionResult result={smtpTestResult} />
</section>
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>IMAP sent-folder append</h3>
</div>
<div className="form-grid compact responsive-form-grid">
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Enable IMAP" checked={imapEnabled} disabled={locked || usingMailProfile} onChange={toggleImap} />
</div>
<FormField label="Host"><input value={getText(imap, "host")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(imap, "port", 993)} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(imap, "username")} disabled={imapCredentialDisabled} onChange={(event) => patch(["server", "imap", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(imap, "password")} disabled={imapCredentialDisabled} onChange={(event) => patch(["server", "imap", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(imap, "security", "tls")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Detected/saved sent folder"><input value={getText(imap, "sent_folder", "auto")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "sent_folder"], event.target.value)} /></FormField>
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Append successfully sent messages to Sent" checked={getBool(imapAppend, "enabled")} disabled={imapDisabled} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
<FormField label="Append folder"><input value={getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"))} disabled={imapDisabled || !getBool(imapAppend, "enabled")} onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runImapTest} disabled={imapDisabled || mailActionState === "imap"}>{mailActionState === "imap" ? "Testing…" : (usingMailProfile ? "Test profile IMAP" : "Test IMAP login")}</Button>
<Button variant="primary" onClick={runFolderLookup} disabled={imapDisabled || mailActionState === "folders"}>{mailActionState === "folders" ? "Looking up…" : "Folders…"}</Button>
</div>
<p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>
<MailActionResult result={imapTestResult} />
<FolderLookupResult result={folderResult} disabled={imapDisabled} onUseDetected={useDetectedSentFolder} />
</section>
</div>
<MailServerSettingsPanel
smtp={{
host: getText(smtp, "host"),
port: getNumber(smtp, "port", 587),
username: getText(smtp, "username"),
password: getText(smtp, "password"),
security: getText(smtp, "security", "starttls"),
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
}}
imap={{
enabled: imapEnabled,
host: getText(imap, "host"),
port: getNumber(imap, "port", 993),
username: getText(imap, "username"),
password: getText(imap, "password"),
security: getText(imap, "security", "tls"),
sent_folder: getText(imap, "sent_folder", "auto"),
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
}}
onSmtpChange={patchSmtpSettings}
onImapChange={patchImapSettings}
onImapEnabledChange={toggleImap}
smtpDisabled={smtpDisabled}
smtpCredentialDisabled={smtpCredentialDisabled}
smtpActionDisabled={locked}
imapToggleDisabled={locked || usingMailProfile}
imapServerDisabled={imapServerDisabled}
imapCredentialDisabled={imapCredentialDisabled}
imapActionDisabled={imapDisabled}
mockToggle={{ checked: mockSandboxEnabled, disabled: locked || usingMailProfile, onChange: toggleMockMailSandbox }}
append={{
enabled: getBool(imapAppend, "enabled"),
folder: getText(imapAppend, "folder", getText(imap, "sent_folder", "auto")),
disabled: imapDisabled,
folderDisabled: imapDisabled || !getBool(imapAppend, "enabled"),
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
}}
smtpTestLabel={usingMailProfile ? "Test profile SMTP" : "Test SMTP login"}
imapTestLabel={usingMailProfile ? "Test profile IMAP" : "Test IMAP login"}
busyAction={mailActionState}
onTestSmtp={runSmtpTest}
onTestImap={runImapTest}
onLookupFolders={runFolderLookup}
smtpTestResult={smtpTestResult}
imapTestResult={imapTestResult}
folderLookupResult={folderResult}
onUseDetectedFolder={useDetectedSentFolder}
useDetectedFolderDisabled={imapDisabled}
floatingResults
/>
</Card>
<Card title="Mock mail sandbox">
@@ -538,7 +558,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
bodyMode="text"
text={selectedMockMessage.body_preview || ""}
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined}
recipientNote={selectedMockMessage.created_at ? formatPlatformDateTime(selectedMockMessage.created_at) : undefined}
metaItems={mockMessageMetaItems(selectedMockMessage)}
attachments={mockMessageAttachments(selectedMockMessage)}
raw={selectedMockMessage.raw_eml}
@@ -572,10 +592,7 @@ function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAtta
}
function formatMockDate(value: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
return formatPlatformDateTime(value);
}
@@ -589,39 +606,3 @@ function mockMailboxColumns(openMockMessage: (id: string) => Promise<void>): Dat
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (message) => <Button onClick={() => openMockMessage(message.id)}>Open</Button> }
];
}
function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) {
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating>
{result.message}
{result.ok && typeof authenticated === "boolean" && (
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
)}
</DismissibleAlert>
);
}
function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) {
if (!result) return null;
if (!result.ok) {
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
}
return (
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p>
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "—"}</strong></p>
{result.detected_sent_folder && <Button onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
{result.folders.length > 0 && (
<div className="field-chip-list">
{result.folders.slice(0, 12).map((folder) => (
<span className="field-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
))}
{result.folders.length > 12 && <span className="field-chip">+{result.folders.length - 12} more</span>}
</div>
)}
</DismissibleAlert>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
import { useNavigate } from "react-router-dom";
import {
BarChart3,
@@ -16,11 +16,13 @@ import {
buildVersion,
getCampaignJobs,
getCampaignJobDetail,
getCampaignSummary,
mockSendCampaign,
sendCampaignNow,
updateCampaignReviewState,
validateVersion,
type CampaignJobsResponse,
type CampaignSummary,
type CampaignVersionDetail,
} from "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
@@ -40,6 +42,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
asArray,
asRecord,
formatDateTime,
getCampaignJson,
getDeliverySection,
humanize,
@@ -101,6 +104,8 @@ const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "ex
export default function ReviewSendPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const navigate = useNavigate();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const [liveSummary, setLiveSummary] = useState<CampaignSummary | null>(null);
const [queueStatusLoading, setQueueStatusLoading] = useState(false);
const version = data.currentVersion;
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
const inlineEntries = useMemo(
@@ -109,8 +114,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
);
const validation = asRecord(version?.validation_summary);
const build = asRecord(version?.build_summary);
const cards = data.summary?.cards;
const attachmentSummary = asRecord(data.summary?.attachments);
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);
@@ -148,6 +154,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
setSelectedMockMessage(null);
setSendResult(null);
setSendConfirmOpen(false);
setLiveSummary(null);
}, [version?.id]);
useEffect(() => {
@@ -176,24 +183,91 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
void loadBuiltMessages(true, reviewPage);
}, [version?.id, hasBuild, reviewPage, showAllReviewJobs, jobsLoadedKey]);
const statusCounts = asRecord(summary?.status_counts);
const sendStatusCounts = asRecord(statusCounts.send);
const attempts = asRecord(summary?.attempts);
const summaryDelivery = asRecord(summary?.delivery);
const queuedSendCount = numberFrom(sendStatusCounts, ["queued"]);
const claimedSendCount = numberFrom(sendStatusCounts, ["claimed"]);
const sendingSendCount = numberFrom(sendStatusCounts, ["sending"]);
const activeSendCount = claimedSendCount + sendingSendCount;
const queuedOrActiveCount = cards?.queued_or_active ?? queuedSendCount + activeSendCount;
const notQueuedCount = cards?.not_attempted ?? numberFrom(sendStatusCounts, ["not_queued"]);
const cancelledCount = cards?.cancelled ?? numberFrom(sendStatusCounts, ["cancelled"]);
const outcomeUnknownCount = cards?.outcome_unknown ?? numberFrom(sendStatusCounts, ["outcome_unknown"]);
const sendAttemptCount = numberFrom(attempts, ["send_attempts"]);
const backgroundWorkersEnabled = summaryDelivery.background_workers_enabled === true || summaryDelivery.celery_enabled === true;
const backgroundWorkersDisabled = summaryDelivery.background_workers_enabled === false || summaryDelivery.celery_enabled === false;
const recentFailures = asArray(summary?.recent_failures).map(asRecord).slice(0, 5);
const jobsTotal = cards?.jobs_total ?? inlineEntries.filter((entry) => entry.active !== false).length;
const sentCount = cards?.sent ?? 0;
const failedCount = cards?.failed ?? 0;
const imapAppended = cards?.imap_appended ?? 0;
const imapFailed = cards?.imap_failed ?? 0;
const deliveryHasTerminalOutcome = sentCount + failedCount + outcomeUnknownCount + cancelledCount > 0;
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
const deliveryQueued = currentWorkflowState === "queued";
const deliveryStarted = ["sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed"].includes(currentWorkflowState);
const deliverySending = activeSendCount > 0 || (currentWorkflowState === "sending" && queuedOrActiveCount > 0);
const deliveryQueued = queuedOrActiveCount > 0 || (currentWorkflowState === "queued" && !deliveryHasTerminalOutcome);
const deliveryStarted = deliverySending || deliveryHasTerminalOutcome || ["sent", "completed", "partially_completed", "outcome_unknown", "failed"].includes(currentWorkflowState);
const queuedWithoutWorker = backgroundWorkersDisabled && queuedSendCount > 0 && activeSendCount === 0;
const directQueuedSendAllowed = queuedWithoutWorker && !deliveryStarted;
const selectedDryRun = dryRun && !directQueuedSendAllowed;
const deliveryPartial = cards?.partially_completed === true || ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState);
const deliveryComplete = queuedOrActiveCount === 0
&& sentCount > 0
&& failedCount === 0
&& outcomeUnknownCount === 0
&& cancelledCount === 0
&& cards?.partially_completed !== true;
const deliveryDanger = failedCount > 0 || outcomeUnknownCount > 0 || ["outcome_unknown", "failed"].includes(currentWorkflowState);
const deliveryDisplayStatus = deliverySending
? "sending"
: deliveryQueued
? "queued"
: deliveryPartial
? "partially_completed"
: deliveryComplete
? "completed"
: deliveryDanger
? failedCount > 0 ? "failed" : "outcome_unknown"
: data.campaign?.status ?? version?.workflow_state ?? "not_started";
const queueStatusNote = queuedWithoutWorker
? "Queued jobs are waiting in the database, but background delivery workers are disabled on this server. Use Send queued now for a small dev run, or start Redis/Celery with CELERY_ENABLED=true."
: deliverySending
? "Delivery is being processed. This page refreshes queue counters without reloading the whole campaign workspace."
: deliveryQueued && backgroundWorkersEnabled
? "Queued jobs are waiting for a background delivery worker. If the counts do not change, check the worker process and queue logs."
: deliveryQueued
? "Queued jobs are waiting. If the counts do not change, check whether background delivery workers are enabled and running."
: "";
const queueStatusTone = queuedWithoutWorker ? "is-warning" : deliveryQueued || deliverySending ? "is-stale" : "";
const historicalVersion = isHistoricalCampaignVersion(version, data.campaign?.current_version_id);
const finalVersion = isFinalLockedVersion(version);
const userLockedVersion = isUserLockedVersion(version);
const readOnlyVersion = historicalVersion || userLockedVersion || finalVersion;
const refreshQueueStatus = useCallback(async (silent = true) => {
if (!version?.id) return;
setQueueStatusLoading(true);
if (!silent) setMessage("Refreshing queue status…");
setError("");
try {
const result = await getCampaignSummary(settings, campaignId, version.id);
setLiveSummary(result);
if (!silent) setMessage("Queue status refreshed.");
} catch (err) {
if (!silent) setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setQueueStatusLoading(false);
}
}, [campaignId, setError, settings, version?.id]);
useEffect(() => {
if (!(deliveryQueued || currentWorkflowState === "sending") || loading || busy === "send") return;
const handle = window.setTimeout(() => { void reload(); }, 3000);
if (!(deliveryQueued || deliverySending) || loading || queueStatusLoading || busy === "send") return;
const handle = window.setTimeout(() => { void refreshQueueStatus(true); }, 3000);
return () => window.clearTimeout(handle);
}, [deliveryQueued, currentWorkflowState, loading, busy, reload]);
}, [deliveryQueued, deliverySending, loading, queueStatusLoading, busy, refreshQueueStatus]);
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
const reviewMetadata = reviewJobs.review ?? {};
@@ -265,25 +339,31 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const mockGateSatisfied = mockComplete || downstreamDeliveryActivity;
const sendState: FlowState = !mockGateSatisfied
? "locked"
: busy === "send" || deliveryQueued || currentWorkflowState === "sending"
: busy === "send" || deliverySending
? "running"
: ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState)
? "partial"
: ["sent", "completed"].includes(currentWorkflowState)
? "complete"
: ["outcome_unknown", "failed"].includes(currentWorkflowState)
? "danger"
: "active";
: queuedWithoutWorker
? "warning"
: deliveryQueued
? "running"
: deliveryPartial
? "partial"
: deliveryComplete || ["sent", "completed"].includes(currentWorkflowState)
? "complete"
: deliveryDanger
? "danger"
: "active";
const resultState: FlowState = !deliveryStarted
? "locked"
: currentWorkflowState === "sending"
: deliverySending
? "running"
: ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState)
: deliveryPartial
? "partial"
: ["sent", "completed"].includes(currentWorkflowState)
: deliveryComplete || ["sent", "completed"].includes(currentWorkflowState)
? "complete"
: "danger";
: deliveryDanger
? "danger"
: "active";
const stages: FlowStageDefinition[] = useMemo(() => [
{
@@ -450,9 +530,14 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
}
async function runSendNow() {
if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted) return;
if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || (deliveryQueued && !directQueuedSendAllowed) || deliveryStarted) return;
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
setBusy("send");
setMessage(dryRun ? "Checking the built queue without sending…" : "Sending the locked campaign version…");
setMessage(effectiveDryRun
? "Checking the built queue without sending…"
: directQueuedSendAllowed
? "Sending the queued jobs synchronously…"
: "Sending the locked campaign version…");
setSendResult(null);
setError("");
try {
@@ -462,7 +547,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
check_files: false,
validate_before_send: false,
build_before_send: false,
dry_run: dryRun,
dry_run: effectiveDryRun,
use_rate_limit: true,
enqueue_imap_task: false,
});
@@ -472,7 +557,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const failed = result.failed_count ?? 0;
const unknown = result.outcome_unknown_count ?? 0;
setMessage(
dryRun
effectiveDryRun
? "Dry run finished. No message was sent."
: `Send finished. SMTP accepted ${String(sent)} message(s), failed ${String(failed)}, outcome unknown ${String(unknown)}.`,
);
@@ -599,6 +684,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => void refreshQueueStatus(false)} disabled={!version || queueStatusLoading || Boolean(busy)}>
{queueStatusLoading ? "Refreshing status…" : "Refresh status"}
</Button>
<Button onClick={reload} disabled={loading || Boolean(busy)}>Reload</Button>
</div>
</div>
@@ -771,6 +859,15 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
<div><span>IMAP append</span><strong>{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}</strong></div>
<div><span>Version</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div>
<div className="review-flow-fact-grid">
<WorkflowFact label="Queued" value={queuedSendCount} />
<WorkflowFact label="Claimed / sending" value={activeSendCount} />
<WorkflowFact label="Not queued" value={notQueuedCount} />
<WorkflowFact label="Send attempts" value={sendAttemptCount} />
</div>
{queueStatusNote && (
<p className={`review-flow-inline-note ${queueStatusTone}`.trim()}>{queueStatusNote}</p>
)}
<div className="review-send-controls">
<ToggleSwitch
label="Dry run"
@@ -783,10 +880,16 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
<div className="button-row compact-actions">
<Button
variant="primary"
onClick={() => dryRun ? void runSendNow() : setSendConfirmOpen(true)}
disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted}
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || (deliveryQueued && !directQueuedSendAllowed) || deliveryStarted}
>
{busy === "send" ? (dryRun ? "Running dry run…" : "Sending…") : dryRun ? "Run dry run" : "Send now"}
{busy === "send"
? (selectedDryRun ? "Running dry run…" : "Sending…")
: directQueuedSendAllowed
? "Send queued now"
: selectedDryRun
? "Run dry run"
: "Send now"}
</Button>
</div>
{sendResult && (
@@ -810,13 +913,32 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
<div className="review-flow-fact-grid">
<WorkflowFact label="SMTP accepted" value={sentCount} />
<WorkflowFact label="SMTP failed" value={failedCount} />
<WorkflowFact label="Queued / active" value={queuedOrActiveCount} />
<WorkflowFact label="Outcome unknown" value={outcomeUnknownCount} />
<WorkflowFact label="IMAP appended" value={imapAppended} />
<WorkflowFact label="IMAP failed" value={imapFailed} />
</div>
<div className="review-flow-result-line">
<StatusBadge status={data.campaign?.status ?? version?.workflow_state ?? "not_started"} />
<StatusBadge status={deliveryDisplayStatus} />
<span>{deliveryStarted ? "Delivery activity is available in the report and audit views." : "No real delivery has started for this campaign version."}</span>
</div>
{recentFailures.length > 0 && (
<div className="review-flow-data-section">
<h3>Recent delivery failures</h3>
<dl className="detail-list">
{recentFailures.map((failure, index) => (
<div key={String(failure.job_id ?? index)}>
<dt>{String(failure.recipient_email ?? failure.entry_id ?? `#${String(failure.entry_index ?? index + 1)}`)}</dt>
<dd>
<strong>{humanize(String(failure.send_status ?? failure.imap_status ?? "failed"))}</strong>
{failure.last_error ? `: ${String(failure.last_error)}` : ""}
{failure.updated_at ? <span className="muted"> · {formatDateTime(String(failure.updated_at))}</span> : null}
</dd>
</div>
))}
</dl>
</div>
)}
<div className="button-row compact-actions">
<Button onClick={() => navigate("../report")}>Open report</Button>
<Button onClick={() => navigate("../audit")}>Open audit log</Button>
@@ -850,7 +972,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
open={sendConfirmOpen}
title="Send this version now?"
message={`This sends the frozen execution snapshot for version ${String(version?.version_number ?? "—")}: ${jobsTotal} planned job(s), ${builtCount} built, ${buildBlocked} blocked, ${String(attachmentSummary.total_matched_files ?? 0)} resolved file(s), rate limit ${String(rateLimit.messages_per_minute ?? "not set")}/minute, IMAP append ${imapAppend.enabled === true ? "enabled" : "disabled"}, snapshot ${version?.execution_snapshot_hash ? `${version.execution_snapshot_hash.slice(0, 12)}` : "missing"}. SMTP-accepted or uncertain jobs will never be resent automatically.`}
confirmLabel="Send now"
confirmLabel={directQueuedSendAllowed ? "Send queued now" : "Send now"}
tone="danger"
busy={busy === "send"}
onCancel={() => setSendConfirmOpen(false)}
@@ -864,7 +986,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
bodyMode="text"
text={selectedMockMessage.body_preview || ""}
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined}
recipientNote={selectedMockMessage.created_at ? formatDateTime(selectedMockMessage.created_at) : undefined}
metaItems={mockMessageMetaItems(selectedMockMessage)}
attachments={mockMessageAttachments(selectedMockMessage)}
raw={selectedMockMessage.raw_eml}

View File

@@ -13,7 +13,7 @@ import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./componen
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
@@ -47,7 +47,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
currentStep: "template",
unsavedTitle: "Unsaved template changes",
unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${new Date(loadedVersion.autosaved_at).toLocaleString()}` : "Loaded",
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "Loaded",
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);

View File

@@ -161,12 +161,14 @@ export function CampaignUnsavedChangesProvider({ children }: { children: ReactNo
);
}
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
hasUnsavedChanges: false,
registerUnsavedChanges: () => () => undefined,
requestNavigation: (action) => action()
};
export function useCampaignUnsavedChanges() {
const context = useContext(UnsavedChangesContext);
if (!context) {
throw new Error("useCampaignUnsavedChanges must be used inside CampaignUnsavedChangesProvider");
}
return context;
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
}
export function useRegisterCampaignUnsavedChanges(registration: UnsavedChangesRegistration | null) {

View File

@@ -1,3 +1,4 @@
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
import type { CampaignListItem } from "../../../types";
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
@@ -153,16 +154,7 @@ export function currentStepLabel(version: CampaignVersionDetail | CampaignVersio
}
export function formatDateTime(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
return formatPlatformDateTime(value);
}
export function humanize(value?: string | null): string {

View File

@@ -1,4 +1,3 @@
import { Activity, FileText, Form, MailCheck, Users } from "lucide-react";
import { createElement, lazy, useCallback } from "react";
import { useParams } from "react-router-dom";
import { Card, ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
@@ -20,17 +19,17 @@ export const campaignModule: PlatformWebModule = {
dependencies: ["access"],
optionalDependencies: ["files", "mail"],
navItems: [
{ to: "/campaigns", label: "Campaigns", icon: MailCheck, anyOf: campaignRead, order: 20 },
{ to: "/campaigns", label: "Campaigns", iconName: "campaign", anyOf: campaignRead, order: 20 },
{
to: "/operator",
label: "Operator Queue",
icon: Activity,
iconName: "activity",
anyOf: operatorScopes,
order: 30
},
{ to: "/reports", label: "Reports", icon: FileText, anyOf: ["campaigns:report:read"], order: 70 },
{ to: "/address-book", label: "Address Book", icon: Users, order: 80 },
{ to: "/templates", label: "Templates", icon: Form, order: 90 }
{ to: "/reports", label: "Reports", iconName: "reports", anyOf: ["campaigns:report:read"], order: 70 },
{ to: "/address-book", label: "Address Book", iconName: "users", order: 80 },
{ to: "/templates", label: "Templates", iconName: "form", order: 90 }
],
routes: [
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },

View File

@@ -2595,7 +2595,7 @@
/* Administration scope separation and ownership safeguards ---------------- */
.admin-overview-section-label {
margin: 2px 0 -4px;
margin: 2px 0 14px;
color: var(--muted);
font-size: 12px;
font-weight: 800;