28 Commits

Author SHA1 Message Date
6fda123fc3 docs(campaign): distinguish worker process evidence 2026-07-22 18:26:42 +02:00
2fa91bb943 test(campaign): complete delivery fault drills 2026-07-22 18:26:28 +02:00
e3cc476508 fix(campaign): initialize delivery worker runtime 2026-07-22 18:26:11 +02:00
01ef541917 docs(campaign): define target-like mail evidence 2026-07-22 16:49:57 +02:00
d567257311 test(campaign): prove GreenMail delivery journey 2026-07-22 16:49:57 +02:00
3075ef7f5b docs(campaign): document aggregate report baseline 2026-07-22 15:20:04 +02:00
1ab4e91ffd test(campaign): add simple announcement acceptance fixture 2026-07-22 15:18:52 +02:00
735e874bd0 fix(campaign): bound operator queue polling 2026-07-22 09:39:03 +02:00
99d44eeb8d feat(campaign): complete durable operator queue 2026-07-22 09:31:33 +02:00
f095a3e2c7 fix(campaign): sanitize synchronous send results 2026-07-22 09:21:44 +02:00
1225802c5d fix(campaign): suppress overlapping aggregate cells 2026-07-22 09:17:00 +02:00
ac3329cafe fix(campaign): use stable aggregate status filters 2026-07-22 09:11:14 +02:00
4eb651c6ac feat(campaign): filter reports from outcome counts 2026-07-22 09:11:10 +02:00
0b4017c240 refactor(campaign): restore route docstrings 2026-07-22 09:08:12 +02:00
79b576b4bc fix(webui): localize Campaign delivery reporting 2026-07-22 09:04:42 +02:00
b0282ebff2 test(campaign): cover bounded delivery modes 2026-07-22 09:00:35 +02:00
aae4ef5952 chore(release): bump Campaign to 0.1.10 2026-07-22 08:50:53 +02:00
8ee87b7558 feat(campaign): surface aggregate reports 2026-07-22 08:49:38 +02:00
7229fb8e3d fix(campaign): mark excluded delivery as skipped 2026-07-22 08:48:46 +02:00
3487ec7048 test(campaign): enforce aggregate report permissions 2026-07-22 08:46:28 +02:00
22d72f82f5 test(campaign): require export authority for report email 2026-07-22 08:45:00 +02:00
fc36aee6c0 feat(webui): add aggregate Campaign reports 2026-07-22 08:44:15 +02:00
06125cc0e8 feat(campaign): add privacy-safe aggregate reports 2026-07-22 08:44:12 +02:00
21f3014ac5 feat(campaign): add durable operator queue controls 2026-07-22 08:39:44 +02:00
62a68792a4 feat(campaign): persist delivery execution mode 2026-07-22 08:37:58 +02:00
aa4ec66b7b fix(campaign): query reports before pagination 2026-07-22 08:31:43 +02:00
60efd1cb5d feat(campaign): make delivery modes explicit 2026-07-22 08:26:52 +02:00
7e1660344d feat(campaign): bound synchronous delivery 2026-07-22 08:21:42 +02:00
67 changed files with 8928 additions and 338 deletions

1
.gitignore vendored
View File

@@ -333,6 +333,7 @@ webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
webui/.review-preview-test-build/
webui/.report-grid-test-build/
# GovOPlaN shared ignore rules from govoplan-core
# Local WebUI test/build scratch directories

View File

@@ -93,6 +93,7 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
- Immediate delivery is bounded to 25 exact eligible recipient jobs by default. Deployments may set `GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0500), and tenants may narrow that ceiling through `campaign_delivery_policy.synchronous_send_max_recipients` in tenant settings.
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.

View File

@@ -44,6 +44,70 @@ If the smoke is started immediately after `docker compose up -d`, GreenMail may
bind the SMTP/IMAP ports before the services are fully ready. The smoke retries
login and folder setup for `GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS`.
## Campaign Acceptance
The transport smoke proves the Mail adapters. The Campaign acceptance runner
proves the public composition: it creates an isolated temporary Core database,
creates a Mail-owned encrypted profile through the API, materializes the
credential-free [`greenmail-delivery`](../../examples/greenmail-delivery/campaign.json)
fixture with only that profile reference, validates/builds it, sends the exact
generated EML through Campaign once, appends it once, and cross-checks Campaign
report/audit state with one unique-subject message in the GreenMail INBOX and
Sent folders. Provider mailbox verification is not a byte-for-byte comparison
after provider-side header or storage transformations.
```bash
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
set -a
. ./.env
set +a
/mnt/DATA/git/govoplan/.venv/bin/python run_campaign_acceptance.py \
--evidence /tmp/govoplan-campaign-greenmail-evidence.json
```
The default run also uses controlled loopback protocol endpoints to prove that
an SMTP connection loss before transmission is temporary, an explicit SMTP
authentication rejection is permanent, a final `451` response after DATA is
temporary, one accepted and one refused RCPT command is retained as partial
envelope acceptance, a connection loss after complete DATA is frozen as
`outcome_unknown`, and an IMAP authentication rejection after SMTP acceptance
leaves the send accepted while the append fails. A
second ordinary send must be rejected before another provider effect.
The worker drill queues one job, starts the registered
`govoplan.campaigns.send_email` task body in a dedicated OS process, waits until
the controlled endpoint has received complete DATA, terminates that process,
and starts the same task body in a fresh process. It proves the durable
`sending`/unfinished-attempt boundary is recovered as `outcome_unknown`
without a second SMTP connection or DATA transaction. This is a real process
and task-boundary interruption, but it does not start a Celery daemon, Redis
broker, or broker redelivery; `celery_broker_redelivery` therefore remains
`false` in the evidence.
The bounded JSON contains no endpoint, account, address, credential, profile,
campaign, version, or job identifiers. It records module versions, the fixture
hash, normalized classifications/counts, the Mail-profile boundary, required
audit actions, provider mailbox increments, and coverage flags. Runtime version
declarations identify the exercised composition; they do not claim that the
sources are clean, tagged, signed, or release-provenanced. The evidence names
them `declared_module_versions` and keeps `source_artifact_provenance` false;
exact commit/artifact provenance belongs to the package and release gate.
This runner is restricted to literal loopback IP addresses and the synchronous
Campaign delivery mode. Hostnames such as `localhost` and every non-loopback
address fail before profile creation, avoiding a DNS change between validation
and connection. It is local target-like evidence, not approval of an
institution's SMTP/IMAP service. The controlled post-DATA, temporary-response,
partial-refusal, and task-process interruption drills are local effect-level
proof, not proof of a target provider's behavior or Redis/Celery broker
redelivery. Use `--success-only` only when testing the success journey without
the local failure endpoints.
Starting the maintained test bed requires a working Docker CLI, Compose plugin,
daemon/socket access, and permission to pull `greenmail/standalone:2.1.9`. The
Campaign runner needs only the already-running loopback endpoints; it neither
starts Docker nor claims that it did.
## Use With A Campaign
Use the same settings in a campaign mail profile:

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,12 @@ been validated, built, reviewed, and locked.
## Operating Modes
- Local direct send: `CELERY_ENABLED=false`. Queueing stores jobs in the DB, and
small development runs can be processed with "Send queued now".
- Local direct send: `CELERY_ENABLED=false`. **Send now** is available only for
exact built runs within the effective synchronous limit. It preflights the
complete batch before the first SMTP effect.
- Worker send: `CELERY_ENABLED=true` with Redis/Celery workers running. Queueing
publishes delivery tasks, and the Review & Send page polls summary counters.
publishes durable delivery tasks, and Review and send polls their persisted
summary counters even after the initiating request has returned.
- Mock send: use only for development review. It does not prove real SMTP/IMAP
credentials or server policy.
@@ -26,6 +28,8 @@ been validated, built, reviewed, and locked.
ZIP before using production recipients.
- Keep the report page open during tests; it is the operational source of truth
for attempts, outcomes, and reconciliation.
- Confirm the effective Send now recipient-job limit. The safe default is 25;
use Queue for workers for ordinary batches or any run above that limit.
## Deliverability Preflight
@@ -49,10 +53,15 @@ Before the first live send for a sender domain or mail-server profile:
1. Validate the version with file checks enabled.
2. Build the version and inspect all blocking review items.
3. Queue only after the selected version is the intended immutable execution
version.
4. In local mode, use "Send queued now" for small test runs.
version. Select **Queue for workers**, then verify the committed and
published counts.
4. Use **Send now** only if the exact eligible count is non-zero and at or below
the effective deployment/tenant limit shown on the page.
5. In worker mode, verify queue counters move from queued/claimed/sending to a
terminal SMTP state.
6. If a synchronous request is used, keep Review and send open: it polls the
durable counters while the request runs. A rejection occurs before SMTP and
directs oversized runs to workers.
## Outcome Handling
@@ -95,9 +104,29 @@ bed where possible:
- IMAP append failure after SMTP acceptance.
- Worker restart with queued, claimed, and sending jobs.
For the maintained loopback baseline, run
`dev/mail-testbed/run_campaign_acceptance.py`. It proves the public Campaign
path for SMTP acceptance, IMAP append, repeat-send blocking, an SMTP connection
failure before transmission, an explicit SMTP authentication rejection, an
explicit temporary `451` response after DATA, partial RCPT refusal, a
connection loss after complete DATA, and an IMAP authentication rejection
after SMTP acceptance. Its evidence is an allowlisted classification/count
projection; raw provider diagnostics and transport/account identifiers are
deliberately excluded.
The runner also terminates a dedicated OS process executing the registered
Campaign send task after complete DATA, then invokes the task in a fresh
process. The unfinished durable attempt must become `outcome_unknown` and the
endpoint must observe no second connection or DATA transaction. This covers
the worker task/process boundary. It does not cover Redis/Celery delivery,
acknowledgement, broker redelivery, or daemon supervision; repeat the drill in
that infrastructure before approving a production worker deployment.
## Reporting Checks
- Partial delivery must show accepted, failed, and unknown counts separately.
- Excluded messages must show SMTP and IMAP as `skipped`, with skipped counts
and filters separate from unattempted or failed delivery.
- Accepted and unknown jobs must not appear in retry selections.
- Reconciled accepted jobs must remain protected from resend.
- Reconciled not-sent jobs must appear only as explicit retry candidates.

View File

@@ -144,10 +144,14 @@ paths, storage keys, worker claim tokens, and raw provider diagnostics require
the dedicated diagnostic permission and must not leak through ordinary campaign,
version, job, or report responses.
The current Campaign Report Web UI additionally requires recipient-read access
and does not yet hide every action control that the actor lacks. The server
still authorizes each action, but an aggregate-only reader UI remains open
work; do not promise that experience from `campaigns:report:read` alone.
Campaign now provides a separate aggregate **Reports** surface for readers with
`campaigns:report:read` and access to the campaign. It loads only the safe
aggregate projections, applies small-cell suppression, and offers no recipient
rows, drill-down, filtering, export, or delivery actions. The recipient-aware
**Campaign Report** still requires recipient-read access and does not yet hide
every action control that the actor lacks. The server authorizes each action,
but permission-aware action visibility on that detailed surface remains open
work; do not confuse it with the aggregate reader experience.
### Deliver and resolve outcomes
@@ -156,11 +160,13 @@ operator sequence.
At a minimum:
1. Queue only a validated, locked, built version. The current Web UI does not
expose Queue; use an authorized supporting client or API.
2. Use worker delivery for ordinary batches. Synchronous Send now has no
server-enforced job-count bound and must be used only after an operator has
deliberately confirmed a small controlled run.
1. Queue only a validated, locked, built version. Use **Queue for workers** for
ordinary batches; the durable progress remains visible after leaving and
returning to Review and send.
2. Use **Send now** only when the exact persisted eligible build is within the
effective synchronous limit shown by the UI. The default deployment limit
is 25 recipient jobs. The backend repeats the count and preflights every
message and the Mail profile revision before contacting SMTP.
3. Treat `smtp_accepted` as protected from ordinary retry.
4. Retry `failed_temporary` explicitly after inspecting the cause.
5. Include `failed_permanent` only after correcting the cause and making a
@@ -179,6 +185,14 @@ Pause stops new eligible work but cannot undo a provider effect already in
progress. Cancel marks work that has not yet produced a protected SMTP outcome;
it cannot recall accepted mail.
The deployment ceiling is configured with
`GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0 disables Send now; the
accepted range is 0500). A tenant may only narrow that ceiling with
`tenant.settings.campaign_delivery_policy.synchronous_send_max_recipients`.
The effective value and source are returned by the protected delivery-options
API, recorded for successful/rejected synchronous commands, and stated in the
configured handbook topic.
### Test and one-message actions
The current baseline includes mock send, queue dry-run, synchronous immediate
@@ -251,6 +265,13 @@ The delivery record should be able to identify:
reconciliation note; and
- actor/system trigger, timestamps, policy context, and corrections.
An excluded recipient/message is a completed validation decision, not a
pending delivery. Its SMTP and IMAP states are both `skipped`; it is counted and
filterable separately from unattempted, failed, accepted, and append outcomes.
No SMTP or IMAP attempt exists for such a row. If historical data contains
actual transport evidence despite an exclusion marker, that evidence is
preserved for audit and reconciliation rather than relabelled.
## Administration and policy
### Roles and permissions

View File

@@ -10,7 +10,11 @@ scenario catalogue lives in `examples/README.md`; committed fixture files should
be added under `examples/` only when they validate against the current campaign
schema and are safe to run in non-production environments.
- simple announcement with one active recipient and no attachments
- [`simple-announcement`](../examples/simple-announcement/campaign.json), a
credential-free campaign with one active recipient and no attachments; its
automated acceptance check physically blocks Mail and Files imports, denies
network connections, and validates/builds from an unrelated temporary
workspace
- multi-recipient message with To, CC, BCC, Reply-To, bounce, and disposition
notification fields
- campaign with global attachments and recipient-specific attachment rules
@@ -22,7 +26,9 @@ schema and are safe to run in non-production environments.
- campaign with blocked recipients or attachment errors that must not be sent
- mock delivery campaign that captures SMTP and IMAP append messages in the mail
development mailbox
- real non-production delivery campaign against the GreenMail test bed
- [`greenmail-delivery`](../examples/greenmail-delivery/campaign.json), a
credential-free real-delivery Campaign materialized with a temporary
Mail-owned profile by the loopback acceptance runner
## Fixture Rules
@@ -41,12 +47,24 @@ Before tagging a campaign release:
- Review `examples/README.md` and update the scenario catalogue when a release
adds or removes delivery behavior.
- Run `python -m unittest discover -s tests -p 'test_example_campaigns.py'` and
retain its isolated validate/build result as release evidence.
- Run core module permutation tests with campaign installed both with and
without files/mail.
- Validate and build each maintained example campaign.
- Run the mock delivery example when the mail development mailbox capability is
enabled.
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path.
- Run `dev/mail-testbed/run_campaign_acceptance.py` and retain its bounded JSON
projection. It must show one SMTP acceptance, one IMAP append, no duplicate
effect from a repeated ordinary send, matching Campaign report/audit state,
and no resolved transport material in Campaign JSON or its execution
snapshot. Its controlled endpoint evidence must also show explicit SMTP 451,
partial RCPT refusal, post-DATA ambiguity, and task-process interruption
classifications without retaining addresses or provider diagnostics.
- Treat the task-process restart proof separately from the still-open
Redis/Celery broker redelivery and daemon-supervision check; the coverage
projection must keep `celery_broker_redelivery` false.
- Confirm reusable mail profile selection is revalidated after campaign owner
transfer.
- Confirm every inline SMTP/IMAP field is rejected on import/write, omitted

View File

@@ -9,7 +9,7 @@ campaign schema and do not require production data.
| Scenario | Required Modules | Release Check |
| --- | --- | --- |
| `simple-announcement` | core, access, campaigns | Validate and build one active recipient without attachments. |
| [`simple-announcement`](simple-announcement/campaign.json) | core, access, campaigns | Validate and build one active recipient without attachments while Mail and Files are absent. |
| `addressing-matrix` | core, access, campaigns | Exercise To, CC, BCC, Reply-To, bounce, and disposition-notification fields. |
| `global-attachment` | core, access, campaigns; optional files | Build one deterministic attachment and verify evidence. |
| `recipient-attachment-rules` | core, access, campaigns; optional files | Match recipient-specific attachment rules and verify per-recipient evidence. |
@@ -19,7 +19,7 @@ campaign schema and do not require production data.
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. |
| `blocked-send` | core, access, campaigns | Confirm blocked recipients or missing attachments cannot be queued. |
| `mock-delivery` | core, access, campaigns, mail with dev capability | Capture messages in the development mailbox. |
| `greenmail-delivery` | core, access, campaigns, mail | Send no-attachment, normal attachment, and ZIP attachment variants through `dev/mail-testbed`. |
| [`greenmail-delivery`](greenmail-delivery/campaign.json) | core, access, audit, campaigns, mail | Run a credential-free Campaign through a Mail-owned profile, GreenMail SMTP/IMAP, report/audit checks, repeat-send protection, and bounded failure drills. |
## Fixture Rules
@@ -37,9 +37,14 @@ campaign schema and do not require production data.
Before a release tag:
1. Run module permutation startup checks from core.
2. Validate every committed example fixture against the current campaign schema.
3. Build exact messages for each fixture.
4. Run the mock-delivery example when the dev mailbox capability is enabled.
5. Run `dev/mail-testbed/run_transport_smoke.py`.
6. Execute the delivery checklist in
2. Run `python -m unittest discover -s tests -p 'test_example_campaigns.py'`
from this repository. The acceptance test copies each maintained fixture to
an unrelated temporary workspace before using Campaign's public loader,
validator, and message builder.
3. Validate every committed example fixture against the current campaign schema.
4. Build exact messages for each fixture.
5. Run the mock-delivery example when the dev mailbox capability is enabled.
6. Run `dev/mail-testbed/run_transport_smoke.py` for low-level transport and attachment variants.
7. Run `dev/mail-testbed/run_campaign_acceptance.py` for the Campaign journey and bounded evidence.
8. Execute the delivery checklist in
`docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md`.

View File

@@ -0,0 +1,88 @@
{
"version": "1.0",
"campaign": {
"id": "greenmail-delivery",
"name": "GreenMail delivery acceptance",
"description": "Credential-free Campaign fixture for the local SMTP/IMAP acceptance test bed.",
"mode": "test"
},
"fields": [
{
"name": "display_name",
"type": "string",
"label": "Display name",
"required": true
},
{
"name": "acceptance_run",
"type": "string",
"label": "Acceptance run",
"required": true
}
],
"server": {
"mail_profile_id": "00000000-0000-4000-8000-000000000001"
},
"recipients": {
"from": [
{
"email": "campaign-test@govoplan.test",
"name": "GovOPlaN acceptance",
"type": "to"
}
],
"allow_individual_to": true
},
"template": {
"subject": "[GovOPlaN acceptance ${acceptance_run}] Campaign delivery",
"text": "Hello ${display_name},\n\nThis is an isolated GovOPlaN Campaign SMTP/IMAP acceptance message.\n",
"body_mode": "text"
},
"attachments": {
"base_path": ".",
"send_without_attachments_behavior": "continue",
"global": []
},
"entries": {
"inline": [
{
"id": "greenmail-recipient",
"to": [
{
"email": "campaign-test@govoplan.test",
"name": "GreenMail recipient",
"type": "to"
}
],
"fields": {
"display_name": "GreenMail recipient",
"acceptance_run": "fixture"
}
}
]
},
"validation_policy": {
"missing_email": "block",
"template_error": "block"
},
"delivery": {
"rate_limit": {
"messages_per_minute": 60
},
"retry": {
"max_attempts": 3,
"backoff_seconds": [
1,
5,
30
]
},
"imap_append_sent": {
"enabled": true,
"folder": "Sent"
}
},
"status_tracking": {
"enabled": true
}
}

View File

@@ -0,0 +1,22 @@
{
"scenario": "greenmail-delivery",
"campaign_file": "campaign.json",
"required_modules": [
"core",
"access",
"audit",
"campaigns",
"mail"
],
"required_capabilities": [
"mail.campaign_delivery"
],
"transport": "local GreenMail SMTP/IMAP test bed",
"credentials": "local environment only; never copied into Campaign JSON or evidence",
"expected": {
"entries_count": 1,
"built_count": 1,
"smtp_accepted_count": 1,
"imap_appended_count": 1
}
}

View File

@@ -0,0 +1,54 @@
{
"version": "1.0",
"campaign": {
"id": "simple-announcement",
"name": "Simple announcement",
"description": "Credential-free release fixture for Campaign validation and message building.",
"mode": "test"
},
"fields": [
{
"name": "display_name",
"type": "string",
"label": "Display name",
"required": true
}
],
"recipients": {
"from": [
{
"email": "announcements@example.test",
"name": "GovOPlaN Example",
"type": "to"
}
],
"allow_individual_to": true
},
"template": {
"subject": "Planned service maintenance for ${display_name}",
"text": "Hello ${display_name},\n\nThe example service will be unavailable during the announced maintenance window.\n\nThis message was built locally and was not sent.\n",
"body_mode": "text"
},
"attachments": {
"base_path": ".",
"send_without_attachments_behavior": "continue",
"global": []
},
"entries": {
"inline": [
{
"id": "example-recipient",
"to": [
{
"email": "recipient@example.test",
"name": "Example Recipient",
"type": "to"
}
],
"fields": {
"display_name": "Example Recipient"
}
}
]
}
}

View File

@@ -0,0 +1,23 @@
{
"schema_version": 1,
"id": "simple-announcement",
"campaign_file": "campaign.json",
"required_modules": [
"core",
"access",
"campaigns"
],
"absent_optional_modules": [
"files",
"mail"
],
"external_effects": "forbidden",
"expected": {
"campaign_id": "simple-announcement",
"entries_count": 1,
"built_count": 1,
"queueable_count": 1,
"attachment_count": 0,
"subject": "Planned service maintenance for Example Recipient"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.9",
"version": "0.1.11",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -22,7 +22,7 @@
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.9",
"@govoplan/core-webui": "^0.1.12",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.9"
version = "0.1.11"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -89,6 +89,7 @@ class BuildStatus(StrEnum):
class SendStatus(StrEnum):
DRAFT = "draft"
QUEUED = "queued"
SKIPPED = "skipped"
class CampaignMeta(StrictModel):

View File

@@ -279,6 +279,12 @@ class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider):
def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService:
from govoplan_campaign.backend.runtime import configure_runtime
configure_runtime(
registry=getattr(context, "registry", None),
settings=getattr(context, "settings", None),
)
return CampaignDeliveryTaskService()

View File

@@ -78,6 +78,7 @@ class JobQueueStatus(StrEnum):
class JobSendStatus(StrEnum):
NOT_QUEUED = "not_queued"
SKIPPED = "skipped"
QUEUED = "queued"
CLAIMED = "claimed"
SENDING = "sending"
@@ -212,6 +213,8 @@ class CampaignVersion(Base, TimestampMixin):
execution_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
execution_snapshot_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
delivery_mode: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
delivery_mode_selected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
campaign: Mapped[Campaign] = relationship(back_populates="versions")

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Mapping
from sqlalchemy.orm import Session
from govoplan_core.tenancy.scope import Tenant
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS = 25
ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS = 500
SYNCHRONOUS_SEND_MAX_ENV = "GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS"
CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY = "campaign_delivery_policy"
SYNCHRONOUS_SEND_MAX_SETTINGS_KEY = "synchronous_send_max_recipients"
class CampaignDeliveryPolicyError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class SynchronousSendPolicy:
max_recipient_jobs: int
source: str
deployment_max_recipient_jobs: int
tenant_max_recipient_jobs: int | None = None
def as_dict(self) -> dict[str, Any]:
return {
"max_recipient_jobs": self.max_recipient_jobs,
"source": self.source,
"deployment_max_recipient_jobs": self.deployment_max_recipient_jobs,
"tenant_max_recipient_jobs": self.tenant_max_recipient_jobs,
"deployment_setting": SYNCHRONOUS_SEND_MAX_ENV,
"tenant_setting": (
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
),
}
def effective_synchronous_send_policy(
session: Session,
*,
tenant_id: str,
environ: Mapping[str, str] | None = None,
) -> SynchronousSendPolicy:
env = os.environ if environ is None else environ
deployment_value = _configured_limit(
env.get(SYNCHRONOUS_SEND_MAX_ENV),
source=SYNCHRONOUS_SEND_MAX_ENV,
default=DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
)
tenant = session.get(Tenant, tenant_id)
tenant_raw = _tenant_limit_value(tenant.settings if tenant is not None else None)
if tenant_raw is None:
return SynchronousSendPolicy(
max_recipient_jobs=deployment_value,
source=("deployment" if env.get(SYNCHRONOUS_SEND_MAX_ENV) not in (None, "") else "deployment_default"),
deployment_max_recipient_jobs=deployment_value,
)
tenant_value = _configured_limit(
tenant_raw,
source=(
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
),
)
effective_value = min(deployment_value, tenant_value)
return SynchronousSendPolicy(
max_recipient_jobs=effective_value,
source="tenant" if tenant_value <= deployment_value else "deployment_ceiling",
deployment_max_recipient_jobs=deployment_value,
tenant_max_recipient_jobs=tenant_value,
)
def _tenant_limit_value(settings: Mapping[str, Any] | None) -> object | None:
if not isinstance(settings, Mapping):
return None
policy = settings.get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY)
if not isinstance(policy, Mapping):
return None
return policy.get(SYNCHRONOUS_SEND_MAX_SETTINGS_KEY)
def _configured_limit(value: object, *, source: str, default: int | None = None) -> int:
if value is None or (isinstance(value, str) and not value.strip()):
if default is not None:
return default
raise CampaignDeliveryPolicyError(f"{source} must be configured as an integer")
if isinstance(value, bool):
raise CampaignDeliveryPolicyError(f"{source} must be an integer, not a boolean")
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise CampaignDeliveryPolicyError(f"{source} must be an integer") from exc
if str(parsed) != str(value).strip() and not isinstance(value, int):
raise CampaignDeliveryPolicyError(f"{source} must be an integer")
if parsed < 0 or parsed > ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS:
raise CampaignDeliveryPolicyError(
f"{source} must be between 0 and {ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS}"
)
return parsed

View File

@@ -2,6 +2,11 @@ from __future__ import annotations
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError,
effective_synchronous_send_policy,
)
_CAMPAIGN_USER_SCOPES = (
"campaigns:campaign:read",
@@ -278,21 +283,24 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
steps=(
"Open Review and send and confirm the selected version, build, recipients, warnings, and review completion.",
"Use an authorized supporting client to run the queue dry run and resolve every blocker.",
"Invoke the Queue action through that client so background workers can process the build.",
"Open the campaign Report and monitor queue, SMTP, IMAP, failure, and uncertain-outcome counts.",
"Select Queue for workers and confirm the exact durable execution that will be committed.",
"Remain on Review and send or leave and return later; both views read the same durable job states.",
"Use the delivery controls to pause, resume, cancel unsent work, or retry eligible failures without requeueing accepted or uncertain outcomes.",
),
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
),
related_modules=("mail", "notifications"),
limitations=("The current Campaign Web UI does not expose the Queue action; use an authorized supporting client or API.",),
),
_workflow_topic(
topic_id="campaigns.workflow.send-small-controlled-run",
title="Send a campaign immediately",
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
body="The current synchronous action does not enforce a server-side job-count limit. It must not replace the worker queue for ordinary batches, but it does preserve the same protected SMTP, IMAP, retry, and uncertain-outcome rules.",
body="Send now is protected by an effective deployment/tenant recipient-job maximum. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect.",
order=36,
audience=("campaign_sender", "campaign_operator"),
required_modules=("campaigns", "mail"),
@@ -308,12 +316,12 @@ CAMPAIGN_USER_DOCUMENTATION = (
help_contexts=("campaign.review-send",),
prerequisites=(
"The selected version is validated, locked, built, and reviewed.",
"An operator has deliberately confirmed that this is a small controlled run; the server does not currently enforce that bound.",
"The exact eligible recipient-job count is within the effective limit shown on Review and send.",
"Its Mail profile remains available and authorized for the current campaign context.",
),
steps=(
"Open Review and send and inspect the exact build and delivery readiness checks.",
"Confirm that this is an intentionally small controlled run; use Queue for an ordinary batch.",
"Review the effective synchronous limit and exact eligible count; use Queue for workers when Send now is unavailable or for an ordinary batch.",
"Select Send now and confirm the protected delivery action.",
"Open Report and inspect each resulting delivery state before attempting any recovery action.",
),
@@ -321,14 +329,36 @@ CAMPAIGN_USER_DOCUMENTATION = (
verification="The Report distinguishes accepted, failed, unattempted, cancelled, and outcome-unknown jobs and retains their attempt history.",
related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"),
related_modules=("mail",),
limitations=("No server-side synchronous job-count limit is currently enforced; use Queue and workers for ordinary batches.",),
),
_workflow_topic(
topic_id="campaigns.workflow.view-aggregate-delivery-report",
title="Review aggregate campaign outcomes",
summary="Inspect privacy-protected Campaign totals without receiving recipient rows, message content, delivery diagnostics, or export authority.",
body="The aggregate Reports view exposes only approved campaign-level business outcomes. Positive cells below the configured threshold are suppressed together with a complementary value or the denominator when needed, so totals cannot be subtracted to recover a small group.",
order=37,
audience=("campaign_aggregate_reader", "campaign_reader", "campaign_manager"),
required_scopes=("campaigns:report:read",),
route="/reports",
screen="Reports",
help_contexts=("campaign.report",),
prerequisites=("The campaign is owned by or explicitly shared with you, or you hold tenant-wide authority.",),
steps=(
"Open Reports and select a campaign available to you.",
"Read the stated denominator before comparing accepted, failed, unknown, active, excluded, cancelled, and unattempted outcomes.",
"Treat Suppressed as an intentional privacy boundary rather than zero or missing data.",
"Request separately authorized recipient-level access only when the task genuinely requires individual evidence.",
),
outcome="A business-level Campaign outcome view with small-group and recipient privacy preserved.",
verification="No row, address, message, attachment, diagnostic, filter, drill-down, or export action is available from the aggregate view.",
related_topic_ids=("campaigns.workflow.view-delivery-report",),
links=(DocumentationLink(label="Aggregate Campaign reports", href="/reports", kind="runtime"),),
),
_workflow_topic(
topic_id="campaigns.workflow.view-delivery-report",
title="Review campaign delivery details",
summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.",
body="The current Report UI requires recipient-read authority in addition to report access. Infrastructure diagnostics remain separately authorized, and every report action is still checked by the server even where the current UI has not yet hidden an unavailable control.",
order=37,
body="The recipient-aware Campaign Report requires campaign-read, report-read, and recipient-read authority. Infrastructure diagnostics remain separately authorized, and the server checks every direct detail route independently of the interface.",
order=38,
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
route="/campaigns/{campaign_id}/report",
@@ -337,21 +367,20 @@ CAMPAIGN_USER_DOCUMENTATION = (
prerequisites=("You may read the selected campaign and its report.",),
steps=(
"Open the campaign and select Report.",
"Review totals for queued, accepted, failed, cancelled, unattempted, and outcome-unknown jobs.",
"Review totals for queued, accepted, failed, cancelled, excluded/skipped, unattempted, and outcome-unknown jobs.",
"Inspect the authorized recipient-level rows and attempt history.",
"Treat outcome-unknown jobs as unresolved and hand them to an authorized operator for evidence-backed reconciliation.",
),
outcome="A recipient-aware view of delivery progress and outcomes with diagnostics still separately protected.",
verification="The report totals and job rows match the selected campaign version, and infrastructure diagnostics are absent unless separately authorized.",
related_topic_ids=("campaigns.workflow.retry-and-reconcile", "campaigns.workflow.export-delivery-report"),
limitations=("An aggregate-only Report Web UI for readers without recipient-read authority is not implemented yet.",),
related_topic_ids=("campaigns.workflow.view-aggregate-delivery-report", "campaigns.workflow.retry-and-reconcile", "campaigns.workflow.export-delivery-report"),
),
_workflow_topic(
topic_id="campaigns.workflow.export-delivery-report",
title="Export recipient delivery results",
summary="Download an authorized CSV snapshot of recipient-level campaign delivery results for controlled downstream use.",
body="A report export contains personal and delivery evidence. Store, transmit, retain, and delete it according to the campaign's purpose and the applicable export and retention policy.",
order=38,
order=39,
audience=("campaign_report_exporter", "campaign_auditor"),
required_scopes=(
"campaigns:campaign:read",
@@ -481,10 +510,13 @@ def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTo
current_configuration = list(_actor_capabilities(principal, mail_available=mail_available))
integration_configuration, integration_limitations = _integration_summary(context.registry, principal)
current_configuration.extend(integration_configuration)
delivery_configuration, delivery_limitations = _delivery_policy_summary(context)
current_configuration.extend(delivery_configuration)
limitations = [
"Access to a particular campaign still depends on its owner or sharing rules and on the campaign's current state.",
"Profile, file, and address pickers re-evaluate the actual resources visible in the selected campaign; this overview does not claim that an eligible item currently exists.",
*integration_limitations,
*delivery_limitations,
]
return (
@@ -640,6 +672,24 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
return tuple(configured), tuple(limitations)
def _delivery_policy_summary(context: DocumentationContext) -> tuple[tuple[str, ...], tuple[str, ...]]:
session = context.session
tenant_id = str(getattr(context.principal, "tenant_id", "") or "").strip()
if session is None or not tenant_id:
return (), ("The effective synchronous Campaign limit could not be resolved for this documentation request.",)
try:
policy = effective_synchronous_send_policy(session, tenant_id=tenant_id) # type: ignore[arg-type]
except CampaignDeliveryPolicyError as exc:
return (), (f"Synchronous Campaign delivery is disabled until its policy configuration is corrected: {exc}",)
return (
(
"Delivery policy: Send now is limited to "
f"{policy.max_recipient_jobs} eligible recipient job(s) for this tenant. "
"The exact built count and Queue for workers alternative are shown before confirmation."
),
), ()
def _append_if(
target: list[str],
principal: object,

View File

@@ -75,6 +75,14 @@ PERMISSIONS = (
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="campaign_aggregate_reader",
name="Campaign aggregate reader",
description="View privacy-protected outcome totals without recipient or delivery diagnostics.",
permissions=(
"campaigns:report:read",
),
),
RoleTemplate(
slug="campaign_manager",
name="Campaign manager",
@@ -148,7 +156,7 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.9",
version="0.1.11",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("files", "mail", "notifications", "addresses"),
provides_interfaces=(
@@ -194,12 +202,12 @@ manifest = ModuleManifest(
path="/operator",
label="Operator Queue",
icon="radio-tower",
required_all=("campaigns:campaign:read",),
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:campaign:control",
"campaigns:campaign:send",
),
order=30,
),
@@ -214,12 +222,12 @@ manifest = ModuleManifest(
path="/operator",
label="Operator Queue",
icon="radio-tower",
required_all=("campaigns:campaign:read",),
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:campaign:control",
"campaigns:campaign:send",
),
order=30,
),

View File

@@ -513,6 +513,12 @@ def _message_draft(
eml_path: str | None = None,
eml_size: int | None = None,
) -> MessageDraft:
if validation_status == MessageValidationStatus.EXCLUDED:
# Exclusion is a completed validation decision, not a pending delivery.
# Keep both transport projections explicit so reports never imply that
# SMTP or IMAP work is still expected for this row.
send_status = SendStatus.SKIPPED
imap_status = ImapStatus.SKIPPED
if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
return MessageDraft(

View File

@@ -0,0 +1,30 @@
"""persist the selected Campaign delivery mode
Revision ID: c7a2f91e4b60
Revises: 4d5e6f7a9203
Create Date: 2026-07-22 09:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c7a2f91e4b60"
down_revision = "4d5e6f7a9203"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("campaign_versions") as batch:
batch.add_column(sa.Column("delivery_mode", sa.String(length=30), nullable=True))
batch.add_column(sa.Column("delivery_mode_selected_at", sa.DateTime(timezone=True), nullable=True))
batch.create_index("ix_campaign_versions_delivery_mode", ["delivery_mode"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("campaign_versions") as batch:
batch.drop_index("ix_campaign_versions_delivery_mode")
batch.drop_column("delivery_mode_selected_at")
batch.drop_column("delivery_mode")

View File

@@ -0,0 +1,42 @@
"""mark untouched excluded jobs as skipped delivery
Revision ID: d8b3e2c1f4a5
Revises: c7a2f91e4b60
Create Date: 2026-07-22 11:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d8b3e2c1f4a5"
down_revision = "c7a2f91e4b60"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Only normalize rows with no recorded transport effect. Unexpected
# historical delivery evidence must remain intact for audit/reconciliation.
op.get_bind().execute(
sa.text(
"UPDATE campaign_jobs "
"SET send_status = 'skipped', imap_status = 'skipped' "
"WHERE validation_status = 'excluded' "
"AND send_status = 'not_queued' "
"AND imap_status IN ('not_requested', 'pending', 'skipped')"
)
)
def downgrade() -> None:
op.get_bind().execute(
sa.text(
"UPDATE campaign_jobs "
"SET send_status = 'not_queued', imap_status = 'not_requested' "
"WHERE validation_status = 'excluded' "
"AND send_status = 'skipped' "
"AND imap_status = 'skipped'"
)
)

View File

@@ -0,0 +1,30 @@
"""persist the selected Campaign delivery mode
Revision ID: c7a2f91e4b60
Revises: 4d5e6f7a9203
Create Date: 2026-07-22 09:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c7a2f91e4b60"
down_revision = "4d5e6f7a9203"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("campaign_versions") as batch:
batch.add_column(sa.Column("delivery_mode", sa.String(length=30), nullable=True))
batch.add_column(sa.Column("delivery_mode_selected_at", sa.DateTime(timezone=True), nullable=True))
batch.create_index("ix_campaign_versions_delivery_mode", ["delivery_mode"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("campaign_versions") as batch:
batch.drop_index("ix_campaign_versions_delivery_mode")
batch.drop_column("delivery_mode_selected_at")
batch.drop_column("delivery_mode")

View File

@@ -0,0 +1,42 @@
"""mark untouched excluded jobs as skipped delivery
Revision ID: d8b3e2c1f4a5
Revises: c7a2f91e4b60
Create Date: 2026-07-22 11:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d8b3e2c1f4a5"
down_revision = "c7a2f91e4b60"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Only normalize rows with no recorded transport effect. Unexpected
# historical delivery evidence must remain intact for audit/reconciliation.
op.get_bind().execute(
sa.text(
"UPDATE campaign_jobs "
"SET send_status = 'skipped', imap_status = 'skipped' "
"WHERE validation_status = 'excluded' "
"AND send_status = 'not_queued' "
"AND imap_status IN ('not_requested', 'pending', 'skipped')"
)
)
def downgrade() -> None:
op.get_bind().execute(
sa.text(
"UPDATE campaign_jobs "
"SET send_status = 'not_queued', imap_status = 'not_requested' "
"WHERE validation_status = 'excluded' "
"AND send_status = 'skipped' "
"AND imap_status = 'skipped'"
)
)

View File

@@ -35,7 +35,7 @@ from govoplan_campaign.backend.campaign.validation import validate_campaign_conf
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.models import CampaignConfig, SendStatus
from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
@@ -406,7 +406,11 @@ def _job_from_message(
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status),
validation_status=_job_validation_status(message.validation_status.value),
queue_status=JobQueueStatus.DRAFT.value,
send_status=JobSendStatus.NOT_QUEUED.value,
send_status=(
JobSendStatus.SKIPPED.value
if message.send_status == SendStatus.SKIPPED
else JobSendStatus.NOT_QUEUED.value
),
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
resolved_recipients={
"from": message.from_.model_dump(mode="json") if message.from_ else None,

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Mapping
from sqlalchemy.orm import Session
from govoplan_core.tenancy.scope import Tenant
DEFAULT_SMALL_CELL_THRESHOLD = 5
MIN_SMALL_CELL_THRESHOLD = 2
MAX_SMALL_CELL_THRESHOLD = 100
SMALL_CELL_THRESHOLD_ENV = "GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD"
CAMPAIGN_REPORT_POLICY_SETTINGS_KEY = "campaign_report_privacy_policy"
SMALL_CELL_THRESHOLD_SETTINGS_KEY = "small_cell_threshold"
class CampaignReportPrivacyPolicyError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class CampaignReportPrivacyPolicy:
small_cell_threshold: int
source: str
deployment_small_cell_threshold: int
tenant_small_cell_threshold: int | None = None
def as_dict(self) -> dict[str, Any]:
return {
"small_cell_threshold": self.small_cell_threshold,
"source": self.source,
"deployment_small_cell_threshold": self.deployment_small_cell_threshold,
"tenant_small_cell_threshold": self.tenant_small_cell_threshold,
"deployment_setting": SMALL_CELL_THRESHOLD_ENV,
"tenant_setting": (
f"tenant.settings.{CAMPAIGN_REPORT_POLICY_SETTINGS_KEY}."
f"{SMALL_CELL_THRESHOLD_SETTINGS_KEY}"
),
}
def effective_campaign_report_privacy_policy(
session: Session,
*,
tenant_id: str,
environ: Mapping[str, str] | None = None,
) -> CampaignReportPrivacyPolicy:
env = os.environ if environ is None else environ
deployment_raw = env.get(SMALL_CELL_THRESHOLD_ENV)
deployment_value = _configured_threshold(
deployment_raw,
source=SMALL_CELL_THRESHOLD_ENV,
default=DEFAULT_SMALL_CELL_THRESHOLD,
)
tenant = session.get(Tenant, tenant_id)
tenant_raw = _tenant_threshold_value(tenant.settings if tenant is not None else None)
if tenant_raw is None:
return CampaignReportPrivacyPolicy(
small_cell_threshold=deployment_value,
source="deployment" if deployment_raw not in (None, "") else "deployment_default",
deployment_small_cell_threshold=deployment_value,
)
tenant_value = _configured_threshold(
tenant_raw,
source=(
f"tenant.settings.{CAMPAIGN_REPORT_POLICY_SETTINGS_KEY}."
f"{SMALL_CELL_THRESHOLD_SETTINGS_KEY}"
),
)
effective_value = max(deployment_value, tenant_value)
return CampaignReportPrivacyPolicy(
small_cell_threshold=effective_value,
source="tenant" if tenant_value >= deployment_value else "deployment_floor",
deployment_small_cell_threshold=deployment_value,
tenant_small_cell_threshold=tenant_value,
)
def _tenant_threshold_value(settings: Mapping[str, Any] | None) -> object | None:
if not isinstance(settings, Mapping):
return None
policy = settings.get(CAMPAIGN_REPORT_POLICY_SETTINGS_KEY)
if not isinstance(policy, Mapping):
return None
return policy.get(SMALL_CELL_THRESHOLD_SETTINGS_KEY)
def _configured_threshold(value: object, *, source: str, default: int | None = None) -> int:
if value is None or (isinstance(value, str) and not value.strip()):
if default is not None:
return default
raise CampaignReportPrivacyPolicyError(f"{source} must be configured as an integer")
if isinstance(value, bool):
raise CampaignReportPrivacyPolicyError(f"{source} must be an integer, not a boolean")
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise CampaignReportPrivacyPolicyError(f"{source} must be an integer") from exc
if str(parsed) != str(value).strip() and not isinstance(value, int):
raise CampaignReportPrivacyPolicyError(f"{source} must be an integer")
if parsed < MIN_SMALL_CELL_THRESHOLD or parsed > MAX_SMALL_CELL_THRESHOLD:
raise CampaignReportPrivacyPolicyError(
f"{source} must be between {MIN_SMALL_CELL_THRESHOLD} and {MAX_SMALL_CELL_THRESHOLD}"
)
return parsed

View File

@@ -0,0 +1,490 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal
from pydantic import BaseModel, ConfigDict
from sqlalchemy import case, func, or_
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.report_privacy_policy import (
CampaignReportPrivacyPolicy,
effective_campaign_report_privacy_policy,
)
class AggregateCampaignReportError(RuntimeError):
pass
class AggregateReportCampaign(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
name: str
status: str
class AggregateReportCampaignListItem(AggregateReportCampaign):
updated_at: datetime
class AggregateReportCampaignList(BaseModel):
model_config = ConfigDict(extra="forbid")
campaigns: list[AggregateReportCampaignListItem]
class AggregateCount(BaseModel):
model_config = ConfigDict(extra="forbid")
value: int | None
suppressed: bool = False
class AggregatePopulation(BaseModel):
model_config = ConfigDict(extra="forbid")
denominator: AggregateCount
denominator_definition: str
inactive_source_entries: AggregateCount
excluded_or_blocked_jobs: AggregateCount
class AggregateOutcomeCounts(BaseModel):
model_config = ConfigDict(extra="forbid")
smtp_accepted: AggregateCount
failed: AggregateCount
outcome_unknown: AggregateCount
queued_or_active: AggregateCount
cancelled: AggregateCount
excluded: AggregateCount
not_attempted: AggregateCount
class AggregateTimeRange(BaseModel):
model_config = ConfigDict(extra="forbid")
first_activity_at: datetime | None
last_activity_at: datetime | None
suppressed: bool
class AggregatePrivacy(BaseModel):
model_config = ConfigDict(extra="forbid")
small_cell_threshold: int
suppression_applied: bool
rule: str
class AggregateCampaignReport(BaseModel):
model_config = ConfigDict(extra="forbid")
generated_at: datetime
campaign: AggregateReportCampaign
version_number: int | None
completion_state: Literal[
"not_started",
"in_progress",
"completed",
"partially_completed",
"incomplete",
"outcome_unknown",
"suppressed",
]
population: AggregatePopulation
outcomes: AggregateOutcomeCounts
time_range: AggregateTimeRange
privacy: AggregatePrivacy
_OUTCOME_KEYS = (
"smtp_accepted",
"failed",
"outcome_unknown",
"queued_or_active",
"cancelled",
"excluded",
"not_attempted",
)
def generate_aggregate_campaign_report(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
) -> AggregateCampaignReport:
"""Build the deliberately small, recipient-free Campaign report projection."""
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
version = _selected_version(session, campaign, version_id)
facts = _query_aggregate_facts(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
)
policy = effective_campaign_report_privacy_policy(session, tenant_id=tenant_id)
return _build_aggregate_campaign_report(
campaign=campaign,
version=version,
facts=facts,
policy=policy,
)
def _get_campaign(session: Session, *, tenant_id: str, campaign_id: str) -> Campaign:
campaign = (
session.query(Campaign)
.filter(Campaign.tenant_id == tenant_id, Campaign.id == campaign_id)
.one_or_none()
)
if campaign is None:
raise AggregateCampaignReportError("Campaign not found")
return campaign
def _selected_version(
session: Session,
campaign: Campaign,
version_id: str | None,
) -> CampaignVersion | None:
selected_id = version_id or campaign.current_version_id
if not selected_id:
return None
version = session.get(CampaignVersion, selected_id)
if version is None or version.campaign_id != campaign.id:
raise AggregateCampaignReportError("Campaign version not found")
return version
def _query_aggregate_facts(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> _AggregateFacts:
if version is None:
return _AggregateFacts.empty()
accepted = CampaignJob.send_status.in_({"smtp_accepted", "sent"})
failed = CampaignJob.send_status.in_({"failed_temporary", "failed_permanent"})
unknown = CampaignJob.send_status == "outcome_unknown"
active = CampaignJob.send_status.in_({"queued", "claimed", "sending"})
cancelled = CampaignJob.send_status == "cancelled"
excluded = CampaignJob.send_status == "skipped"
excluded_or_blocked = or_(
CampaignJob.validation_status.in_({"blocked", "excluded", "inactive"}),
CampaignJob.build_status != "built",
)
row = (
session.query(
func.count(CampaignJob.id).label("denominator"),
func.sum(case((accepted, 1), else_=0)).label("smtp_accepted"),
func.sum(case((failed, 1), else_=0)).label("failed"),
func.sum(case((unknown, 1), else_=0)).label("outcome_unknown"),
func.sum(case((active, 1), else_=0)).label("queued_or_active"),
func.sum(case((cancelled, 1), else_=0)).label("cancelled"),
func.sum(case((excluded, 1), else_=0)).label("excluded"),
func.sum(
case((or_(accepted, failed, unknown, active, cancelled, excluded), 0), else_=1)
).label("not_attempted"),
func.sum(case((excluded_or_blocked, 1), else_=0)).label("excluded_or_blocked"),
func.min(CampaignJob.queued_at).label("queued_min"),
func.max(CampaignJob.queued_at).label("queued_max"),
func.min(CampaignJob.smtp_started_at).label("smtp_min"),
func.max(CampaignJob.smtp_started_at).label("smtp_max"),
func.min(CampaignJob.sent_at).label("sent_min"),
func.max(CampaignJob.sent_at).label("sent_max"),
func.min(CampaignJob.outcome_unknown_at).label("unknown_min"),
func.max(CampaignJob.outcome_unknown_at).label("unknown_max"),
)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id,
CampaignJob.campaign_version_id == version.id,
)
.one()
)
values = row._mapping
activity = [
values[key]
for key in (
"queued_min",
"queued_max",
"smtp_min",
"smtp_max",
"sent_min",
"sent_max",
"unknown_min",
"unknown_max",
)
if values[key] is not None
]
return _AggregateFacts(
outcomes={key: int(values[key] or 0) for key in _OUTCOME_KEYS},
denominator=int(values["denominator"] or 0),
excluded_or_blocked=int(values["excluded_or_blocked"] or 0),
first_activity_at=min(activity) if activity else None,
last_activity_at=max(activity) if activity else None,
)
def build_aggregate_campaign_report(
*,
campaign: Campaign,
version: CampaignVersion | None,
jobs: list[CampaignJob],
policy: CampaignReportPrivacyPolicy,
generated_at: datetime | None = None,
) -> AggregateCampaignReport:
return _build_aggregate_campaign_report(
campaign=campaign,
version=version,
facts=_facts_from_jobs(jobs),
policy=policy,
generated_at=generated_at,
)
def _build_aggregate_campaign_report(
*,
campaign: Campaign,
version: CampaignVersion | None,
facts: _AggregateFacts,
policy: CampaignReportPrivacyPolicy,
generated_at: datetime | None = None,
) -> AggregateCampaignReport:
outcome_values = facts.outcomes
outcomes, denominator = _suppress_partition(
outcome_values,
threshold=policy.small_cell_threshold,
)
outcome_suppression_applied = denominator.suppressed or any(
item.suppressed for item in outcomes.values()
)
inactive_entries = _inactive_entry_count(version)
standalone_counts = {
"inactive_source_entries": _suppress_standalone_count(
inactive_entries,
threshold=policy.small_cell_threshold,
),
# This population count overlaps the outcome partition, so exposing it
# can make a suppressed outcome recoverable through subtraction.
"excluded_or_blocked_jobs": (
AggregateCount(value=None, suppressed=True)
if outcome_suppression_applied
else _suppress_standalone_count(
facts.excluded_or_blocked,
threshold=policy.small_cell_threshold,
)
),
}
suppression_applied = denominator.suppressed or any(
item.suppressed for item in (*outcomes.values(), *standalone_counts.values())
)
first_activity = facts.first_activity_at
last_activity = facts.last_activity_at
suppress_time_range = suppression_applied or (
0 < facts.denominator < policy.small_cell_threshold
)
return AggregateCampaignReport(
generated_at=generated_at or datetime.now(timezone.utc),
campaign=AggregateReportCampaign(
id=campaign.id,
name=campaign.name,
status=campaign.status,
),
version_number=version.version_number if version else None,
completion_state=(
"suppressed"
if denominator.suppressed
else _completion_state(outcome_values, facts.denominator)
),
population=AggregatePopulation(
denominator=denominator,
denominator_definition=(
"All persisted recipient delivery jobs for the selected campaign version, "
"including excluded or blocked jobs. Inactive source entries without a job "
"record are excluded and reported separately."
),
inactive_source_entries=standalone_counts["inactive_source_entries"],
excluded_or_blocked_jobs=standalone_counts["excluded_or_blocked_jobs"],
),
outcomes=AggregateOutcomeCounts(**outcomes),
time_range=AggregateTimeRange(
first_activity_at=None if suppress_time_range else first_activity,
last_activity_at=None if suppress_time_range else last_activity,
suppressed=suppress_time_range and first_activity is not None,
),
privacy=AggregatePrivacy(
small_cell_threshold=policy.small_cell_threshold,
suppression_applied=suppression_applied,
rule=(
"Positive counts below the threshold are hidden. At least one additional "
"count or the denominator is hidden when needed to prevent subtraction. "
"Overlapping population counts are hidden whenever outcome suppression applies."
),
),
)
def aggregate_report_campaign_item(campaign: Campaign) -> AggregateReportCampaignListItem:
return AggregateReportCampaignListItem(
id=campaign.id,
name=campaign.name,
status=campaign.status,
updated_at=campaign.updated_at,
)
def _outcome_counts(jobs: list[CampaignJob]) -> dict[str, int]:
counts: Counter[str] = Counter()
for job in jobs:
status = job.send_status
if status in {"smtp_accepted", "sent"}:
counts["smtp_accepted"] += 1
elif status in {"failed_temporary", "failed_permanent"}:
counts["failed"] += 1
elif status == "outcome_unknown":
counts["outcome_unknown"] += 1
elif status in {"queued", "claimed", "sending"}:
counts["queued_or_active"] += 1
elif status == "cancelled":
counts["cancelled"] += 1
elif status == "skipped":
counts["excluded"] += 1
else:
counts["not_attempted"] += 1
return {key: counts[key] for key in _OUTCOME_KEYS}
@dataclass(frozen=True, slots=True)
class _AggregateFacts:
outcomes: dict[str, int]
denominator: int
excluded_or_blocked: int
first_activity_at: datetime | None
last_activity_at: datetime | None
@classmethod
def empty(cls) -> _AggregateFacts:
return cls(
outcomes={key: 0 for key in _OUTCOME_KEYS},
denominator=0,
excluded_or_blocked=0,
first_activity_at=None,
last_activity_at=None,
)
def _facts_from_jobs(jobs: list[CampaignJob]) -> _AggregateFacts:
first_activity, last_activity = _activity_range(jobs)
return _AggregateFacts(
outcomes=_outcome_counts(jobs),
denominator=len(jobs),
excluded_or_blocked=sum(
1
for job in jobs
if job.validation_status in {"blocked", "excluded", "inactive"}
or job.build_status != "built"
),
first_activity_at=first_activity,
last_activity_at=last_activity,
)
def _suppress_partition(
counts: dict[str, int],
*,
threshold: int,
) -> tuple[dict[str, AggregateCount], AggregateCount]:
total = sum(counts.values())
suppressed = {key for key, value in counts.items() if 0 < value < threshold}
suppress_denominator = False
if suppressed:
companions = [
(value, key)
for key, value in counts.items()
if key not in suppressed and value > 0
]
if companions:
suppressed.add(max(companions)[1])
else:
suppress_denominator = True
denominator = AggregateCount(
value=None if suppress_denominator else total,
suppressed=suppress_denominator,
)
return (
{
key: AggregateCount(
value=None if key in suppressed else value,
suppressed=key in suppressed,
)
for key, value in counts.items()
},
denominator,
)
def _suppress_standalone_count(value: int, *, threshold: int) -> AggregateCount:
if 0 < value < threshold:
return AggregateCount(value=None, suppressed=True)
return AggregateCount(value=value, suppressed=False)
def _completion_state(
counts: dict[str, int],
total: int,
) -> Literal[
"not_started",
"in_progress",
"completed",
"partially_completed",
"incomplete",
"outcome_unknown",
]:
if total == 0 or counts["not_attempted"] + counts["excluded"] == total:
return "not_started"
if counts["outcome_unknown"]:
return "outcome_unknown"
if counts["queued_or_active"]:
return "in_progress"
accepted = counts["smtp_accepted"]
if accepted == total:
return "completed"
if accepted:
return "partially_completed"
return "incomplete"
def _activity_range(jobs: list[CampaignJob]) -> tuple[datetime | None, datetime | None]:
activity = [
value
for job in jobs
for value in (
job.queued_at,
job.smtp_started_at,
job.sent_at,
job.outcome_unknown_at,
)
if value is not None
]
if not activity:
return None, None
return min(activity), max(activity)
def _inactive_entry_count(version: CampaignVersion | None) -> int:
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)

View File

@@ -87,6 +87,10 @@ def _version_info(
),
"execution_snapshot_hash": version.execution_snapshot_hash,
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
"delivery_mode": version.delivery_mode,
"delivery_mode_selected_at": (
version.delivery_mode_selected_at.isoformat() if version.delivery_mode_selected_at else None
),
}
@@ -110,6 +114,12 @@ def _load_delivery_info(
"queueable_job_count": 0,
"estimated_remaining_send_seconds": None,
"estimated_remaining_send_human": None,
"delivery_mode": getattr(version, "delivery_mode", None) if version else None,
"delivery_mode_selected_at": (
getattr(version, "delivery_mode_selected_at", None).isoformat()
if version and getattr(version, "delivery_mode_selected_at", None)
else None
),
}
if include_diagnostics:
default.update(
@@ -159,6 +169,10 @@ def _load_delivery_info(
"queueable_job_count": snapshot.queueable_job_count,
"estimated_remaining_send_seconds": estimated_seconds,
"estimated_remaining_send_human": _human_duration(estimated_seconds),
"delivery_mode": version.delivery_mode,
"delivery_mode_selected_at": (
version.delivery_mode_selected_at.isoformat() if version.delivery_mode_selected_at else None
),
}
if include_diagnostics:
result.update(
@@ -544,6 +558,27 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
send_counts = _counter([job.send_status for job in jobs])
imap_counts = _counter([job.imap_status for job in jobs])
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
queueable_unattempted = sum(
1
for job in jobs
if job.attempt_count == 0
and job.send_status in {"not_queued", "cancelled"}
and job.validation_status in {"ready", "warning"}
and job.build_status == "built"
)
retry_max_attempts = _retry_max_attempts(version)
retryable = sum(
1
for job in jobs
if job.send_status == "failed_temporary"
and (retry_max_attempts is None or job.attempt_count < retry_max_attempts)
)
cancellable = sum(
1
for job in jobs
if job.send_status
not in {"skipped", "smtp_accepted", "sent", "outcome_unknown", "claimed", "sending", "cancelled"}
)
needs_attention = sum(
1
for job in jobs
@@ -555,6 +590,7 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0)
outcome_unknown = send_counts.get("outcome_unknown", 0)
not_attempted = send_counts.get("not_queued", 0)
skipped = send_counts.get("skipped", 0)
queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 0)
cancelled = send_counts.get("cancelled", 0)
inactive_entries = _inactive_entry_count(version)
@@ -562,20 +598,34 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
"jobs_total": len(jobs),
"inactive": inactive_entries,
"queueable": queueable,
"queueable_unattempted": queueable_unattempted,
"retryable": retryable,
"cancellable": cancellable,
"needs_attention": needs_attention,
"sent": sent,
"smtp_accepted": sent,
"failed": failed,
"outcome_unknown": outcome_unknown,
"not_attempted": not_attempted,
"skipped": skipped,
"queued_or_active": queued,
"cancelled": cancelled,
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
"imap_appended": imap_counts.get("appended", 0),
"imap_failed": imap_counts.get("failed", 0),
"imap_skipped": imap_counts.get("skipped", 0),
}
def _retry_max_attempts(version: CampaignVersion | None) -> int | None:
if version is None or not isinstance(version.execution_snapshot, dict):
return None
try:
return ExecutionSnapshot.model_validate(version.execution_snapshot).delivery.retry.max_attempts
except Exception:
return None
def _inactive_entry_count(version: CampaignVersion | None) -> int:
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)

View File

@@ -88,8 +88,10 @@ def _text_summary(report: dict[str, Any]) -> str:
f"- Needs attention: {cards['needs_attention']}",
f"- Sent: {cards['sent']}",
f"- Failed: {cards['failed']}",
f"- SMTP skipped (excluded): {cards.get('skipped', status.get('send', {}).get('skipped', 0))}",
f"- IMAP appended: {cards['imap_appended']}",
f"- IMAP failed: {cards['imap_failed']}",
f"- IMAP skipped: {cards.get('imap_skipped', status.get('imap', {}).get('skipped', 0))}",
"",
f"Build status: {status.get('build', {})}",
f"Validation status: {status.get('validation', {})}",

View File

@@ -40,6 +40,51 @@ CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS = frozenset(
}
)
_SEND_NOW_RESULT_KEYS = (
"campaign_id",
"version_id",
"attempted_count",
"sent_count",
"failed_count",
"outcome_unknown_count",
"skipped_count",
"preflight_count",
"delivery_mode",
"dry_run",
)
_SEND_NOW_JOB_RESULT_KEYS = (
"campaign_id",
"version_id",
"job_id",
"status",
"attempt_number",
"dry_run",
"queued_count",
"skipped_count",
"blocked_count",
"enqueued_count",
"delivery_mode",
"worker_queue_available",
)
_SYNCHRONOUS_POLICY_KEYS = (
"max_recipient_jobs",
"source",
"deployment_max_recipient_jobs",
"tenant_max_recipient_jobs",
)
_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count")
_BUILD_SUMMARY_KEYS = (
"built_count",
"build_failed_count",
"ready_count",
"warning_count",
"needs_review_count",
"blocked_count",
"excluded_count",
"inactive_count",
"queueable_count",
)
def public_campaign_payload(value: Any, *, include_diagnostics: bool = False) -> Any:
"""Return a detached payload without infrastructure-only locators."""
@@ -83,6 +128,59 @@ def public_delivery_result_message(
return "Delivery recorded a warning; an operator can inspect restricted diagnostics."
def public_send_campaign_now_result(
value: dict[str, Any],
*,
validation_summary: dict[str, Any],
build_summary: dict[str, Any],
) -> dict[str, Any]:
"""Project synchronous delivery into its recipient-authorized public contract.
Per-job provider messages are deliberately omitted. They can contain SMTP
diagnostics or refused envelope addresses and belong only in restricted
diagnostics backed by persisted job state.
"""
result = _selected_payload(value, _SEND_NOW_RESULT_KEYS)
policy = value.get("synchronous_send_policy")
result["synchronous_send_policy"] = _selected_payload(
policy if isinstance(policy, dict) else {},
_SYNCHRONOUS_POLICY_KEYS,
)
rows = value.get("results")
if isinstance(rows, list):
result["results"] = [
_selected_payload(row, _SEND_NOW_JOB_RESULT_KEYS)
for row in rows
if isinstance(row, dict)
]
else:
result["results"] = []
result["validation"] = _selected_payload(validation_summary, _VALIDATION_SUMMARY_KEYS)
result["build"] = _selected_payload(build_summary, _BUILD_SUMMARY_KEYS)
return result
def send_campaign_now_audit_details(value: dict[str, Any]) -> dict[str, Any]:
"""Return aggregate-only evidence for a synchronous Campaign send audit."""
details = _selected_payload(value, _SEND_NOW_RESULT_KEYS)
policy = value.get("synchronous_send_policy")
details["synchronous_send_policy"] = _selected_payload(
policy if isinstance(policy, dict) else {},
_SYNCHRONOUS_POLICY_KEYS,
)
return details
def _selected_payload(value: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
return {
key: copy.deepcopy(value[key])
for key in keys
if key in value
}
def public_campaign_configuration(value: Any) -> Any:
"""Return campaign JSON without infrastructure locators or mail secrets.

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
import copy
import dataclasses
import json
import logging
from collections.abc import Callable
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import and_, exists, func, or_
@@ -44,6 +45,7 @@ from govoplan_campaign.backend.schemas import (
CampaignSendJobRequest,
CampaignSendUnattemptedRequest,
CampaignResolveOutcomeRequest,
CampaignDeliveryOptionsResponse,
CampaignListResponse,
CampaignResponse,
CampaignVersionDetailResponse,
@@ -65,7 +67,7 @@ from govoplan_campaign.backend.schemas import (
SendCampaignNowRequest,
SendCampaignNowResponse,
)
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.core.change_sequence import (
@@ -98,14 +100,25 @@ from govoplan_campaign.backend.db.models import (
JobImapStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
RecipientImportMappingProfile,
SendAttempt,
)
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.report_privacy_policy import CampaignReportPrivacyPolicyError
from govoplan_campaign.backend.reports.aggregate import (
AggregateCampaignReport,
AggregateCampaignReportError,
AggregateReportCampaignList,
aggregate_report_campaign_item,
generate_aggregate_campaign_report,
)
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
public_delivery_result_message,
public_send_campaign_now_result,
send_campaign_now_audit_details,
)
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from govoplan_campaign.backend.persistence.campaigns import (
@@ -148,6 +161,7 @@ from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, r
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
from govoplan_campaign.backend.sending.jobs import (
QueueingError,
SynchronousSendRejected,
cancel_campaign_jobs,
enqueue_pending_imap_appends,
pause_campaign_jobs,
@@ -158,6 +172,7 @@ from govoplan_campaign.backend.sending.jobs import (
resume_campaign_jobs,
send_campaign_now,
send_single_campaign_job,
synchronous_send_options,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
@@ -957,6 +972,52 @@ def delete_recipient_import_mapping_profile(
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/aggregate-reports", response_model=AggregateReportCampaignList)
def list_aggregate_campaign_reports(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
):
"""List only the business metadata needed to select an aggregate report."""
campaigns = (
_campaign_query_for_principal(session, principal)
.order_by(Campaign.updated_at.desc())
.all()
)
return AggregateReportCampaignList(
campaigns=[aggregate_report_campaign_item(campaign) for campaign in campaigns]
)
@router.get("/aggregate-reports/{campaign_id}", response_model=AggregateCampaignReport)
def aggregate_campaign_report(
campaign_id: str,
version_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
):
"""Return the privacy-safe aggregate projection without recipient detail."""
_get_campaign_for_principal(session, campaign_id, principal)
try:
return generate_aggregate_campaign_report(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
except AggregateCampaignReportError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign report not found",
) from exc
except CampaignReportPrivacyPolicyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Campaign report privacy policy is invalid",
) from exc
@router.get("/{campaign_id}/address-lookup", response_model=CampaignAddressLookupResponse)
def lookup_campaign_addresses(
campaign_id: str,
@@ -1572,14 +1633,14 @@ def fork_version_for_edit(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
"""Create the campaign's next and only editable working version.
A new working copy may be created only after the current version is
permanently user-locked or delivery-final. Validation and temporary user
locks must be removed in place instead of creating parallel drafts.
"""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or CampaignVersionUpdateRequest()
source_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
@@ -1654,12 +1715,12 @@ def unlock_version_validation(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
"""Unlock a successfully validated version before delivery starts.
Unlocking invalidates validation/build state and removes generated jobs for
that version. Sent/final versions cannot be unlocked and must be copied.
"""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
@@ -2249,6 +2310,125 @@ def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[st
return result
CAMPAIGN_JOB_GRID_SORT_COLUMNS = {
"number": CampaignJob.entry_index,
"recipient": func.lower(func.coalesce(CampaignJob.recipient_email, "")),
"subject": func.lower(func.coalesce(CampaignJob.subject, "")),
"validation": CampaignJob.validation_status,
"queue": CampaignJob.queue_status,
"send": CampaignJob.send_status,
"imap": CampaignJob.imap_status,
"attempts": CampaignJob.attempt_count,
"updated": CampaignJob.updated_at,
}
CAMPAIGN_JOB_GRID_LIST_FILTERS = {
"validation": (CampaignJob.validation_status, {item.value for item in JobValidationStatus}),
"queue": (CampaignJob.queue_status, {item.value for item in JobQueueStatus}),
"send": (CampaignJob.send_status, {item.value for item in JobSendStatus}),
"imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}),
}
def _campaign_jobs_grid_filter_expressions(grid_filters: dict[str, str] | None) -> list[object]:
values = grid_filters or {}
expressions: list[object] = []
recipient = values.get("recipient", "").strip()
if recipient:
pattern = _contains_pattern(recipient)
expressions.append(or_(
CampaignJob.recipient_email.ilike(pattern, escape="\\"),
CampaignJob.entry_id.ilike(pattern, escape="\\"),
))
subject = values.get("subject", "").strip()
if subject:
expressions.append(CampaignJob.subject.ilike(_contains_pattern(subject), escape="\\"))
evidence = values.get("evidence", "").strip()
if evidence:
pattern = _contains_pattern(evidence)
expressions.append(or_(
CampaignJob.message_id_header.ilike(pattern, escape="\\"),
CampaignJob.eml_sha256.ilike(pattern, escape="\\"),
))
attempts = values.get("attempts", "").strip()
if attempts:
expressions.append(_campaign_jobs_integer_filter(CampaignJob.attempt_count, attempts, column_id="attempts"))
for column_id, (column, allowed_values) in CAMPAIGN_JOB_GRID_LIST_FILTERS.items():
raw_value = values.get(column_id, "").strip()
if not raw_value:
continue
selected = _campaign_jobs_list_filter(raw_value, column_id=column_id, allowed_values=allowed_values)
expressions.append(column.in_(selected))
return expressions
def _campaign_jobs_list_filter(raw_value: str, *, column_id: str, allowed_values: set[str]) -> list[str]:
if raw_value.startswith("list:"):
try:
parsed = json.loads(raw_value[5:])
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid {column_id} list filter",
) from exc
if not isinstance(parsed, list) or any(not isinstance(value, str) for value in parsed):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid {column_id} list filter",
)
selected = list(dict.fromkeys(value.strip() for value in parsed if value.strip()))
else:
selected = list(dict.fromkeys(value.strip() for value in raw_value.split(",") if value.strip()))
if len(selected) > 50 or any(value not in allowed_values for value in selected):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid {column_id} list filter",
)
return selected
def _campaign_jobs_integer_filter(column: object, raw_value: str, *, column_id: str):
operator, separator, value = raw_value.partition(":")
if not separator:
operator, value = "eq", operator
if operator not in {"eq", "gt", "gte", "lt", "lte"}:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid {column_id} filter operator",
)
try:
expected = int(value)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid {column_id} filter value",
) from exc
if operator == "gt":
return column > expected
if operator == "gte":
return column >= expected
if operator == "lt":
return column < expected
if operator == "lte":
return column <= expected
return column == expected
def _contains_pattern(value: str) -> str:
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
def _campaign_jobs_ordering(sort_by: str, sort_direction: str) -> list[object]:
column = CAMPAIGN_JOB_GRID_SORT_COLUMNS.get(sort_by)
if column is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Unsupported Campaign job sort column",
)
primary = column.desc() if sort_direction == "desc" else column.asc()
return [primary, CampaignJob.id.asc()]
def _campaign_jobs_query_context(
session: Session,
principal: ApiPrincipal,
@@ -2259,6 +2439,7 @@ def _campaign_jobs_query_context(
validation_status: list[str] | None,
imap_status: list[str] | None,
query_text: str | None,
grid_filters: dict[str, str] | None = None,
) -> tuple[Campaign, list[object], list[object], dict[str, object], set[str]]:
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:read")
@@ -2287,6 +2468,7 @@ def _campaign_jobs_query_context(
CampaignJob.subject.ilike(pattern),
CampaignJob.entry_id.ilike(pattern),
))
filtered.extend(_campaign_jobs_grid_filter_expressions(grid_filters))
return campaign, base_filters, filtered, review_metadata, reviewed_keys
@@ -2305,6 +2487,9 @@ def _campaign_jobs_page_response(
validation_status: list[str] | None = None,
imap_status: list[str] | None = None,
query_text: str | None = None,
grid_filters: dict[str, str] | None = None,
sort_by: str = "number",
sort_direction: str = "asc",
cursor: str | None = None,
changed_job_ids: set[str] | None = None,
) -> CampaignJobsResponse:
@@ -2319,11 +2504,20 @@ def _campaign_jobs_page_response(
validation_status=validation_status,
imap_status=imap_status,
query_text=query_text,
grid_filters=grid_filters,
sort_by=sort_by,
sort_direction=sort_direction,
)
ordered_query = session.query(CampaignJob).filter(*filtered).order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
ordering = _campaign_jobs_ordering(sort_by, sort_direction)
ordered_query = session.query(CampaignJob).filter(*filtered).order_by(*ordering)
start_cursor: str | None = None
effective_offset = 0
if cursor:
if sort_by != "number" or sort_direction != "asc":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Campaign job cursors require number ascending order",
)
try:
cursor_values = decode_keyset_cursor(CAMPAIGN_JOBS_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
if cursor_values is None:
@@ -2335,14 +2529,14 @@ def _campaign_jobs_page_response(
else:
effective_offset = (page - 1) * page_size
page_query = session.query(CampaignJob).filter(*filtered)
if effective_offset > 0:
if effective_offset > 0 and sort_by == "number" and sort_direction == "asc":
previous_row = ordered_query.offset(effective_offset - 1).limit(1).first()
if previous_row is not None:
start_cursor = _campaign_jobs_cursor_for_row(previous_row, fingerprint=fingerprint)
rows_plus_one = (
page_query
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
.order_by(*ordering)
.offset(effective_offset)
.limit(page_size + 1)
.all()
@@ -2350,7 +2544,12 @@ def _campaign_jobs_page_response(
jobs = rows_plus_one[:page_size]
next_cursor = (
_campaign_jobs_cursor_for_row(jobs[-1], fingerprint=fingerprint)
if changed_job_ids is None and len(rows_plus_one) > page_size and jobs else None
if changed_job_ids is None
and sort_by == "number"
and sort_direction == "asc"
and len(rows_plus_one) > page_size
and jobs
else None
)
if changed_job_ids is not None:
jobs = [job for job in jobs if job.id in changed_job_ids]
@@ -2378,6 +2577,9 @@ def _campaign_jobs_cursor_fingerprint(
validation_status: list[str] | None,
imap_status: list[str] | None,
query_text: str | None,
grid_filters: dict[str, str] | None,
sort_by: str,
sort_direction: str,
) -> str:
return keyset_query_fingerprint(
CAMPAIGN_JOBS_CURSOR_SCOPE,
@@ -2389,7 +2591,8 @@ def _campaign_jobs_cursor_fingerprint(
"validation_status": sorted(validation_status or []),
"imap_status": sorted(imap_status or []),
"query": (query_text or "").strip(),
"order": ["entry_index:asc", "id:asc"],
"grid_filters": sorted((grid_filters or {}).items()),
"order": [f"{sort_by}:{sort_direction}", "id:asc"],
},
)
@@ -2436,6 +2639,16 @@ class CampaignJobsQuery:
validation_status: list[str] | None = Query(default=None),
imap_status: list[str] | None = Query(default=None),
query_text: str | None = Query(default=None, alias="q", max_length=200),
sort_by: Literal["number", "recipient", "subject", "validation", "queue", "send", "imap", "attempts", "updated"] = Query(default="number"),
sort_direction: Literal["asc", "desc"] = Query(default="asc"),
filter_recipient: str | None = Query(default=None, max_length=500),
filter_subject: str | None = Query(default=None, max_length=1000),
filter_validation: str | None = Query(default=None, max_length=1000),
filter_queue: str | None = Query(default=None, max_length=1000),
filter_send: str | None = Query(default=None, max_length=1000),
filter_imap: str | None = Query(default=None, max_length=1000),
filter_attempts: str | None = Query(default=None, max_length=100),
filter_evidence: str | None = Query(default=None, max_length=500),
) -> None:
self.version_id = version_id
self.page = page
@@ -2445,6 +2658,22 @@ class CampaignJobsQuery:
self.validation_status = validation_status
self.imap_status = imap_status
self.query_text = query_text
self.sort_by = sort_by
self.sort_direction = sort_direction
self.grid_filters = {
column_id: value
for column_id, value in {
"recipient": filter_recipient,
"subject": filter_subject,
"validation": filter_validation,
"queue": filter_queue,
"send": filter_send,
"imap": filter_imap,
"attempts": filter_attempts,
"evidence": filter_evidence,
}.items()
if value is not None and value.strip()
}
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
@@ -2469,6 +2698,7 @@ def list_jobs(
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
)
return _campaign_jobs_page_response(
session,
@@ -2484,6 +2714,9 @@ def list_jobs(
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
)
@@ -2500,6 +2733,9 @@ def _campaign_jobs_full_delta_response(
validation_status: list[str] | None,
imap_status: list[str] | None,
query_text: str | None,
grid_filters: dict[str, str] | None,
sort_by: str,
sort_direction: str,
cursor: str | None = None,
) -> CampaignJobsDeltaResponse:
_campaign, base_filters, filtered, review_metadata, reviewed_keys = _campaign_jobs_query_context(
@@ -2511,6 +2747,7 @@ def _campaign_jobs_full_delta_response(
validation_status=validation_status,
imap_status=imap_status,
query_text=query_text,
grid_filters=grid_filters,
)
payload = _campaign_jobs_page_response(
session,
@@ -2526,6 +2763,9 @@ def _campaign_jobs_full_delta_response(
validation_status=validation_status,
imap_status=imap_status,
query_text=query_text,
grid_filters=grid_filters,
sort_by=sort_by,
sort_direction=sort_direction,
cursor=cursor,
)
return CampaignJobsDeltaResponse(
@@ -2543,8 +2783,19 @@ def _job_filter_membership_can_shift(
validation_status: list[str] | None,
imap_status: list[str] | None,
query_text: str | None,
grid_filters: dict[str, str] | None,
sort_by: str,
sort_direction: str,
) -> bool:
return bool(send_status or validation_status or imap_status or (query_text and query_text.strip()))
return bool(
send_status
or validation_status
or imap_status
or (query_text and query_text.strip())
or grid_filters
or sort_by != "number"
or sort_direction != "asc"
)
@router.get("/{campaign_id}/jobs/delta", response_model=CampaignJobsDeltaResponse)
@@ -2568,6 +2819,9 @@ def list_jobs_delta(
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
)
@@ -2580,6 +2834,7 @@ def list_jobs_delta(
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
)
try:
since_sequence = decode_sequence_watermark(since)
@@ -2603,6 +2858,9 @@ def list_jobs_delta(
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
)
@@ -2629,6 +2887,9 @@ def list_jobs_delta(
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
)
or any(entry.operation in {"created", "deleted"} for entry in relevant_entries)
):
@@ -2643,6 +2904,9 @@ def list_jobs_delta(
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
)
@@ -2665,6 +2929,9 @@ def list_jobs_delta(
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
changed_job_ids=changed_job_ids,
)
@@ -2726,6 +2993,7 @@ def get_job_diagnostics(
"""Return infrastructure details only to campaign operators/admins."""
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:read")
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
job = session.get(CampaignJob, job_id)
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
@@ -2753,10 +3021,10 @@ def campaign_summary(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
"""Return dashboard-friendly campaign status counters and summaries."""
_get_campaign_for_principal(session, campaign_id, principal)
if include_jobs:
_require_permission(principal, "campaigns:recipient:read")
"""Return dashboard-friendly campaign status counters and summaries."""
try:
return generate_campaign_report(
@@ -2780,10 +3048,9 @@ def campaign_report(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
):
"""Return the recipient-level JSON report for one campaign."""
_get_campaign_for_principal(session, campaign_id, principal)
if include_jobs:
_require_permission(principal, "campaigns:recipient:read")
"""Return the full JSON report for one campaign."""
try:
return generate_campaign_report(
@@ -2806,9 +3073,9 @@ def campaign_jobs_csv(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:export")),
):
"""Export per-job campaign status as CSV."""
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:export")
"""Export per-job campaign status as CSV."""
try:
csv_text = generate_jobs_csv(
@@ -2836,7 +3103,9 @@ def email_campaign_report(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")),
):
"""Generate a campaign report and send it to one or more email addresses."""
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:export")
selected_version_id = payload.version_id or campaign.current_version_id
selected_version = session.get(CampaignVersion, selected_version_id) if selected_version_id else None
if selected_version is not None and selected_version.campaign_id == campaign.id:
@@ -2844,10 +3113,6 @@ def email_campaign_report(
principal,
selected_version.raw_json if isinstance(selected_version.raw_json, dict) else {},
)
if payload.include_jobs or payload.attach_jobs_csv:
_require_permission(principal, "campaigns:recipient:export")
"""Generate a campaign report and send it to one or more email addresses."""
try:
result = send_campaign_report_email(
session,
@@ -3019,6 +3284,30 @@ def revoke_campaign_share(
# Queue / delivery control -------------------------------------------------
@router.get("/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse)
def campaign_delivery_options(
campaign_id: str,
version_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("campaigns:campaign:send", "campaigns:campaign:queue")
),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
try:
return CampaignDeliveryOptionsResponse(
**synchronous_send_options(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
def queue_campaign(
campaign_id: str,
@@ -3210,14 +3499,14 @@ def mock_send_campaign(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
"""Run a fully visible mock delivery flow without mutating campaign state.
The route validates and builds the selected version, then optionally records
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
real SMTP/IMAP servers and it does not mark the version sent/final.
"""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or MockCampaignSendRequest()
_require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id)
@@ -3262,14 +3551,9 @@ def send_campaign_now_endpoint(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
):
"""Preflight and synchronously send a policy-bounded built execution."""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
"""Validate/build/queue and synchronously send every eligible job.
No server-side job-count bound is currently enforced. Ordinary batches
must use the queue/Celery flow; this endpoint is for deliberately small
interactive runs.
"""
payload = payload or SendCampaignNowRequest()
try:
@@ -3298,7 +3582,7 @@ def send_campaign_now_endpoint(
detail="Campaign version must be built before dry-run or sending.",
)
result = send_campaign_now(
delivery_result = send_campaign_now(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
@@ -3308,18 +3592,35 @@ def send_campaign_now_endpoint(
use_rate_limit=payload.use_rate_limit,
enqueue_imap_task=payload.enqueue_imap_task,
).as_dict()
result["validation"] = validation_result
result["build"] = build_result
response_result = public_send_campaign_now_result(
delivery_result,
validation_summary=validation_result,
build_summary=build_result,
)
audit_from_principal(
session,
principal,
action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
details=send_campaign_now_audit_details(delivery_result),
commit=True,
)
return SendCampaignNowResponse(result=result)
return SendCampaignNowResponse(result=response_result)
except SynchronousSendRejected as exc:
audit_from_principal(
session,
principal,
action="campaign.send_now_rejected",
object_type="campaign",
object_id=campaign_id,
details={
**exc.audit_details(),
"version_id": payload.version_id,
},
commit=True,
)
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except HTTPException:
raise
except (CampaignPersistenceError, QueueingError) as exc:

View File

@@ -112,6 +112,8 @@ class CampaignVersionResponse(BaseModel):
build_summary: dict[str, Any] | None = None
execution_snapshot_hash: str | None = None
execution_snapshot_at: datetime | None = None
delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = None
delivery_mode_selected_at: datetime | None = None
@field_validator("editor_state", mode="before")
@classmethod
@@ -492,6 +494,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
@@ -512,6 +516,13 @@ class SendCampaignNowResponse(BaseModel):
result: dict[str, Any]
class CampaignDeliveryOptionsResponse(BaseModel):
campaign_id: str
version_id: str
worker_queue_available: bool
synchronous_send: dict[str, Any] = Field(default_factory=dict)
class MockCampaignSendRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

View File

@@ -28,7 +28,17 @@ from govoplan_campaign.backend.db.models import (
ImapAppendAttempt,
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot
from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError,
SynchronousSendPolicy,
effective_synchronous_send_policy,
)
from govoplan_campaign.backend.sending.execution import (
ExecutionSnapshot,
ExecutionSnapshotError,
ensure_execution_snapshot,
profile_delivery_summary,
)
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.integrations import (
ImapAppendError,
@@ -49,6 +59,29 @@ class SendJobError(RuntimeError):
pass
class SynchronousSendRejected(QueueingError):
def __init__(
self,
message: str,
*,
reason: str,
eligible_count: int | None = None,
policy: SynchronousSendPolicy | None = None,
) -> None:
super().__init__(message)
self.reason = reason
self.eligible_count = eligible_count
self.policy = policy
def audit_details(self) -> dict[str, Any]:
return {
"delivery_mode": "synchronous",
"rejection_reason": self.reason,
"eligible_recipient_job_count": self.eligible_count,
"synchronous_send_policy": self.policy.as_dict() if self.policy is not None else None,
}
@dataclass(frozen=True, slots=True)
class QueueCampaignResult:
campaign_id: str
@@ -57,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]:
@@ -67,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,
}
@@ -80,6 +117,8 @@ class SendCampaignNowResult:
failed_count: int
outcome_unknown_count: int
skipped_count: int
preflight_count: int = 0
synchronous_send_policy: dict[str, Any] | None = None
dry_run: bool = False
results: list[dict[str, Any]] | None = None
@@ -92,6 +131,9 @@ class SendCampaignNowResult:
"failed_count": self.failed_count,
"outcome_unknown_count": self.outcome_unknown_count,
"skipped_count": self.skipped_count,
"preflight_count": self.preflight_count,
"delivery_mode": "synchronous",
"synchronous_send_policy": self.synchronous_send_policy or {},
"dry_run": self.dry_run,
"results": self.results or [],
}
@@ -159,9 +201,18 @@ QUEUEABLE_VALIDATION_STATUSES = {
JobValidationStatus.WARNING.value,
}
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
DELIVERY_MODE_SYNCHRONOUS = "synchronous"
DELIVERY_MODE_WORKER_QUEUE = "worker_queue"
DELIVERY_MODE_DATABASE_QUEUE = "database_queue"
DELIVERY_MODES = {
DELIVERY_MODE_SYNCHRONOUS,
DELIVERY_MODE_WORKER_QUEUE,
DELIVERY_MODE_DATABASE_QUEUE,
}
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.SKIPPED.value,
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
JobSendStatus.OUTCOME_UNKNOWN.value,
@@ -231,6 +282,13 @@ def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _set_version_delivery_mode(version: CampaignVersion, mode: str) -> None:
if mode not in DELIVERY_MODES:
raise QueueingError(f"Unsupported Campaign delivery mode: {mode}")
version.delivery_mode = mode
version.delivery_mode_selected_at = _utcnow()
def _get_campaign_for_tenant(session: Session, *, campaign_id: str, tenant_id: str) -> Campaign:
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
if not campaign:
@@ -269,6 +327,10 @@ def _should_enqueue_celery(enqueue_celery: bool) -> bool:
return bool(enqueue_celery and _celery_enabled())
def _asynchronous_delivery_mode(enqueue_celery: bool) -> str:
return DELIVERY_MODE_WORKER_QUEUE if _should_enqueue_celery(enqueue_celery) else DELIVERY_MODE_DATABASE_QUEUE
def _campaign_notification_body(campaign: Campaign, status: str) -> str:
return {
CampaignStatus.QUEUED.value: f"{campaign.name} has been queued for delivery.",
@@ -444,17 +506,128 @@ def _select_campaign_jobs_for_queue(
return queued, skipped_count, blocked_count
def synchronous_send_candidate_jobs(
version: CampaignVersion,
jobs: list[CampaignJob],
*,
include_warnings: bool = True,
) -> list[CampaignJob]:
"""Return the exact persisted job set an immediate send may attempt.
This projection is deliberately side-effect free so the delivery-options
API and adaptive documentation can explain the current built execution
without changing queue state. The final command repeats the projection
after queueing and before any SMTP effect.
"""
allowed_validation = _queue_validation_statuses(include_warnings=include_warnings)
reviewed_needs_review_keys = _reviewed_needs_review_keys(version)
candidates: list[CampaignJob] = []
for job in jobs:
if (
job.queue_status == JobQueueStatus.QUEUED.value
and job.send_status == JobSendStatus.QUEUED.value
):
candidates.append(job)
continue
if job.send_status in INITIAL_QUEUE_SKIPPED_SEND_STATUSES:
continue
if job.queue_status in INITIAL_QUEUE_SKIPPED_QUEUE_STATUSES:
continue
validation_allowed = job.validation_status in allowed_validation or (
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
and _job_review_key(job) in reviewed_needs_review_keys
)
if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed:
continue
if not job.eml_local_path and not job.eml_storage_key:
continue
candidates.append(job)
return candidates
def synchronous_send_options(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
include_warnings: bool = True,
) -> dict[str, Any]:
"""Describe the actor-independent effective delivery-mode constraints."""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_version_for_campaign(session, campaign, version_id=version_id)
jobs = _campaign_jobs_for_version(session, tenant_id=tenant_id, version_id=version.id)
candidates = synchronous_send_candidate_jobs(version, jobs, include_warnings=include_warnings)
try:
policy = effective_synchronous_send_policy(session, tenant_id=tenant_id)
except CampaignDeliveryPolicyError as exc:
return {
"campaign_id": campaign.id,
"version_id": version.id,
"worker_queue_available": bool(_celery_enabled()),
"synchronous_send": {
"allowed": False,
"reason": "policy_configuration_invalid",
"message": str(exc),
"eligible_recipient_job_count": len(candidates),
"policy": {},
},
}
ready = _version_is_validated_and_locked(version) and bool(version.build_summary)
eligible_count = len(candidates)
if not ready:
reason = "version_not_ready"
elif eligible_count == 0:
reason = "no_eligible_recipient_jobs"
elif eligible_count > policy.max_recipient_jobs:
reason = "recipient_limit_exceeded"
else:
reason = None
return {
"campaign_id": campaign.id,
"version_id": version.id,
"worker_queue_available": bool(_celery_enabled()),
"synchronous_send": {
"allowed": reason is None,
"reason": reason,
"eligible_recipient_job_count": eligible_count,
"policy": policy.as_dict(),
},
}
def _campaign_jobs_for_version(
session: Session,
*,
tenant_id: str,
version_id: str,
) -> list[CampaignJob]:
return (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_version_id == version_id,
)
.order_by(CampaignJob.entry_index.asc())
.all()
)
def _persist_campaign_queue(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
queued: list[CampaignJob],
delivery_mode: str,
) -> None:
if queued:
previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
_set_version_delivery_mode(version, delivery_mode)
if version.locked_at is None:
version.locked_at = _utcnow()
session.add(version)
@@ -487,9 +660,13 @@ def queue_campaign_jobs(
enqueue_celery: bool = True,
include_warnings: bool = True,
dry_run: bool = False,
delivery_mode: str | None = None,
) -> QueueCampaignResult:
"""Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks."""
selected_delivery_mode = delivery_mode or _asynchronous_delivery_mode(enqueue_celery)
if selected_delivery_mode not in DELIVERY_MODES:
raise QueueingError(f"Unsupported Campaign delivery mode: {selected_delivery_mode}")
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_current_version(session, campaign, version_id=version_id)
_ensure_version_validated_and_locked(version)
@@ -516,6 +693,7 @@ def queue_campaign_jobs(
campaign=campaign,
version=version,
queued=queued,
delivery_mode=selected_delivery_mode,
)
enqueued_count = _enqueue_campaign_jobs(
queued,
@@ -529,6 +707,8 @@ def queue_campaign_jobs(
skipped_count=skipped_count,
blocked_count=blocked_count,
enqueued_count=enqueued_count,
delivery_mode=selected_delivery_mode,
worker_queue_available=_celery_enabled(),
dry_run=dry_run,
)
@@ -546,13 +726,36 @@ def send_campaign_now(
) -> SendCampaignNowResult:
"""Queue and send all eligible jobs for one campaign synchronously.
The service currently has no server-side job-count bound. Callers must use
the worker queue for ordinary batches and reserve this path for deliberately
small interactive runs.
The effective deployment/tenant limit is checked against the immutable,
persisted job set before queue state changes. After queueing, every message
and transport revision is preflighted before the first SMTP effect starts.
"""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_current_version(session, campaign, version_id=version_id)
_ensure_version_validated_and_locked(version)
_ensure_campaign_execution_snapshot(session, version)
try:
synchronous_policy = effective_synchronous_send_policy(session, tenant_id=tenant_id)
except CampaignDeliveryPolicyError as exc:
raise SynchronousSendRejected(
f"Synchronous Campaign delivery is disabled by an invalid policy configuration: {exc}",
reason="policy_configuration_invalid",
) from exc
initial_jobs = _campaign_jobs_for_queue(
session,
tenant_id=tenant_id,
version_id=version.id,
)
initial_candidates = synchronous_send_candidate_jobs(
version,
initial_jobs,
include_warnings=include_warnings,
)
_ensure_synchronous_send_count_allowed(
len(initial_candidates),
policy=synchronous_policy,
)
queue_result = queue_campaign_jobs(
session,
@@ -562,6 +765,7 @@ def send_campaign_now(
include_warnings=include_warnings,
enqueue_celery=False,
dry_run=dry_run,
delivery_mode=DELIVERY_MODE_SYNCHRONOUS,
)
if dry_run:
return SendCampaignNowResult(
@@ -572,20 +776,31 @@ def send_campaign_now(
failed_count=0,
outcome_unknown_count=0,
skipped_count=queue_result.skipped_count + queue_result.blocked_count,
preflight_count=len(initial_candidates),
synchronous_send_policy=synchronous_policy.as_dict(),
dry_run=True,
results=[queue_result.as_dict()],
)
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_version_id == version.id,
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]),
jobs = [
job
for job in _campaign_jobs_for_version(
session,
tenant_id=tenant_id,
version_id=version.id,
)
.order_by(CampaignJob.entry_index.asc())
.all()
if job.queue_status == JobQueueStatus.QUEUED.value
and job.send_status == JobSendStatus.QUEUED.value
]
# Repeat the hard bound against the post-queue set. This closes the window
# where a concurrent queue operation could otherwise enlarge an immediate
# run between the initial decision and the first provider effect.
_ensure_synchronous_send_count_allowed(len(jobs), policy=synchronous_policy)
delivery_contexts = _preflight_synchronous_send_batch(
session,
version=version,
jobs=jobs,
policy=synchronous_policy,
)
results: list[dict[str, Any]] = []
@@ -595,10 +810,16 @@ def send_campaign_now(
skipped_after_queue = 0
for job in jobs:
try:
result = send_campaign_job(
claimed = _claimed_campaign_job_for_delivery(session, job)
if isinstance(claimed, SendJobResult):
result = claimed
else:
claimed_job, claim_token = claimed
result = _send_claimed_campaign_job(
session,
job_id=job.id,
dry_run=False,
job=claimed_job,
claim_token=claim_token,
context=delivery_contexts[job.id],
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
@@ -622,11 +843,75 @@ def send_campaign_now(
failed_count=failed_count,
outcome_unknown_count=outcome_unknown_count,
skipped_count=queue_result.skipped_count + queue_result.blocked_count + skipped_after_queue,
preflight_count=len(delivery_contexts),
synchronous_send_policy=synchronous_policy.as_dict(),
dry_run=False,
results=results,
)
def _ensure_synchronous_send_count_allowed(
eligible_count: int,
*,
policy: SynchronousSendPolicy,
) -> None:
if eligible_count <= 0:
raise SynchronousSendRejected(
"No eligible built recipient messages are available for synchronous delivery.",
reason="no_eligible_recipient_jobs",
eligible_count=eligible_count,
policy=policy,
)
if eligible_count > policy.max_recipient_jobs:
raise SynchronousSendRejected(
(
f"This built run contains {eligible_count} eligible recipient jobs, above the effective "
f"synchronous limit of {policy.max_recipient_jobs}. Queue it for background workers instead."
),
reason="recipient_limit_exceeded",
eligible_count=eligible_count,
policy=policy,
)
def _preflight_synchronous_send_batch(
session: Session,
*,
version: CampaignVersion,
jobs: list[CampaignJob],
policy: SynchronousSendPolicy,
) -> dict[str, _SendJobDeliveryContext]:
"""Freeze and validate every local input before the first SMTP effect."""
contexts: dict[str, _SendJobDeliveryContext] = {}
try:
for job in jobs:
state_result = _preflight_send_campaign_job(session, job, dry_run=True)
if state_result is not None:
raise SendJobError(
f"Job {job.id} changed to {state_result.status} during synchronous preflight"
)
contexts[job.id] = _send_job_delivery_context(session, job)
profile_summary = profile_delivery_summary(session, version)
expected_revision = next(iter(contexts.values())).snapshot.smtp_transport_revision
if profile_summary.get("smtp_transport_revision") != expected_revision:
raise SendJobError(
"The selected Mail profile changed after this execution was built. Revalidate and rebuild before delivery."
)
except (ExecutionSnapshotError, MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
raise SynchronousSendRejected(
(
"Synchronous preflight stopped before contacting SMTP because one or more frozen message "
"inputs or the selected Mail profile no longer match the reviewed build. Rebuild and review "
"the version before trying again."
),
reason="batch_preflight_failed",
eligible_count=len(jobs),
policy=policy,
) from exc
return contexts
def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int:
if not _celery_enabled():
return 0
@@ -682,6 +967,12 @@ def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str,
job.send_status = JobSendStatus.QUEUED.value
session.add(job)
if jobs:
delivery_mode = _asynchronous_delivery_mode(enqueue_celery)
for version_id in {job.campaign_version_id for job in jobs}:
version = session.get(CampaignVersion, version_id)
if version is not None and version.campaign_id == campaign.id:
_set_version_delivery_mode(version, delivery_mode)
session.add(version)
previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value
session.add(campaign)
@@ -711,9 +1002,13 @@ def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str)
)
cancelled_count = 0
protected_count = 0
skipped_count = 0
version_ids: set[str] = set()
for job in jobs:
version_ids.add(job.campaign_version_id)
if job.send_status == JobSendStatus.SKIPPED.value:
skipped_count += 1
continue
if job.send_status in SMTP_ACCEPTED_STATUSES | {
JobSendStatus.OUTCOME_UNKNOWN.value,
JobSendStatus.CLAIMED.value,
@@ -734,6 +1029,7 @@ def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str)
"campaign_id": campaign.id,
"cancelled_count": cancelled_count,
"protected_count": protected_count,
"skipped_count": skipped_count,
"campaign_status": campaign.status,
}
@@ -808,6 +1104,10 @@ def queue_failed_jobs_for_retry(
if selected:
campaign.status = CampaignStatus.QUEUED.value
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
_set_version_delivery_mode(
version,
_asynchronous_delivery_mode(enqueue_celery),
)
session.add(campaign)
session.add(version)
session.commit()
@@ -877,6 +1177,10 @@ def queue_unattempted_jobs(
if selected:
campaign.status = CampaignStatus.QUEUED.value
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
_set_version_delivery_mode(
version,
_asynchronous_delivery_mode(enqueue_celery),
)
session.add(campaign)
session.add(version)
session.commit()
@@ -945,6 +1249,7 @@ def send_single_campaign_job(
previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
_set_version_delivery_mode(version, DELIVERY_MODE_SYNCHRONOUS)
session.add(campaign)
session.add(version)
session.commit()

View File

@@ -0,0 +1,245 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
from govoplan_campaign.backend.report_privacy_policy import (
CampaignReportPrivacyPolicy,
CampaignReportPrivacyPolicyError,
DEFAULT_SMALL_CELL_THRESHOLD,
effective_campaign_report_privacy_policy,
)
from govoplan_campaign.backend.reports.aggregate import build_aggregate_campaign_report
class _PolicySession:
def __init__(self, settings: dict[str, object] | None = None) -> None:
self.tenant = SimpleNamespace(settings=settings or {})
def get(self, _model, _id):
return self.tenant
def _policy(threshold: int = 5) -> CampaignReportPrivacyPolicy:
return CampaignReportPrivacyPolicy(
small_cell_threshold=threshold,
source="test",
deployment_small_cell_threshold=threshold,
)
def _campaign() -> SimpleNamespace:
now = datetime(2026, 7, 22, 8, 0, tzinfo=UTC)
return SimpleNamespace(
id="campaign-safe",
tenant_id="tenant-safe",
external_id="must-not-leak-external-id",
name="Semester notification",
description="Business-safe description",
status="partially_completed",
owner_user_id="must-not-leak-owner",
current_version_id="must-not-leak-version-id",
updated_at=now,
)
def _version(*, inactive_count: int = 0) -> SimpleNamespace:
return SimpleNamespace(
id="must-not-leak-version-id",
version_number=7,
build_summary={"inactive_count": inactive_count, "build_token": "must-not-leak-token"},
execution_snapshot={"smtp": {"password": "must-not-leak-secret"}},
raw_json={"entries": [{"email": "must-not-leak@example.test"}]},
)
def _job(index: int, send_status: str, **overrides: object) -> SimpleNamespace:
started = datetime(2026, 7, 22, 8, 0, tzinfo=UTC) + timedelta(minutes=index)
values: dict[str, object] = {
"id": f"job-{index}",
"recipient_email": f"private-{index}@example.test",
"subject": f"Personal subject {index}",
"last_error": "smtp.internal.example provider-secret",
"resolved_attachments": [{"storage_key": f"private/{index}"}],
"send_status": send_status,
"validation_status": "ready",
"build_status": "built",
"queued_at": started,
"smtp_started_at": started,
"sent_at": started if send_status in {"smtp_accepted", "sent"} else None,
"outcome_unknown_at": started if send_status == "outcome_unknown" else None,
}
values.update(overrides)
return SimpleNamespace(**values)
def test_aggregate_projection_has_a_strict_recipient_free_shape() -> None:
jobs = [
*[_job(index, "smtp_accepted") for index in range(10)],
*[_job(index + 10, "failed_permanent") for index in range(5)],
]
payload = build_aggregate_campaign_report(
campaign=_campaign(), # type: ignore[arg-type]
version=_version(inactive_count=5), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=_policy(),
generated_at=datetime(2026, 7, 22, 12, 0, tzinfo=UTC),
).model_dump(mode="json")
assert set(payload) == {
"generated_at",
"campaign",
"version_number",
"completion_state",
"population",
"outcomes",
"time_range",
"privacy",
}
assert set(payload["campaign"]) == {"id", "name", "status"}
assert payload["population"]["denominator"] == {"value": 15, "suppressed": False}
assert payload["outcomes"]["smtp_accepted"] == {"value": 10, "suppressed": False}
assert payload["outcomes"]["failed"] == {"value": 5, "suppressed": False}
assert payload["completion_state"] == "partially_completed"
serialized = repr(payload)
for forbidden in (
"private-0@example.test",
"Personal subject",
"smtp.internal.example",
"provider-secret",
"storage_key",
"must-not-leak",
"Business-safe description",
"job-0",
):
assert forbidden not in serialized
def test_small_cells_use_primary_and_complementary_suppression() -> None:
jobs = [
*[_job(index, "smtp_accepted") for index in range(8)],
_job(8, "failed_permanent"),
]
report = build_aggregate_campaign_report(
campaign=_campaign(), # type: ignore[arg-type]
version=_version(inactive_count=1), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=_policy(5),
)
assert report.population.denominator.value == 9
assert report.outcomes.failed.suppressed is True
assert report.outcomes.failed.value is None
assert report.outcomes.smtp_accepted.suppressed is True
assert report.outcomes.smtp_accepted.value is None
assert report.population.inactive_source_entries.suppressed is True
assert report.time_range.suppressed is True
assert report.privacy.suppression_applied is True
def test_outcome_suppression_also_hides_overlapping_population_count() -> None:
jobs = [
*[
_job(
index,
"smtp_accepted",
validation_status="blocked" if index < 4 else "ready",
)
for index in range(8)
],
_job(8, "skipped", validation_status="excluded"),
]
report = build_aggregate_campaign_report(
campaign=_campaign(), # type: ignore[arg-type]
version=_version(inactive_count=5), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=_policy(5),
)
assert report.outcomes.excluded.suppressed is True
assert report.population.excluded_or_blocked_jobs.model_dump() == {
"value": None,
"suppressed": True,
}
# Source entries without jobs are outside the outcome denominator and do
# not overlap the suppressed partition, so their threshold-safe count stays visible.
assert report.population.inactive_source_entries.model_dump() == {
"value": 5,
"suppressed": False,
}
def test_all_small_cells_also_suppress_the_denominator_and_state() -> None:
jobs = [_job(0, "smtp_accepted"), _job(1, "failed_permanent")]
report = build_aggregate_campaign_report(
campaign=_campaign(), # type: ignore[arg-type]
version=_version(), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=_policy(5),
)
assert report.population.denominator.model_dump() == {"value": None, "suppressed": True}
assert report.completion_state == "suppressed"
assert report.outcomes.smtp_accepted.value is None
assert report.outcomes.failed.value is None
def test_explicitly_skipped_jobs_are_exclusions_not_unattempted_outcomes() -> None:
jobs = [_job(index, "skipped") for index in range(5)]
report = build_aggregate_campaign_report(
campaign=_campaign(), # type: ignore[arg-type]
version=_version(), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=_policy(5),
)
assert report.outcomes.excluded.value == 5
assert report.outcomes.not_attempted.value == 0
assert report.completion_state == "not_started"
def test_report_privacy_policy_defaults_to_five_and_tenant_can_only_strengthen() -> None:
default = effective_campaign_report_privacy_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={},
)
assert default.small_cell_threshold == DEFAULT_SMALL_CELL_THRESHOLD == 5
assert default.source == "deployment_default"
strengthened = effective_campaign_report_privacy_policy(
_PolicySession(
{"campaign_report_privacy_policy": {"small_cell_threshold": 10}}
), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD": "5"},
)
assert strengthened.small_cell_threshold == 10
assert strengthened.source == "tenant"
floor = effective_campaign_report_privacy_policy(
_PolicySession(
{"campaign_report_privacy_policy": {"small_cell_threshold": 3}}
), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD": "7"},
)
assert floor.small_cell_threshold == 7
assert floor.source == "deployment_floor"
@pytest.mark.parametrize("value", [True, 0, 1, 101, "2.5", "disabled"])
def test_invalid_report_privacy_policy_fails_closed(value: object) -> None:
with pytest.raises(CampaignReportPrivacyPolicyError):
effective_campaign_report_privacy_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD": value}, # type: ignore[dict-item]
)

View File

@@ -0,0 +1,199 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend import router
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.reports.aggregate import (
AggregateCampaignReportError,
generate_aggregate_campaign_report,
)
from govoplan_campaign.backend.schemas import ReportEmailRequest
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
class _Principal:
def __init__(self, *scopes: str, tenant_id: str = "tenant-1") -> None:
self.scopes = set(scopes)
self.tenant_id = tenant_id
self.user = SimpleNamespace(id="reader-1")
def has(self, scope: str) -> bool:
return scope in self.scopes
def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
principal = _Principal("campaigns:report:read")
session = Mock()
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as full_report_denied,
):
router.campaign_report(
"campaign-1",
session=session,
principal=principal, # type: ignore[arg-type]
)
assert full_report_denied.value.status_code == 403
assert "campaigns:recipient:read" in full_report_denied.value.detail
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as job_detail_denied,
):
router.get_job_detail(
"campaign-1",
"job-1",
session=session,
principal=principal, # type: ignore[arg-type]
)
assert job_detail_denied.value.status_code == 403
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as report_email_denied,
):
router.email_campaign_report(
"campaign-1",
ReportEmailRequest(to=["auditor@example.test"]),
session=session,
principal=_Principal("campaigns:report:send"), # type: ignore[arg-type]
)
assert report_email_denied.value.status_code == 403
assert "campaigns:recipient:export" in report_email_denied.value.detail
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as diagnostics_denied,
):
router.get_job_diagnostics(
"campaign-1",
"job-1",
session=session,
principal=_Principal("campaigns:diagnostic:read"), # type: ignore[arg-type]
)
assert diagnostics_denied.value.status_code == 403
assert "campaigns:recipient:read" in diagnostics_denied.value.detail
def test_aggregate_route_uses_only_the_safe_projection() -> None:
principal = _Principal("campaigns:report:read")
session = Mock()
safe_projection = Mock()
with (
patch.object(router, "_get_campaign_for_principal") as acl,
patch.object(
router,
"generate_aggregate_campaign_report",
return_value=safe_projection,
) as generate,
):
result = router.aggregate_campaign_report(
"campaign-1",
session=session,
principal=principal, # type: ignore[arg-type]
)
assert result is safe_projection
acl.assert_called_once_with(session, "campaign-1", principal)
generate.assert_called_once_with(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
version_id=None,
)
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
route = next(item for item in router.router.routes if item.path == path)
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
with pytest.raises(HTTPException) as denied:
dependency.call(_Principal())
assert denied.value.status_code == 403
principal = _Principal("campaigns:report:read")
assert dependency.call(principal) is principal
def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
create_scope_tables(engine)
Base.metadata.create_all(
engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
ChangeSequenceEntry.__table__,
],
)
with Session(engine) as session:
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={}))
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
external_id="external-1",
name="Safe aggregate",
description=None,
status="sent",
)
version = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
raw_json={"mail": {"profile_id": "optional-module-not-loaded"}},
schema_version="5",
build_summary={},
)
campaign.current_version_id = version.id
session.add_all([campaign, version])
for index in range(5):
session.add(CampaignJob(
id=f"job-{index}",
tenant_id="tenant-1",
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=index,
recipient_email=f"private-{index}@example.test",
subject="Private",
build_status="built",
validation_status="ready",
queue_status="queued",
send_status="smtp_accepted",
imap_status="not_requested",
))
session.commit()
report = generate_aggregate_campaign_report(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
)
assert report.population.denominator.value == 5
assert report.outcomes.smtp_accepted.value == 5
with pytest.raises(AggregateCampaignReportError):
generate_aggregate_campaign_report(
session,
tenant_id="tenant-2",
campaign_id="campaign-1",
)
engine.dispose()

View File

@@ -123,6 +123,9 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
message = result.report.messages[0]
self.assertEqual(message.build_status.value, build_status)
self.assertEqual(message.validation_status.value, validation_status)
if validation_status == "excluded":
self.assertEqual(message.send_status.value, "skipped")
self.assertEqual(message.imap_status.value, "skipped")
coverage_issues = [issue for issue in message.issues if issue.code == "missing_attachment_coverage"]
if issue_behavior is None:
self.assertEqual(coverage_issues, [])

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -12,6 +14,7 @@ from govoplan_core.core.modules import DocumentationContext
@dataclass(frozen=True)
class _Principal:
scopes: frozenset[str]
tenant_id: str = "tenant-1"
def has(self, scope: str) -> bool:
namespace, resource, _action = scope.split(":", 2)
@@ -181,6 +184,32 @@ def test_runtime_documentation_full_composition_uses_only_user_facing_names() ->
assert "secret" not in rendered.lower()
def test_runtime_documentation_states_the_effective_synchronous_limit() -> None:
session = SimpleNamespace(
get=lambda _model, _id: SimpleNamespace(
settings={
"campaign_delivery_policy": {
"synchronous_send_max_recipients": 12,
}
}
)
)
with patch.dict("os.environ", {"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "25"}):
topic = documentation_topics(
DocumentationContext(
registry=_Registry({"mail.campaign_delivery"}),
principal=_Principal(frozenset({"campaigns:campaign:read", "campaigns:campaign:send"})),
session=session,
documentation_type="user",
)
)[0]
assert any(
"Send now is limited to 12 eligible recipient job(s)" in item
for item in topic.metadata["current_configuration"]
)
def _visible_static_topics(
scopes: set[str],
*,
@@ -256,6 +285,12 @@ def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_auth
assert "campaigns.workflow.queue-delivery" in with_mail
assert "campaigns.workflow.send-small-controlled-run" in with_mail
queue_topic = next(
topic for topic in CAMPAIGN_USER_DOCUMENTATION
if topic.id == "campaigns.workflow.queue-delivery"
)
assert any(link.href == "/operator" for link in queue_topic.links)
def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None:
attachment_scopes = {
@@ -318,6 +353,30 @@ def test_report_export_and_lifecycle_tasks_are_independently_permission_gated()
assert "campaigns.workflow.export-delivery-report" not in custodian
def test_aggregate_report_task_never_implies_recipient_detail_or_export_authority() -> None:
visible = _visible_static_topics({"campaigns:report:read"})
assert visible == {"campaigns.workflow.view-aggregate-delivery-report"}
topic = next(
item for item in CAMPAIGN_USER_DOCUMENTATION
if item.id == "campaigns.workflow.view-aggregate-delivery-report"
)
assert topic.metadata["route"] == "/reports"
assert "export" in topic.metadata["verification"].lower()
def test_handbook_distinguishes_shipped_aggregate_reports_from_detailed_report_gaps() -> None:
handbook = " ".join(
(
Path(__file__).resolve().parents[1] / "docs" / "CAMPAIGN_HANDBOOK.md"
).read_text(encoding="utf-8").lower().split()
)
assert "aggregate-only reader ui remains open" not in handbook
assert "separate aggregate **reports** surface" in handbook
assert "permission-aware action visibility on that detailed surface remains open work" in handbook
def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_resend_claim() -> None:
from govoplan_campaign.backend.manifest import get_manifest

View File

@@ -0,0 +1,207 @@
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
FIXTURE_ROOT = REPOSITORY_ROOT / "examples" / "simple-announcement"
_ISOLATED_ACCEPTANCE_PROGRAM = r"""
from __future__ import annotations
import hashlib
import importlib.abc
import json
import socket
import sys
from email import policy
from email.parser import BytesParser
from pathlib import Path
source_root = Path(sys.argv[1]).resolve()
fixture_root = Path(sys.argv[2]).resolve()
sys.path.insert(0, str(source_root))
class AbsentOptionalModuleFinder(importlib.abc.MetaPathFinder):
absent_roots = {"govoplan_files", "govoplan_mail"}
def find_spec(self, fullname, path=None, target=None):
if fullname.partition(".")[0] in self.absent_roots:
raise ModuleNotFoundError(
f"{fullname} is intentionally absent in the Campaign fixture check",
name=fullname,
)
return None
sys.meta_path.insert(0, AbsentOptionalModuleFinder())
def deny_network(*args, **kwargs):
raise AssertionError("the Campaign validate/build fixture must not open a network connection")
class NoNetworkSocket(socket.socket):
def connect(self, address):
deny_network(address)
def connect_ex(self, address):
deny_network(address)
socket.create_connection = deny_network
socket.socket = NoNetworkSocket
from govoplan_campaign.backend.campaign import ( # noqa: E402
load_campaign_config,
load_campaign_json,
validate_campaign_config,
)
from govoplan_campaign.backend.messages import build_campaign_messages # noqa: E402
metadata = json.loads((fixture_root / "fixture.json").read_text(encoding="utf-8"))
assert metadata["required_modules"] == ["core", "access", "campaigns"]
assert metadata["absent_optional_modules"] == ["files", "mail"]
assert metadata["external_effects"] == "forbidden"
campaign_file = fixture_root / metadata["campaign_file"]
raw_campaign = load_campaign_json(campaign_file)
def iter_keys(value):
if isinstance(value, dict):
for key, nested in value.items():
yield key.casefold()
yield from iter_keys(nested)
elif isinstance(value, list):
for nested in value:
yield from iter_keys(nested)
forbidden_key_fragments = ("credential", "imap", "mail_profile", "password", "secret", "smtp")
assert not [
key
for key in iter_keys(raw_campaign)
if any(fragment in key for fragment in forbidden_key_fragments)
]
config = load_campaign_config(campaign_file)
assert config.server.mail_profile_id is None
assert not config.attachments.global_
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
assert validation.ok
assert validation.error_count == 0
assert validation.warning_count == 0
assert validation.entries_count == metadata["expected"]["entries_count"]
def build(output_name):
return build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=fixture_root.parent / output_name,
write_eml=True,
)
first = build("build-first")
second = build("build-second")
expected = metadata["expected"]
for result in (first, second):
assert result.report.campaign_id == expected["campaign_id"]
assert result.report.entries_count == expected["entries_count"]
assert result.report.built_count == expected["built_count"]
assert result.report.build_failed_count == 0
assert result.report.queueable_count == expected["queueable_count"]
assert len(result.built_messages) == 1
built = result.built_messages[0]
assert built.mime is not None
assert built.draft.subject == expected["subject"]
assert built.draft.validation_status.value == "ready"
assert built.draft.send_status.value == "draft"
assert built.draft.imap_status.value == "not_requested"
assert built.draft.attachment_count == expected["attachment_count"]
assert not built.draft.attachments
assert not built.draft.issues
assert built.draft.from_ is not None
assert built.draft.from_.email == "announcements@example.test"
assert [address.email for address in built.draft.to] == ["recipient@example.test"]
assert built.mime["Subject"] == expected["subject"]
assert "Hello Example Recipient" in built.mime.get_content()
assert list(built.mime.iter_attachments()) == []
def normalized_eml(path_value):
message = BytesParser(policy=policy.default).parsebytes(Path(path_value).read_bytes())
del message["Date"]
del message["Message-ID"]
return message.as_bytes(policy=policy.default)
first_eml = normalized_eml(first.report.messages[0].eml_path)
second_eml = normalized_eml(second.report.messages[0].eml_path)
assert first_eml == second_eml
assert not any(
name == root or name.startswith(root + ".")
for name in sys.modules
for root in ("govoplan_files", "govoplan_mail")
)
print(json.dumps({
"campaign_id": first.report.campaign_id,
"built_count": first.report.built_count,
"queueable_count": first.report.queueable_count,
"normalized_eml_sha256": hashlib.sha256(first_eml).hexdigest(),
}, sort_keys=True))
"""
class CampaignExampleAcceptanceTests(unittest.TestCase):
def test_simple_announcement_validates_and_builds_without_mail_or_files(self) -> None:
self.assertTrue((FIXTURE_ROOT / "campaign.json").is_file())
self.assertTrue((FIXTURE_ROOT / "fixture.json").is_file())
with tempfile.TemporaryDirectory(prefix="govoplan-campaign-acceptance-", dir="/tmp") as temp_dir:
workspace = Path(temp_dir).resolve()
self.assertNotIn(REPOSITORY_ROOT, workspace.parents)
isolated_fixture = workspace / "simple-announcement"
shutil.copytree(FIXTURE_ROOT, isolated_fixture)
completed = subprocess.run(
[
sys.executable,
"-I",
"-c",
_ISOLATED_ACCEPTANCE_PROGRAM,
str(REPOSITORY_ROOT / "src"),
str(isolated_fixture),
],
cwd=workspace,
check=False,
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(completed.returncode, 0, completed.stderr or completed.stdout)
evidence = json.loads(completed.stdout)
self.assertEqual(evidence["campaign_id"], "simple-announcement")
self.assertEqual(evidence["built_count"], 1)
self.assertEqual(evidence["queueable_count"], 1)
self.assertRegex(evidence["normalized_eml_sha256"], r"^[0-9a-f]{64}$")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from importlib import import_module
from unittest.mock import patch
from sqlalchemy import create_engine, text
from govoplan_campaign.backend.campaign.models import BuildStatus, SendStatus
from govoplan_campaign.backend.messages.models import ImapStatus, MessageDraft, MessageValidationStatus
from govoplan_campaign.backend.persistence.campaigns import _job_from_message
def test_excluded_message_persists_explicit_skipped_transport_states() -> None:
message = MessageDraft(
entry_index=0,
entry_id="excluded-entry",
active=True,
build_status=BuildStatus.BUILT,
validation_status=MessageValidationStatus.EXCLUDED,
send_status=SendStatus.SKIPPED,
imap_status=ImapStatus.SKIPPED,
)
job = _job_from_message(
tenant_id="tenant-1",
campaign_id="campaign-1",
version_id="version-1",
message=message,
)
assert job.send_status == "skipped"
assert job.imap_status == "skipped"
def test_status_migration_only_normalizes_excluded_rows_without_transport_evidence() -> None:
migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"d8b3e2c1f4a5_v0110_excluded_delivery_skipped"
)
engine = create_engine("sqlite+pysqlite:///:memory:")
with engine.begin() as connection:
connection.execute(text(
"CREATE TABLE campaign_jobs ("
"id TEXT PRIMARY KEY, validation_status TEXT NOT NULL, "
"send_status TEXT NOT NULL, imap_status TEXT NOT NULL)"
))
connection.execute(
text(
"INSERT INTO campaign_jobs (id, validation_status, send_status, imap_status) VALUES "
"('untouched', 'excluded', 'not_queued', 'pending'), "
"('evidence', 'excluded', 'smtp_accepted', 'appended'), "
"('queueable', 'ready', 'not_queued', 'pending')"
)
)
with patch.object(migration.op, "get_bind", return_value=connection):
migration.upgrade()
rows = {
row.id: (row.send_status, row.imap_status)
for row in connection.execute(
text("SELECT id, send_status, imap_status FROM campaign_jobs ORDER BY id")
)
}
assert rows["untouched"] == ("skipped", "skipped")
assert rows["evidence"] == ("smtp_accepted", "appended")
assert rows["queueable"] == ("not_queued", "pending")

View File

@@ -0,0 +1,149 @@
from __future__ import annotations
import unittest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.router import (
_campaign_jobs_grid_filter_expressions,
_campaign_jobs_ordering,
_campaign_jobs_page_response,
)
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
class CampaignJobListQueryTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
ChangeSequenceEntry.__table__,
],
)
self.session = Session(self.engine)
rows = [
(0, "ordinary-0@example.test", "General notice", "ready", "draft", "not_queued", "not_requested", 0),
(1, "ordinary-1@example.test", "General notice", "ready", "draft", "not_queued", "not_requested", 1),
(2, "target-c@example.test", "Target notice C", "warning", "queued", "queued", "pending", 2),
(3, "ordinary-3@example.test", "General notice", "blocked", "draft", "failed_permanent", "failed", 3),
(4, "target-b@example.test", "Target notice B", "ready", "draft", "failed_temporary", "not_requested", 4),
(5, "target-a@example.test", "Target notice A", "ready", "draft", "outcome_unknown", "outcome_unknown", 5),
(6, "excluded@example.test", "Excluded notice", "excluded", "draft", "skipped", "skipped", 0),
]
for entry_index, recipient, subject, validation, queue, send, imap, attempts in rows:
self.session.add(CampaignJob(
id=f"job-{entry_index}",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=entry_index,
entry_id=f"entry-{entry_index}",
recipient_email=recipient,
subject=subject,
message_id_header=f"<message-{entry_index}@example.test>",
eml_sha256=f"sha-{entry_index}",
build_status="built",
validation_status=validation,
queue_status=queue,
send_status=send,
imap_status=imap,
attempt_count=attempts,
resolved_attachments=[],
issues_snapshot=[],
))
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_grid_filters_apply_before_pagination_and_report_filtered_totals(self) -> None:
base_filters = [CampaignJob.tenant_id == "tenant-1", CampaignJob.campaign_id == "campaign-1"]
grid_filters = {"recipient": "target"}
filtered = [*base_filters, *_campaign_jobs_grid_filter_expressions(grid_filters)]
page = _campaign_jobs_page_response(
self.session,
campaign_id="campaign-1",
version_id="version-1",
base_filters=base_filters,
filtered=filtered,
reviewed_keys=set(),
review_metadata={},
page=1,
page_size=2,
grid_filters=grid_filters,
sort_by="recipient",
sort_direction="asc",
)
self.assertEqual(page.total, 3)
self.assertEqual(page.total_unfiltered, 7)
self.assertEqual(page.pages, 2)
self.assertEqual(
[row["recipient_email"] for row in page.jobs],
["target-a@example.test", "target-b@example.test"],
)
self.assertIsNone(page.next_cursor)
def test_list_and_integer_filters_share_the_full_backend_query(self) -> None:
expressions = _campaign_jobs_grid_filter_expressions({
"send": 'list:["failed_temporary","outcome_unknown"]',
"attempts": "gte:4",
})
rows = (
self.session.query(CampaignJob)
.filter(*expressions)
.order_by(*_campaign_jobs_ordering("attempts", "desc"))
.all()
)
self.assertEqual([row.id for row in rows], ["job-5", "job-4"])
def test_invalid_list_filters_fail_closed(self) -> None:
with self.assertRaises(HTTPException) as raised:
_campaign_jobs_grid_filter_expressions({"send": 'list:["not-a-status"]'})
self.assertEqual(raised.exception.status_code, 422)
def test_skipped_transport_filters_and_counts_remain_separate(self) -> None:
base_filters = [CampaignJob.tenant_id == "tenant-1", CampaignJob.campaign_id == "campaign-1"]
grid_filters = {"send": 'list:["skipped"]', "imap": 'list:["skipped"]'}
filtered = [*base_filters, *_campaign_jobs_grid_filter_expressions(grid_filters)]
page = _campaign_jobs_page_response(
self.session,
campaign_id="campaign-1",
version_id="version-1",
base_filters=base_filters,
filtered=filtered,
reviewed_keys=set(),
review_metadata={},
page=1,
page_size=20,
grid_filters=grid_filters,
)
self.assertEqual([row["id"] for row in page.jobs], ["job-6"])
self.assertEqual(page.counts["send"]["skipped"], 1)
self.assertEqual(page.counts["send"]["not_queued"], 2)
self.assertEqual(page.counts["imap"]["skipped"], 1)
self.assertEqual(page.filtered_counts["send"], {"skipped": 1})
self.assertEqual(page.filtered_counts["imap"], {"skipped": 1})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,480 @@
from __future__ import annotations
import importlib.util
import json
import smtplib
import sys
from pathlib import Path
from typing import Any
import pytest
from govoplan_campaign.backend.campaign import load_campaign_config
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
RUNNER_PATH = REPOSITORY_ROOT / "dev" / "mail-testbed" / "run_campaign_acceptance.py"
FIXTURE_PATH = REPOSITORY_ROOT / "examples" / "greenmail-delivery" / "campaign.json"
def _load_runner():
spec = importlib.util.spec_from_file_location("govoplan_campaign_greenmail_acceptance", RUNNER_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
runner = _load_runner()
class _Response:
def __init__(self, status_code: int, payload: dict[str, Any]) -> None:
self.status_code = status_code
self._payload = payload
def json(self) -> dict[str, Any]:
return self._payload
class _AcceptanceClient:
def __init__(self) -> None:
self.campaign_json: dict[str, Any] | None = None
self.send_calls = 0
def post(self, path: str, **kwargs: Any) -> _Response:
if path == "/api/v1/campaigns":
self.campaign_json = kwargs["json"]["config"]
return _Response(
200,
{
"campaign": {"id": "campaign-internal"},
"version": {"id": "version-internal"},
},
)
if path.endswith("/validate"):
return _Response(200, {"ok": True, "error_count": 0, "warning_count": 0})
if path.endswith("/build"):
return _Response(
200,
{
"built_count": 1,
"build_failed_count": 0,
"queueable_count": 1,
},
)
if path.endswith("/send-now"):
self.send_calls += 1
if self.send_calls == 2:
return _Response(422, {"detail": "Already accepted"})
return _Response(
200,
{
"result": {
"attempted_count": 1,
"sent_count": 1,
"failed_count": 0,
"outcome_unknown_count": 0,
"skipped_count": 0,
"delivery_mode": "synchronous",
"results": [{"job_id": "not-retained", "status": "smtp_accepted"}],
}
},
)
if path.endswith("/append-sent"):
return _Response(
200,
{
"result": {
"pending_count": 1,
"processed_count": 1,
"appended_count": 1,
"failed_count": 0,
"skipped_count": 0,
"results": [{"job_id": "not-retained", "status": "appended"}],
}
},
)
raise AssertionError(f"unexpected POST {path}")
def get(self, path: str, **kwargs: Any) -> _Response:
if path.endswith("/report"):
return _Response(
200,
{
"cards": {
"jobs_total": 1,
"sent": 1,
"smtp_accepted": 1,
"failed": 0,
"outcome_unknown": 0,
"retryable": 0,
"needs_attention": 0,
"imap_appended": 1,
"imap_failed": 0,
},
"status_counts": {
"send": {"smtp_accepted": 1},
"imap": {"appended": 1},
},
},
)
raise AssertionError(f"unexpected GET {path}")
def _settings():
return runner.TestbedSettings(
smtp_host="127.0.0.1",
smtp_port=3025,
imap_host="127.0.0.1",
imap_port=3143,
username="campaign-test@govoplan.test",
password="local-test-password",
sender="campaign-test@govoplan.test",
recipient="campaign-test@govoplan.test",
sent_folder="Sent",
provider_timeout_seconds=5,
)
@pytest.mark.parametrize("host", ["localhost", "mail.test", "192.168.1.20", "8.8.8.8"])
def test_testbed_rejects_hostnames_and_non_loopback_addresses(host: str) -> None:
settings = _settings()
rejected = runner.TestbedSettings(
smtp_host=host,
smtp_port=settings.smtp_port,
imap_host=settings.imap_host,
imap_port=settings.imap_port,
username=settings.username,
password=settings.password,
sender=settings.sender,
recipient=settings.recipient,
sent_folder=settings.sent_folder,
provider_timeout_seconds=settings.provider_timeout_seconds,
)
with pytest.raises(runner.AcceptanceError, match="literal loopback|restricted to the loopback"):
rejected.assert_local_testbed()
@pytest.mark.parametrize("host", ["127.0.0.1", "127.8.9.10", "::1"])
def test_testbed_accepts_literal_loopback_and_preserves_it_in_profile(host: str) -> None:
settings = _settings()
accepted = runner.TestbedSettings(
smtp_host=host,
smtp_port=settings.smtp_port,
imap_host=host,
imap_port=settings.imap_port,
username=settings.username,
password=settings.password,
sender=settings.sender,
recipient=settings.recipient,
sent_folder=settings.sent_folder,
provider_timeout_seconds=settings.provider_timeout_seconds,
)
accepted.assert_local_testbed()
profile = runner._profile_payload(accepted, name="Literal loopback")
assert profile["smtp"]["host"] == host
assert profile["imap"]["host"] == host
def test_campaign_acceptance_orchestration_retains_only_profile_reference_and_safe_evidence() -> None:
client = _AcceptanceClient()
def snapshot_probe(_version_id: str):
assert client.campaign_json is not None
return client.campaign_json, {
"mail_profile_id": "profile-1",
"smtp_transport_revision": "opaque-smtp-revision",
"imap_transport_revision": "opaque-imap-revision",
"delivery": {"imap_append_sent": {"enabled": True, "folder": "Sent"}},
}
audit = {
"campaign.created": 1,
"campaign.validated": 1,
"campaign.messages_built": 1,
"campaign.sent_now": 1,
"campaign.send_now_rejected": 1,
"campaign.append_sent_enqueued": 1,
}
evidence, subject = runner.execute_campaign_scenario(
client,
{"Authorization": "not-retained"},
fixture_path=FIXTURE_PATH,
profile_id="profile-1",
settings=_settings(),
scenario="success",
snapshot_probe=snapshot_probe,
audit_probe=lambda _campaign_id, _version_id: audit,
append_sent=True,
repeat_send=True,
)
evidence["provider_verification"] = {
"inbox_increment": 1,
"sent_increment": 1,
"unique_subject_matches_in_inbox": 1,
"unique_subject_matches_in_sent": 1,
}
runner._assert_success_evidence(evidence)
runner._assert_evidence_safe(
{
"schema_version": runner.EVIDENCE_SCHEMA,
"coverage": {
"smtp_acceptance": True,
"partial_envelope_refusal": False,
"post_data_connection_loss_outcome_unknown": False,
"source_artifact_provenance": False,
"worker_restart_interruption": False,
},
"success": evidence,
},
settings=_settings(),
)
assert subject.startswith("[GovOPlaN acceptance ")
assert client.send_calls == 2
assert client.campaign_json is not None
assert client.campaign_json["server"] == {"mail_profile_id": "profile-1"}
assert "credentials" not in json.dumps(client.campaign_json).casefold()
serialized = json.dumps(evidence, sort_keys=True)
assert "not-retained" not in serialized
assert "local-test-password" not in serialized
def test_campaign_boundary_rejects_resolved_transport_material() -> None:
with pytest.raises(runner.AcceptanceError, match="forbidden transport material"):
runner.assert_campaign_boundary(
{"server": {"mail_profile_id": "profile-1"}},
{
"mail_profile_id": "profile-1",
"smtp_transport_revision": "smtp-revision",
"imap_transport_revision": "imap-revision",
"smtp": {"host": "should-not-be-here"},
},
profile_id="profile-1",
)
def test_success_projection_fails_closed_on_inconsistent_campaign_report() -> None:
evidence = {
"send": {
"attempted_count": 1,
"sent_count": 1,
"failed_count": 0,
"outcome_unknown_count": 0,
"skipped_count": 0,
"delivery_mode": "synchronous",
"statuses": {"smtp_accepted": 1},
},
"append_sent": {
"pending_count": 1,
"processed_count": 1,
"appended_count": 1,
"failed_count": 0,
"skipped_count": 0,
"statuses": {"appended": 1},
},
"report": {
"cards": {
"jobs_total": 1,
"sent": 0,
"smtp_accepted": 0,
"failed": 0,
"outcome_unknown": 0,
"needs_attention": 0,
"imap_appended": 0,
"imap_failed": 0,
},
"send_status_counts": {},
"imap_status_counts": {},
},
"provider_verification": {
"inbox_increment": 1,
"sent_increment": 1,
"unique_subject_matches_in_inbox": 1,
"unique_subject_matches_in_sent": 1,
},
"campaign_mail_boundary": {
"profile_reference_only": True,
"smtp_revision_frozen": True,
"imap_revision_frozen": True,
"resolved_transport_material_present": False,
},
}
with pytest.raises(runner.AcceptanceError, match="report does not agree"):
runner._assert_success_evidence(evidence)
def test_evidence_projection_rejects_unknown_status_keys() -> None:
with pytest.raises(runner.AcceptanceError, match="unsupported status"):
runner._send_evidence(
{
"delivery_mode": "synchronous",
"results": [{"status": "provider diagnostic: recipient@example.test"}],
}
)
with pytest.raises(runner.AcceptanceError, match="durable attempt status"):
runner._durable_state_evidence(
{
"job_count": 1,
"send_status_counts": {"sending": 1},
"attempt_status_counts": {"provider-secret": 1},
"unfinished_attempt_count": 1,
}
)
with pytest.raises(runner.AcceptanceError, match="synchronous delivery mode"):
runner._send_evidence(
{
"delivery_mode": "provider diagnostic: recipient@example.test",
"results": [],
}
)
with pytest.raises(runner.AcceptanceError, match="unsupported"):
runner._report_evidence(
{
"cards": {},
"status_counts": {
"send": {"smtp_accepted": 1, "provider-secret": 1},
"imap": {},
},
}
)
def _open_fault_smtp(endpoint):
client = smtplib.SMTP(endpoint.host, endpoint.port, timeout=5)
client.ehlo()
client.login("acceptance-user", "acceptance-password")
return client
def test_explicit_temporary_smtp_response_occurs_after_complete_data() -> None:
with runner.smtp_fault_endpoint("temporary_data_response") as endpoint:
client = _open_fault_smtp(endpoint)
try:
with pytest.raises(smtplib.SMTPDataError) as captured:
client.sendmail(
"sender@example.test",
["recipient@example.test"],
b"Subject: temporary\r\n\r\nmessage",
)
finally:
client.close()
assert captured.value.smtp_code == 451
assert endpoint.evidence() == {
"connection_count": 1,
"accepted_rcpt_commands": 1,
"refused_rcpt_commands": 0,
"data_transactions": 1,
}
def test_partial_recipient_refusal_retains_one_accepted_envelope() -> None:
with runner.smtp_fault_endpoint("partial_recipient_refusal") as endpoint:
client = _open_fault_smtp(endpoint)
try:
refused = client.sendmail(
"sender@example.test",
["accepted@example.test", "refused@example.test"],
b"Subject: partial\r\n\r\nmessage",
)
finally:
client.quit()
assert set(refused) == {"refused@example.test"}
assert endpoint.evidence() == {
"connection_count": 1,
"accepted_rcpt_commands": 1,
"refused_rcpt_commands": 1,
"data_transactions": 1,
}
def test_post_data_disconnect_is_a_real_ambiguous_protocol_boundary() -> None:
with runner.smtp_fault_endpoint("post_data_disconnect") as endpoint:
client = _open_fault_smtp(endpoint)
try:
with pytest.raises(smtplib.SMTPServerDisconnected):
client.sendmail(
"sender@example.test",
["recipient@example.test"],
b"Subject: ambiguous\r\n\r\nmessage",
)
finally:
client.close()
assert endpoint.wait_for_data(1)
assert endpoint.evidence()["data_transactions"] == 1
def test_partial_refusal_fixture_adds_a_second_distinct_recipient() -> None:
raw, _subject = runner.materialize_campaign_fixture(
FIXTURE_PATH,
profile_id="profile-1",
settings=_settings(),
scenario="partial_envelope_refusal",
run_token="0123456789ab",
additional_envelope_recipient=True,
)
recipients = raw["entries"]["inline"][0]["to"]
assert len(recipients) == 2
assert recipients[0]["email"] != recipients[1]["email"]
assert recipients[1]["email"].endswith("@govoplan.test")
def test_fixture_contains_no_transport_credentials() -> None:
raw = json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
runner._assert_no_forbidden_campaign_keys(raw)
assert raw["server"] == {"mail_profile_id": "00000000-0000-4000-8000-000000000001"}
config = load_campaign_config(FIXTURE_PATH)
assert config.server.mail_profile_id == "00000000-0000-4000-8000-000000000001"
def test_fixture_composition_versions_are_complete_and_exact() -> None:
versions = {
"core": "0.1.13",
"access": "0.1.11",
"audit": "0.1.8",
"campaigns": "0.1.11",
"mail": "0.1.10",
"files": "0.1.9",
}
assert runner.required_composition_versions(FIXTURE_PATH, versions) == {
"core": "0.1.13",
"access": "0.1.11",
"audit": "0.1.8",
"campaigns": "0.1.11",
"mail": "0.1.10",
}
def test_fixture_composition_fails_closed_when_a_required_version_is_missing() -> None:
with pytest.raises(runner.AcceptanceError, match="versions are unavailable"):
runner.required_composition_versions(
FIXTURE_PATH,
{
"core": "0.1.13",
"access": "0.1.11",
"campaigns": "0.1.11",
"mail": "0.1.10",
},
)
def test_testbed_documentation_distinguishes_proven_and_open_failure_drills() -> None:
testbed = (REPOSITORY_ROOT / "dev" / "mail-testbed" / "README.md").read_text(encoding="utf-8")
runbook = (REPOSITORY_ROOT / "docs" / "CAMPAIGN_DELIVERY_RUNBOOK.md").read_text(encoding="utf-8")
assert "second ordinary send must be rejected before another provider effect" in testbed
assert "connection loss after complete DATA is frozen" in testbed
assert "dedicated OS process" in testbed
assert "Redis/Celery delivery" in runbook
assert "broker redelivery" in runbook
assert "celery_broker_redelivery" in testbed
assert "raw provider diagnostics" in runbook

View File

@@ -0,0 +1,216 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignStatus,
CampaignVersion,
JobBuildStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
)
from govoplan_campaign.backend.sending.jobs import (
QueueingError,
cancel_campaign_jobs,
pause_campaign_jobs,
resume_campaign_jobs,
)
from govoplan_campaign.backend.reports.campaigns import _campaign_report_cards, _version_info
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.db.base import Base
class CampaignQueueControlTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
],
)
self.session = Session(self.engine)
self._add_campaign("campaign-1", "tenant-1", "version-1")
self._add_campaign("campaign-2", "tenant-2", "version-2")
self._add_job("queued", queue="queued", send="queued")
self._add_job("paused", queue="paused", send="queued")
self._add_job("failed", queue="draft", send="failed_temporary")
self._add_job("unknown", queue="draft", send="outcome_unknown")
self._add_job("accepted", queue="draft", send="smtp_accepted")
self._add_job("claimed", queue="sending", send="claimed")
self._add_job(
"excluded",
queue="draft",
send="skipped",
validation="excluded",
imap="skipped",
)
self._add_job(
"other-tenant",
tenant_id="tenant-2",
campaign_id="campaign-2",
version_id="version-2",
queue="queued",
send="queued",
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_pause_and_resume_change_only_valid_jobs_in_the_selected_tenant(self) -> None:
with patch("govoplan_campaign.backend.sending.jobs._emit_campaign_status_notification"):
paused = pause_campaign_jobs(
self.session,
tenant_id="tenant-1",
campaign_id="campaign-1",
)
resumed = resume_campaign_jobs(
self.session,
tenant_id="tenant-1",
campaign_id="campaign-1",
enqueue_celery=False,
)
self.assertEqual(paused["paused_count"], 1)
self.assertEqual(resumed, {
"campaign_id": "campaign-1",
"resumed_count": 2,
"enqueued_count": 0,
})
self.session.expire_all()
self.assertEqual(self.session.get(Campaign, "campaign-1").status, CampaignStatus.QUEUED.value)
version = self.session.get(CampaignVersion, "version-1")
self.assertEqual(version.delivery_mode, "database_queue")
self.assertIsNotNone(version.delivery_mode_selected_at)
self.assertEqual(self.session.get(CampaignJob, "queued").queue_status, JobQueueStatus.QUEUED.value)
self.assertEqual(self.session.get(CampaignJob, "paused").queue_status, JobQueueStatus.QUEUED.value)
self.assertEqual(self.session.get(CampaignJob, "other-tenant").queue_status, JobQueueStatus.QUEUED.value)
def test_cancel_is_auditable_idempotent_state_change_and_protects_started_effects(self) -> None:
with patch("govoplan_campaign.backend.sending.jobs._emit_campaign_status_notification"):
first = cancel_campaign_jobs(
self.session,
tenant_id="tenant-1",
campaign_id="campaign-1",
)
second = cancel_campaign_jobs(
self.session,
tenant_id="tenant-1",
campaign_id="campaign-1",
)
self.assertEqual(first["cancelled_count"], 3)
self.assertEqual(first["protected_count"], 3)
self.assertEqual(first["skipped_count"], 1)
self.assertEqual(second["cancelled_count"], 0)
self.assertEqual(second["protected_count"], 3)
self.assertEqual(second["skipped_count"], 1)
self.session.expire_all()
for job_id in ("queued", "paused", "failed"):
job = self.session.get(CampaignJob, job_id)
self.assertEqual(job.queue_status, JobQueueStatus.CANCELLED.value)
self.assertEqual(job.send_status, JobSendStatus.CANCELLED.value)
self.assertEqual(self.session.get(CampaignJob, "unknown").send_status, JobSendStatus.OUTCOME_UNKNOWN.value)
self.assertEqual(self.session.get(CampaignJob, "accepted").send_status, JobSendStatus.SMTP_ACCEPTED.value)
self.assertEqual(self.session.get(CampaignJob, "claimed").send_status, JobSendStatus.CLAIMED.value)
self.assertEqual(self.session.get(CampaignJob, "excluded").send_status, JobSendStatus.SKIPPED.value)
self.assertEqual(self.session.get(CampaignJob, "excluded").queue_status, JobQueueStatus.DRAFT.value)
self.assertEqual(self.session.get(CampaignJob, "other-tenant").send_status, JobSendStatus.QUEUED.value)
def test_controls_fail_closed_for_a_campaign_owned_by_another_tenant(self) -> None:
for control in (pause_campaign_jobs, resume_campaign_jobs, cancel_campaign_jobs):
with self.subTest(control=control.__name__), self.assertRaises(QueueingError):
control(
self.session,
tenant_id="tenant-1",
campaign_id="campaign-2",
)
def test_queue_projection_exposes_exact_action_counts_and_persisted_mode_only(self) -> None:
version = self.session.get(CampaignVersion, "version-1")
version.delivery_mode = "worker_queue"
version.delivery_mode_selected_at = version.updated_at
jobs = (
self.session.query(CampaignJob)
.filter(CampaignJob.tenant_id == "tenant-1")
.all()
)
cards = _campaign_report_cards(version, jobs)
projected_version = _version_info(version)
self.assertEqual(cards["retryable"], 1)
self.assertEqual(cards["queueable_unattempted"], 0)
self.assertEqual(cards["cancellable"], 3)
self.assertEqual(cards["skipped"], 1)
self.assertEqual(cards["imap_skipped"], 1)
self.assertEqual(projected_version["delivery_mode"], "worker_queue")
self.assertIn("delivery_mode_selected_at", projected_version)
self.assertNotIn("execution_snapshot", projected_version)
def _add_campaign(self, campaign_id: str, tenant_id: str, version_id: str) -> None:
self.session.add(Campaign(
id=campaign_id,
tenant_id=tenant_id,
external_id=f"external-{campaign_id}",
name=campaign_id,
status=CampaignStatus.QUEUED.value,
current_version_id=version_id,
settings={},
mail_profile_policy={},
))
self.session.add(CampaignVersion(
id=version_id,
campaign_id=campaign_id,
version_number=1,
raw_json={},
))
def _add_job(
self,
job_id: str,
*,
queue: str,
send: str,
tenant_id: str = "tenant-1",
campaign_id: str = "campaign-1",
version_id: str = "version-1",
validation: str = JobValidationStatus.READY.value,
imap: str = "not_requested",
) -> None:
self.session.add(CampaignJob(
id=job_id,
tenant_id=tenant_id,
campaign_id=campaign_id,
campaign_version_id=version_id,
entry_index=len(self.session.new),
entry_id=f"entry-{job_id}",
build_status=JobBuildStatus.BUILT.value,
validation_status=validation,
queue_status=queue,
send_status=send,
imap_status=imap,
resolved_attachments=[],
issues_snapshot=[],
))
if __name__ == "__main__":
unittest.main()

View File

@@ -74,7 +74,10 @@ def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> No
def test_report_email_requires_permission_to_use_selected_mail_profile() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"])
principal = SimpleNamespace(tenant_id="tenant-1")
principal = SimpleNamespace(
tenant_id="tenant-1",
has=lambda scope: scope == "campaigns:recipient:export",
)
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
version = SimpleNamespace(
id="version-1",
@@ -106,7 +109,10 @@ def test_report_email_requires_permission_to_use_selected_mail_profile() -> None
def test_unexpected_report_failure_does_not_leak_internal_details() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"])
principal = SimpleNamespace(tenant_id="tenant-1")
principal = SimpleNamespace(
tenant_id="tenant-1",
has=lambda scope: scope == "campaigns:recipient:export",
)
with (
patch.object(
router,

View File

@@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock, patch
from govoplan_campaign.backend import router
from govoplan_campaign.backend.schemas import SendCampaignNowRequest
from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult
def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() -> None:
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
version = SimpleNamespace(
id="version-1",
raw_json={},
locked_at=datetime(2026, 7, 22, tzinfo=UTC),
validation_summary={
"ok": True,
"error_count": 0,
"warning_count": 1,
"issues": [{"message": "provider-secret validation detail"}],
},
build_summary={
"built_count": 2,
"build_failed_count": 0,
"ready_count": 2,
"warning_count": 1,
"messages": [{"recipient": "refused-recipient@example.test"}],
"build_token": "provider-secret-build-token",
},
)
delivery = SendCampaignNowResult(
campaign_id="campaign-1",
version_id="version-1",
attempted_count=2,
sent_count=1,
failed_count=1,
outcome_unknown_count=0,
skipped_count=0,
preflight_count=2,
synchronous_send_policy={
"max_recipient_jobs": 25,
"source": "deployment_default",
"deployment_max_recipient_jobs": 25,
"tenant_max_recipient_jobs": None,
"provider_diagnostic": "provider-secret-policy",
},
results=[
{
"job_id": "job-failed",
"status": "failed",
"attempt_number": 1,
"message": "provider-secret smtp.internal.example",
"recipient_email": "private@example.test",
},
{
"job_id": "job-refused",
"status": "smtp_accepted",
"attempt_number": 1,
"message": (
"SMTP accepted 1/2 envelope recipient(s); refused recipients: "
'{"refused-recipient@example.test": [550, "not allowed"]}'
),
},
],
)
principal = SimpleNamespace(tenant_id="tenant-1", user=SimpleNamespace(id="user-1"), api_key=None)
audit = Mock()
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_require_permission"),
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
patch.object(router, "_get_version_for_tenant", return_value=version),
patch.object(router, "_require_mail_profile_use_if_needed"),
patch.object(router, "is_user_locked_version", return_value=False),
patch.object(router, "send_campaign_now", return_value=delivery),
patch.object(router, "audit_from_principal", audit),
):
response = router.send_campaign_now_endpoint(
"campaign-1",
SendCampaignNowRequest(),
session=Mock(),
principal=principal, # type: ignore[arg-type]
)
payload = response.model_dump(mode="json")["result"]
assert payload["results"] == [
{"job_id": "job-failed", "status": "failed", "attempt_number": 1},
{"job_id": "job-refused", "status": "smtp_accepted", "attempt_number": 1},
]
assert payload["validation"] == {"ok": True, "error_count": 0, "warning_count": 1}
assert payload["build"] == {
"built_count": 2,
"build_failed_count": 0,
"ready_count": 2,
"warning_count": 1,
}
assert payload["synchronous_send_policy"] == {
"max_recipient_jobs": 25,
"source": "deployment_default",
"deployment_max_recipient_jobs": 25,
"tenant_max_recipient_jobs": None,
}
audit_details = audit.call_args.kwargs["details"]
assert set(audit_details) == {
"campaign_id",
"version_id",
"attempted_count",
"sent_count",
"failed_count",
"outcome_unknown_count",
"skipped_count",
"preflight_count",
"delivery_mode",
"dry_run",
"synchronous_send_policy",
}
assert "results" not in audit_details
assert "validation" not in audit_details
assert "build" not in audit_details
serialized = repr({"response": payload, "audit": audit_details})
for forbidden in (
"provider-secret",
"smtp.internal.example",
"private@example.test",
"refused-recipient@example.test",
"not allowed",
):
assert forbidden not in serialized

View File

@@ -10,6 +10,7 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus,
JobValidationStatus,
)
from govoplan_campaign.backend.capabilities import delivery_tasks_capability
from govoplan_campaign.backend.sending.jobs import (
SendJobResult,
_queue_validation_statuses,
@@ -48,6 +49,17 @@ def _job(entry_id: str, **overrides):
class CampaignQueueSelectionTests(unittest.TestCase):
def test_delivery_task_capability_configures_module_runtime_for_worker_processes(self):
registry = object()
settings = object()
context = SimpleNamespace(registry=registry, settings=settings)
with patch("govoplan_campaign.backend.runtime.configure_runtime") as configure:
capability = delivery_tasks_capability(context)
self.assertIsNotNone(capability)
configure.assert_called_once_with(registry=registry, settings=settings)
def test_selects_queueable_jobs_without_reclassifying_retry_states(self):
skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value)
skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value)

View File

@@ -0,0 +1,358 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from govoplan_campaign.backend import router
from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError,
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
effective_synchronous_send_policy,
)
from govoplan_campaign.backend.db.models import (
JobBuildStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
)
from govoplan_campaign.backend.sending.jobs import (
QueueCampaignResult,
SynchronousSendRejected,
_ensure_synchronous_send_count_allowed,
_preflight_synchronous_send_batch,
queue_campaign_jobs,
send_campaign_now,
synchronous_send_candidate_jobs,
synchronous_send_options,
)
class _PolicySession:
def __init__(self, settings: dict[str, object] | None = None) -> None:
self.tenant = SimpleNamespace(settings=settings or {})
def get(self, _model, _id):
return self.tenant
def _version() -> SimpleNamespace:
return SimpleNamespace(
id="version-1",
locked_at=object(),
published_at=None,
validation_summary={"ok": True},
build_summary={"build_token": "build-1"},
editor_state={
"review_send": {
"build_token": "build-1",
"inspection_complete": True,
"reviewed_message_keys": ["reviewed"],
}
},
)
class _Principal:
def __init__(self, *scopes: str) -> None:
self.scopes = set(scopes)
self.tenant_id = "tenant-1"
self.user = SimpleNamespace(id="user-1")
def has(self, scope: str) -> bool:
return scope in self.scopes
def _job(job_id: str, **overrides: object) -> SimpleNamespace:
values: dict[str, object] = {
"id": job_id,
"entry_id": job_id,
"entry_index": 1,
"build_status": JobBuildStatus.BUILT.value,
"validation_status": JobValidationStatus.READY.value,
"queue_status": JobQueueStatus.DRAFT.value,
"send_status": JobSendStatus.NOT_QUEUED.value,
"eml_local_path": f"{job_id}.eml",
"eml_storage_key": None,
}
values.update(overrides)
return SimpleNamespace(**values)
def test_synchronous_policy_defaults_to_25_and_tenant_can_only_narrow() -> None:
default = effective_synchronous_send_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={},
)
assert default.max_recipient_jobs == DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS == 25
assert default.source == "deployment_default"
narrowed = effective_synchronous_send_policy(
_PolicySession(
{"campaign_delivery_policy": {"synchronous_send_max_recipients": 10}}
), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "40"},
)
assert narrowed.max_recipient_jobs == 10
assert narrowed.source == "tenant"
ceiling = effective_synchronous_send_policy(
_PolicySession(
{"campaign_delivery_policy": {"synchronous_send_max_recipients": 100}}
), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "40"},
)
assert ceiling.max_recipient_jobs == 40
assert ceiling.source == "deployment_ceiling"
@pytest.mark.parametrize("value", [True, -1, 501, "2.5", "unbounded"])
def test_invalid_synchronous_policy_fails_closed(value: object) -> None:
with pytest.raises(CampaignDeliveryPolicyError):
effective_synchronous_send_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": value}, # type: ignore[dict-item]
)
def test_candidate_count_uses_exact_built_and_reviewed_job_states() -> None:
jobs = [
_job("ready"),
_job("queued", queue_status=JobQueueStatus.QUEUED.value, send_status=JobSendStatus.QUEUED.value),
_job("reviewed", validation_status=JobValidationStatus.NEEDS_REVIEW.value),
_job("blocked", validation_status=JobValidationStatus.BLOCKED.value),
_job("failed", send_status=JobSendStatus.FAILED_TEMPORARY.value),
_job("missing-eml", eml_local_path=None),
]
candidates = synchronous_send_candidate_jobs(_version(), jobs) # type: ignore[arg-type]
assert [job.id for job in candidates] == ["ready", "queued", "reviewed"]
def test_limit_and_zero_count_reject_before_delivery() -> None:
policy = effective_synchronous_send_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "2"},
)
with pytest.raises(SynchronousSendRejected, match="No eligible") as empty:
_ensure_synchronous_send_count_allowed(0, policy=policy)
assert empty.value.reason == "no_eligible_recipient_jobs"
with pytest.raises(SynchronousSendRejected, match="Queue it") as oversized:
_ensure_synchronous_send_count_allowed(3, policy=policy)
assert oversized.value.audit_details()["eligible_recipient_job_count"] == 3
assert oversized.value.audit_details()["synchronous_send_policy"]["max_recipient_jobs"] == 2
def test_exact_synchronous_policy_boundary_is_allowed() -> None:
policy = effective_synchronous_send_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "2"},
)
_ensure_synchronous_send_count_allowed(2, policy=policy)
def test_post_queue_growth_is_rejected_before_batch_or_provider_preflight() -> None:
policy = effective_synchronous_send_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "2"},
)
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
initial_jobs = [_job("one"), _job("two")]
post_queue_jobs = [
_job(
job_id,
queue_status=JobQueueStatus.QUEUED.value,
send_status=JobSendStatus.QUEUED.value,
)
for job_id in ("one", "two", "concurrent")
]
queued = QueueCampaignResult(
campaign_id="campaign-1",
version_id="version-1",
queued_count=2,
skipped_count=0,
blocked_count=0,
enqueued_count=0,
delivery_mode="synchronous",
)
with (
patch("govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign),
patch("govoplan_campaign.backend.sending.jobs._get_current_version", return_value=_version()),
patch("govoplan_campaign.backend.sending.jobs._ensure_version_validated_and_locked"),
patch("govoplan_campaign.backend.sending.jobs._ensure_campaign_execution_snapshot"),
patch("govoplan_campaign.backend.sending.jobs.effective_synchronous_send_policy", return_value=policy),
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_queue", return_value=initial_jobs),
patch("govoplan_campaign.backend.sending.jobs.queue_campaign_jobs", return_value=queued),
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_version", return_value=post_queue_jobs),
patch("govoplan_campaign.backend.sending.jobs._preflight_synchronous_send_batch") as batch_preflight,
):
with pytest.raises(SynchronousSendRejected, match="above the effective") as rejected:
send_campaign_now(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
)
assert rejected.value.eligible_count == 3
batch_preflight.assert_not_called()
@pytest.mark.parametrize(
("workers_available", "expected_mode", "expected_enqueued"),
((False, "database_queue", 0), (True, "worker_queue", 1)),
)
def test_asynchronous_mode_matches_actual_worker_availability(
workers_available: bool,
expected_mode: str,
expected_enqueued: int,
) -> None:
campaign = SimpleNamespace(id="campaign-1")
version = _version()
job = _job("one")
with (
patch("govoplan_campaign.backend.sending.jobs._celery_enabled", return_value=workers_available),
patch("govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign),
patch("govoplan_campaign.backend.sending.jobs._get_current_version", return_value=version),
patch("govoplan_campaign.backend.sending.jobs._ensure_version_validated_and_locked"),
patch("govoplan_campaign.backend.sending.jobs._ensure_campaign_execution_snapshot"),
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_queue", return_value=[job]),
patch(
"govoplan_campaign.backend.sending.jobs._select_campaign_jobs_for_queue",
return_value=([job], 0, 0),
),
patch("govoplan_campaign.backend.sending.jobs._persist_campaign_queue") as persist,
patch(
"govoplan_campaign.backend.sending.jobs._enqueue_campaign_jobs",
return_value=expected_enqueued,
) as enqueue,
):
result = queue_campaign_jobs(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
enqueue_celery=True,
)
assert result.delivery_mode == expected_mode
assert result.worker_queue_available is workers_available
assert result.enqueued_count == expected_enqueued
assert persist.call_args.kwargs["delivery_mode"] == expected_mode
assert enqueue.call_args.kwargs["enabled"] is workers_available
def test_invalid_policy_disables_synchronous_mode_without_hiding_queue_availability() -> None:
campaign = SimpleNamespace(id="campaign-1")
version = _version()
with (
patch("govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign),
patch("govoplan_campaign.backend.sending.jobs._get_version_for_campaign", return_value=version),
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_version", return_value=[_job("one")]),
patch("govoplan_campaign.backend.sending.jobs._celery_enabled", return_value=True),
patch(
"govoplan_campaign.backend.sending.jobs.effective_synchronous_send_policy",
side_effect=CampaignDeliveryPolicyError("invalid deployment value"),
),
):
options = synchronous_send_options(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
)
assert options["worker_queue_available"] is True
assert options["synchronous_send"]["allowed"] is False
assert options["synchronous_send"]["reason"] == "policy_configuration_invalid"
assert options["synchronous_send"]["policy"] == {}
@pytest.mark.parametrize(
("path", "required_scope"),
(
("/campaigns/{campaign_id}/send-now", "campaigns:campaign:send"),
("/campaigns/{campaign_id}/queue", "campaigns:campaign:queue"),
),
)
def test_delivery_endpoints_require_their_mode_permission_and_recipient_authority(
path: str,
required_scope: str,
) -> None:
route = next(item for item in router.router.routes if item.path == path)
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
with pytest.raises(HTTPException) as missing_mode_permission:
dependency.call(_Principal("campaigns:recipient:read"))
assert missing_mode_permission.value.status_code == 403
allowed = _Principal(required_scope, "campaigns:recipient:read")
assert dependency.call(allowed) is allowed
mode_only = _Principal(required_scope)
with (
patch.object(router, "_get_campaign_for_principal"),
pytest.raises(HTTPException) as missing_recipient_authority,
):
if required_scope == "campaigns:campaign:send":
router.send_campaign_now_endpoint(
"campaign-1",
session=Mock(),
principal=mode_only, # type: ignore[arg-type]
)
else:
router.queue_campaign(
"campaign-1",
session=Mock(),
principal=mode_only, # type: ignore[arg-type]
)
assert missing_recipient_authority.value.status_code == 403
assert "campaigns:recipient:read" in missing_recipient_authority.value.detail
def test_batch_preflight_checks_every_message_before_provider_effects() -> None:
jobs = [_job("one"), _job("two")]
contexts = {
"one": SimpleNamespace(snapshot=SimpleNamespace(smtp_transport_revision="revision-1")),
"two": SimpleNamespace(snapshot=SimpleNamespace(smtp_transport_revision="revision-1")),
}
policy = effective_synchronous_send_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={},
)
provider = Mock()
with (
patch("govoplan_campaign.backend.sending.jobs._preflight_send_campaign_job", return_value=None) as state_preflight,
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
side_effect=lambda _session, job: contexts[job.id],
) as input_preflight,
patch(
"govoplan_campaign.backend.sending.jobs.profile_delivery_summary",
return_value={"smtp_transport_revision": "revision-1"},
),
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=provider),
):
result = _preflight_synchronous_send_batch(
object(), # type: ignore[arg-type]
version=_version(), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=policy,
)
assert list(result) == ["one", "two"]
assert state_preflight.call_count == 2
assert input_preflight.call_count == 2
provider.send_campaign_email_bytes.assert_not_called()

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.9",
"version": "0.1.11",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -17,7 +17,7 @@
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.9",
"@govoplan/core-webui": "^0.1.12",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -27,7 +27,10 @@
"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:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.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",
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs"
},
"devDependencies": {
"typescript": "^5.7.2"

View File

@@ -1,5 +1,6 @@
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
import { apiDownload, apiFetch } from "./client";
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type {
AccessDecisionProvenanceItem,
@@ -76,6 +77,8 @@ export type CampaignVersionListItem = {
build_summary?: Record<string, unknown> | null;
execution_snapshot_hash?: string | null;
execution_snapshot_at?: string | null;
delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null;
delivery_mode_selected_at?: string | null;
};
export type CampaignVersionDetail = CampaignVersionListItem & {
@@ -239,22 +242,29 @@ export type CampaignSummary = {
created_at?: string | null;
validation_summary?: Record<string, unknown> | null;
build_summary?: Record<string, unknown> | null;
delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null;
delivery_mode_selected_at?: string | null;
} | null;
cards?: {
jobs_total?: number;
inactive?: number;
queueable?: number;
queueable_unattempted?: number;
retryable?: number;
cancellable?: number;
needs_attention?: number;
sent?: number;
smtp_accepted?: number;
failed?: number;
outcome_unknown?: number;
not_attempted?: number;
skipped?: number;
queued_or_active?: number;
cancelled?: number;
partially_completed?: boolean;
imap_appended?: number;
imap_failed?: number;
imap_skipped?: number;
};
status_counts?: Record<string, Record<string, number>>;
issues?: Record<string, unknown>;
@@ -271,6 +281,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;
@@ -386,16 +414,7 @@ export type CampaignSendJobPayload = {
};
export type CampaignJobsQuery = {
versionId?: string;
page?: number;
pageSize?: number;
cursor?: string | null;
sendStatus?: string[];
validationStatus?: string[];
imapStatus?: string[];
query?: string;
};
export type CampaignJobsQuery = Omit<CampaignJobsQueryParameters, "since" | "limit">;
export type CampaignJobsResponse = {
jobs: Record<string, unknown>[];
@@ -441,6 +460,53 @@ export type CampaignReportEmailPayload = {
dry_run?: boolean;
};
export type AggregateReportCount = {
value: number | null;
suppressed: boolean;
};
export type AggregateReportCampaign = {
id: string;
name: string;
status: string;
};
export type AggregateReportCampaignListItem = AggregateReportCampaign & {
updated_at: string;
};
export type AggregateCampaignReport = {
generated_at: string;
campaign: AggregateReportCampaign;
version_number: number | null;
completion_state: "not_started" | "in_progress" | "completed" | "partially_completed" | "incomplete" | "outcome_unknown" | "suppressed";
population: {
denominator: AggregateReportCount;
denominator_definition: string;
inactive_source_entries: AggregateReportCount;
excluded_or_blocked_jobs: AggregateReportCount;
};
outcomes: {
smtp_accepted: AggregateReportCount;
failed: AggregateReportCount;
outcome_unknown: AggregateReportCount;
queued_or_active: AggregateReportCount;
cancelled: AggregateReportCount;
excluded: AggregateReportCount;
not_attempted: AggregateReportCount;
};
time_range: {
first_activity_at: string | null;
last_activity_at: string | null;
suppressed: boolean;
};
privacy: {
small_cell_threshold: number;
suppression_applied: boolean;
rule: string;
};
};
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
@@ -451,6 +517,13 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
return response.campaigns ?? response.items ?? response.results ?? [];
}
export async function listAggregateReportCampaigns(
settings: ApiSettings)
: Promise<AggregateReportCampaignListItem[]> {
const response = await apiFetch<{campaigns: AggregateReportCampaignListItem[]}>(settings, "/api/v1/campaigns/aggregate-reports");
return response.campaigns;
}
export async function listCampaignsDelta(
settings: ApiSettings,
options: {since?: string | null;limit?: number;} = {})
@@ -783,15 +856,7 @@ settings: ApiSettings,
campaignId: string,
options: CampaignJobsQuery = {})
: Promise<CampaignJobsResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
if (options.query?.trim()) params.set("q", options.query.trim());
const params = campaignJobsQueryParams(options);
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
}
@@ -801,17 +866,7 @@ settings: ApiSettings,
campaignId: string,
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
: Promise<CampaignJobsDeltaResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
if (options.query?.trim()) params.set("q", options.query.trim());
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const params = campaignJobsQueryParams(options);
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
}
@@ -834,6 +889,16 @@ versionId?: string)
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
}
export async function getAggregateCampaignReport(
settings: ApiSettings,
campaignId: string)
: Promise<AggregateCampaignReport> {
return apiFetch<AggregateCampaignReport>(
settings,
`/api/v1/campaigns/aggregate-reports/${encodeURIComponent(campaignId)}`
);
}
export async function downloadCampaignJobsCsv(
settings: ApiSettings,
campaignId: string,
@@ -928,6 +993,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(
settings: ApiSettings,
campaignId: string,

View File

@@ -5,7 +5,7 @@ import {
downloadCampaignJobsCsv,
emailCampaignReport,
getCampaignJobDetail,
getCampaignJobsDelta,
getCampaignJobs,
resolveCampaignJobOutcome,
retryCampaignJobs,
sendCampaignJob,
@@ -16,19 +16,29 @@ import {
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridListOption } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine";
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
import { emptyCampaignJobsResponse } from "./utils/jobDeltas";
import type { CampaignJobSortColumn } from "./utils/jobListQuery";
import {
DEFAULT_REPORT_GRID_SORT,
activeReportGridShortcut,
reportGridQueriesEqual,
toggleReportGridShortcut,
type ReportGridShortcutId
} from "./utils/reportGridShortcuts";
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"not_queued",
"skipped",
"queued",
"claimed",
"sending",
@@ -38,21 +48,41 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"failed_temporary",
"failed_permanent",
"cancelled"].
map((value) => ({ value, label: humanize(value) }));
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"pending",
"appending",
"appended",
"outcome_unknown",
"failed",
"skipped"].
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const VALIDATION_STATUS_OPTIONS: DataGridListOption[] = [
"ready",
"warning",
"needs_review",
"blocked",
"excluded",
"inactive"].
map((value) => ({ value, label: humanize(value) }));
const QUEUE_STATUS_OPTIONS: DataGridListOption[] = [
"draft",
"queued",
"sending",
"paused",
"cancelled"].
map((value) => ({ value, label: humanize(value) }));
const JOB_GRID_QUERY_DELAY_MS = 300;
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const version = data.currentVersion;
const cards = data.summary?.cards;
const delivery = asRecord(data.summary?.delivery);
@@ -60,14 +90,19 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
const imapPolicy = asRecord(delivery.imap_append_sent);
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const jobsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
const jobPageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
const jobsRequestRef = useRef(0);
const [jobsLoading, setJobsLoading] = useState(false);
const [page, setPage] = useState(1);
const [sendStatus, setSendStatus] = useState("");
const [imapStatus, setImapStatus] = useState("");
const [query, setQuery] = useState("");
const [appliedQuery, setAppliedQuery] = useState("");
const [pageSize, setPageSize] = useState(50);
const [initialGridFilters] = useState<Record<string, string | string[]>>(() => initialReportGridFilters());
const initialGridQuery = useMemo<DataGridQueryState>(() => ({
sort: DEFAULT_REPORT_GRID_SORT,
filters: serializeInitialGridFilters(initialGridFilters)
}), [initialGridFilters]);
const [jobGridQuery, setJobGridQuery] = useState<DataGridQueryState>(initialGridQuery);
const [appliedJobGridQuery, setAppliedJobGridQuery] = useState<DataGridQueryState>(initialGridQuery);
const [query, setQuery] = useState(() => initialReportQuery());
const [appliedQuery, setAppliedQuery] = useState(query.trim());
const [actionMessage, setActionMessage] = useState("");
const [actionError, setActionError] = useState("");
const [busyAction, setBusyAction] = useState("");
@@ -81,86 +116,75 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
useEffect(() => {
const handle = window.setTimeout(() => {
setAppliedQuery(query.trim());
setAppliedJobGridQuery((current) => reportGridQueriesEqual(current, jobGridQuery) ? current : jobGridQuery);
setPage(1);
}, 350);
}, JOB_GRID_QUERY_DELAY_MS);
return () => window.clearTimeout(handle);
}, [query]);
}, [query, jobGridQuery]);
const jobsQueryKey = useMemo(
() => JSON.stringify({
campaignId,
versionId: version?.id ?? null,
page,
pageSize: 50,
sendStatus,
imapStatus,
appliedQuery,
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
}),
[campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
const handleJobGridQuery = useCallback((next: DataGridQueryState) => {
setJobGridQuery((current) => reportGridQueriesEqual(current, next) ? current : next);
}, []);
const activeJobGridShortcut = useMemo(
() => query.trim() ? null : activeReportGridShortcut(jobGridQuery),
[jobGridQuery, query]
);
useEffect(() => {
jobPageCursorsRef.current = { 1: null };
}, [campaignId, version?.id, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const applyJobGridShortcut = useCallback((shortcutId: ReportGridShortcutId) => {
const next = toggleReportGridShortcut(jobGridQuery, shortcutId);
setQuery("");
setAppliedQuery("");
setJobGridQuery(next);
setAppliedJobGridQuery(next);
setPage(1);
}, [jobGridQuery]);
const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
{ label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" },
{ label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" },
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" },
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: cards?.outcome_unknown ?? 0, shortcutId: "outcome_unknown" },
{ label: "i18n:govoplan-campaign.not_attempted.e1be3c69", value: cards?.not_attempted ?? 0, shortcutId: "not_attempted" },
{ label: "i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19", value: cards?.skipped ?? jobs.counts.send?.skipped ?? 0, shortcutId: "smtp_skipped" },
{ label: "i18n:govoplan-campaign.cancelled.a1bf92ef", value: cards?.cancelled ?? 0, shortcutId: "cancelled" }
];
const imapOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
{ label: "i18n:govoplan-campaign.imap_appended.56017ea3", value: cards?.imap_appended ?? 0, shortcutId: "imap_appended" },
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: cards?.imap_failed ?? 0, shortcutId: "imap_failed" },
{ label: "i18n:govoplan-campaign.imap_skipped.5a97b542", value: cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0, shortcutId: "imap_skipped" }
];
const loadJobs = useCallback(async () => {
if (!campaignId) return;
const requestId = ++jobsRequestRef.current;
setJobsLoading(true);
setActionError("");
try {
let nextWatermark = getDeltaWatermark(jobsQueryKey);
let merged = jobsRef.current;
let hasMore = false;
const pageCursor = page === 1 ? null : jobPageCursorsRef.current[page];
do {
const response = await getCampaignJobsDelta(settings, campaignId, {
const response = await getCampaignJobs(settings, campaignId, {
versionId: version?.id,
page,
pageSize: 50,
cursor: pageCursor,
sendStatus: sendStatus ? [sendStatus] : undefined,
imapStatus: imapStatus ? [imapStatus] : undefined,
pageSize,
query: appliedQuery || undefined,
since: nextWatermark
sortBy: campaignJobSortColumn(appliedJobGridQuery.sort?.columnId),
sortDirection: appliedJobGridQuery.sort?.direction ?? "asc",
filters: appliedJobGridQuery.filters
});
merged = mergeCampaignJobsDelta(merged, response);
if (response.cursor !== undefined) jobPageCursorsRef.current[page] = response.cursor ?? null;
if (response.next_cursor !== undefined) {
if (response.next_cursor) jobPageCursorsRef.current[page + 1] = response.next_cursor;
else delete jobPageCursorsRef.current[page + 1];
}
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(jobsQueryKey, nextWatermark);
jobsRef.current = merged;
setJobs(merged);
if (merged.pages > 0 && page > merged.pages) setPage(merged.pages);
if (requestId !== jobsRequestRef.current) return;
setJobs(response);
if (response.pages > 0 && page > response.pages) setPage(response.pages);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
if (requestId === jobsRequestRef.current) setActionError(err instanceof Error ? err.message : String(err));
} finally {
setJobsLoading(false);
if (requestId === jobsRequestRef.current) setJobsLoading(false);
}
}, [settings, campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, jobsQueryKey, getDeltaWatermark, setDeltaWatermark]);
useEffect(() => {
resetDeltaWatermark(jobsQueryKey);
jobsRef.current = emptyCampaignJobsResponse();
setJobs(emptyCampaignJobsResponse());
}, [jobsQueryKey, resetDeltaWatermark]);
}, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery]);
useEffect(() => {
void loadJobs();
}, [loadJobs]);
async function reloadAll() {
resetDeltaWatermark(jobsQueryKey);
jobPageCursorsRef.current = { 1: null };
jobsRef.current = emptyCampaignJobsResponse();
setJobs(emptyCampaignJobsResponse());
await Promise.all([reload({ force: true }), loadJobs()]);
}
@@ -327,10 +351,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
value: (row) => String(row.recipient_email ?? "—")
},
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} />, value: (row) => String(row.send_status ?? "unknown") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, columnType: "from-list", list: { options: VALIDATION_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: QUEUE_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, value: (row) => String(row.send_status ?? "unknown") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} label={deliveryStatusLabel(String(row.imap_status ?? "unknown"))} />, value: (row) => String(row.imap_status ?? "unknown") },
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
{
id: "evidence",
@@ -351,7 +375,6 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
header: "i18n:govoplan-campaign.last_result.110b888b",
width: "minmax(220px, 1fr)",
resizable: true,
filterable: true,
render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
value: (row) => String(row.last_error ?? "—")
},
@@ -395,18 +418,36 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
{deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => {
const active = activeJobGridShortcut === shortcutId;
return (
<div key={shortcutId}>
<dt>{label}</dt>
<dd>
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
{value}
</Button>
</dd>
</div>
);
})}
</dl>
</Card>
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
{imapOutcomeShortcuts.map(({ label, value, shortcutId }) => {
const active = activeJobGridShortcut === shortcutId;
return (
<div key={shortcutId}>
<dt>{label}</dt>
<dd>
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
{value}
</Button>
</dd>
</div>
);
})}
<div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div>
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
@@ -426,34 +467,43 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
</div>
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
<p className="muted small-note">
i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00
</p>
<div className="page-heading split">
<div className="button-row compact-actions">
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5" />
<select value={sendStatus} onChange={(event) => {setSendStatus(event.target.value);setPage(1);}}>
<option value="">i18n:govoplan-campaign.all_smtp_states.739597b1</option>
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select value={imapStatus} onChange={(event) => {setImapStatus(event.target.value);setPage(1);}}>
<option value="">i18n:govoplan-campaign.all_imap_states.8546b84c</option>
{IMAP_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<FormField label="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5">
<input value={query} onChange={(event) => setQuery(event.target.value)} />
</FormField>
</div>
<span className="muted">{jobs.total} i18n:govoplan-campaign.matching_of.66a3778e {jobs.total_unfiltered} i18n:govoplan-campaign.total_job_s.c94b7d20</span>
</div>
<LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
<DataGrid<Record<string, unknown>>
id={`campaign-report-jobs-${campaignId}`}
id={`campaign-report-jobs-v2-${campaignId}`}
rows={jobs.jobs}
columns={columns}
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5" />
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5"
initialFilters={initialGridFilters}
initialSort={DEFAULT_REPORT_GRID_SORT}
query={jobGridQuery}
pagination={{
mode: "server",
page,
pageSize,
totalRows: jobs.total,
pageSizeOptions: [25, 50, 100, 200],
disabled: jobsLoading,
onPageChange: setPage,
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(1);
}
}}
onQueryChange={handleJobGridQuery} />
</LoadingFrame>
<div className="button-row compact-actions">
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>i18n:govoplan-campaign.previous.50f94286</Button>
<span>i18n:govoplan-campaign.page.fb06270f {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>i18n:govoplan-campaign.next.bc981983</Button>
</div>
</Card>
</LoadingFrame>
@@ -489,8 +539,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
</dl>
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
@@ -545,6 +595,52 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
}
function initialReportGridFilters(): Record<string, string | string[]> {
if (typeof window === "undefined") return {};
const params = new URLSearchParams(window.location.search);
const result: Record<string, string | string[]> = {};
const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS);
const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS);
const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS);
if (send.length > 0) result.send = send;
if (imap.length > 0) result.imap = imap;
if (validation.length > 0) result.validation = validation;
return result;
}
function deliveryStatusLabel(status: string): string | undefined {
return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7" : undefined;
}
function initialReportQuery(): string {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("q")?.trim() ?? "";
}
function statusParameters(params: URLSearchParams, name: string, options: DataGridListOption[]): string[] {
const allowed = new Set(options.map((option) => option.value));
return [...new Set(
params.getAll(name).
flatMap((value) => value.split(",")).
map((value) => value.trim()).
filter((value) => allowed.has(value))
)];
}
function serializeInitialGridFilters(filters: Record<string, string | string[]>): Record<string, string> {
return Object.fromEntries(Object.entries(filters).map(([columnId, value]) => [
columnId,
Array.isArray(value) ? `list:${JSON.stringify([...new Set(value)])}` : value
]));
}
function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "imap" || value === "attempts" || value === "updated") {
return value;
}
return "number";
}
function retryableFailedStatus(status: string): boolean {
return status === "failed_temporary" || status === "failed_permanent";
}

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="policies" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="policy" />} />
<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="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="reports" element={<Navigate to="../report" replace />} />

View File

@@ -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";
@@ -63,6 +70,7 @@ import {
isVersionReadyForDelivery,
stringifyPreview } from
"./utils/campaignView";
import { deliveryModeLabel } from "./utils/deliveryMode";
import { getBool, getText } from "./utils/draftEditor";
import { attachmentPreviewLinkableFiles, attachmentPreviewMatchedFiles } from "./utils/attachmentPreview";
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
@@ -97,7 +105,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<FlowState, string> = {
complete: "var(--green)",
@@ -121,7 +129,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<MailDevMailboxUiCapability>("mail.devMailbox");
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
@@ -134,6 +142,12 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
const [liveSummary, setLiveSummary] = useState<CampaignSummary | null>(null);
const [queueStatusLoading, setQueueStatusLoading] = useState(false);
const version = data.currentVersion;
const canSendSynchronously = hasScope(auth, "campaigns:campaign:send");
const canQueueForWorkers = hasScope(auth, "campaigns:campaign:queue");
const canControlDelivery = hasScope(auth, "campaigns:campaign:control");
const canRetryDelivery = hasScope(auth, "campaigns:campaign:retry");
const [deliveryOptions, setDeliveryOptions] = useState<CampaignDeliveryOptions | null>(null);
const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false);
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
const server = asRecord(campaignJson.server);
const selectedMailProfileId = getText(server, "mail_profile_id");
@@ -177,7 +191,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<Record<string, unknown> | null>(null);
const [queueResult, setQueueResult] = useState<Record<string, unknown> | null>(null);
const [imapAppendResult, setImapAppendResult] = useState<Record<string, unknown> | null>(null);
const [imapDiagnostics, setImapDiagnostics] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const imapDiagnosticsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
@@ -200,11 +217,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 +255,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 +285,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,12 +300,13 @@ 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;
const failedCount = cards?.failed ?? 0;
const retryableCount = cards?.retryable ?? numberFrom(sendStatusCounts, ["failed_temporary"]);
const imapAppended = cards?.imap_appended ?? 0;
const imapFailed = cards?.imap_failed ?? 0;
const imapPending = numberFrom(imapStatusCounts, ["pending"]);
@@ -272,6 +318,14 @@ 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 persistedDeliveryMode = version?.delivery_mode ?? null;
const persistedDeliveryModeSelectedAt = version?.delivery_mode_selected_at ?? null;
const selectedDryRun = dryRun && !directQueuedSendAllowed;
const deliveryPartial = cards?.partially_completed === true || ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState);
const deliveryComplete = queuedOrActiveCount === 0 &&
@@ -325,8 +379,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 +512,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 +819,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 +852,96 @@ 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("i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2");
setQueueResult(null);
setError("");
try {
const response = await queueCampaign(settings, campaignId, {
version_id: version.id,
include_warnings: true,
enqueue_celery: true,
dry_run: false
});
const result = asRecord(response);
setQueueResult(result);
setMessage(i18nMessage("i18n:govoplan-campaign.queued_value0_message_s_value1_worker_task_s_pub.18d67ec8", {
value0: String(result.queued_count ?? 0),
value1: String(result.enqueued_count ?? 0)
}));
setQueueConfirmOpen(false);
await reload();
await refreshDeliveryOptions(true);
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function runDeliveryControl(action: "pause" | "resume" | "cancel") {
if (busy || !canControlDelivery) return;
setBusy("control");
setMessage(deliveryControlProgressMessage(action));
setError("");
try {
const response = action === "pause" ?
await pauseCampaign(settings, campaignId) :
action === "resume" ?
await resumeCampaign(settings, campaignId) :
await cancelCampaign(settings, campaignId);
const result = asRecord(response.result ?? response);
setMessage(
action === "pause" ? i18nMessage("i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2", {
value0: String(result.paused_count ?? 0)
}) :
action === "resume" ? i18nMessage("i18n:govoplan-campaign.resumed_value0_message_s_value1_worker_task_s_pu.0f59afb4", {
value0: String(result.resumed_count ?? 0),
value1: String(result.enqueued_count ?? 0)
}) :
i18nMessage("i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24", {
value0: String(result.cancelled_count ?? 0),
value1: String(result.protected_count ?? 0),
value2: String(result.skipped_count ?? 0)
})
);
if (action === "cancel") setCancelDeliveryConfirmOpen(false);
await reload();
await refreshDeliveryOptions(true);
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function runRetryFailed() {
if (busy || !canRetryDelivery || retryableCount <= 0 || !workerQueueAvailable) return;
setBusy("retry");
setMessage("i18n:govoplan-campaign.queueing_retryable_failed_messages_for_backgroun.4bc80cd9");
setError("");
try {
const response = await retryCampaignJobs(settings, campaignId, { enqueue_celery: true });
const result = asRecord(response.result ?? response);
setMessage(i18nMessage("i18n:govoplan-campaign.queued_value0_retryable_message_s_value1_worker_.f4795bdb", {
value0: String(result.selected_count ?? 0),
value1: String(result.enqueued_count ?? 0)
}));
await reload();
await refreshDeliveryOptions(true);
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
@@ -1062,6 +1207,27 @@ 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: "i18n:govoplan-campaign.delivery_mode.109ed9d1",
detail: deliveryOptionsLoading ?
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011" :
synchronousSendAllowed ?
i18nMessage("i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c", {
value0: synchronousEligibleCount,
value1: synchronousSendLimit,
value2: workerQueueAvailable
? "i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e"
: "i18n:govoplan-campaign.background_workers_are_not_configured_.efa72396"
}) :
workerQueueAvailable ?
i18nMessage("i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873", {
value0: synchronousSendReason(synchronousSendOption)
}) :
i18nMessage("i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f", {
value0: synchronousSendReason(synchronousSendOption)
}),
state: synchronousSendAllowed || workerQueueAvailable ? "ready" : deliveryOptionsLoading ? "info" : "blocked"
},
{
label: "Sent copy",
detail: Boolean(imapAppend.enabled) ? mailProfileSelected ? i18nMessage("i18n:govoplan-campaign.imap_append_requested_for_value0_validation_chec.51527a20", { value0: String(imapAppend.folder ?? "auto") }) : "i18n:govoplan-campaign.imap_append_is_enabled_but_no_mail_profile_is_se.cf50419e" : "i18n:govoplan-campaign.imap_append_is_disabled_for_this_campaign.7757f7f1",
@@ -1303,6 +1469,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.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.synchronous_limit.f88c0bcd</span><strong>{deliveryOptionsLoading ? "…" : synchronousSendLimit}</strong></div>
<div><span>i18n:govoplan-campaign.limit_source.bd933adb</span><strong>{deliveryPolicySourceLabel(String(synchronousSendPolicy.source ?? ""))}</strong></div>
<div><span>i18n:govoplan-campaign.worker_queue.c911e32c</span><strong>{workerQueueAvailable ? "i18n:govoplan-campaign.available.7c62a142" : "i18n:govoplan-campaign.not_configured.811931bb"}</strong></div>
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div>
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
@@ -1327,8 +1496,13 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
<div className="button-row compact-actions">
<Button
variant="primary"
onClick={() => setQueueConfirmOpen(true)}
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
{busy === "queue" ? "i18n:govoplan-campaign.queueing_for_workers_.d24584fe" : "i18n:govoplan-campaign.queue_for_workers.dae9a3e4"}
</Button>
<Button
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
{busy === "send" ?
selectedDryRun ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" :
@@ -1339,6 +1513,26 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
"i18n:govoplan-campaign.send_now.dae33010"}
</Button>
</div>
<p className="muted small-note">
{i18nMessage("i18n:govoplan-campaign.queue_for_workers_commits_durable_jobs_and_retur.d0dbe81a", {
value0: synchronousSendLimit || "i18n:govoplan-campaign.the_configured_maximum.eb10006b"
})}
</p>
{!synchronousSendAllowed && canSendSynchronously &&
<p className="review-flow-inline-note is-warning">
{i18nMessage("i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29", {
value0: synchronousSendReason(synchronousSendOption)
})}
</p>
}
{queueResult &&
<p className="review-flow-inline-note is-stale">
{i18nMessage("i18n:govoplan-campaign.worker_queue_committed_value0_message_s_and_publ.24400d3c", {
value0: String(queueResult.queued_count ?? 0),
value1: String(queueResult.enqueued_count ?? 0)
})}
</p>
}
{sendResult &&
<div className="review-flow-data-section">
<p className="muted small-note">i18n:govoplan-campaign.attempted.a9eb9c90 {String(sendResult.attempted_count ?? "—")}i18n:govoplan-campaign.smtp_accepted.a5d0dccc {String(sendResult.sent_count ?? "—")}i18n:govoplan-campaign.failed.fac9f871 {String(sendResult.failed_count ?? "—")}i18n:govoplan-campaign.outcome_unknown.4383023a {String(sendResult.outcome_unknown_count ?? 0)}i18n:govoplan-campaign.skipped.6b98496c {String(sendResult.skipped_count ?? "—")}.</p>
@@ -1361,6 +1555,8 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
<WorkflowFact label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
<WorkflowFact label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
<WorkflowFact label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
<WorkflowFact label="i18n:govoplan-campaign.paused.c7dfb6f1" value={pausedQueueCount} />
<WorkflowFact label="i18n:govoplan-campaign.delivery_mode.109ed9d1" value={deliveryModeLabel(persistedDeliveryMode)} />
<WorkflowFact label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
<WorkflowFact label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
<WorkflowFact label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
@@ -1371,6 +1567,40 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
<StatusBadge status={deliveryDisplayStatus} />
<span>{deliveryStarted ? "i18n:govoplan-campaign.delivery_activity_is_available_in_the_report_and.cb163d1d" : "i18n:govoplan-campaign.no_real_delivery_has_started_for_this_campaign_v.3b9235ff"}</span>
</div>
{persistedDeliveryMode &&
<p className="muted small-note">
{persistedDeliveryModeSelectedAt
? i18nMessage("i18n:govoplan-campaign.this_version_last_entered_value0_mode_at_value1_.c087d2f3", {
value0: deliveryModeLabel(persistedDeliveryMode),
value1: formatDateTime(persistedDeliveryModeSelectedAt)
})
: i18nMessage("i18n:govoplan-campaign.this_version_last_entered_value0_mode_this_recor.ea2f201f", {
value0: deliveryModeLabel(persistedDeliveryMode)
})}
</p>
}
<div className="button-row compact-actions review-flow-stage-actions">
<Button
onClick={() => void runDeliveryControl("pause")}
disabled={Boolean(busy) || !canControlDelivery || queuedSendCount <= 0}>
i18n:govoplan-campaign.pause_queued_work.35ab4a5b
</Button>
<Button
onClick={() => void runDeliveryControl("resume")}
disabled={Boolean(busy) || !canControlDelivery || pausedQueueCount <= 0 || !workerQueueAvailable}>
i18n:govoplan-campaign.resume_with_workers.510a2a9a
</Button>
<Button
onClick={() => setCancelDeliveryConfirmOpen(true)}
disabled={Boolean(busy) || !canControlDelivery || queuedSendCount + pausedQueueCount <= 0}>
i18n:govoplan-campaign.cancel_unsent_work.66df9f0d
</Button>
<Button
onClick={() => void runRetryFailed()}
disabled={Boolean(busy) || !canRetryDelivery || retryableCount <= 0 || !workerQueueAvailable}>
i18n:govoplan-campaign.retry_failed_with_workers.a4b7dcc5
</Button>
</div>
{Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 &&
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.imap_sent_append_is_still_pending_for.475700e4 {imapPendingForDisplay} i18n:govoplan-campaign.job_s_pending_with_no_imap_attempt_usually_means.0776d29f</p>
}
@@ -1476,16 +1706,44 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
onCancel={() => setReviewConfirmOpen(false)} />
<ConfirmDialog
open={queueConfirmOpen}
title="i18n:govoplan-campaign.queue_this_version_for_background_workers_.943d032c"
message={i18nMessage("i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222", {
value0: synchronousEligibleCount,
value1: String(version?.version_number ?? "—")
})}
confirmLabel="i18n:govoplan-campaign.queue_for_workers.dae9a3e4"
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
busy={busy === "queue"}
onCancel={() => setQueueConfirmOpen(false)}
onConfirm={() => void runQueueForWorkers()} />
<ConfirmDialog
open={sendConfirmOpen}
title="i18n:govoplan-campaign.send_this_version_now.10a0ca56"
message={i18nMessage("i18n:govoplan-campaign.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.value0_this_is_a_synchronous_request_for_value1_.0267bc6b", {
value0: i18nMessage("i18n:govoplan-campaign.this_sends_the_frozen_execution_snapshot_for_ver.1f7c53cb", { value0: String(version?.version_number ?? "—"), value1: jobsTotal, value2: builtCount, value3: buildBlocked, value4: String(attachmentSummary.total_matched_files ?? 0), value5: String(rateLimit.messages_per_minute ?? "i18n:govoplan-campaign.not_set.ef374c57"), value6: imapAppend.enabled === true ? "enabled" : "disabled", value7: version?.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: version.execution_snapshot_hash.slice(0, 12) }) : "missing" }),
value1: synchronousEligibleCount,
value2: synchronousSendLimit
})}
confirmLabel={directQueuedSendAllowed ? "i18n:govoplan-campaign.send_queued_now.bbace803" : "i18n:govoplan-campaign.send_now.dae33010"}
tone="danger"
busy={busy === "send"}
onCancel={() => setSendConfirmOpen(false)}
onConfirm={() => void runSendNow()} />
<ConfirmDialog
open={cancelDeliveryConfirmOpen}
title="i18n:govoplan-campaign.cancel_all_unsent_delivery_work_.a2f56cda"
message="i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc"
confirmLabel="i18n:govoplan-campaign.cancel_unsent_work.66df9f0d"
cancelLabel="i18n:govoplan-campaign.keep_delivery_work.10dbcb13"
tone="danger"
busy={busy === "control"}
onCancel={() => setCancelDeliveryConfirmOpen(false)}
onConfirm={() => void runDeliveryControl("cancel")} />
<ConfirmDialog
open={singleSendConfirmRow !== null}
title="Send this one message now?"
@@ -2348,6 +2606,32 @@ function formatSingleAddress(value: unknown): string {
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 "i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a";
case "no_eligible_recipient_jobs":return "i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c";
case "version_not_ready":return "i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc";
case "policy_configuration_invalid":return "i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8";
default:return "i18n:govoplan-campaign.delivery_options_are_still_being_evaluated.8395096e";
}
}
function deliveryControlProgressMessage(action: "pause" | "resume" | "cancel"): string {
if (action === "pause") return "i18n:govoplan-campaign.pausing_eligible_delivery_jobs_.eb6b9d58";
if (action === "resume") return "i18n:govoplan-campaign.resuming_eligible_delivery_jobs_.dd12c5a2";
return "i18n:govoplan-campaign.cancelling_eligible_delivery_jobs_.4090fa47";
}
function deliveryPolicySourceLabel(source: string): string {
if (source === "tenant") return "i18n:govoplan-campaign.tenant.3ca93c78";
if (source === "deployment") return "i18n:govoplan-campaign.deployment.327a55f8";
if (source === "deployment_default") return "i18n:govoplan-campaign.deployment_default.aa39f7b4";
if (source === "deployment_ceiling") return "i18n:govoplan-campaign.deployment_ceiling.abbec75b";
return "i18n:govoplan-campaign.not_available.d1a17af1";
}
function numberFrom(record: Record<string, unknown>, keys: string[]): number {
for (const key of keys) {
const value = record[key];

View File

@@ -0,0 +1,6 @@
export function deliveryModeLabel(mode: string | null | undefined): string {
if (mode === "synchronous") return "i18n:govoplan-campaign.synchronous.77c61919";
if (mode === "worker_queue") return "i18n:govoplan-campaign.worker_queue.c911e32c";
if (mode === "database_queue") return "i18n:govoplan-campaign.database_queue.8bf98437";
return mode ? "i18n:govoplan-campaign.unknown.bc7819b3" : "—";
}

View File

@@ -0,0 +1,58 @@
export type CampaignJobSortColumn =
| "number"
| "recipient"
| "subject"
| "validation"
| "queue"
| "send"
| "imap"
| "attempts"
| "updated";
export type CampaignJobsQueryParameters = {
versionId?: string;
page?: number;
pageSize?: number;
cursor?: string | null;
sendStatus?: string[];
validationStatus?: string[];
imapStatus?: string[];
query?: string;
sortBy?: CampaignJobSortColumn;
sortDirection?: "asc" | "desc";
filters?: Record<string, string>;
since?: string | null;
limit?: number;
};
const FILTER_PARAMETERS: Record<string, string> = {
recipient: "filter_recipient",
subject: "filter_subject",
validation: "filter_validation",
queue: "filter_queue",
send: "filter_send",
imap: "filter_imap",
attempts: "filter_attempts",
evidence: "filter_evidence"
};
export function campaignJobsQueryParams(options: CampaignJobsQueryParameters = {}): URLSearchParams {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
if (options.query?.trim()) params.set("q", options.query.trim());
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [columnId, value] of Object.entries(options.filters ?? {})) {
const parameter = FILTER_PARAMETERS[columnId];
if (parameter && value.trim()) params.set(parameter, value);
}
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
return params;
}

View File

@@ -0,0 +1,85 @@
export type ReportGridQueryState = {
sort: { columnId: string; direction: "asc" | "desc" } | null;
filters: Record<string, string>;
};
export const DEFAULT_REPORT_GRID_SORT = { columnId: "number", direction: "asc" as const };
export type ReportGridShortcutId =
| "all"
| "smtp_accepted"
| "failed"
| "outcome_unknown"
| "not_attempted"
| "smtp_skipped"
| "cancelled"
| "imap_appended"
| "imap_failed"
| "imap_skipped";
const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = {
all: {},
smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) },
failed: { send: listFilter(["failed_temporary", "failed_permanent"]) },
outcome_unknown: { send: listFilter(["outcome_unknown"]) },
not_attempted: { send: listFilter(["not_queued"]) },
smtp_skipped: { send: listFilter(["skipped"]) },
cancelled: { send: listFilter(["cancelled"]) },
imap_appended: { imap: listFilter(["appended"]) },
imap_failed: { imap: listFilter(["failed"]) },
imap_skipped: { imap: listFilter(["skipped"]) }
};
/**
* Return the complete grid query for a count shortcut. Shortcuts are exact
* report views, so applying one intentionally clears every unrelated filter
* and restores the stable report ordering.
*/
export function reportGridQueryForShortcut(shortcutId: ReportGridShortcutId): ReportGridQueryState {
return {
sort: { ...DEFAULT_REPORT_GRID_SORT },
filters: { ...REPORT_GRID_SHORTCUT_FILTERS[shortcutId] }
};
}
/** Applying an already selected outcome shortcut returns to the unfiltered report. */
export function toggleReportGridShortcut(
current: ReportGridQueryState,
shortcutId: ReportGridShortcutId
): ReportGridQueryState {
const target = reportGridQueryForShortcut(shortcutId);
if (shortcutId !== "all" && reportGridFiltersEqual(current.filters, target.filters)) {
return reportGridQueryForShortcut("all");
}
return target;
}
export function activeReportGridShortcut(query: ReportGridQueryState): ReportGridShortcutId | null {
const shortcutIds = Object.keys(REPORT_GRID_SHORTCUT_FILTERS) as ReportGridShortcutId[];
return shortcutIds.find((shortcutId) => reportGridFiltersEqual(
query.filters,
REPORT_GRID_SHORTCUT_FILTERS[shortcutId]
)) ?? null;
}
export function reportGridQueriesEqual(left: ReportGridQueryState, right: ReportGridQueryState): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
for (const key of keys) {
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
}
return true;
}
function listFilter(values: string[]): string {
return `list:${JSON.stringify(values)}`;
}
function reportGridFiltersEqual(left: Record<string, string>, right: Record<string, string>): boolean {
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
for (const key of keys) {
if ((left[key] ?? "") !== (right[key] ?? "")) return false;
}
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
export type OperatorQueueAction =
| "details"
| "report"
| "pause"
| "resume"
| "retry"
| "queue-unsent"
| "reconcile"
| "cancel";
export type OperatorQueuePermissions = {
canRead: boolean;
canOpenReport: boolean;
canControl: boolean;
canRetry: boolean;
canQueue: boolean;
canReconcile: boolean;
};
export type OperatorQueueFacts = {
retryable: number;
outcomeUnknown: number;
queueableUnattempted: number;
pausable: number;
paused: number;
cancellable: number;
};
export const OPERATOR_QUEUE_REASON = {
permissionDenied: "i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6",
noQueued: "i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e",
noPaused: "i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275",
noFailed: "i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9",
noUnsent: "i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87",
noUnknown: "i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c",
noCancellable: "i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49"
} as const;
export type CampaignVersionWorkItem<T> = {
campaignId: string;
versionId: string;
versionNumber: number;
isCurrent: boolean;
summary: T | null;
};
/** Preserve every audit version so work does not disappear when a new draft becomes current. */
export function campaignVersionWorkItems<T>(
campaignId: string,
currentVersionId: string | null | undefined,
versions: Array<{id: string;version_number: number;}>,
summaries: Record<string, T | null>
): CampaignVersionWorkItem<T>[] {
return versions.map((version) => ({
campaignId,
versionId: version.id,
versionNumber: version.version_number,
isCurrent: version.id === currentVersionId,
summary: summaries[version.id] ?? null
}));
}
export type CampaignLifecycleTotals = {pausable: number;paused: number;cancellable: number;};
export function campaignLifecycleTotals(items: CampaignLifecycleTotals[]): CampaignLifecycleTotals {
return items.reduce((totals, item) => ({
pausable: totals.pausable + item.pausable,
paused: totals.paused + item.paused,
cancellable: totals.cancellable + item.cancellable
}), { pausable: 0, paused: 0, cancellable: 0 });
}
export function operatorDeliveryStateCounts(
queue: {queued?: number;},
// send.queued intentionally does not contribute: paused queue rows retain that send state.
send: {queued?: number;claimed?: number;sending?: number;smtpAccepted?: number;sent?: number;}
) {
const queued = queue.queued ?? 0;
const claimed = send.claimed ?? 0;
const sending = send.sending ?? 0;
const completed = (send.smtpAccepted ?? 0) + (send.sent ?? 0);
return { queued, claimed, sending, completed, queuedOrActive: queued + claimed + sending };
}
export function shouldRefreshOperatorVersionSummary(input: {
fullDiscovery: boolean;
versionChanged: boolean;
selected: boolean;
cached: boolean;
queuedOrActive: number;
needsAttention: number;
}): boolean {
return input.fullDiscovery
|| input.versionChanged
|| !input.cached
|| input.selected
|| input.queuedOrActive > 0
|| input.needsAttention > 0;
}
/**
* Return the reason each fixed queue action is unavailable. A null result means
* the action is permitted and meaningful for the current queue projection.
*/
export function operatorQueueActionBlocks(
facts: OperatorQueueFacts,
permissions: OperatorQueuePermissions
): Record<OperatorQueueAction, string | null> {
return {
details: permissions.canRead ? null : OPERATOR_QUEUE_REASON.permissionDenied,
report: permissions.canOpenReport ? null : OPERATOR_QUEUE_REASON.permissionDenied,
pause: !permissions.canControl
? OPERATOR_QUEUE_REASON.permissionDenied
: facts.pausable > 0
? null
: OPERATOR_QUEUE_REASON.noQueued,
resume: !permissions.canControl
? OPERATOR_QUEUE_REASON.permissionDenied
: facts.paused > 0
? null
: OPERATOR_QUEUE_REASON.noPaused,
retry: !permissions.canRetry
? OPERATOR_QUEUE_REASON.permissionDenied
: facts.retryable > 0
? null
: OPERATOR_QUEUE_REASON.noFailed,
"queue-unsent": !permissions.canQueue
? OPERATOR_QUEUE_REASON.permissionDenied
: facts.queueableUnattempted > 0
? null
: OPERATOR_QUEUE_REASON.noUnsent,
reconcile: !permissions.canReconcile
? OPERATOR_QUEUE_REASON.permissionDenied
: facts.outcomeUnknown > 0
? null
: OPERATOR_QUEUE_REASON.noUnknown,
cancel: !permissions.canControl
? OPERATOR_QUEUE_REASON.permissionDenied
: facts.cancellable > 0
? null
: OPERATOR_QUEUE_REASON.noCancellable
};
}

View File

@@ -0,0 +1,267 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Eye, RefreshCw } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import {
Button,
Card,
DataGrid,
DismissibleAlert,
LoadingFrame,
MetricCard,
PageTitle,
StatusBadge,
TableActionGroup,
formatDateTime,
i18nMessage,
type ApiSettings,
type DataGridColumn,
type DataGridListOption
} from "@govoplan/core-webui";
import {
getAggregateCampaignReport,
listAggregateReportCampaigns,
type AggregateCampaignReport,
type AggregateReportCampaignListItem,
type AggregateReportCount
} from "../../api/campaigns";
const CAMPAIGN_STATUS_OPTIONS: DataGridListOption[] = [
{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" },
{ value: "validated", label: "i18n:govoplan-campaign.valid.a4aefa35" },
{ value: "needs_review", label: "i18n:govoplan-campaign.needs_review.33a506cf" },
{ value: "ready_to_queue", label: "i18n:govoplan-campaign.ready.20c7c552" },
{ value: "queued", label: "i18n:govoplan-campaign.queued.6a599877" },
{ value: "sending", label: "i18n:govoplan-campaign.sending.cf765512" },
{ value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" },
{ value: "partially_completed", label: "i18n:govoplan-campaign.partially_completed.760bc5e6" },
{ value: "outcome_unknown", label: "i18n:govoplan-campaign.outcome_unknown.6e929fca" },
{ value: "failed", label: "i18n:govoplan-campaign.failed.09fef5d8" },
{ value: "cancelled", label: "i18n:govoplan-campaign.cancelled.a1bf92ef" },
{ value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }
];
export default function AggregateReportsPage({ settings }: {settings: ApiSettings;}) {
const [searchParams, setSearchParams] = useSearchParams();
const selectedFromUrl = searchParams.get("campaign") ?? "";
const [campaigns, setCampaigns] = useState<AggregateReportCampaignListItem[]>([]);
const [report, setReport] = useState<AggregateCampaignReport | null>(null);
const [listLoading, setListLoading] = useState(true);
const [reportLoading, setReportLoading] = useState(false);
const [error, setError] = useState("");
const reportRequest = useRef(0);
const selectCampaign = useCallback((campaignId: string) => {
setSearchParams(campaignId ? { campaign: campaignId } : {}, { replace: true });
}, [setSearchParams]);
const loadCampaigns = useCallback(async () => {
setListLoading(true);
setError("");
try {
const rows = await listAggregateReportCampaigns(settings);
setCampaigns(rows);
const requested = selectedFromUrl && rows.some((row) => row.id === selectedFromUrl)
? selectedFromUrl
: rows[0]?.id ?? "";
if (requested !== selectedFromUrl) selectCampaign(requested);
if (!requested) setReport(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setCampaigns([]);
setReport(null);
} finally {
setListLoading(false);
}
}, [settings, selectedFromUrl, selectCampaign]);
const loadReport = useCallback(async (campaignId: string) => {
if (!campaignId) {
setReport(null);
return;
}
const requestId = ++reportRequest.current;
setReportLoading(true);
setError("");
try {
const next = await getAggregateCampaignReport(settings, campaignId);
if (requestId === reportRequest.current) setReport(next);
} catch (err) {
if (requestId === reportRequest.current) {
setError(err instanceof Error ? err.message : String(err));
setReport(null);
}
} finally {
if (requestId === reportRequest.current) setReportLoading(false);
}
}, [settings]);
useEffect(() => {
void loadCampaigns();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!listLoading && selectedFromUrl && campaigns.some((row) => row.id === selectedFromUrl)) {
void loadReport(selectedFromUrl);
}
}, [campaigns, listLoading, loadReport, selectedFromUrl]);
const columns = useMemo<DataGridColumn<AggregateReportCampaignListItem>[]>(() => [
{
id: "campaign",
header: "i18n:govoplan-campaign.campaign.69390e16",
width: "minmax(260px, 1fr)",
sortable: true,
filterable: true,
sticky: "start",
render: (campaign) => (
<div>
<strong>{campaign.name}</strong>
</div>
),
value: (campaign) => campaign.name
},
{
id: "status",
header: "i18n:govoplan-campaign.status.bae7d5be",
width: 170,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: CAMPAIGN_STATUS_OPTIONS, display: "pill" },
render: (campaign) => <StatusBadge status={campaign.status} />,
value: (campaign) => campaign.status
},
{
id: "updated",
header: "i18n:govoplan-campaign.updated.f2f8570d",
width: 190,
sortable: true,
filterable: true,
filterType: "date",
render: (campaign) => formatDateTime(campaign.updated_at),
value: (campaign) => campaign.updated_at
},
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 72,
sticky: "end",
align: "right",
render: (campaign) => (
<TableActionGroup actions={[{
id: "view-aggregate-report",
label: "i18n:govoplan-campaign.view_aggregate_report.94b42124",
icon: <Eye aria-hidden="true" />,
variant: campaign.id === selectedFromUrl ? "primary" : "secondary",
disabled: campaign.id === selectedFromUrl,
disabledReason: campaign.id === selectedFromUrl ? "i18n:govoplan-campaign.this_report_is_already_selected_.983c5be4" : undefined,
onClick: () => selectCampaign(campaign.id)
}]} />
)
}
], [selectCampaign, selectedFromUrl]);
const threshold = report?.privacy.small_cell_threshold ?? 5;
const outcomes = report?.outcomes;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={listLoading || reportLoading}>i18n:govoplan-campaign.reports.88bc3fe3</PageTitle>
<p className="muted">i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d</p>
</div>
<div className="button-row compact-actions">
<Button
onClick={() => void Promise.all([loadCampaigns(), selectedFromUrl ? loadReport(selectedFromUrl) : Promise.resolve()])}
disabled={listLoading || reportLoading}
>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-campaign.refresh.56e3badc
</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403">
<LoadingFrame loading={listLoading} label="i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8">
<DataGrid
id="campaign-aggregate-report-list"
rows={campaigns}
columns={columns}
getRowKey={(campaign) => campaign.id}
initialSort={{ columnId: "updated", direction: "desc" }}
emptyText="i18n:govoplan-campaign.no_aggregate_campaign_reports_are_available_to_y.dafb2781"
/>
</LoadingFrame>
</Card>
<LoadingFrame loading={reportLoading} label="i18n:govoplan-campaign.loading_aggregate_campaign_report_.72f2f091">
{report && outcomes && (
<>
<Card title={report.campaign.name}>
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={report.campaign.status} /></dd></div>
<div><dt>i18n:govoplan-campaign.completion.2ff25568</dt><dd><StatusBadge status={report.completion_state} label={aggregateCompletionLabel(report.completion_state)} /></dd></div>
<div><dt>i18n:govoplan-campaign.version.2da600bf</dt><dd>{report.version_number ?? "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(report.generated_at)}</dd></div>
</dl>
</Card>
{/* Aggregate outcome cards are deliberately not filter shortcuts: each subgroup
would require a separately suppressed aggregate response from the server. */}
<div className="dashboard-grid">
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={countValue(outcomes.failed)} tone="danger" />
<MetricCard label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={countValue(outcomes.outcome_unknown)} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.queued_or_active.afdcd7da" value={countValue(outcomes.queued_or_active)} tone="info" />
<MetricCard label="i18n:govoplan-campaign.not_attempted.e1be3c69" value={countValue(outcomes.not_attempted)} />
<MetricCard label="i18n:govoplan-campaign.cancelled.a1bf92ef" value={countValue(outcomes.cancelled)} />
<MetricCard label="i18n:govoplan-campaign.excluded.9804952b" value={countValue(outcomes.excluded)} />
</div>
<Card title="i18n:govoplan-campaign.population_and_exclusions.cdff62ad">
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.report_denominator.d089074a</dt><dd>{countValue(report.population.denominator)}</dd></div>
<div><dt>i18n:govoplan-campaign.inactive_source_entries.88fa0cf8</dt><dd>{countValue(report.population.inactive_source_entries)}</dd></div>
<div><dt>i18n:govoplan-campaign.excluded_or_blocked_jobs.e2190d47</dt><dd>{countValue(report.population.excluded_or_blocked_jobs)}</dd></div>
</dl>
<p className="muted">i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc</p>
</Card>
<Card title="i18n:govoplan-campaign.delivery_activity_range.0d1b62ec">
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.first_activity.3fe36ef4</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.first_activity_at ?? undefined)}</dd></div>
<div><dt>i18n:govoplan-campaign.last_activity.1c73b806</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.last_activity_at ?? undefined)}</dd></div>
</dl>
</Card>
<Card title="i18n:govoplan-campaign.privacy_boundary.d37e12bc">
<p>i18n:govoplan-campaign.positive_counts_below_the_threshold_are_hidden_a.1dc97093</p>
<p className="muted">
{i18nMessage("i18n:govoplan-campaign.small_cell_threshold_value0_suppressed_values_ca.d73f87a6", { value0: threshold })}
</p>
</Card>
</>
)}
</LoadingFrame>
</div>
);
}
function countValue(count: AggregateReportCount): string | number {
return count.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : count.value ?? "—";
}
function aggregateCompletionLabel(state: AggregateCampaignReport["completion_state"]): string {
const labels: Record<AggregateCampaignReport["completion_state"], string> = {
not_started: "i18n:govoplan-campaign.not_started.db2c45d5",
in_progress: "i18n:govoplan-campaign.in_progress.b6bd42e4",
completed: "i18n:govoplan-campaign.completed.1798b3ba",
partially_completed: "i18n:govoplan-campaign.partially_completed.760bc5e6",
incomplete: "i18n:govoplan-campaign.incomplete.387fd1bb",
outcome_unknown: "i18n:govoplan-campaign.outcome_unknown.6e929fca",
suppressed: "i18n:govoplan-campaign.suppressed.fce88db5"
};
return labels[state];
}

View File

@@ -4,6 +4,117 @@ export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Appended",
"i18n:govoplan-campaign.appending.167de6b2": "Appending",
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Another queue action is still running.",
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Cancel eligible queued work?",
"i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b": "Cancel all eligible queued work for this Campaign",
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Cancel queued work",
"i18n:govoplan-campaign.claimed.83c87884": "Claimed",
"i18n:govoplan-campaign.current.4fc0e2bc": "Current",
"i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70": "Delivery jobs for {value0}",
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Eligible jobs that have not started will be cancelled. Accepted, sending, and outcome-unknown jobs remain protected.",
"i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34": "Eligible jobs in every version of this Campaign that have not started will be cancelled. Accepted, sending, and outcome-unknown jobs remain protected.",
"i18n:govoplan-campaign.historical.bb2fe205": "Historical",
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "No cancellable queued work remains.",
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "No failed jobs are eligible for retry.",
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "No paused jobs can be resumed.",
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "No queued jobs can be paused.",
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "No uncertain outcomes require reconciliation.",
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "No unsent jobs are available to queue.",
"i18n:govoplan-campaign.not_requested.a0f3bce4": "Not requested",
"i18n:govoplan-campaign.open_recipient_report.d6892ee4": "Open recipient report",
"i18n:govoplan-campaign.pause_queue.b66e7696": "Pause queue",
"i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf": "Pause all queued work for this Campaign",
"i18n:govoplan-campaign.paused.c7dfb6f1": "Paused",
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Queue action for {value0} completed: {value1} ({value2}).",
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Queue refreshes automatically while delivery is active.",
"i18n:govoplan-campaign.queued_at.1ac179b3": "Queued at",
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Reconcile outcomes",
"i18n:govoplan-campaign.resume_queue.1f22f964": "Resume queue",
"i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a": "Resume all paused work for this Campaign",
"i18n:govoplan-campaign.sending.ceafde86": "Sending",
"i18n:govoplan-campaign.sent_at.a6f45556": "Sent at",
"i18n:govoplan-campaign.temporarily_failed.50d9b1a8": "Temporarily failed",
"i18n:govoplan-campaign.permanently_failed.32eda23a": "Permanently failed",
"i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8": "This Campaign queue is already selected.",
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "You do not have permission for this action.",
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Campaign reports available to you",
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "Cancelled {value0} unsent message(s); {value1} protected outcome(s) and {value2} excluded/skipped message(s) were retained.",
"i18n:govoplan-campaign.completion.2ff25568": "Completion",
"i18n:govoplan-campaign.database_queue.8bf98437": "Database queue",
"i18n:govoplan-campaign.delivery_activity_range.0d1b62ec": "Delivery activity range",
"i18n:govoplan-campaign.delivery_mode.109ed9d1": "Delivery mode",
"i18n:govoplan-campaign.excluded_or_blocked_jobs.e2190d47": "Excluded or blocked jobs",
"i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00": "Excluded rows are intentionally omitted from delivery. Their SMTP and IMAP states are shown as Skipped because no transport effect is attempted; use the separate status filters to isolate them.",
"i18n:govoplan-campaign.first_activity.3fe36ef4": "First activity",
"i18n:govoplan-campaign.in_progress.b6bd42e4": "In progress",
"i18n:govoplan-campaign.inactive_source_entries.88fa0cf8": "Inactive source entries",
"i18n:govoplan-campaign.incomplete.387fd1bb": "Incomplete",
"i18n:govoplan-campaign.last_activity.1c73b806": "Last activity",
"i18n:govoplan-campaign.loading_aggregate_campaign_report_.72f2f091": "Loading aggregate Campaign report…",
"i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8": "Loading Campaign reports…",
"i18n:govoplan-campaign.no_aggregate_campaign_reports_are_available_to_y.dafb2781": "No aggregate Campaign reports are available to you.",
"i18n:govoplan-campaign.not_started.db2c45d5": "Not started",
"i18n:govoplan-campaign.partially_completed.760bc5e6": "Partially completed",
"i18n:govoplan-campaign.population_and_exclusions.cdff62ad": "Population and exclusions",
"i18n:govoplan-campaign.privacy_boundary.d37e12bc": "Privacy boundary",
"i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d": "Privacy-protected Campaign outcomes without recipient or delivery diagnostics.",
"i18n:govoplan-campaign.queued_or_active.afdcd7da": "Queued or active",
"i18n:govoplan-campaign.report_denominator.d089074a": "Report denominator",
"i18n:govoplan-campaign.small_cell_threshold_value0_suppressed_values_ca.d73f87a6": "Small-cell threshold: {value0}. Suppressed values cannot be opened, filtered, exported, or inspected from this view.",
"i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19": "SMTP skipped (excluded)",
"i18n:govoplan-campaign.suppressed.fce88db5": "Suppressed",
"i18n:govoplan-campaign.synchronous.77c61919": "Synchronous",
"i18n:govoplan-campaign.this_report_is_already_selected_.983c5be4": "This report is already selected.",
"i18n:govoplan-campaign.this_version_last_entered_value0_mode_at_value1_.c087d2f3": "This version last entered {value0} mode at {value1}. This record remains available after leaving and returning.",
"i18n:govoplan-campaign.this_version_last_entered_value0_mode_this_recor.ea2f201f": "This version last entered {value0} mode. This record remains available after leaving and returning.",
"i18n:govoplan-campaign.view_aggregate_report.94b42124": "View aggregate report",
"i18n:govoplan-campaign.worker_queue.c911e32c": "Worker queue",
"i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc": "All persisted recipient delivery jobs for the selected campaign version, including excluded or blocked jobs. Inactive source entries without a job record are excluded and reported separately.",
"i18n:govoplan-campaign.background_workers_are_not_configured_.efa72396": "Background workers are not configured.",
"i18n:govoplan-campaign.cancel_all_unsent_delivery_work_.a2f56cda": "Cancel all unsent delivery work?",
"i18n:govoplan-campaign.cancel_unsent_work.66df9f0d": "Cancel unsent work",
"i18n:govoplan-campaign.cancelling_eligible_delivery_jobs_.4090fa47": "Cancelling eligible delivery jobs…",
"i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2": "Committing the reviewed execution to the background-worker queue…",
"i18n:govoplan-campaign.delivery_options_are_still_being_evaluated.8395096e": "delivery options are still being evaluated",
"i18n:govoplan-campaign.deployment.327a55f8": "Deployment",
"i18n:govoplan-campaign.deployment_ceiling.abbec75b": "Deployment ceiling",
"i18n:govoplan-campaign.deployment_default.aa39f7b4": "Deployment default",
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Keep delivery work",
"i18n:govoplan-campaign.limit_source.bd933adb": "Limit source",
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Loading the effective delivery policy…",
"i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f": "No real delivery mode is currently available ({value0}).",
"i18n:govoplan-campaign.not_available.d1a17af1": "Not available",
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Pause queued work",
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "Paused {value0} queued message(s).",
"i18n:govoplan-campaign.pausing_eligible_delivery_jobs_.eb6b9d58": "Pausing eligible delivery jobs…",
"i18n:govoplan-campaign.positive_counts_below_the_threshold_are_hidden_a.1dc97093": "Positive counts below the threshold are hidden. At least one additional count or the denominator is hidden when needed to prevent subtraction.",
"i18n:govoplan-campaign.queued_value0_message_s_value1_worker_task_s_pub.18d67ec8": "Queued {value0} message(s); {value1} worker task(s) published.",
"i18n:govoplan-campaign.queued_value0_retryable_message_s_value1_worker_.f4795bdb": "Queued {value0} retryable message(s); {value1} worker task(s) published.",
"i18n:govoplan-campaign.queue_for_workers.dae9a3e4": "Queue for workers",
"i18n:govoplan-campaign.queue_for_workers_commits_durable_jobs_and_retur.d0dbe81a": "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 {value0} eligible recipient jobs.",
"i18n:govoplan-campaign.queue_this_version_for_background_workers_.943d032c": "Queue this version for background workers?",
"i18n:govoplan-campaign.queueing_for_workers_.d24584fe": "Queueing for workers…",
"i18n:govoplan-campaign.queueing_retryable_failed_messages_for_backgroun.4bc80cd9": "Queueing retryable failed messages for background workers…",
"i18n:govoplan-campaign.resume_with_workers.510a2a9a": "Resume with workers",
"i18n:govoplan-campaign.resumed_value0_message_s_value1_worker_task_s_pu.0f59afb4": "Resumed {value0} message(s); {value1} worker task(s) published.",
"i18n:govoplan-campaign.resuming_eligible_delivery_jobs_.dd12c5a2": "Resuming eligible delivery jobs…",
"i18n:govoplan-campaign.retry_failed_with_workers.a4b7dcc5": "Retry failed with workers",
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "Send now is available for {value0} eligible message(s), within the effective limit of {value1}. {value2}",
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "Send now is unavailable: {value0}.",
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "Send now is unavailable ({value0}). Queue for workers remains available.",
"i18n:govoplan-campaign.synchronous_limit.f88c0bcd": "Synchronous limit",
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "the built run has no eligible message",
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "the configured maximum",
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "the delivery policy configuration is invalid",
"i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a": "the exact built run exceeds the effective limit for recipient jobs",
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "The worker queue is also available.",
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "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.",
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "This commits {value0} eligible message(s) from version {value1} to durable worker jobs. You may leave this page and return to the same progress and recovery state.",
"i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc": "validate, lock, build, and review the current version first",
"i18n:govoplan-campaign.value0_this_is_a_synchronous_request_for_value1_.0267bc6b": "{value0} This is a synchronous request for {value1} eligible message(s), within the effective limit of {value2}. All messages are preflighted before SMTP is contacted.",
"i18n:govoplan-campaign.worker_queue_committed_value0_message_s_and_publ.24400d3c": "Worker queue committed {value0} message(s) and published {value1} task(s). Refreshing progress does not requeue them.",
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Campaign-scoped Mail profile",
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign stores only this stable profile reference. The Mail module owns, encrypts, authorizes, tests, and resolves all SMTP/IMAP settings and credentials.",
"i18n:govoplan-campaign.configured.668c5fff": "Configured",
@@ -1176,6 +1287,117 @@ export const generatedTranslations: PlatformTranslations = {
"de": {
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Abgelegt",
"i18n:govoplan-campaign.appending.167de6b2": "Wird abgelegt",
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Eine andere Warteschlangenaktion wird noch ausgeführt.",
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Geeignete Warteschlangenaufträge abbrechen?",
"i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b": "Alle geeigneten Warteschlangenaufträge dieser Kampagne abbrechen",
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Warteschlangenaufträge abbrechen",
"i18n:govoplan-campaign.claimed.83c87884": "Beansprucht",
"i18n:govoplan-campaign.current.4fc0e2bc": "Aktuell",
"i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70": "Versandaufträge für {value0}",
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Geeignete Aufträge, die noch nicht begonnen haben, werden abgebrochen. Angenommene, laufende und ungeklärte Aufträge bleiben geschützt.",
"i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34": "Geeignete Aufträge in allen Versionen dieser Kampagne, die noch nicht begonnen haben, werden abgebrochen. Angenommene, laufende und ungeklärte Aufträge bleiben geschützt.",
"i18n:govoplan-campaign.historical.bb2fe205": "Historisch",
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "Es sind keine abbrechbaren Warteschlangenaufträge mehr vorhanden.",
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "Keine fehlgeschlagenen Aufträge können erneut versucht werden.",
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "Es sind keine pausierten Aufträge zum Fortsetzen vorhanden.",
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "Es sind keine wartenden Aufträge zum Pausieren vorhanden.",
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "Es müssen keine ungeklärten Ergebnisse abgestimmt werden.",
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "Es sind keine ungesendeten Aufträge für die Warteschlange verfügbar.",
"i18n:govoplan-campaign.not_requested.a0f3bce4": "Nicht angefordert",
"i18n:govoplan-campaign.open_recipient_report.d6892ee4": "Empfängerbezogenen Bericht öffnen",
"i18n:govoplan-campaign.pause_queue.b66e7696": "Warteschlange pausieren",
"i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf": "Alle eingereihten Aufträge dieser Kampagne pausieren",
"i18n:govoplan-campaign.paused.c7dfb6f1": "Pausiert",
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Warteschlangenaktion für {value0} abgeschlossen: {value1} ({value2}).",
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Die Warteschlange wird während eines laufenden Versands automatisch aktualisiert.",
"i18n:govoplan-campaign.queued_at.1ac179b3": "Eingereiht am",
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Ergebnisse abstimmen",
"i18n:govoplan-campaign.resume_queue.1f22f964": "Warteschlange fortsetzen",
"i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a": "Alle pausierten Aufträge dieser Kampagne fortsetzen",
"i18n:govoplan-campaign.sending.ceafde86": "Im Versand",
"i18n:govoplan-campaign.sent_at.a6f45556": "Gesendet am",
"i18n:govoplan-campaign.temporarily_failed.50d9b1a8": "Vorübergehend fehlgeschlagen",
"i18n:govoplan-campaign.permanently_failed.32eda23a": "Endgültig fehlgeschlagen",
"i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8": "Diese Kampagnenwarteschlange ist bereits ausgewählt.",
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "Sie haben keine Berechtigung für diese Aktion.",
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Für Sie verfügbare Kampagnenberichte",
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "{value0} ungesendete Nachricht(en) wurden abgebrochen; {value1} geschützte Ergebnisse und {value2} ausgeschlossene/übersprungene Nachricht(en) wurden beibehalten.",
"i18n:govoplan-campaign.completion.2ff25568": "Abschluss",
"i18n:govoplan-campaign.database_queue.8bf98437": "Datenbank-Warteschlange",
"i18n:govoplan-campaign.delivery_activity_range.0d1b62ec": "Zeitraum der Versandaktivität",
"i18n:govoplan-campaign.delivery_mode.109ed9d1": "Versandmodus",
"i18n:govoplan-campaign.excluded_or_blocked_jobs.e2190d47": "Ausgeschlossene oder blockierte Aufträge",
"i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00": "Ausgeschlossene Zeilen werden absichtlich nicht versendet. Ihr SMTP- und IMAP-Status wird als „Übersprungen“ angezeigt, da kein Transport versucht wird; über die separaten Statusfilter können Sie diese Zeilen gezielt anzeigen.",
"i18n:govoplan-campaign.first_activity.3fe36ef4": "Erste Aktivität",
"i18n:govoplan-campaign.in_progress.b6bd42e4": "In Bearbeitung",
"i18n:govoplan-campaign.inactive_source_entries.88fa0cf8": "Inaktive Quelleinträge",
"i18n:govoplan-campaign.incomplete.387fd1bb": "Unvollständig",
"i18n:govoplan-campaign.last_activity.1c73b806": "Letzte Aktivität",
"i18n:govoplan-campaign.loading_aggregate_campaign_report_.72f2f091": "Aggregierter Kampagnenbericht wird geladen…",
"i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8": "Kampagnenberichte werden geladen…",
"i18n:govoplan-campaign.no_aggregate_campaign_reports_are_available_to_y.dafb2781": "Für Sie sind keine aggregierten Kampagnenberichte verfügbar.",
"i18n:govoplan-campaign.not_started.db2c45d5": "Nicht begonnen",
"i18n:govoplan-campaign.partially_completed.760bc5e6": "Teilweise abgeschlossen",
"i18n:govoplan-campaign.population_and_exclusions.cdff62ad": "Grundgesamtheit und Ausschlüsse",
"i18n:govoplan-campaign.privacy_boundary.d37e12bc": "Datenschutzgrenze",
"i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d": "Datenschutzgeschützte Kampagnenergebnisse ohne Empfänger- oder Versanddiagnosen.",
"i18n:govoplan-campaign.queued_or_active.afdcd7da": "In Warteschlange oder aktiv",
"i18n:govoplan-campaign.report_denominator.d089074a": "Bezugsgröße des Berichts",
"i18n:govoplan-campaign.small_cell_threshold_value0_suppressed_values_ca.d73f87a6": "Schwellenwert für kleine Fallzahlen: {value0}. Unterdrückte Werte können in dieser Ansicht nicht geöffnet, gefiltert, exportiert oder geprüft werden.",
"i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19": "SMTP übersprungen (ausgeschlossen)",
"i18n:govoplan-campaign.suppressed.fce88db5": "Unterdrückt",
"i18n:govoplan-campaign.synchronous.77c61919": "Synchron",
"i18n:govoplan-campaign.this_report_is_already_selected_.983c5be4": "Dieser Bericht ist bereits ausgewählt.",
"i18n:govoplan-campaign.this_version_last_entered_value0_mode_at_value1_.c087d2f3": "Diese Version wechselte zuletzt am {value1} in den Modus „{value0}“. Der Eintrag bleibt auch nach dem Verlassen und erneuten Öffnen verfügbar.",
"i18n:govoplan-campaign.this_version_last_entered_value0_mode_this_recor.ea2f201f": "Diese Version wechselte zuletzt in den Modus „{value0}“. Der Eintrag bleibt auch nach dem Verlassen und erneuten Öffnen verfügbar.",
"i18n:govoplan-campaign.view_aggregate_report.94b42124": "Aggregierten Bericht anzeigen",
"i18n:govoplan-campaign.worker_queue.c911e32c": "Worker-Warteschlange",
"i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc": "Alle dauerhaft gespeicherten Empfänger-Versandaufträge der ausgewählten Kampagnenversion werden berücksichtigt, einschließlich ausgeschlossener oder blockierter Aufträge. Inaktive Quelleinträge ohne Auftragsdatensatz sind ausgeschlossen und werden separat ausgewiesen.",
"i18n:govoplan-campaign.background_workers_are_not_configured_.efa72396": "Hintergrund-Worker sind nicht konfiguriert.",
"i18n:govoplan-campaign.cancel_all_unsent_delivery_work_.a2f56cda": "Alle ungesendeten Versandaufträge abbrechen?",
"i18n:govoplan-campaign.cancel_unsent_work.66df9f0d": "Ungesendete Aufträge abbrechen",
"i18n:govoplan-campaign.cancelling_eligible_delivery_jobs_.4090fa47": "Geeignete Versandaufträge werden abgebrochen…",
"i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2": "Die geprüfte Ausführung wird an die Hintergrund-Worker-Warteschlange übergeben…",
"i18n:govoplan-campaign.delivery_options_are_still_being_evaluated.8395096e": "die Versandoptionen werden noch ausgewertet",
"i18n:govoplan-campaign.deployment.327a55f8": "Bereitstellung",
"i18n:govoplan-campaign.deployment_ceiling.abbec75b": "Bereitstellungsobergrenze",
"i18n:govoplan-campaign.deployment_default.aa39f7b4": "Bereitstellungsstandard",
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Versandaufträge beibehalten",
"i18n:govoplan-campaign.limit_source.bd933adb": "Quelle des Grenzwerts",
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Wirksame Versandrichtlinie wird geladen…",
"i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f": "Derzeit ist kein echter Versandmodus verfügbar ({value0}).",
"i18n:govoplan-campaign.not_available.d1a17af1": "Nicht verfügbar",
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Wartende Aufträge pausieren",
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "{value0} wartende Nachricht(en) pausiert.",
"i18n:govoplan-campaign.pausing_eligible_delivery_jobs_.eb6b9d58": "Geeignete Versandaufträge werden pausiert…",
"i18n:govoplan-campaign.positive_counts_below_the_threshold_are_hidden_a.1dc97093": "Positive Anzahlen unterhalb des Schwellenwerts werden ausgeblendet. Falls erforderlich, wird mindestens eine weitere Anzahl oder die Bezugsgröße ausgeblendet, damit keine Rückrechnung möglich ist.",
"i18n:govoplan-campaign.queued_value0_message_s_value1_worker_task_s_pub.18d67ec8": "{value0} Nachricht(en) wurden eingereiht; {value1} Worker-Aufgabe(n) wurden veröffentlicht.",
"i18n:govoplan-campaign.queued_value0_retryable_message_s_value1_worker_.f4795bdb": "{value0} wiederholbare Nachricht(en) wurden eingereiht; {value1} Worker-Aufgabe(n) wurden veröffentlicht.",
"i18n:govoplan-campaign.queue_for_workers.dae9a3e4": "Für Worker einreihen",
"i18n:govoplan-campaign.queue_for_workers_commits_durable_jobs_and_retur.d0dbe81a": "Durch das Einreihen für Worker werden dauerhafte Aufträge erstellt und die Aktion kehrt sofort zurück; der Fortschritt bleibt nach dem Verlassen sichtbar. „Jetzt senden“ hält diese Anfrage offen und ist auf {value0} geeignete Empfängeraufträge begrenzt.",
"i18n:govoplan-campaign.queue_this_version_for_background_workers_.943d032c": "Diese Version für Hintergrund-Worker einreihen?",
"i18n:govoplan-campaign.queueing_for_workers_.d24584fe": "Wird für Worker eingereiht…",
"i18n:govoplan-campaign.queueing_retryable_failed_messages_for_backgroun.4bc80cd9": "Wiederholbare fehlgeschlagene Nachrichten werden für Hintergrund-Worker eingereiht…",
"i18n:govoplan-campaign.resume_with_workers.510a2a9a": "Mit Workern fortsetzen",
"i18n:govoplan-campaign.resumed_value0_message_s_value1_worker_task_s_pu.0f59afb4": "{value0} Nachricht(en) wurden fortgesetzt; {value1} Worker-Aufgabe(n) wurden veröffentlicht.",
"i18n:govoplan-campaign.resuming_eligible_delivery_jobs_.dd12c5a2": "Geeignete Versandaufträge werden fortgesetzt…",
"i18n:govoplan-campaign.retry_failed_with_workers.a4b7dcc5": "Fehlgeschlagene Aufträge mit Workern wiederholen",
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "„Jetzt senden“ ist für {value0} geeignete Nachricht(en) innerhalb des wirksamen Grenzwerts von {value1} verfügbar. {value2}",
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "„Jetzt senden“ ist nicht verfügbar: {value0}.",
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "„Jetzt senden“ ist nicht verfügbar ({value0}). Die Worker-Warteschlange bleibt verfügbar.",
"i18n:govoplan-campaign.synchronous_limit.f88c0bcd": "Grenzwert für synchronen Versand",
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "der erstellte Lauf enthält keine geeignete Nachricht",
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "das konfigurierte Maximum",
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "die Konfiguration der Versandrichtlinie ist ungültig",
"i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a": "der exakt erstellte Lauf überschreitet den wirksamen Grenzwert für Empfängeraufträge",
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "Die Worker-Warteschlange ist ebenfalls verfügbar.",
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "Dadurch werden wartende oder pausierte Nachrichten abgebrochen, die die SMTP-Grenze noch nicht überschritten haben. Angenommene, aktive oder ungeklärte Nachrichten bleiben für Audit und Abstimmung geschützt.",
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "Dadurch werden {value0} geeignete Nachricht(en) aus Version {value1} als dauerhafte Worker-Aufträge eingereiht. Sie können diese Seite verlassen und später zum selben Fortschritts- und Wiederherstellungszustand zurückkehren.",
"i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc": "validieren, sperren, erstellen und prüfen Sie zuerst die aktuelle Version",
"i18n:govoplan-campaign.value0_this_is_a_synchronous_request_for_value1_.0267bc6b": "{value0} Dies ist eine synchrone Anfrage für {value1} geeignete Nachricht(en) innerhalb des wirksamen Grenzwerts von {value2}. Alle Nachrichten werden vor dem SMTP-Kontakt vollständig vorgeprüft.",
"i18n:govoplan-campaign.worker_queue_committed_value0_message_s_and_publ.24400d3c": "Die Worker-Warteschlange hat {value0} Nachricht(en) übernommen und {value1} Aufgabe(n) veröffentlicht. Eine Fortschrittsaktualisierung reiht sie nicht erneut ein.",
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Kampagnenspezifisches Mail-Profil",
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign speichert nur diese stabile Profilreferenz. Das Mail-Modul verwaltet, verschlüsselt, autorisiert, testet und löst alle SMTP-/IMAP-Einstellungen und Zugangsdaten auf.",
"i18n:govoplan-campaign.configured.668c5fff": "Konfiguriert",
@@ -1397,7 +1619,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.can_view.1b3e4006": "Can view",
"i18n:govoplan-campaign.cancel_filename_changes.b558598e": "Cancel filename changes",
"i18n:govoplan-campaign.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-campaign.cancelled.a1bf92ef": "Cancelled",
"i18n:govoplan-campaign.cancelled.a1bf92ef": "Abgebrochen",
"i18n:govoplan-campaign.captured_mailbox_preview_is_disabled_for_this_ru.6559458d": "Captured mailbox preview is disabled for this run. Recipient outcomes remain available.",
"i18n:govoplan-campaign.captured_mailbox_preview_requires_the_mail_devel.9b3d9f0d": "Captured mailbox preview requires the Mail development mailbox API.",
"i18n:govoplan-campaign.captured_messages.a833d293": "Captured messages",
@@ -1429,7 +1651,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.compatibility_notes.7d648a6d": "Compatibility notes",
"i18n:govoplan-campaign.complete_a_successful_mock_delivery_first.bc2a587d": "Complete a successful mock delivery first.",
"i18n:govoplan-campaign.complete_review.4c2ed8c8": "Complete review",
"i18n:govoplan-campaign.completed.1798b3ba": "Completed",
"i18n:govoplan-campaign.completed.1798b3ba": "Abgeschlossen",
"i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b": "Compose the subject and body. Merge fields can later be inserted from the field picker.",
"i18n:govoplan-campaign.concurrency.2ec390bf": "Concurrency",
"i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2": "Configure campaign-wide attachment behavior and global matching rules.",
@@ -1832,7 +2054,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.not_claimed.aa712c1b": "Not claimed",
"i18n:govoplan-campaign.not_configured.67f2141f": "not configured",
"i18n:govoplan-campaign.not_loaded_yet.9968c191": "Not loaded yet",
"i18n:govoplan-campaign.not_queued.b7f41e1e": "Not queued",
"i18n:govoplan-campaign.not_queued.b7f41e1e": "Nicht eingereiht",
"i18n:govoplan-campaign.not_reviewed.0a0e3cff": "Not reviewed",
"i18n:govoplan-campaign.not_run.9e019cd5": "Not run",
"i18n:govoplan-campaign.not_sent.587c501e": "Not sent",
@@ -1864,7 +2086,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.optional.0c6c4102": "Optional",
"i18n:govoplan-campaign.or_click_to_select_file.0e72f25d": "or click to select file",
"i18n:govoplan-campaign.outcome_unknown.4383023a": ", outcome unknown",
"i18n:govoplan-campaign.outcome_unknown.6e929fca": "Outcome unknown",
"i18n:govoplan-campaign.outcome_unknown.6e929fca": "Ergebnis ungeklärt",
"i18n:govoplan-campaign.overview.0efc2e6b": "Übersicht",
"i18n:govoplan-campaign.owner_type.6b86eacc": "Owner type",
"i18n:govoplan-campaign.owner.719379ae": "Owner:",
@@ -1927,7 +2149,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.queued_jobs_are_waiting_for_a_background_deliver.cc703afb": "Queued jobs are waiting for a background delivery worker. If the counts do not change, check the worker process and queue logs.",
"i18n:govoplan-campaign.queued_jobs_are_waiting_if_the_counts_do_not_cha.7d2164d6": "Queued jobs are waiting. If the counts do not change, check whether background delivery workers are enabled and running.",
"i18n:govoplan-campaign.queued_jobs_are_waiting_in_the_database_but_back.5b1fadfd": "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.",
"i18n:govoplan-campaign.queued.6a599877": "Queued",
"i18n:govoplan-campaign.queued.6a599877": "Eingereiht",
"i18n:govoplan-campaign.queueing_pending_imap_append_jobs.31dd388e": "Queueing pending IMAP append jobs...",
"i18n:govoplan-campaign.quoted_contents.f7fd335a": "Quoted contents",
"i18n:govoplan-campaign.rate_limit.d08e55f5": "Rate limit",
@@ -2109,10 +2331,10 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.show_review_candidates_only.f49df60b": "Show review candidates only",
"i18n:govoplan-campaign.showing.163d8174": "Showing",
"i18n:govoplan-campaign.size.b7152342": "Größe",
"i18n:govoplan-campaign.skipped.5a000ad7": "Skipped",
"i18n:govoplan-campaign.skipped.5a000ad7": "Übersprungen",
"i18n:govoplan-campaign.skipped.6b98496c": ", skipped",
"i18n:govoplan-campaign.smtp_accepted.a5d0dccc": ", SMTP accepted",
"i18n:govoplan-campaign.smtp_accepted.e3aa7603": "SMTP accepted",
"i18n:govoplan-campaign.smtp_accepted.e3aa7603": "Von SMTP angenommen",
"i18n:govoplan-campaign.smtp_attempts.eb0a9ca6": "SMTP attempts",
"i18n:govoplan-campaign.smtp_credentials.10f75c8a": "). SMTP credentials:",
"i18n:govoplan-campaign.smtp_failed.0ce5516d": "SMTP failed",

View File

@@ -1,6 +1,6 @@
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";
import { ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
import { getCampaign } from "./api/campaigns";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/campaign-workspace.css";
@@ -8,10 +8,11 @@ import "./styles/campaign-workspace.css";
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
const AggregateReportsPage = lazy(() => import("./features/reports/AggregateReportsPage"));
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
const campaignRead = ["campaigns:campaign:read"];
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"];
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
@@ -31,6 +32,7 @@ export const campaignModule: PlatformWebModule = {
label: "i18n:govoplan-campaign.operator_queue.ddf23260",
iconName: "radio-tower",
anyOf: operatorScopes,
allOf: campaignRead,
order: 30
},
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
@@ -39,8 +41,8 @@ export const campaignModule: PlatformWebModule = {
routes: [
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
{ path: "/operator", anyOf: operatorScopes, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: ({ settings }) => createElement(AggregateReportsPage, { settings }) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
};
@@ -58,18 +60,4 @@ function CampaignResourceRoute({ settings, auth }: {settings: ApiSettings;auth:
});
}
function ReportsPage() {
return createElement(
"div",
{ className: "content-pad workspace-data-page" },
createElement(
"div",
{ className: "page-heading workspace-heading" },
createElement("h1", null, "i18n:govoplan-campaign.reports"),
createElement("p", null, "i18n:govoplan-campaign.reports_module_prepared")
),
createElement(Card, null, createElement("p", { className: "muted" }, "i18n:govoplan-campaign.next_passes_will_add_functionality_here"))
);
}
export default campaignModule;

View File

@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const api = readFileSync("src/api/campaigns.ts", "utf8");
const page = readFileSync("src/features/reports/AggregateReportsPage.tsx", "utf8");
const reportPage = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8");
const reviewPage = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8");
const operatorPage = readFileSync("src/features/operator/OperatorQueuePage.tsx", "utf8");
const deliveryMode = readFileSync("src/features/campaigns/utils/deliveryMode.ts", "utf8");
const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8");
assert(api.includes('"/api/v1/campaigns/aggregate-reports"'), "the report list uses the safe aggregate collection");
assert(api.includes("/api/v1/campaigns/aggregate-reports/${encodeURIComponent(campaignId)}"), "the selected report uses the safe aggregate projection");
assert(page.includes("listAggregateReportCampaigns"), "the page loads only the safe Campaign selector projection");
assert(page.includes("getAggregateCampaignReport"), "the page loads only the safe aggregate report projection");
assert(!page.includes("getCampaignReport"), "the aggregate page cannot load the recipient-aware full report");
assert(!page.includes("getCampaignJobs"), "the aggregate page cannot cache recipient jobs");
assert(!page.includes("getCampaignJobDetail"), "the aggregate page has no detail route");
assert(!page.includes("downloadCampaignJobsCsv"), "the aggregate page has no export path");
assert(!page.includes("localStorage"), "the aggregate page does not persist report data in local storage");
assert(!page.includes("sessionStorage"), "the aggregate page does not persist report data in session storage");
assert(page.includes("TableActionGroup"), "campaign selection uses the central icon-only table action group");
assert(page.includes("disabled: campaign.id === selectedFromUrl"), "the selected row action stays visible and disabled");
assert(page.includes('columnType: "from-list"'), "campaign status uses the stable shared list-filter model");
const expectedCampaignStatuses = [
"draft",
"validated",
"needs_review",
"ready_to_queue",
"queued",
"sending",
"sent",
"partially_completed",
"outcome_unknown",
"failed",
"cancelled",
"archived"
];
for (const status of expectedCampaignStatuses) {
assert(page.includes(`{ value: "${status}", label:`), `the aggregate list declares the ${status} status explicitly`);
}
const outcomeCardsStart = page.indexOf("Aggregate outcome cards are deliberately not filter shortcuts");
const outcomeCardsEnd = page.indexOf('<Card title="i18n:govoplan-campaign.population_and_exclusions', outcomeCardsStart);
assert(outcomeCardsStart >= 0 && outcomeCardsEnd > outcomeCardsStart, "the aggregate privacy boundary is documented beside the outcome cards");
assert(!page.slice(outcomeCardsStart, outcomeCardsEnd).includes("onClick"), "aggregate outcome cards cannot create unsuppressed filtered subgroups");
assert(!page.includes("{report.population.denominator_definition}"), "the known denominator contract uses a localized UI explanation");
assert(!page.includes("{report.privacy.rule}"), "the known privacy contract uses a localized UI explanation");
assert(page.includes("all_persisted_recipient_delivery_jobs_for_the_se.208e90cc"), "the denominator explanation is bilingual");
assert(page.includes("positive_counts_below_the_threshold_are_hidden_a.1dc97093"), "the suppression rule is bilingual");
const touchedSources = [page, reportPage, reviewPage, operatorPage, deliveryMode].join("\n");
const touchedKeys = new Set(touchedSources.match(/i18n:govoplan-campaign\.[a-z0-9_]+\.[a-f0-9]{8}/g) ?? []);
for (const key of touchedKeys) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const catalogEntries = [...translationCatalog.matchAll(new RegExp(`"${escapedKey}":\\s*("(?:[^"\\\\]|\\\\.)*")`, "g"))];
assert.equal(catalogEntries.length, 2, `${key} is present exactly once in both language catalogs`);
const english = JSON.parse(catalogEntries[0][1]);
const german = JSON.parse(catalogEntries[1][1]);
assert.notEqual(english.trim(), "", `${key} has a non-empty English translation`);
assert.notEqual(german.trim(), "", `${key} has a non-empty German translation`);
}
const sliceSections = [...translationCatalog.matchAll(/ "i18n:govoplan-campaign\.campaign_reports_available_to_you\.f14fa403":[\s\S]*? "i18n:govoplan-campaign\.worker_queue_committed_value0_message_s_and_publ\.24400d3c":.*\n/g)];
assert.equal(sliceSections.length, 2, "the current slice has one contiguous English and German catalog section");
const newEnglishEntries = [...sliceSections[0][0].matchAll(/"(i18n:govoplan-campaign\.[a-z0-9_]+\.[a-f0-9]{8})":\s*("(?:[^"\\]|\\.)*")/g)];
for (const [, key, encodedEnglish] of newEnglishEntries) {
const english = JSON.parse(encodedEnglish);
const digest = createHash("sha1").update(english).digest("hex").slice(0, 8);
const slug = english.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+/, "").slice(0, 48);
assert.equal(key, `i18n:govoplan-campaign.${slug}.${digest}`, `${key} uses the normalized source slug and exact SHA-1 suffix`);
assert(touchedKeys.has(key), `${key} is referenced by the current Campaign UI slices`);
}
console.log("aggregate report UI structure checks passed");

View File

@@ -0,0 +1,26 @@
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");
const modeSource = readFileSync(resolve(here, "../src/features/campaigns/utils/deliveryMode.ts"), "utf8");
assert.match(source, /getCampaignDeliveryOptions/);
assert.match(source, /queue_for_workers\.dae9a3e4/);
assert.match(source, /queue_for_workers_commits_durable_jobs_and_retur\.d0dbe81a/);
assert.match(source, /loading_the_effective_delivery_policy_\.d6893011/);
assert.match(source, /deliveryControlProgressMessage/);
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.match(source, /deliveryModeLabel\(persistedDeliveryMode\)/);
assert.match(source, /this_version_last_entered_value0_mode_at_value1_\.c087d2f3/);
assert.match(source, /this_version_last_entered_value0_mode_this_recor\.ea2f201f/);
assert.doesNotMatch(source, /This version last entered/);
assert.match(modeSource, /synchronous\.77c61919/);
assert.match(modeSource, /worker_queue\.c911e32c/);
assert.match(modeSource, /database_queue\.8bf98437/);
assert.doesNotMatch(source, /window\.alert\s*\(/);

View File

@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
OPERATOR_QUEUE_REASON,
campaignLifecycleTotals,
campaignVersionWorkItems,
operatorDeliveryStateCounts,
operatorQueueActionBlocks,
shouldRefreshOperatorVersionSummary,
type OperatorQueueFacts,
type OperatorQueuePermissions
} from "../src/features/operator/operatorQueueModel.ts";
const everything: OperatorQueueFacts = {
retryable: 2,
outcomeUnknown: 1,
queueableUnattempted: 3,
pausable: 4,
paused: 5,
cancellable: 14
};
const allPermissions: OperatorQueuePermissions = {
canRead: true,
canOpenReport: true,
canControl: true,
canRetry: true,
canQueue: true,
canReconcile: true
};
test("enables every fixed queue action when state and permission allow it", () => {
assert.deepEqual(operatorQueueActionBlocks(everything, allPermissions), {
details: null,
report: null,
pause: null,
resume: null,
retry: null,
"queue-unsent": null,
reconcile: null,
cancel: null
});
});
test("retains every unavailable action with a state-specific explanation", () => {
assert.deepEqual(
operatorQueueActionBlocks(
{ retryable: 0, outcomeUnknown: 0, queueableUnattempted: 0, pausable: 0, paused: 0, cancellable: 0 },
allPermissions
),
{
details: null,
report: null,
pause: OPERATOR_QUEUE_REASON.noQueued,
resume: OPERATOR_QUEUE_REASON.noPaused,
retry: OPERATOR_QUEUE_REASON.noFailed,
"queue-unsent": OPERATOR_QUEUE_REASON.noUnsent,
reconcile: OPERATOR_QUEUE_REASON.noUnknown,
cancel: OPERATOR_QUEUE_REASON.noCancellable
}
);
});
test("permission explanations take precedence over queue state", () => {
const noPermissions: OperatorQueuePermissions = {
canRead: false,
canOpenReport: false,
canControl: false,
canRetry: false,
canQueue: false,
canReconcile: false
};
const blocks = operatorQueueActionBlocks(everything, noPermissions);
assert.deepEqual(
Object.values(blocks),
Array.from({ length: 8 }, () => OPERATOR_QUEUE_REASON.permissionDenied)
);
});
test("allows in-page job selection without report permission while keeping report access disabled", () => {
const blocks = operatorQueueActionBlocks(everything, {
...allPermissions,
canOpenReport: false
});
assert.equal(blocks.details, null);
assert.equal(blocks.report, OPERATOR_QUEUE_REASON.permissionDenied);
});
test("keeps historical active work visible after a new draft becomes current", () => {
const items = campaignVersionWorkItems(
"campaign-1",
"version-2",
[
{ id: "version-2", version_number: 2 },
{ id: "version-1", version_number: 1 }
],
{
"version-2": { state: "draft", queued: 0, failed: 0 },
"version-1": { state: "active", queued: 3, failed: 1 }
}
);
assert.deepEqual(items.map((item) => [item.versionId, item.isCurrent]), [
["version-2", true],
["version-1", false]
]);
assert.deepEqual(items[1].summary, { state: "active", queued: 3, failed: 1 });
});
test("campaign-wide lifecycle totals are not multiplied across version rows", () => {
assert.deepEqual(campaignLifecycleTotals([
{ pausable: 3, paused: 1, cancellable: 4 },
{ pausable: 0, paused: 2, cancellable: 2 }
]), { pausable: 3, paused: 3, cancellable: 6 });
});
test("paused jobs do not overlap with queued or active delivery counts", () => {
assert.deepEqual(operatorDeliveryStateCounts(
{ queued: 0 },
{ queued: 4, claimed: 0, sending: 0, smtpAccepted: 2, sent: 1 }
), {
queued: 0,
claimed: 0,
sending: 0,
completed: 3,
queuedOrActive: 0
});
});
test("fast polling refreshes only selected, changed, uncached, active, or attention versions", () => {
const baseline = {
fullDiscovery: false,
versionChanged: false,
selected: false,
cached: true,
queuedOrActive: 0,
needsAttention: 0
};
assert.equal(shouldRefreshOperatorVersionSummary(baseline), false);
for (const override of [
{ fullDiscovery: true },
{ versionChanged: true },
{ selected: true },
{ cached: false },
{ queuedOrActive: 1 },
{ needsAttention: 1 }
]) {
assert.equal(shouldRefreshOperatorVersionSummary({ ...baseline, ...override }), true);
}
});

View File

@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(here, "../src/features/operator/OperatorQueuePage.tsx"), "utf8");
const translations = readFileSync(resolve(here, "../src/i18n/generatedTranslations.ts"), "utf8");
const backendRouter = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/router.py"), "utf8");
const responseSecurity = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/response_security.py"), "utf8");
assert.match(source, /<DataGrid/);
assert.match(source, /<TableActionGroup actions=/);
for (const action of ["details", "report", "pause", "resume", "retry", "queue-unsent", "reconcile", "cancel", "open-recipient-report"]) {
assert.match(source, new RegExp(`id: "${action}"`));
}
assert.match(source, /useDeltaWatermarks/);
assert.match(source, /useSearchParams/);
assert.match(source, /searchParams\.get\("campaign"\)/);
assert.match(source, /searchParams\.get\("version"\)/);
assert.match(source, /next\.set\("campaign", campaignId\)[\s\S]*next\.set\("version", versionId\)/);
assert.match(source, /setSearchParams\(next, \{ replace: true \}\)/);
assert.match(source, /window\.setInterval[\s\S]*10_000/);
assert.match(source, /window\.setInterval[\s\S]*60_000/);
assert.match(source, /pendingFullDiscoveryRef\.current = true/);
assert.match(source, /if \(pendingFullDiscoveryRef\.current\)[\s\S]*fullDiscoveryLoadRef\.current\(\)/);
assert.match(source, /<ConfirmDialog[\s\S]*cancelTarget/);
assert.match(source, /includeVersions: true/);
assert.match(source, /getCampaignSummary\(settings, campaign\.id, version\.id\)/);
assert.match(source, /campaignVersionWorkItems/);
assert.match(source, /operatorDeliveryStateCounts/);
assert.doesNotMatch(source, /queuedOrActive: numberValue\(cards\.queued_or_active\)/);
assert.match(source, /getCampaignJobs\(settings, selectedRow\.campaign\.id,[\s\S]*versionId: selectedVersionId/);
assert.doesNotMatch(source, /getCampaignJobDetail|getCampaignJobsDelta|diagnostics/);
assert.match(source, /mode: "server"/);
assert.match(source, /totalRows: jobs\.total/);
assert.match(source, /query=\{jobGridQuery\}/);
assert.match(source, /onQueryChange=\{handleJobGridQuery\}/);
assert.match(source, /pageSizeOptions: \[25, 50, 100, 200\]/);
assert.match(source, /jobsRequestRef\.current/);
assert.match(source, /requestId !== jobsRequestRef\.current/);
assert.match(source, /selectedQueueActive[\s\S]*backgroundJobsLoadRef/);
assert.match(source, /getRowKey=\{\(row\) => `\$\{row\.campaign\.id\}:\$\{rowVersionId\(row\)/);
assert.match(source, /row\.campaign\.id === selectedCampaignId && rowVersionId\(row\) === selectedVersionParam/);
assert.match(source, /retryCampaignJobs[\s\S]*version_id: rowVersionId\(row\)/);
assert.match(source, /sendUnattemptedCampaignJobs[\s\S]*version_id: rowVersionId\(row\)/);
assert.match(source, /report\?version=\$\{encodeURIComponent\(rowVersionId\(row\)\)\}&send_status=outcome_unknown/);
for (const count of ["queued", "claimed", "sending", "completed"]) {
assert.match(source, new RegExp(`row\\.${count}`));
assert.match(source, new RegExp(`totals\\.${count}`));
}
for (const field of ["campaign_version_id", "entry_id", "entry_index", "queue_status", "send_status", "imap_status", "attempt_count", "queued_at", "sent_at", "updated_at", "last_error"]) {
assert.match(source, new RegExp(field));
}
assert.match(source, /deliveryResultSummary[\s\S]*send_status[\s\S]*imap_status/);
assert.match(source, /durableResultText[\s\S]*slice\(0, 239\)/);
assert.match(source, /permissions\.canOpenReport[\s\S]*you_do_not_have_permission/);
assert.doesNotMatch(source, /claim_token|eml_local_path|storage_key|smtp_transport_revision|eml_sha256|message_id_header|smtp_response|provider_response|resolved_attachments/);
assert.match(backendRouter, /"last_error": public_delivery_result_message\([\s\S]*last_error=job\.last_error/);
assert.match(responseSecurity, /Map persisted provider text to a stable business-safe explanation/);
assert.match(responseSecurity, /SMTP delivery failed; an operator can inspect restricted diagnostics\./);
assert.doesNotMatch(source, /window\.alert\s*\(/);
assert.doesNotMatch(source, /function QueuePressureGrid|function OperatorJob/);
assert.doesNotMatch(source, /humanize\(/);
const localized = new Map([
["i18n:govoplan-campaign.claimed.83c87884", ["Claimed", "Beansprucht"]],
["i18n:govoplan-campaign.sending.ceafde86", ["Sending", "Im Versand"]],
["i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70", ["Delivery jobs for {value0}", "Versandaufträge für {value0}"]],
["i18n:govoplan-campaign.open_recipient_report.d6892ee4", ["Open recipient report", "Empfängerbezogenen Bericht öffnen"]],
["i18n:govoplan-campaign.queued_at.1ac179b3", ["Queued at", "Eingereiht am"]],
["i18n:govoplan-campaign.sent_at.a6f45556", ["Sent at", "Gesendet am"]],
["i18n:govoplan-campaign.current.4fc0e2bc", ["Current", "Aktuell"]],
["i18n:govoplan-campaign.historical.bb2fe205", ["Historical", "Historisch"]],
["i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf", ["Pause all queued work for this Campaign", "Alle eingereihten Aufträge dieser Kampagne pausieren"]]
]);
for (const [key, values] of localized) {
assert.equal(translations.split(`"${key}"`).length - 1, 2, `${key} occurs once per language`);
for (const value of values) assert(translations.includes(`"${key}": ${JSON.stringify(value)}`));
}

View File

@@ -0,0 +1,88 @@
import { campaignJobsQueryParams } from "../src/features/campaigns/utils/jobListQuery";
import {
activeReportGridShortcut,
reportGridQueryForShortcut,
toggleReportGridShortcut
} from "../src/features/campaigns/utils/reportGridShortcuts";
declare function require(name: string): {
readFileSync(path: string, encoding: string): string;
};
const { readFileSync } = require("node:fs");
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
const params = campaignJobsQueryParams({
versionId: "version-1",
page: 2,
pageSize: 25,
query: " recipient ",
sortBy: "recipient",
sortDirection: "desc",
filters: {
recipient: "target",
send: 'list:["failed_temporary","outcome_unknown"]',
attempts: "gte:2",
unsupported: "must-not-leak"
}
});
assert(params.get("version_id") === "version-1", "the selected Campaign version is retained");
assert(params.get("page") === "2" && params.get("page_size") === "25", "server pagination is serialized");
assert(params.get("q") === "recipient", "the deep-link search query is normalized");
assert(params.get("sort_by") === "recipient" && params.get("sort_direction") === "desc", "DataGrid sorting is server-owned");
assert(params.get("filter_recipient") === "target", "text filters are sent to the backend");
assert(params.get("filter_send") === 'list:["failed_temporary","outcome_unknown"]', "stable list selections are sent intact");
assert(params.get("filter_attempts") === "gte:2", "typed number filters retain their operator");
assert(!params.toString().includes("unsupported") && !params.toString().includes("must-not-leak"), "only the declared backend filter contract is serialized");
const shortcutFilters = {
smtp_accepted: { send: 'list:["smtp_accepted","sent"]' },
failed: { send: 'list:["failed_temporary","failed_permanent"]' },
outcome_unknown: { send: 'list:["outcome_unknown"]' },
not_attempted: { send: 'list:["not_queued"]' },
smtp_skipped: { send: 'list:["skipped"]' },
cancelled: { send: 'list:["cancelled"]' },
imap_appended: { imap: 'list:["appended"]' },
imap_failed: { imap: 'list:["failed"]' },
imap_skipped: { imap: 'list:["skipped"]' }
} as const;
for (const [shortcutId, filters] of Object.entries(shortcutFilters)) {
const shortcut = reportGridQueryForShortcut(shortcutId as keyof typeof shortcutFilters);
assert(JSON.stringify(shortcut.filters) === JSON.stringify(filters), `${shortcutId} uses the exact backend list filter`);
assert(shortcut.sort?.columnId === "number" && shortcut.sort.direction === "asc", `${shortcutId} restores stable report ordering`);
}
const failedShortcut = toggleReportGridShortcut({
sort: { columnId: "updated", direction: "desc" },
filters: { recipient: "old search", imap: 'list:["appended"]' }
}, "failed");
assert(JSON.stringify(failedShortcut.filters) === JSON.stringify(shortcutFilters.failed), "a shortcut clears unrelated filters");
assert(activeReportGridShortcut(failedShortcut) === "failed", "the exact shortcut query is recognized as active");
const toggledBack = toggleReportGridShortcut(failedShortcut, "failed");
assert(Object.keys(toggledBack.filters).length === 0, "selecting an active count returns to all jobs");
assert(activeReportGridShortcut(toggledBack) === "all", "the cleared query activates the all-jobs shortcut");
const sortedFailedShortcut = { ...failedShortcut, sort: { columnId: "recipient", direction: "desc" as const } };
assert(Object.keys(toggleReportGridShortcut(sortedFailedShortcut, "failed").filters).length === 0, "the active shortcut toggles to all even after the user changes sorting");
const reportSource = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8");
assert(reportSource.includes('mode: "server"'), "the report DataGrid declares server query ownership");
assert(reportSource.includes("totalRows: jobs.total"), "the shared pagination count uses the filtered backend total");
assert(reportSource.includes("onQueryChange={handleJobGridQuery}"), "header sort and filter changes drive the backend query");
assert(reportSource.includes("initialReportGridFilters()"), "status deep links initialize the DataGrid filters");
assert(reportSource.includes("initialReportQuery()"), "q deep links initialize the report search");
assert(reportSource.includes("query={jobGridQuery}"), "external count shortcuts synchronize the visible DataGrid query");
assert(reportSource.includes('setQuery("");') && reportSource.includes('setAppliedQuery("");'), "count shortcuts clear both visible and applied search terms");
assert(reportSource.includes("deliveryOutcomeShortcuts.map") && reportSource.includes("imapOutcomeShortcuts.map"), "top-level delivery counts use one coherent shortcut model");
assert(reportSource.includes('<Button type="button" variant={active ? "primary" : "ghost"}'), "count shortcuts render the central Button directly");
assert(reportSource.includes('aria-pressed={active}'), "count shortcuts expose their selected state accessibly");
assert(reportSource.includes('"skipped",\n"queued"'), "SMTP skipped is a first-class report filter option");
assert(reportSource.includes("i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00"), "the report explains excluded transport semantics through the bilingual catalog");
assert(reportSource.includes('return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7"'), "the new skipped filter and status badges use the localized label");
assert(reportSource.includes("cards?.skipped ?? jobs.counts.send?.skipped"), "SMTP skipped has a separate report count");
assert(reportSource.includes("cards?.imap_skipped ?? jobs.counts.imap?.skipped"), "IMAP skipped has a separate report count");
assert(!reportSource.includes("setPage((value) => Math.max(1, value - 1))"), "the one-off report pager is removed in favor of the central DataGrid pager");

View File

@@ -0,0 +1,30 @@
{
"extends": "../../govoplan-core/webui/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": [
"../../govoplan-core/webui/src/index.ts"
],
"@govoplan/core-webui/app": [
"../../govoplan-core/webui/src/app.ts"
],
"react": [
"../../govoplan-core/webui/node_modules/@types/react/index.d.ts"
],
"react/jsx-runtime": [
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
]
},
"typeRoots": [
"../../govoplan-core/webui/node_modules/@types"
]
},
"include": [],
"files": [
"../../govoplan-core/webui/src/vite-env.d.ts",
"src/features/reports/AggregateReportsPage.tsx"
]
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".report-grid-test-build",
"rootDir": "."
},
"include": [
"tests/report-grid-query.test.ts",
"src/features/campaigns/utils/jobListQuery.ts",
"src/features/campaigns/utils/reportGridShortcuts.ts"
]
}