60 Commits

Author SHA1 Message Date
4774a8025c fix(campaign): roll back rejected immediate queues 2026-07-22 20:30:00 +02:00
689dc1fd6b fix(campaign): honor acceptance temp selection 2026-07-22 20:20:31 +02:00
90677348ff docs(campaign): define broker redelivery evidence 2026-07-22 20:19:51 +02:00
06786e86ef test(campaign): prove Celery broker redelivery 2026-07-22 20:19:29 +02:00
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
a8c0750dd7 docs(campaign): clarify legacy mail secret treatment 2026-07-21 20:49:20 +02:00
bfbb86564c fix(campaign): align adaptive handbook with shipped UI 2026-07-21 19:15:52 +02:00
c05bb8e474 docs: surface permission-gated Campaign tasks 2026-07-21 18:53:07 +02:00
99ef25b08f docs: add adaptive Campaign composition guide 2026-07-21 18:46:22 +02:00
03100b77db refactor: simplify Campaign editor validation 2026-07-21 18:16:09 +02:00
0ac903c82d security: make Campaign mutation audits atomic 2026-07-21 17:56:59 +02:00
60776803c5 security: authorize every Campaign effect version 2026-07-21 17:56:47 +02:00
9a709b1264 security: separate Campaign reader and operator data 2026-07-21 17:55:54 +02:00
af833ca38c security: bound Campaign editor metadata 2026-07-21 17:55:30 +02:00
50c509d161 security: seal Campaign delivery inputs 2026-07-21 17:54:34 +02:00
9f4eab07f6 docs: expand adaptive Campaign guidance 2026-07-21 17:29:08 +02:00
25a69b3fa9 docs: add multi-perspective Campaign handbook 2026-07-21 17:29:01 +02:00
24538c2a99 chore: release Campaign 0.1.9 2026-07-21 17:15:02 +02:00
7d8579194d docs: document the Campaign-to-Mail delivery boundary 2026-07-21 17:14:55 +02:00
09c63de813 security: harden Campaign delivery effects and reconciliation 2026-07-21 17:14:33 +02:00
057e660b17 feat: add Campaign delivery ownership and IMAP claim primitives 2026-07-21 17:13:49 +02:00
701c0fe184 refactor(webui): select Mail-owned profiles for campaigns 2026-07-21 17:13:22 +02:00
2c5519908a refactor(webui): consume Core navigation and retention components 2026-07-21 13:52:59 +02:00
dc56687af6 Remove shared CSS ownership from Campaign 2026-07-21 13:47:28 +02:00
2c70c553ac Use central metric cards for Campaign summaries 2026-07-21 13:24:18 +02:00
8627d0e135 Import Campaign data grids directly from Core 2026-07-21 13:24:04 +02:00
d2adcca7ae Remove Campaign-owned shared CSS 2026-07-21 13:19:20 +02:00
002ca4b371 refactor(webui): use core access explanation 2026-07-21 13:18:40 +02:00
641bead0d8 Deduplicate campaign template rendering 2026-07-21 13:14:32 +02:00
3c305753d6 Refactor campaign job queue selection 2026-07-21 12:54:11 +02:00
ef513816f8 Refactor mock campaign send reporting 2026-07-21 12:54:08 +02:00
b7653b58f4 refactor(webui): use central campaign components 2026-07-21 12:04:30 +02:00
ce92499333 fix(api): hide campaign operational internals 2026-07-21 12:03:56 +02:00
120 changed files with 17309 additions and 2653 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

@@ -31,7 +31,15 @@ The module has one required runtime dependency:
Files and mail are optional module integrations declared in the campaign manifest:
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable.
- `govoplan-mail` owns reusable profiles, encrypted SMTP/IMAP credentials, delivery policy checks, connection tests, and transport execution. Campaign JSON stores only `server.mail_profile_id`; inline transport settings and credentials are rejected. Without Mail, campaigns can still be authored, but profile validation and real delivery are unavailable.
Public campaign, version, job, and report responses expose business data and
delivery evidence, but never process-local paths, storage-backend keys, or
worker claim tokens. Operational troubleshooting uses the dedicated job
diagnostics endpoint and requires the tenant-level
`campaigns:diagnostic:read` permission. The campaign sender role receives this
permission; tenant-wide administrator scopes continue to grant it through the
standard policy evaluator.
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
@@ -83,7 +91,11 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
## Operations
- [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.
- [Recipient and address boundary](docs/RECIPIENT_ADDRESS_BOUNDARY.md) defines the split between campaign-local recipients and future reusable address management.
- [Example campaigns and release checklist](docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md) defines the maintained example scenarios and release gates.

View File

@@ -9,3 +9,5 @@ GOVOPLAN_MAIL_TEST_IMAP_PORT=3143
GOVOPLAN_MAIL_TEST_SENT_FOLDER=Sent
GOVOPLAN_MAIL_TEST_ZIP_PASSWORD=zip-test-password
GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS=45
GOVOPLAN_CAMPAIGN_TEST_REDIS_PORT=36379
GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS=3

View File

@@ -44,6 +44,111 @@ 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.
## Redis/Celery Redelivery Acceptance
Run the maintained broker/worker-loss acceptance separately from the GreenMail
journey:
```bash
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
set -a
. ./.env
set +a
/mnt/DATA/git/govoplan/.venv/bin/python run_celery_redelivery_acceptance.py \
--evidence /tmp/govoplan-campaign-celery-redelivery-evidence.json
```
The runner creates a unique Compose project, starts only its loopback-bound,
AOF-enabled Redis service, creates an isolated temporary GovOPlaN database,
and starts a real Celery worker subscribed to `send_email`. A controlled SMTP
server holds the transaction after complete DATA and before the final response.
The runner kills that solo worker with the task still unacknowledged and starts
a replacement worker. After the configured Redis visibility timeout, the same Celery task identity must be redelivered.
The replacement must turn the durable
unfinished attempt into `outcome_unknown`, acknowledge the task, drain the
broker queue/unacked records, and leave the SMTP endpoint at exactly one
connection and one DATA transaction.
Only bounded counts, classifications, and booleans are retained. Worker logs,
task IDs, database identifiers, endpoints, credentials, and raw diagnostics are
kept in the temporary runtime and deleted. The evidence proves the local Redis
transport, real Celery process boundary, runner-supervised replacement, and
Campaign's duplicate-effect guard. It deliberately keeps production daemon supervision,
target-provider behavior, and source-artifact provenance false.
It does not claim that systemd, Kubernetes, another container orchestrator, or
an institution's Redis/SMTP deployment behaves identically.
The default run requires Docker CLI/Compose/daemon access and permission to
pull `redis:7-alpine`; the Celery workers execute from the current Python
environment. The isolated Compose project and volume are removed on exit.
`GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS` defaults to three
seconds only to make this destructive local drill finish promptly; it is not a
production recommendation.
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:

View File

@@ -15,3 +15,19 @@ services:
- "${GOVOPLAN_MAIL_TEST_SMTP_PORT:-3025}:3025"
- "${GOVOPLAN_MAIL_TEST_IMAP_PORT:-3143}:3143"
- "127.0.0.1:38080:8080"
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
ports:
- "127.0.0.1:${GOVOPLAN_CAMPAIGN_TEST_REDIS_PORT:-36379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 1s
timeout: 1s
retries: 30
volumes:
- campaign-redis-data:/data
volumes:
campaign-redis-data:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,856 @@
#!/usr/bin/env python3
"""Prove Redis/Celery redelivery does not repeat an ambiguous SMTP effect.
The default run starts an isolated Redis Compose service, two successive real
Celery worker processes, and a controlled loopback SMTP endpoint. It kills the
first worker after complete DATA but before a final SMTP response. The same
unacknowledged broker task must be delivered to the replacement worker, which
must freeze the unfinished durable attempt as ``outcome_unknown`` without a
second SMTP connection or DATA transaction.
"""
from __future__ import annotations
import argparse
from collections import Counter
from contextlib import contextmanager
from dataclasses import dataclass, replace
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from typing import Any, Callable, Iterator, Mapping
from uuid import uuid4
from redis import Redis
from redis.exceptions import RedisError
SCRIPT_ROOT = Path(__file__).resolve().parent
REPOSITORY_ROOT = SCRIPT_ROOT.parents[1]
if str(SCRIPT_ROOT) not in sys.path:
sys.path.insert(0, str(SCRIPT_ROOT))
from run_campaign_acceptance import ( # noqa: E402
AcceptanceError,
DEFAULT_FIXTURE,
EXPECTED_AUDIT_ACTIONS,
TestbedSettings,
_assert_evidence_safe,
_core_package_version,
_durable_state_evidence,
_expect,
_report_evidence,
create_mail_profile,
prepare_campaign_scenario,
required_composition_versions,
smtp_fault_endpoint,
)
EVIDENCE_SCHEMA = "govoplan.campaign.celery-redelivery-acceptance.v1"
MAX_EVIDENCE_BYTES = 128 * 1024
DEFAULT_COMPOSE_FILE = SCRIPT_ROOT / "docker-compose.yml"
TASK_RECEIVED_PATTERN = re.compile(
r"Task govoplan[.]campaigns[.]send_email\[([0-9a-f-]{36})\] received",
re.IGNORECASE,
)
TASK_SUCCEEDED_PATTERN = re.compile(
r"Task govoplan[.]campaigns[.]send_email\[([0-9a-f-]{36})\] succeeded",
re.IGNORECASE,
)
WORKER_BOOTSTRAP = r"""
import os
import sys
from govoplan_core.celery_app import celery
visibility_timeout = int(os.environ["GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS"])
celery.conf.broker_transport_options = {
**dict(celery.conf.broker_transport_options or {}),
"polling_interval": 0.25,
"visibility_timeout": visibility_timeout,
}
celery.worker_main(
[
"worker",
"--loglevel=INFO",
"--pool=solo",
"--concurrency=1",
"--queues=send_email",
f"--hostname={sys.argv[1]}@%h",
"--without-gossip",
"--without-mingle",
"--without-heartbeat",
]
)
"""
@dataclass(slots=True)
class WorkerProcess:
process: subprocess.Popen[bytes]
log_path: Path
log_handle: Any
def text(self) -> str:
self.log_handle.flush()
try:
return self.log_path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
raise AcceptanceError("Celery worker evidence log could not be read") from exc
def received_task_ids(self) -> tuple[str, ...]:
return tuple(TASK_RECEIVED_PATTERN.findall(self.text()))
def succeeded_task_ids(self) -> tuple[str, ...]:
return tuple(TASK_SUCCEEDED_PATTERN.findall(self.text()))
@dataclass(frozen=True, slots=True)
class RedisBrokerState:
queue_depth: int
unacked_hash_count: int
unacked_index_count: int
def as_dict(self) -> dict[str, int]:
return {
"queue_depth": self.queue_depth,
"unacked_hash_count": self.unacked_hash_count,
"unacked_index_count": self.unacked_index_count,
}
def _positive_int(value: str, *, label: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"{label} must be a positive integer") from exc
if parsed <= 0:
raise argparse.ArgumentTypeError(f"{label} must be a positive integer")
return parsed
def _unused_loopback_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
return int(probe.getsockname()[1])
def _compose_command(
*, compose_file: Path, project_name: str, operation: str
) -> list[str]:
prefix = [
"docker",
"compose",
"--file",
str(compose_file),
"--project-name",
project_name,
]
if operation == "up":
return [*prefix, "up", "--detach", "redis"]
if operation == "down":
return [*prefix, "down", "--volumes", "--remove-orphans"]
raise AcceptanceError("Unsupported Redis Compose operation")
def _run_compose(
command: list[str],
*,
environment: Mapping[str, str],
timeout_seconds: int,
) -> None:
try:
completed = subprocess.run(
command,
env=dict(environment),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_seconds,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise AcceptanceError("Redis Compose lifecycle command failed") from exc
if completed.returncode != 0:
raise AcceptanceError("Redis Compose lifecycle command failed")
def _wait_for_redis(redis_url: str, *, timeout_seconds: int) -> None:
deadline = time.monotonic() + timeout_seconds
client = Redis.from_url(
redis_url,
socket_connect_timeout=1,
socket_timeout=1,
decode_responses=False,
)
try:
while time.monotonic() < deadline:
try:
if client.ping() is True:
return
except RedisError:
pass
time.sleep(0.25)
finally:
client.close()
raise AcceptanceError("Isolated Redis broker did not become ready")
@contextmanager
def isolated_redis_broker(
*,
compose_file: Path,
timeout_seconds: int,
requested_port: int | None = None,
) -> Iterator[str]:
if shutil.which("docker") is None:
raise AcceptanceError("Docker CLI is required to start the isolated Redis broker")
if not compose_file.is_file():
raise AcceptanceError("Redis Compose definition is unavailable")
port = requested_port or _unused_loopback_port()
if port <= 0 or port > 65_535:
raise AcceptanceError("Redis test port is invalid")
project_name = f"govoplan-campaign-redelivery-{uuid4().hex[:12]}"
environment = {
**os.environ,
"GOVOPLAN_CAMPAIGN_TEST_REDIS_PORT": str(port),
}
lifecycle_attempted = False
try:
lifecycle_attempted = True
_run_compose(
_compose_command(
compose_file=compose_file,
project_name=project_name,
operation="up",
),
environment=environment,
timeout_seconds=timeout_seconds,
)
redis_url = f"redis://127.0.0.1:{port}/0"
_wait_for_redis(redis_url, timeout_seconds=timeout_seconds)
yield redis_url
finally:
if lifecycle_attempted:
_run_compose(
_compose_command(
compose_file=compose_file,
project_name=project_name,
operation="down",
),
environment=environment,
timeout_seconds=timeout_seconds,
)
def _start_worker(runtime_root: Path, *, label: str) -> WorkerProcess:
log_path = runtime_root / f"{label}.log"
log_handle = log_path.open("wb")
environment = {**os.environ, "PYTHONUNBUFFERED": "1"}
try:
process = subprocess.Popen(
[sys.executable, "-c", WORKER_BOOTSTRAP, label],
env=environment,
stdin=subprocess.DEVNULL,
stdout=log_handle,
stderr=subprocess.STDOUT,
close_fds=True,
)
except Exception:
log_handle.close()
raise
return WorkerProcess(process=process, log_path=log_path, log_handle=log_handle)
def _wait_for_worker_ready(worker: WorkerProcess, *, timeout_seconds: int) -> None:
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
if worker.process.poll() is not None:
raise AcceptanceError("Celery worker exited before becoming ready")
if " ready." in worker.text():
return
time.sleep(0.2)
raise AcceptanceError("Celery worker did not become ready")
def _wait_for_received_task(
worker: WorkerProcess,
*,
timeout_seconds: int,
expected_task_id: str | None = None,
) -> str:
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
received = worker.received_task_ids()
if received:
if len(set(received)) != 1:
raise AcceptanceError("Celery worker received more than one task identity")
task_id = received[0]
if expected_task_id is not None and task_id != expected_task_id:
raise AcceptanceError("Replacement worker received a different broker task")
return task_id
if worker.process.poll() is not None:
raise AcceptanceError("Celery worker exited before receiving the task")
time.sleep(0.2)
raise AcceptanceError("Celery worker did not receive the broker task")
def _wait_for_task_success(
worker: WorkerProcess,
*,
task_id: str,
timeout_seconds: int,
) -> None:
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
succeeded = worker.succeeded_task_ids()
if task_id in succeeded:
return
if worker.process.poll() is not None:
raise AcceptanceError("Replacement Celery worker exited before task success")
time.sleep(0.2)
raise AcceptanceError("Redelivered Celery task did not complete")
def _kill_worker(worker: WorkerProcess, *, timeout_seconds: int) -> int:
if worker.process.poll() is not None:
raise AcceptanceError("Celery worker exited before controlled termination")
worker.process.kill()
try:
return_code = worker.process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired as exc:
raise AcceptanceError("Celery worker could not be killed") from exc
if return_code == 0:
raise AcceptanceError("Celery worker termination was not forced")
return return_code
def _stop_worker(worker: WorkerProcess, *, timeout_seconds: int) -> None:
if worker.process.poll() is None:
worker.process.terminate()
try:
worker.process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
worker.process.kill()
worker.process.wait(timeout=timeout_seconds)
worker.log_handle.close()
def _broker_state(redis_url: str) -> RedisBrokerState:
client = Redis.from_url(
redis_url,
socket_connect_timeout=2,
socket_timeout=2,
decode_responses=False,
)
try:
return RedisBrokerState(
queue_depth=int(client.llen("send_email")),
unacked_hash_count=int(client.hlen("unacked")),
unacked_index_count=int(client.zcard("unacked_index")),
)
except RedisError as exc:
raise AcceptanceError("Redis broker state could not be inspected") from exc
finally:
client.close()
def _wait_for_broker_drained(
redis_url: str,
*,
timeout_seconds: int,
) -> RedisBrokerState:
deadline = time.monotonic() + timeout_seconds
last = RedisBrokerState(0, 0, 0)
while time.monotonic() < deadline:
last = _broker_state(redis_url)
if last == RedisBrokerState(0, 0, 0):
return last
time.sleep(0.2)
raise AcceptanceError("Redis broker retained delivery state after recovery")
def _queue_evidence(payload: Mapping[str, Any]) -> dict[str, Any]:
expected = {
"queued_count": 1,
"skipped_count": 0,
"blocked_count": 0,
"enqueued_count": 1,
"delivery_mode": "worker_queue",
"worker_queue_available": True,
"dry_run": False,
}
evidence = {key: payload.get(key) for key in expected}
if evidence != expected:
raise AcceptanceError("Campaign was not durably queued to one Celery task")
return evidence
def execute_redelivery_scenario(
client: Any,
headers: Mapping[str, str],
*,
fixture_path: Path,
settings: TestbedSettings,
endpoint: Any,
redis_url: str,
runtime_root: Path,
snapshot_probe: Callable[[str], tuple[Mapping[str, Any], Mapping[str, Any]]],
audit_probe: Callable[[str, str], Mapping[str, int]],
delivery_probe: Callable[[str, str], Mapping[str, Any]],
) -> dict[str, Any]:
profile_id = create_mail_profile(
client,
headers,
settings,
name="Campaign Redis Celery redelivery drill",
smtp_host=endpoint.host,
smtp_port=endpoint.port,
)
prepared = prepare_campaign_scenario(
client,
headers,
fixture_path=fixture_path,
profile_id=profile_id,
settings=settings,
scenario="celery_broker_redelivery",
snapshot_probe=snapshot_probe,
)
first_worker = _start_worker(runtime_root, label="first-worker")
replacement_worker: WorkerProcess | None = None
try:
_wait_for_worker_ready(
first_worker,
timeout_seconds=settings.provider_timeout_seconds,
)
queued = _expect(
client.post(
f"/api/v1/campaigns/{prepared.campaign_id}/queue",
headers=dict(headers),
json={
"version_id": prepared.version_id,
"include_warnings": True,
"enqueue_celery": True,
"dry_run": False,
},
),
200,
"Celery-redelivery Campaign queue",
)
queue_evidence = _queue_evidence(queued)
first_task_id = _wait_for_received_task(
first_worker,
timeout_seconds=settings.provider_timeout_seconds,
)
if not endpoint.wait_for_data(settings.provider_timeout_seconds):
raise AcceptanceError("Celery worker did not reach complete SMTP DATA")
first_exit_code = _kill_worker(
first_worker,
timeout_seconds=settings.provider_timeout_seconds,
)
endpoint.release_held_connection()
interrupted_state = _durable_state_evidence(
delivery_probe(prepared.campaign_id, prepared.version_id)
)
expected_interrupted = {
"job_count": 1,
"send_status_counts": {"sending": 1},
"attempt_status_counts": {"smtp_in_progress": 1},
"unfinished_attempt_count": 1,
}
if interrupted_state != expected_interrupted:
raise AcceptanceError("Killed worker state was not durably SMTP-in-progress")
replacement_worker = _start_worker(runtime_root, label="replacement-worker")
_wait_for_worker_ready(
replacement_worker,
timeout_seconds=settings.provider_timeout_seconds,
)
redelivered_task_id = _wait_for_received_task(
replacement_worker,
timeout_seconds=settings.provider_timeout_seconds,
expected_task_id=first_task_id,
)
_wait_for_task_success(
replacement_worker,
task_id=redelivered_task_id,
timeout_seconds=settings.provider_timeout_seconds,
)
recovered_state = _durable_state_evidence(
delivery_probe(prepared.campaign_id, prepared.version_id)
)
expected_recovered = {
"job_count": 1,
"send_status_counts": {"outcome_unknown": 1},
"attempt_status_counts": {"outcome_unknown": 1},
"unfinished_attempt_count": 0,
}
if recovered_state != expected_recovered:
raise AcceptanceError("Redelivered task did not freeze the unfinished attempt")
protocol = endpoint.evidence()
expected_protocol = {
"connection_count": 1,
"accepted_rcpt_commands": 1,
"refused_rcpt_commands": 0,
"data_transactions": 1,
}
if protocol != expected_protocol:
raise AcceptanceError("Broker redelivery caused an unexpected SMTP transaction")
broker_after = _wait_for_broker_drained(
redis_url,
timeout_seconds=settings.provider_timeout_seconds,
)
first_received = first_worker.received_task_ids()
replacement_received = replacement_worker.received_task_ids()
if first_received != (first_task_id,) or replacement_received != (
redelivered_task_id,
):
raise AcceptanceError(
"Celery workers did not each receive the broker task exactly once"
)
report = _report_evidence(
_expect(
client.get(
f"/api/v1/campaigns/{prepared.campaign_id}/report",
headers=dict(headers),
params={"version_id": prepared.version_id},
),
200,
"Celery-redelivery Campaign report",
)
)
if report["send_status_counts"] != {"outcome_unknown": 1}:
raise AcceptanceError("Campaign report did not retain outcome_unknown")
audit_actions = dict(
sorted(audit_probe(prepared.campaign_id, prepared.version_id).items())
)
if not {
"campaign.created",
"campaign.validated",
"campaign.messages_built",
"campaign.queued",
}.issubset(audit_actions):
raise AcceptanceError("Celery-redelivery Campaign audit evidence is incomplete")
return {
**prepared.public_evidence(),
"queue": queue_evidence,
"interrupted_durable_state": interrupted_state,
"recovered_durable_state": recovered_state,
"protocol": protocol,
"report": report,
"audit_actions": audit_actions,
"broker": {
"transport": "redis",
"same_task_identity_redelivered": first_task_id
== redelivered_task_id,
"first_worker_received_count": len(first_received),
"replacement_worker_received_count": len(replacement_received),
**broker_after.as_dict(),
},
"supervision": {
"first_worker_killed_after_complete_data": True,
"first_worker_forced_exit": first_exit_code != 0,
"replacement_worker_started": True,
"replacement_worker_completed_redelivery": True,
},
}
finally:
endpoint.release_held_connection()
_stop_worker(first_worker, timeout_seconds=5)
if replacement_worker is not None:
_stop_worker(replacement_worker, timeout_seconds=5)
def _runtime_module_versions(registry: Any) -> dict[str, str]:
versions = {"core": _core_package_version()}
versions.update(
{
manifest.id: manifest.version
for manifest in registry.manifests()
if manifest.id in {"access", "audit", "campaigns", "mail"}
}
)
return versions
def _create_runtime_root() -> Path:
"""Create the isolated runtime under the platform-selected temp root."""
return Path(tempfile.mkdtemp(prefix="govoplan-campaign-celery-redelivery-"))
def _bootstrap_and_run(
*,
settings: TestbedSettings,
fixture_path: Path,
redis_url: str,
visibility_timeout_seconds: int,
) -> dict[str, Any]:
runtime_root = _create_runtime_root()
database = None
try:
os.environ.update(
{
"APP_ENV": "test",
"DATABASE_URL": f"sqlite:///{runtime_root / 'acceptance.db'}",
"FILE_STORAGE_BACKEND": "local",
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "files"),
"MOCK_MAILBOX_DIR": str(runtime_root / "mock-mailbox"),
"DEV_BOOTSTRAP_ENABLED": "false",
"CELERY_ENABLED": "true",
"REDIS_URL": redis_url,
"GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS": str(
visibility_timeout_seconds
),
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true",
}
)
from fastapi.testclient import TestClient
from govoplan_core.db.base import Base
from govoplan_core.db.bootstrap import bootstrap_dev_data
from govoplan_core.db.session import configure_database, set_database
from govoplan_core.settings import Settings, settings as core_settings
from govoplan_core.tenancy.scope import create_scope_tables
isolated_settings = Settings()
for field_name in Settings.model_fields:
setattr(core_settings, field_name, getattr(isolated_settings, field_name))
database = configure_database(os.environ["DATABASE_URL"])
set_database(database)
from govoplan_core.server.app import app
create_scope_tables(database.engine)
Base.metadata.create_all(bind=database.engine)
with database.SessionLocal() as session:
bootstrap_dev_data(
session,
api_key_secret="celery-redelivery-unused-api-key",
user_password="celery-redelivery-admin",
)
def snapshot_probe(
version_id: str,
) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
from govoplan_campaign.backend.db.models import CampaignVersion
with database.SessionLocal() as session:
version = session.get(CampaignVersion, version_id)
if version is None:
raise AcceptanceError("Campaign execution snapshot is unavailable")
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
snapshot = (
version.execution_snapshot
if isinstance(version.execution_snapshot, dict)
else {}
)
return raw, snapshot
def audit_probe(campaign_id: str, version_id: str) -> Mapping[str, int]:
from govoplan_audit.backend.db.models import AuditLog
with database.SessionLocal() as session:
actions = [
row[0]
for row in session.query(AuditLog.action)
.filter(AuditLog.object_id.in_([campaign_id, version_id]))
.all()
if row[0] in EXPECTED_AUDIT_ACTIONS
]
return dict(Counter(actions))
def delivery_probe(campaign_id: str, version_id: str) -> Mapping[str, Any]:
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
with database.SessionLocal() as session:
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.campaign_id == campaign_id,
CampaignJob.campaign_version_id == version_id,
)
.all()
)
job_ids = [job.id for job in jobs]
attempts = (
session.query(SendAttempt)
.filter(SendAttempt.job_id.in_(job_ids))
.all()
if job_ids
else []
)
return {
"job_count": len(jobs),
"send_status_counts": dict(
Counter(job.send_status for job in jobs)
),
"attempt_status_counts": dict(
Counter(attempt.status for attempt in attempts)
),
"unfinished_attempt_count": sum(
1 for attempt in attempts if attempt.finished_at is None
),
}
with TestClient(app) as client:
login = _expect(
client.post(
"/api/v1/auth/login",
json={
"email": "admin@example.local",
"password": "celery-redelivery-admin",
},
),
200,
"Acceptance login",
)
access_token = str(login.get("access_token") or "")
if not access_token:
raise AcceptanceError("Acceptance login returned no access token")
from govoplan_core.core.runtime import get_registry
registry = get_registry()
if registry is None:
raise AcceptanceError("The GovOPlaN module registry is unavailable")
composition_versions = required_composition_versions(
fixture_path,
_runtime_module_versions(registry),
)
with smtp_fault_endpoint("post_data_hold") as endpoint:
scenario = execute_redelivery_scenario(
client,
{"Authorization": f"Bearer {access_token}"},
fixture_path=fixture_path,
settings=settings,
endpoint=endpoint,
redis_url=redis_url,
runtime_root=runtime_root,
snapshot_probe=snapshot_probe,
audit_probe=audit_probe,
delivery_probe=delivery_probe,
)
evidence = {
"schema_version": EVIDENCE_SCHEMA,
"generated_at": datetime.now(timezone.utc).isoformat(),
"fixture_sha256": hashlib.sha256(fixture_path.read_bytes()).hexdigest(),
"declared_module_versions": composition_versions,
"target": {
"kind": "local_redis_celery_controlled_smtp",
"isolated_temporary_database": True,
"redis_started_by_runner": True,
"worker_pool": "solo",
"worker_prefetch_multiplier": 1,
"task_acks_late": True,
"task_reject_on_worker_lost": True,
"visibility_timeout_seconds": visibility_timeout_seconds,
},
"scenario": scenario,
"coverage": {
"redis_broker_delivery": True,
"celery_worker_processes": True,
"forced_worker_loss_after_complete_data": True,
"same_task_broker_redelivery": True,
"durable_outcome_unknown_recovery": True,
"duplicate_smtp_transaction_prevented": True,
"production_daemon_supervisor": False,
"target_provider": False,
"source_artifact_provenance": False,
},
}
_assert_evidence_safe(evidence, settings=settings)
rendered = json.dumps(
evidence,
ensure_ascii=False,
indent=2,
sort_keys=True,
).encode("utf-8") + b"\n"
if len(rendered) > MAX_EVIDENCE_BYTES:
raise AcceptanceError("Celery-redelivery evidence exceeds its size limit")
return evidence
finally:
if database is not None:
database.engine.dispose()
shutil.rmtree(runtime_root, ignore_errors=True)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE)
parser.add_argument("--compose-file", type=Path, default=DEFAULT_COMPOSE_FILE)
parser.add_argument("--redis-port", type=int)
parser.add_argument(
"--visibility-timeout-seconds",
type=lambda value: _positive_int(value, label="visibility timeout"),
default=os.environ.get(
"GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS",
"3",
),
)
parser.add_argument(
"--timeout-seconds",
type=lambda value: _positive_int(value, label="timeout"),
default=60,
)
parser.add_argument("--evidence", type=Path)
args = parser.parse_args(argv)
try:
settings = TestbedSettings.from_environment()
settings.assert_local_testbed()
settings = replace(
settings,
provider_timeout_seconds=args.timeout_seconds,
)
with isolated_redis_broker(
compose_file=args.compose_file.resolve(),
timeout_seconds=args.timeout_seconds,
requested_port=args.redis_port,
) as redis_url:
evidence = _bootstrap_and_run(
settings=settings,
fixture_path=args.fixture.resolve(),
redis_url=redis_url,
visibility_timeout_seconds=args.visibility_timeout_seconds,
)
rendered = json.dumps(
evidence,
ensure_ascii=False,
indent=2,
sort_keys=True,
) + "\n"
if args.evidence:
args.evidence.parent.mkdir(parents=True, exist_ok=True)
args.evidence.write_text(rendered, encoding="utf-8")
else:
sys.stdout.write(rendered)
return 0
except AcceptanceError as exc:
print(f"Campaign Celery-redelivery acceptance failed: {exc}", file=sys.stderr)
return 1
except Exception as exc:
print(
"Campaign Celery-redelivery acceptance failed unexpectedly "
f"({type(exc).__name__}); inspect local service logs.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -5,16 +5,20 @@ 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.
## Before First Live Use
- Use dedicated non-production SMTP/IMAP credentials.
- Store and test those credentials in a Mail-module profile. Campaign must
contain only the selected `server.mail_profile_id` reference.
- Start the repository test bed in `dev/mail-testbed/` when a local
production-like SMTP/IMAP server is sufficient.
- Use a dedicated mailbox/folder for append-to-Sent tests.
@@ -24,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
@@ -47,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
@@ -64,6 +75,13 @@ Before the first live send for a sender domain or mail-server profile:
- `claimed` or `sending` that does not progress: treat as a worker interruption.
Re-run worker handling or reconcile if SMTP may already have accepted the
message.
- IMAP `appending`: A worker owns the durable append claim. Do not start a
second append; if the worker cannot finish, reconcile only after checking the
mailbox.
- IMAP `outcome_unknown`: Never append automatically. An operator with
`campaigns:campaign:reconcile` must record an evidence note and resolve it as
`imap_appended` or `imap_not_appended`. Only the latter becomes explicitly
retryable.
## Reconciliation
@@ -86,9 +104,41 @@ 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 but not a broker or daemon.
Run `dev/mail-testbed/run_celery_redelivery_acceptance.py` for the maintained
Redis/Celery delivery and broker redelivery boundary. It starts an isolated
Redis Compose service and real Celery workers, kills the first solo worker after complete DATA while the
late-ack task is unacknowledged, and requires the same task identity to reach a
replacement worker after Redis visibility recovery. Passing evidence also
requires durable `outcome_unknown`, an empty broker queue/unacked set, and
exactly one SMTP connection and DATA transaction. Raw worker logs and task,
database, endpoint, and credential identifiers are never retained.
That second runner proves local runner-supervised process replacement, not the
production process manager. Repeat the worker-loss drill under the selected
systemd, container, Kubernetes, or other production supervisor and the target
Redis/SMTP infrastructure before deployment approval.
## 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.

479
docs/CAMPAIGN_HANDBOOK.md Normal file
View File

@@ -0,0 +1,479 @@
# Campaign Handbook
## Purpose and status
This is the canonical, multi-perspective handbook for the Campaign module. It
describes the current implementation, the operational contract it relies on,
and the remaining work required before Campaign can be presented as GovOPlaN's
maintained reference composition.
Use the section that matches the task at hand:
| Perspective | Start here |
| --- | --- |
| Campaign author | [Prepare a campaign](#prepare-a-campaign) |
| Reviewer | [Review and complete review](#review-and-complete-review) |
| Sender or delivery operator | [Deliver and resolve outcomes](#deliver-and-resolve-outcomes) |
| Campaign or tenant administrator | [Administration and policy](#administration-and-policy) |
| Platform operator | [Operations and recovery](#operations-and-recovery) |
| Integrator or developer | [Composition and integration contracts](#composition-and-integration-contracts) |
| Security, privacy, or audit reviewer | [Assurance model](#assurance-model) |
| Release reviewer | [Reference-composition acceptance](#reference-composition-acceptance) |
The shorter task documents remain useful companions:
- [Campaign delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md)
- [Mail profile boundary](MAIL_PROFILE_BOUNDARY.md)
- [Recipient import guide](RECIPIENT_IMPORT_GUIDE.md)
- [Recipient and Addresses boundary](RECIPIENT_ADDRESS_BOUNDARY.md)
- [Examples and release checklist](EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md)
## What Campaign is for
Campaign turns governed source data into individually built messages and then
controls their review, delivery, and evidence. It is intentionally a
composition module: it demonstrates how one user journey can use optional Mail,
Files, Addresses, and Notifications capabilities alongside Core access/audit
infrastructure without copying ownership from those modules. Policy may consume
Campaign context through a narrow capability; Campaign does not import Policy.
Campaign owns:
- the communication purpose, content, campaign-local fields, and templates;
- campaign-local recipient snapshots, exclusions, and personalization;
- message and attachment rules for a version;
- validation, review, build, queue, and delivery-control state;
- the durable jobs and attempts needed to explain delivery outcomes; and
- campaign-specific reports, shares, and frozen execution evidence.
Campaign does not own:
- SMTP/IMAP profiles, credentials, protocol adapters, or mailbox policy;
- long-lived address-book master data, consent lifecycle, or deduplication;
- managed file bytes, connector credentials, or external-file provenance;
- general-purpose workflow definitions; or
- global identity, organization, permission, or retention policy.
Those boundaries matter in both persistence and UI. A campaign references an
authorized Mail profile and managed file versions; it must never become a
second secret store, file store, or address directory.
## Process view
The supported process is a controlled progression, not a single "send" call:
```text
create/edit
-> validate and resolve policy/integrations
-> review warnings and blockers
-> build exact recipient messages
-> complete review and queue
-> SMTP attempt per job
-> optional IMAP append per accepted job
-> report, retry, reconcile, or correct
-> archive when no active/uncertain delivery remains
```
Campaign status summarizes the whole campaign. Version workflow state describes
the selected immutable/editable version. Each recipient job separately records
build, validation, queue, SMTP, and IMAP state. Operators must use the job-level
states when deciding whether another external effect is safe.
Important distinctions:
- **Validation lock** is the reversible lock created by a successful
validation/build path. Editing requires unlocking and invalidates derived
evidence as appropriate.
- **User lock** is an explicit audit-safe lock. A permanently locked or
delivery-final version is not edited in place; create an editable successor.
- **SMTP accepted** means the provider accepted the message. It does not prove
inbox delivery, reading, or business acknowledgement.
- **Outcome unknown** means an attempt may have had an external effect. It must
be investigated and reconciled before retry.
- **IMAP append** is a separate effect after SMTP acceptance. An append failure
is not evidence that sending failed.
- **Archive** preserves evidence. Draft-only campaigns without built, locked,
or delivery evidence may be deleted where policy allows; evidence-bearing
campaigns are archived instead.
## User tasks
### Prepare a campaign
1. Create a campaign and confirm its owner or owning group.
2. Define global settings, fields, templates, and recipient data.
3. Import one-off recipient data or select a reusable Addresses source when the
optional capability is installed. Review source provenance and stale-source
warnings; Campaign freezes the selected rows rather than following later
directory changes silently.
4. Select managed attachments through Files. Server/API campaigns do not accept
arbitrary local paths. Preview rules and unmatched files before building.
5. Open **Mail settings** and select an available Mail profile. The campaign
stores only `server.mail_profile_id`; it never accepts SMTP/IMAP settings,
usernames, passwords, or credential references.
6. Save the editable version, validate the relevant sections, and resolve every
blocking issue. Warnings remain explicit review decisions.
7. Build the exact messages and inspect recipient, addressing, template,
attachment, and generated-message evidence.
If a selected optional module is absent, Campaign remains loadable and explains
which function is unavailable. It must not fail startup because Mail, Files, or
Addresses is not installed.
### Review and complete review
The reviewer should verify the immutable candidate that will be delivered, not
just the authoring form:
1. Confirm purpose, owner, selected version, and recipient count.
2. Inspect blocking errors, warnings, exclusions, and recipients requiring
review.
3. Inspect representative and exceptional rendered messages, including From,
To/CC/BCC, Reply-To, subject, body, and attachment evidence.
4. Confirm the selected Mail profile is authorized for the campaign's current
tenant and owner context.
5. Confirm attachment behavior when a rule matches no files, ZIP/password
behavior, and any recipient-specific files.
6. Record review completion and the inspected message keys through the review
surface. The current baseline does not persist a distinct approve/reject
decision or review reason. If content, recipients, attachment inputs, owner
context, or non-secret transport identity changes, revalidate and rebuild.
Normal readers and reviewers see business state and safe evidence. Process-local
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.
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
Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed
operator sequence.
At a minimum:
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
conscious override.
6. Never retry `outcome_unknown` blindly. Inspect SMTP/provider evidence and
reconcile it as **accepted** or **not sent**, with a note identifying the
evidence.
7. Process append-to-Sent only for SMTP-accepted jobs and investigate append
failure independently. Never retry an `outcome_unknown` IMAP append blindly:
reconcile mailbox evidence as **appended** or **not appended** with a note.
Only the latter becomes explicitly retryable, and neither decision resends
the already SMTP-accepted message.
8. Archive only after active and uncertain effects are resolved.
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
delivery, sending one selected job, retry selection, and unattempted-job
selection. Their audit and state behavior is not yet the final vocabulary
accepted for issue `govoplan-campaign#69`.
The intended distinction is:
- **Test:** deliver once to the configured test path, audit it, and leave the
production job unsent.
- **Single send:** send one unsent job, audit it, and mark its production effect
complete.
- **Single resend:** intentionally send one job again regardless of an earlier
failure or acceptance, with distinct authority, warning, reason, and audit.
Until that slice is implemented and target-tested, do not present the existing
buttons as a complete resend policy. Ordinary retry protection remains the safe
default.
## Data and evidence model
### Versions and snapshots
Editable campaign JSON is versioned. Build creates recipient jobs and an
execution snapshot. A new profile-only snapshot contains the stable Mail
profile id, delivery policy/evidence, and opaque Mail-issued transport
revisions. It contains no resolved SMTP/IMAP configuration or credentials.
At delivery time Mail re-authorizes the profile, compares the expected opaque
revisions, resolves credentials inside the same Mail-owned operation, and
performs the transport effect. Campaign receives only the sanitized result it
needs for job state and evidence. This same-call check prevents a
validate-then-use race across the module boundary.
Credential rotation that does not change the non-secret transport identity may
continue to satisfy the snapshot. A host, account identity, protocol, sender,
or other revisioned transport change stops delivery until the campaign is
revalidated and rebuilt.
### Existing legacy database records
The current Campaign database may contain versions with inline SMTP/IMAP
material. No separate historical Campaign JSON corpus exists. Inline transport
material in those rows is treated as inert legacy data and is never interpreted
as an executable Mail configuration:
- public responses remove legacy transport fields and secrets;
- validation, build, queue, retry, and delivery fail closed;
- an editable version changes to profile-only form only through an explicit
Mail-settings save; and
- a locked version is preserved and must be forked to an editable successor.
Normal database backup/restore and access controls cover those rows together
with the rest of the current database. There is no separate historical-JSON,
backup-scanning, or inline-secret migration program. Restoring an existing
legacy row preserves it as inert evidence and does not make it deliverable.
### Recipient and attachment evidence
The delivery record should be able to identify:
- source/import context and the frozen recipient row;
- effective addressing and message id;
- template inputs and unresolved-placeholder decisions;
- managed file/version ids, source provenance, checksums, and ZIP evidence;
- generated EML checksum and size;
- Mail profile reference and opaque transport revisions;
- each SMTP and IMAP attempt, its classification, safe provider response, and
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
The supplied role templates deliberately separate preparation, review, and
delivery:
- **Campaign manager** prepares, validates, and builds campaigns and recipients.
- **Campaign reviewer** inspects prepared material and records review
completion for the exact messages checked.
- **Campaign sender** queues, sends, pauses/resumes/cancels, retries, reconciles,
reads reports, and has diagnostic access.
Administrators may compose narrower roles from the declared permissions. Keep
these separations where institutional policy requires four-eyes approval. Mail
profile use additionally requires `mail:profile:use`; profile and credential
administration remains a Mail permission.
Campaign access is also constrained by tenant, owner/group context, and explicit
shares. A share grants only its declared campaign permission; it does not grant
Mail credentials, Files administration, or tenant-wide recipient access.
### Configuration checklist
- Install compatible Core and Campaign versions. Access and base audit are Core
infrastructure; install optional Mail, Files, Addresses, Notifications, and
Policy modules only where the configured journey requires them.
- Configure Mail profiles and policy in Mail, not in campaign fixtures or JSON.
- Configure Files storage/connectors and attachment permissions in Files.
- Define role assignments and separation-of-duty policy.
- Configure Redis/Celery for durable batch workers where production volume
requires it. Local execution is a development/small-run mode, not horizontal
worker coordination.
- Set rate limits for the target provider. Mail may use Redis for shared
throttling when present and a process-local fallback in development.
- Establish retention, deletion, archive, report-export, and diagnostic-access
policy before production recipient data is loaded.
- Use non-production SMTP/IMAP identities and the maintained examples before
allowing a production profile.
### Owner transfer
Mail profile visibility can depend on campaign owner or group. Transferring an
editable campaign with a selected profile clears/requires reselection and
revalidation. A locked or delivery-final version is never silently rewritten;
create an editable successor.
## Operations and recovery
### Health to observe
- database and migration health;
- compatible module interface versions;
- queue publication, worker heartbeat, claim age, and backlog;
- SMTP/IMAP profile test result and deployment egress policy;
- Mail profile authorization/transport-revision mismatch;
- Files resolution and frozen attachment availability;
- counts by queue, SMTP, IMAP, and reconciliation state; and
- audit and report generation failures.
### Worker interruption
A crashed worker can leave a job claimed or sending. Do not infer non-delivery
from worker loss. If the SMTP boundary may have been crossed, classify the
outcome as unknown and reconcile from external evidence. Only work that is
provably unattempted may be returned to an ordinary queue.
### Retry and reconciliation
Retries create new attempt evidence; they do not overwrite the previous
attempt. Reconciliation is a privileged factual correction supported by an
operator note. Repeated automated requests should be idempotent for the same
eligible job state; a deliberate resend is a different, not-yet-finalized
business action and must never be disguised as a retry.
### Backups and restoration
Back up Campaign, Mail, Files, Core/Audit, and shared storage consistently for
the composition. A database restore without generated EML/file storage, or file
storage without matching metadata, does not reconstruct the evidence chain.
After restore, keep outbound delivery paused until queue/attempt state and
provider evidence have been reconciled; never let restored accepted jobs send
again merely because a queue message was lost.
### Incident handling
1. Pause new delivery when duplicate or unknown effects are possible.
2. Preserve database, queue, provider, worker, and audit evidence.
3. Scope affected campaigns/jobs without exposing recipient content broadly.
4. Reconcile uncertain jobs individually or through an approved bounded tool.
5. Correct configuration in its owning module, then revalidate/rebuild where
revisioned transport inputs changed.
6. Record the incident reference and recovery rationale in audit evidence.
## Composition and integration contracts
Campaign has one required platform dependency: Core. Optional module behavior
is discovered through versioned, Core-mediated capabilities; Campaign must not
import optional sibling ORM, services, or WebUI implementation.
Current principal contracts include:
| Contract | Direction | Purpose |
| --- | --- | --- |
| `mail.campaign_delivery` 0.2.x | Mail -> Campaign | Summarize a known reference; authorize and revision-gate it; send/append using Mail-owned configuration and credentials; return sanitized results |
| `files.campaign_attachments` 0.1.x | Files -> Campaign | Select/materialize governed file versions and preserve campaign usage/evidence |
| `addresses.lookup` 0.1.x | Addresses -> Campaign | Optional address suggestions |
| `addresses.recipient_source` 0.1.x | Addresses -> Campaign | Optional versioned recipient-source snapshots |
| `campaigns.access` 0.1.x | Campaign -> platform | Explain campaign access/existence without exporting ORM objects |
| `campaigns.mail_policy_context` 0.1.x | Campaign -> Mail | Resolve campaign tenant/owner context for Mail policy |
| `campaigns.delivery_tasks` 0.1.x | Campaign -> workers | Execute narrow queued send/append tasks |
| `campaigns.retention` 0.1.x | Campaign -> retention | Apply Campaign-owned retention behavior |
Breaking payload or ownership changes require an interface-version bump and a
release-composition alignment gate. Optional absence must be tested physically,
not only hidden in navigation.
### External API expectations
- Tenant and campaign access are evaluated for every operation.
- Writes require CSRF/auth behavior supplied by Core and the specific declared
scope.
- Queue/retry/reconcile endpoints operate on persisted state and return safe
summaries; initiating HTTP success is not proof of external delivery.
- Delta endpoints are optimization surfaces, not a separate source of truth.
- Report exports contain permitted business/evidence fields but no credentials,
local paths, storage keys, or worker claims.
## Assurance model
### Security invariants
- No new campaign payload, fixture, response, or execution snapshot contains
SMTP/IMAP settings or credentials.
- Mail resolves credentials and performs transports inside Mail-owned calls.
- Every real connector peer is validated and pinned at connection time under
deployment-wide private-network policy.
- API/server attachment paths use managed Files references; arbitrary and
traversal-capable local paths are rejected.
- Public responses recursively remove infrastructure locators and secret-like
legacy fields.
- Accepted and outcome-unknown jobs are protected from ordinary retry.
- Diagnostic permission is separate from campaign read/report access.
### Privacy
Recipient fields and rendered messages may contain personal or sensitive data.
Grant recipient read/export, report export, and diagnostic access separately.
Prefer aggregate status for readers who do not need recipient detail. Define
purpose, lawful basis, minimization, export control, and retention before the
campaign starts; do not use Campaign as a substitute consent or address-master
system.
### Audit and destructive actions
Material authoring, validation, locking, review, queueing, send, retry,
reconciliation, sharing, owner transfer, archive, and permitted deletion actions
emit attributable evidence. Audit details must be non-secret and should refer to
stable ids rather than repeat message bodies or credentials.
Draft deletion is allowed only while no audit-relevant build, delivery job, or
lock exists. Evidence-bearing campaigns are archived. Destructive module
retirement remains a separately confirmed installer operation with backup and
retirement evidence.
## Reference-composition acceptance
Campaign is ready to serve as the demonstration module only when all of the
following are repeatable in a pinned clean installation:
1. The maintained examples validate and build with Mail/Files present, and
Campaign still starts with each optional module absent.
2. A user can import recipients, select managed files, choose an authorized Mail
profile, validate, review, build, and queue without entering transport ids or
secrets manually.
3. Campaign JSON and all ordinary APIs reject/omit inline transport material;
legacy records are visible as migration-required and cannot execute.
4. SMTP success, temporary/permanent failure, connection loss, worker loss,
outcome unknown, retry, reconciliation, IMAP success, and IMAP failure have
tested, non-duplicating outcomes against the target environment.
5. Author, reviewer, sender/operator, reader, and administrator views use the
central component system and expose only task-relevant actions.
6. Reports and audit can reconstruct recipient/message/file/profile/attempt
evidence without exposing secrets or ordinary-reader infrastructure details.
7. Clean install, upgrade, backup/restore, module permutations, version
alignment, security audit, and target SMTP/IMAP tests pass.
8. This handbook and its adaptive Docs topics match the shipped UI wording and
distinguish implemented behavior from planned work.
## Explicitly planned, not yet claimed
The following are part of the selected reference journey but are not implied by
the current baseline:
- the final audited **test / single send / single resend** semantics;
- reusable SMTP batch sessions and their measured throughput benefit;
- durable, idempotent Campaign report delivery through a Mail-owned outbox
([`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17));
- a fully packaged one-command Campaign reference composition with production
policy presets and target-provider certification;
- function-bound Postbox delivery (stage 2 of the reference program);
- generic workflow-driven campaign transitions.
Each item needs an owning issue, implementation, failure tests, documentation,
and release evidence before the wording above can move from planned to current.

View File

@@ -10,26 +10,32 @@ 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
- campaign with password-protected ZIP attachments
- campaign using a reusable mail profile from the mail module
- campaign using inline SMTP/IMAP settings where policy allows campaign-local
settings
- legacy inline SMTP/IMAP campaign rejected with an actionable profile-migration
error and no returned transport data
- campaign with validation warnings that may be sent only after explicit review
- 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
- Examples must not contain production recipient data or production credentials.
- Attachment examples should use deterministic small files and checksums.
- Secret values must be represented through saved-credential placeholders or
secret references.
- Mail secrets belong only to encrypted Mail profiles and must never appear in
a campaign fixture, placeholder, or campaign-local secret reference.
- Examples that require optional modules must declare the required modules and
capabilities in their README or fixture metadata.
- Examples must stay valid when files or mail modules are physically absent,
@@ -41,16 +47,37 @@ 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.
- Run `dev/mail-testbed/run_celery_redelivery_acceptance.py` as a separate
destructive worker-loss check. Retain its bounded evidence only when the same
broker task is observed at both workers, the durable state is
`outcome_unknown`, broker queue/unacked counts are zero, and the controlled
SMTP endpoint observed one connection and one DATA transaction. This closes
local Redis/Celery redelivery coverage, while production supervisor and
target-provider coverage remain false until separately tested.
- Confirm reusable mail profile selection is revalidated after campaign owner
transfer.
- Confirm inline SMTP/IMAP settings are hidden or blocked when policy disables
campaign-local mail settings.
- Confirm every inline SMTP/IMAP field is rejected on import/write, omitted
from responses, and blocked from legacy execution until explicitly migrated.
- Confirm execution snapshots store only the Mail profile reference and
opaque Mail-owned transport revisions, not resolved transport material.
- Confirm delivery reports include SMTP outcome, IMAP append outcome, latest
error, generated EML reference, and attachment evidence.
- Confirm retries cannot resend messages already accepted by SMTP unless an
@@ -71,6 +98,6 @@ Use the Review & Send preflight panel and the delivery runbook together:
2. Build exact messages.
3. Review warnings, generated recipients, body content, and attachment evidence.
4. Run mock delivery if available for the release channel.
5. Test SMTP and IMAP settings against non-production infrastructure.
5. Test the selected Mail profile against non-production infrastructure.
6. Send only after queue, rate limit, and append-to-Sent behavior are understood.
7. Reconcile failed, unknown, or pending jobs from the report/audit surfaces.

View File

@@ -0,0 +1,93 @@
# Campaign and Mail Profile Boundary
## Product view
A campaign chooses an authorized Mail profile. It does not define a mail
server. The Campaign module owns recipients, content, attachment rules,
delivery intent, review state, and delivery evidence. The Mail module owns the
SMTP/IMAP endpoints, encrypted credentials, connection tests, profile policy,
and runtime transport adapters.
The persisted campaign contract is therefore deliberately narrow:
```json
{
"server": {
"mail_profile_id": "stable-mail-profile-id"
}
}
```
No `server.smtp`, `server.imap`, `server.credentials`, credential-inheritance
override, or password is valid campaign JSON.
## User journey
1. A Mail administrator creates and tests a reusable profile in Mail.
2. A campaign author opens **Mail settings** and selects one profile available
for the campaign's tenant, owner, and policy context.
3. Campaign stores only the stable profile identifier.
4. Validation asks Mail to authorize the active profile and returns only
availability flags plus opaque Mail-owned transport revisions. Reading that
summary does not decrypt credentials.
5. Build stores the profile identifier, delivery policy, job manifest, and
non-secret transport revisions as execution evidence. It stores no
resolved host, username, password, or other transport material.
6. A delivery worker invokes one Mail-owned effect operation. Mail re-authorizes
the profile, resolves credentials, compares the expected transport
revision, checks policy, and sends or appends without returning transport
material to Campaign. If the selected profile or its non-secret transport settings
changed after build, delivery stops until the campaign is revalidated and
rebuilt. A password rotation that leaves the transport identity unchanged
does not invalidate the build.
7. Mail returns only Campaign-owned envelope addresses, counts, sanitized
refusal classifications/status codes, or the selected Sent folder. Raw
server banners, provider bytes, host details, and credentials never enter
Campaign attempts or public evidence.
Non-dry Campaign report email currently fails closed. It must not bypass the
durable job/effect model through a direct SMTP call. Re-enabling it requires the
Mail-owned idempotent outbox, attempt, unknown-outcome, and reconciliation path
tracked in
[`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17).
Report generation and dry-run validation remain separate from an external
effect; recipient-level exports require recipient-export authorization.
Campaign authors need `mail:profile:use` in addition to the relevant Campaign
permission. Profile visibility remains governed by Mail policy and campaign
owner context.
## Existing database rows and migration
The current database can contain campaign versions with inline SMTP/IMAP
settings or credentials. There is no separate historical Campaign JSON corpus
to import or remediate. GovOPlaN treats those inline fields as inert legacy
material and does not delete or rewrite the stored audit rows automatically:
- API responses omit all legacy transport fields and secrets and expose a
`mail_profile_migration_required` marker.
- validation, build, queue, retry, and delivery fail closed with an actionable
profile-migration error;
- unrelated edits cannot silently scrub the legacy fields;
- an editable version is migrated only through an explicit Mail-settings save
with an authorized profile; and
- a locked version remains unchanged. Creating its editable successor records
the migration while retaining the locked source as audit evidence.
Legacy execution snapshots are likewise retained but cannot be used for
delivery. Revalidate and rebuild an editable profile-only version. Normal
database backup, restore, encryption, and access controls apply to the current
database as a whole; the product does not define a separate historical-JSON or
inline-secret recovery workflow. A restored legacy row remains inert and
fail-closed under the same rules.
## Operator checks
Before live delivery, confirm that:
- the profile is active and still authorized for the campaign owner;
- SMTP and optional IMAP profile tests pass;
- validation and build occurred after the latest transport-identity change;
- append-to-Sent is enabled only when the selected profile has IMAP; and
- reports show the profile-bound snapshot revisions and delivery outcomes,
never credentials or resolved transport configuration.

View File

@@ -9,25 +9,25 @@ 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. |
| `zip-protected` | core, access, campaigns | Build password-protected AES ZIP output and verify password-source metadata. |
| `mail-profile-send` | core, access, campaigns, mail | Select a reusable mail profile and send through the GreenMail test bed. |
| `inline-mail-settings` | core, access, campaigns, mail | Use campaign-local SMTP/IMAP settings only when policy allows it. |
| `legacy-inline-mail-rejected` | core, access, campaigns, mail | Confirm legacy campaign-local SMTP/IMAP data fails closed and requires explicit profile migration. |
| `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
- Do not commit real recipients, mail credentials, or production attachment
names.
- Keep attachments deterministic and small.
- Store secrets as placeholders, saved-credential references, or local `.env`
values consumed by the test bed.
- Store transport secrets only in encrypted Mail profiles or local `.env`
values consumed directly by the test bed, never in campaign JSON.
- Declare optional module requirements in fixture metadata.
- Fixtures must not import files or mail modules directly; optional behavior is
discovered through core module metadata and capabilities.
@@ -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.8",
"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.8",
"@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.8"
version = "0.1.11"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import fnmatch
import re
import time
from dataclasses import dataclass, field
from enum import StrEnum
@@ -18,6 +17,7 @@ from govoplan_campaign.backend.path_security import (
assert_logical_relative_path,
is_managed_source,
)
from govoplan_campaign.backend.template_rendering import render_template
class AttachmentScope(StrEnum):
@@ -145,43 +145,8 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
return (campaign_path.parent / path).resolve()
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def _render_template(template: str, values: dict[str, Any]) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0)
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
rendered = _render_template(config.base_dir, values).strip()
rendered = render_template(config.base_dir, values, keep_missing=True).strip()
return rendered or "."
@@ -265,7 +230,7 @@ def _attachment_zip_archive(
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
if archive is None:
return None
rendered = _render_template(archive.name or "attachments.zip", values).strip() or "attachments.zip"
rendered = render_template(archive.name or "attachments.zip", values, keep_missing=True).strip() or "attachments.zip"
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
@@ -444,7 +409,7 @@ def _resolve_one_config(
match_index: AttachmentMatchIndex | None = None,
) -> ResolvedAttachment:
rendered_base_dir = _rendered_base_dir(config, values)
rendered_file_filter = _render_template(config.file_filter, values)
rendered_file_filter = render_template(config.file_filter, values, keep_missing=True)
directory, selected_base_path = _resolve_attachment_directory(
campaign_file=campaign_file,
campaign_config=campaign_config,

View File

@@ -7,6 +7,7 @@ from typing import Any
from jsonschema import Draft202012Validator, FormatChecker
from .mail_profile_boundary import assert_campaign_uses_mail_profile_reference
from .models import CampaignConfig
@@ -83,6 +84,7 @@ def load_campaign_config(
schema_path: str | Path | None = None,
) -> CampaignConfig:
data = load_campaign_json(path)
assert_campaign_uses_mail_profile_reference(data)
if validate_schema:
validate_against_schema(data, schema_path=schema_path)
return CampaignConfig.model_validate(data)

View File

@@ -0,0 +1,258 @@
from __future__ import annotations
import copy
from typing import Any
CAMPAIGN_MAIL_SERVER_KEYS = frozenset({"mail_profile_id"})
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"})
CAMPAIGN_OPT_IN_KEYS = frozenset(
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
)
CAMPAIGN_REVIEW_STATE_KEYS = frozenset(
{
"build_token",
"inspection_complete",
"reviewed_message_keys",
"updated_at",
"updated_by_user_id",
}
)
class CampaignMailProfileBoundaryError(ValueError):
"""Raised when campaign JSON owns mail transport configuration.
SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON
may select one Mail-owned profile, but it must never copy or override that
profile's transport configuration.
"""
def _validated_opt_ins(value: Any) -> dict[str, bool]:
if not isinstance(value, dict) or any(
key not in CAMPAIGN_OPT_IN_KEYS for key in value
):
raise CampaignMailProfileBoundaryError(
"Campaign editor opt_ins contains unsupported fields"
)
if any(not isinstance(item, bool) for item in value.values()):
raise CampaignMailProfileBoundaryError(
"Campaign editor opt_ins values must be booleans"
)
return copy.deepcopy(value)
def _is_valid_field_override(key: Any, value: Any) -> bool:
return (
isinstance(key, str)
and bool(key.strip())
and len(key) <= 256
and isinstance(value, bool)
)
def _validated_field_overrides(value: Any) -> dict[str, bool]:
if not isinstance(value, dict) or len(value) > 10_000:
raise CampaignMailProfileBoundaryError(
"Campaign editor field_overrides must be a bounded object"
)
if any(not _is_valid_field_override(key, item) for key, item in value.items()):
raise CampaignMailProfileBoundaryError(
"Campaign editor field_overrides must map short field names to booleans"
)
return copy.deepcopy(value)
def _validated_required_string(value: Any, *, max_length: int, error: str) -> str:
if not isinstance(value, str) or not value.strip() or len(value) > max_length:
raise CampaignMailProfileBoundaryError(error)
return value.strip()
def validate_campaign_editor_state(
value: dict[str, Any] | None,
*,
allow_server_review_state: bool = False,
) -> dict[str, Any]:
"""Validate the bounded Campaign-owned UI metadata contract.
Arbitrary editor metadata would be a second, weakly typed persistence and
response channel for Mail credentials. Review evidence is server-owned and
cannot be supplied through ordinary version create/update requests.
"""
if value is None:
return {}
if not isinstance(value, dict):
raise CampaignMailProfileBoundaryError("Campaign editor state must be an object")
allowed = set(CAMPAIGN_CLIENT_EDITOR_STATE_KEYS)
if allow_server_review_state:
allowed.add("review_send")
if any(key not in allowed for key in value):
raise CampaignMailProfileBoundaryError(
"Campaign editor state contains unsupported or transport-owned fields"
)
result: dict[str, Any] = {}
if "created_from" in value:
result["created_from"] = _validated_required_string(
value["created_from"],
max_length=128,
error="Campaign editor created_from must be a short string",
)
if "opt_ins" in value:
result["opt_ins"] = _validated_opt_ins(value["opt_ins"])
if "field_overrides" in value:
result["field_overrides"] = _validated_field_overrides(
value["field_overrides"]
)
if "review_send" in value:
result["review_send"] = _validated_server_review_state(value["review_send"])
return result
def public_campaign_editor_state(
value: Any,
*,
include_diagnostics: bool = False,
) -> dict[str, Any]:
"""Return only known non-secret UI metadata from current or legacy rows."""
if not isinstance(value, dict):
return {}
result: dict[str, Any] = {}
for key in CAMPAIGN_CLIENT_EDITOR_STATE_KEYS:
if key not in value:
continue
try:
result.update(validate_campaign_editor_state({key: value[key]}))
except CampaignMailProfileBoundaryError:
continue
if "review_send" in value:
try:
review_state = _validated_server_review_state(value["review_send"])
if not include_diagnostics:
review_state.pop("build_token", None)
result["review_send"] = review_state
except CampaignMailProfileBoundaryError:
pass
return result
def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
"""Copy only client-owned safe metadata into a new editable version."""
state = public_campaign_editor_state(value)
state.pop("review_send", None)
return state
def _is_valid_reviewed_message_key(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip()) and len(value) <= 512
def _validated_reviewed_message_keys(value: Any) -> list[str]:
if not isinstance(value, list) or len(value) > 100_000:
raise CampaignMailProfileBoundaryError(
"Campaign reviewed message keys are invalid"
)
if any(not _is_valid_reviewed_message_key(key) for key in value):
raise CampaignMailProfileBoundaryError(
"Campaign reviewed message keys are invalid"
)
return list(dict.fromkeys(key.strip() for key in value))
def _validated_review_actor(value: Any) -> str | None:
if value is not None and (not isinstance(value, str) or len(value) > 256):
raise CampaignMailProfileBoundaryError("Campaign review actor is invalid")
return value
def _validated_server_review_state(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or any(
key not in CAMPAIGN_REVIEW_STATE_KEYS for key in value
):
raise CampaignMailProfileBoundaryError(
"Campaign review editor state is invalid"
)
build_token = value.get("build_token")
inspected = value.get("inspection_complete")
keys = value.get("reviewed_message_keys", [])
updated_at = value.get("updated_at")
updated_by = value.get("updated_by_user_id")
validated_build_token = _validated_required_string(
build_token,
max_length=256,
error="Campaign review build token is invalid",
)
if not isinstance(inspected, bool):
raise CampaignMailProfileBoundaryError(
"Campaign review completion state is invalid"
)
validated_keys = _validated_reviewed_message_keys(keys)
validated_updated_at = _validated_required_string(
updated_at,
max_length=128,
error="Campaign review timestamp is invalid",
)
validated_updated_by = _validated_review_actor(updated_by)
return {
"build_token": validated_build_token,
"inspection_complete": inspected,
"reviewed_message_keys": validated_keys,
"updated_at": validated_updated_at,
"updated_by_user_id": validated_updated_by,
}
def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return None
value = server.get("mail_profile_id")
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized or None
def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return ()
violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS]
if "mail_profile_id" in server:
profile_id = server["mail_profile_id"]
if not isinstance(profile_id, str) or not profile_id.strip():
violations.append("/server/mail_profile_id")
return tuple(violations)
def assert_campaign_uses_mail_profile_reference(
raw_json: dict[str, Any] | None,
*,
require_profile: bool = False,
) -> None:
violations = campaign_mail_profile_boundary_violations(raw_json)
if violations:
fields = ", ".join(violations)
raise CampaignMailProfileBoundaryError(
"Campaign JSON may only reference a Mail-module profile through "
f"server.mail_profile_id; remove campaign-local SMTP/IMAP settings ({fields}), "
"select an authorized Mail profile, and save a new campaign version."
)
if require_profile and campaign_mail_profile_id(raw_json) is None:
raise CampaignMailProfileBoundaryError(
"Campaign delivery requires server.mail_profile_id. Select an authorized, active "
"profile from the Mail module and validate the campaign again."
)
def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]:
"""Return the complete public/persisted Campaign-to-Mail contract."""
profile_id = campaign_mail_profile_id(raw_json)
return {"mail_profile_id": profile_id} if profile_id else {}

View File

@@ -6,15 +6,6 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_core.mail.config import (
ImapConfig,
ImapServerConfig,
SmtpConfig,
SmtpServerConfig,
TransportCredentials,
normalize_split_transport_credentials,
)
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
@@ -98,6 +89,7 @@ class BuildStatus(StrEnum):
class SendStatus(StrEnum):
DRAFT = "draft"
QUEUED = "queued"
SKIPPED = "skipped"
class CampaignMeta(StrictModel):
@@ -115,37 +107,14 @@ class FieldDefinition(StrictModel):
can_override: bool = True
class MailServerCredentials(StrictModel):
smtp: TransportCredentials = Field(default_factory=TransportCredentials)
imap: TransportCredentials = Field(default_factory=TransportCredentials)
class MailProfileCapabilities(StrictModel):
smtp_available: bool = False
imap_available: bool = False
class ServerConfig(StrictModel):
mail_profile_id: str | None = None
inherit_smtp_credentials: bool = True
inherit_imap_credentials: bool = True
smtp: SmtpServerConfig | None = None
imap: ImapServerConfig | None = None
credentials: MailServerCredentials = Field(default_factory=MailServerCredentials)
@model_validator(mode="before")
@classmethod
def normalize_legacy_credentials(cls, value: Any) -> Any:
return normalize_split_transport_credentials(value)
def runtime_smtp_config(self) -> SmtpConfig | None:
if self.smtp is None:
return None
payload = self.smtp.model_dump(mode="json")
payload.update(self.credentials.smtp.model_dump(mode="json", exclude_none=True))
return SmtpConfig.model_validate(payload)
def runtime_imap_config(self) -> ImapConfig | None:
if self.imap is None:
return None
payload = self.imap.model_dump(mode="json")
payload.update(self.credentials.imap.model_dump(mode="json", exclude_none=True))
return ImapConfig.model_validate(payload)
profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities)
class RecipientConfig(StrictModel):

View File

@@ -8,6 +8,7 @@ from typing import Iterable
from pydantic import BaseModel, ConfigDict, Field
from .addressing import effective_address_lists
from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
from ..attachments.resolver import resolve_campaign_attachments
@@ -338,52 +339,72 @@ def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> li
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
runtime_imap = config.server.runtime_imap_config()
if config.delivery.imap_append_sent.enabled:
issues.extend(_imap_delivery_issues(runtime_imap))
runtime_smtp = config.server.runtime_smtp_config()
if config.campaign.mode == "send" and not runtime_smtp:
profile_id = (config.server.mail_profile_id or "").strip()
if (config.campaign.mode == "send" or config.delivery.imap_append_sent.enabled) and not profile_id:
issues.append(
_issue(
Severity.ERROR,
"missing_smtp_config",
"campaign mode is 'send', but no server.smtp configuration is present",
"/server/smtp",
"missing_mail_profile",
"Select an authorized Mail-module profile; campaigns cannot store SMTP/IMAP settings or credentials.",
"/server/mail_profile_id",
)
)
if runtime_smtp:
missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")]
if missing:
issues.append(
_issue(
Severity.WARNING,
"incomplete_smtp_config",
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
"/server/smtp",
)
capabilities = config.server.profile_capabilities
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available:
issues.append(
_issue(
Severity.ERROR,
"mail_profile_without_smtp",
"campaign mode is 'send', but the selected Mail profile has no SMTP configuration",
"/server/mail_profile_id",
)
)
if config.delivery.imap_append_sent.enabled and profile_id and not capabilities.imap_available:
issues.append(
_issue(
Severity.ERROR,
"mail_profile_without_imap",
"IMAP append is enabled, but the selected Mail profile has no IMAP configuration",
"/server/mail_profile_id",
)
)
return issues
def _imap_delivery_issues(runtime_imap: object | None) -> list[SemanticIssue]:
if runtime_imap is None:
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
"""Require Campaign-owned sender data before a send-mode build."""
if config.campaign.mode != "send":
return []
if config.entries.is_inline:
return [
_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
"/delivery/imap_append_sent/enabled",
Severity.ERROR,
"missing_sender",
"No effective From address is configured; Campaign must resolve the sender before building.",
f"/entries/inline/{index}/from",
)
for index, entry in enumerate(config.entries.inline or [])
if entry.active and not effective_address_lists(config, entry)["from"]
]
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
if not missing:
if config.recipients.from_:
return []
defaults = config.entries.defaults
mapping_can_supply_sender = bool(
config.recipients.allow_individual_from
and (
(defaults is not None and defaults.from_)
or "from.email" in (config.entries.mapping or {})
)
)
if mapping_can_supply_sender:
return []
return [
_issue(
Severity.ERROR,
"incomplete_imap_config",
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
"/server/imap",
"missing_sender",
"Configure recipients.from, or allow and map an individual From address, before building a send-mode campaign.",
"/recipients/from",
)
]
@@ -602,6 +623,7 @@ def validate_campaign_config(
issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config))
issues.extend(_delivery_issues(config))
issues.extend(_sender_issues(config))
entries = _entries_validation(
config,

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"
@@ -92,7 +93,9 @@ class JobSendStatus(StrEnum):
class JobImapStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
APPENDING = "appending"
APPENDED = "appended"
OUTCOME_UNKNOWN = "outcome_unknown"
FAILED = "failed"
SKIPPED = "skipped"
@@ -210,9 +213,17 @@ 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")
@property
def mail_profile_migration_required(self) -> bool:
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_boundary_violations
return bool(campaign_mail_profile_boundary_violations(self.raw_json))
class CampaignJob(Base, TimestampMixin):
__tablename__ = "campaign_jobs"
@@ -232,6 +243,7 @@ class CampaignJob(Base, TimestampMixin):
eml_local_path: Mapped[str | None] = mapped_column(String(1000))
eml_size_bytes: Mapped[int | None] = mapped_column(Integer)
eml_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
execution_input_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True)
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
@@ -246,6 +258,8 @@ class CampaignJob(Base, TimestampMixin):
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
smtp_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
outcome_unknown_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
imap_claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
imap_claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
@@ -315,10 +329,14 @@ class SendAttempt(Base, TimestampMixin):
class ImapAppendAttempt(Base, TimestampMixin):
__tablename__ = "imap_append_attempts"
__table_args__ = (
UniqueConstraint("job_id", "attempt_number", name="uq_imap_append_attempts_job_attempt"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True)
folder: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(50), nullable=False)
error_message: Mapped[str | None] = mapped_column(Text)

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

@@ -299,6 +299,237 @@ def _mock_sent_folder(config: Any) -> str:
return "Sent"
def _mock_campaign_version(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None,
) -> tuple[Campaign, CampaignVersion]:
campaign = (
session.query(Campaign)
.filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id)
.one_or_none()
)
if not campaign:
raise MockCampaignSendError("Campaign not found or not accessible")
wanted_version_id = version_id or campaign.current_version_id
if not wanted_version_id:
raise MockCampaignSendError("Campaign has no current version")
version = session.get(CampaignVersion, wanted_version_id)
if not version or version.campaign_id != campaign.id:
raise MockCampaignSendError(
"Campaign version not found or not part of campaign"
)
return campaign, version
def _mock_mailbox_for_run(*, send: bool, clear_mailbox: bool) -> Any | None:
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
if clear_mailbox and mailbox is not None:
mailbox.clear_records()
return mailbox
def _build_mock_campaign_run(
session: Session,
*,
tenant_id: str,
campaign: Campaign,
version: CampaignVersion,
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
check_files: bool,
) -> tuple[Any, Any, _MockSendBatch]:
files = files_integration()
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
assert_server_safe_campaign_paths(
raw_json,
managed_files_available=files.available,
)
with files.prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=raw_json,
include_bytes=True,
prefix="govoplan-mock-send-",
) as prepared:
prepared_raw = load_campaign_json(prepared.path)
config = load_campaign_config_from_json(
session,
tenant_id=tenant_id,
raw_json=prepared_raw,
campaign_id=campaign.id,
)
validation_report = validate_campaign_config(
config,
campaign_file=prepared.path,
check_files=check_files,
)
build_result = build_campaign_messages(
config,
campaign_file=prepared.path,
write_eml=False,
)
files.annotate_built_messages_with_managed_files(
build_result.built_messages,
prepared.managed_files_by_local_path,
)
send_batch = _mock_send_batch(
config=config,
built_messages=build_result.built_messages,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
return validation_report, build_result, send_batch
def _mock_validation_payload(validation_report: Any) -> dict[str, Any]:
payload = validation_report.model_dump(mode="json")
payload.update(
{
"ok": validation_report.ok,
"error_count": validation_report.error_count,
"warning_count": validation_report.warning_count,
}
)
return payload
def _mock_build_payload(build_result: Any) -> dict[str, Any]:
report = build_result.report
payload = report.model_dump(mode="json")
payload.update(
{
"built_count": report.built_count,
"queueable_count": report.queueable_count,
"needs_review_count": report.needs_review_count,
"blocked_count": report.blocked_count,
"warning_count": report.warning_count,
"ready_count": report.ready_count,
"messages": [_message_payload(message) for message in report.messages],
}
)
return payload
def _mock_send_steps(
*,
validation_report: Any,
validation_payload: dict[str, Any],
build_report: Any,
send_batch: _MockSendBatch,
send: bool,
append_sent: bool,
) -> list[dict[str, Any]]:
return [
{
"key": "validate",
"label": "Validate campaign JSON",
"status": "ok" if validation_report.ok else "needs_review",
"summary": validation_payload,
},
{
"key": "build",
"label": "Build messages",
"status": "ok" if build_report.queueable_count else "needs_review",
"summary": {
"built": build_report.built_count,
"queueable": build_report.queueable_count,
"needs_review": build_report.needs_review_count,
"blocked": build_report.blocked_count,
},
},
{
"key": "send",
"label": "Mock SMTP delivery",
"status": (
"skipped"
if not send
else "ok"
if send_batch.failed_count == 0
else "needs_review"
),
"summary": {
"attempted": send_batch.attempted_count,
"sent": send_batch.sent_count,
"failed": send_batch.failed_count,
"skipped": send_batch.skipped_count,
},
},
{
"key": "imap",
"label": "Mock IMAP Sent append",
"status": (
"skipped"
if not send or not append_sent
else "ok"
if send_batch.imap_failed_count == 0
else "needs_review"
),
"summary": {
"appended": send_batch.imap_appended_count,
"failed": send_batch.imap_failed_count,
},
},
]
def _mock_campaign_send_response(
*,
campaign: Campaign,
version: CampaignVersion,
mailbox: Any | None,
validation_report: Any,
validation_payload: dict[str, Any],
build_result: Any,
build_payload: dict[str, Any],
send_batch: _MockSendBatch,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> dict[str, Any]:
return {
"campaign_id": campaign.id,
"version_id": version.id,
"version_number": version.version_number,
"send_requested": send,
"include_warnings": include_warnings,
"include_needs_review": include_needs_review,
"append_sent": append_sent,
"steps": _mock_send_steps(
validation_report=validation_report,
validation_payload=validation_payload,
build_report=build_result.report,
send_batch=send_batch,
send=send,
append_sent=append_sent,
),
"validation": validation_payload,
"build": build_payload,
"send": {
"attempted_count": send_batch.attempted_count,
"sent_count": send_batch.sent_count,
"failed_count": send_batch.failed_count,
"skipped_count": send_batch.skipped_count,
"imap_appended_count": send_batch.imap_appended_count,
"imap_failed_count": send_batch.imap_failed_count,
"results": send_batch.results,
},
"mailbox": {
"messages": mailbox.list_records(limit=200) if mailbox is not None else []
},
}
def run_mock_campaign_send(
session: Session,
*,
@@ -320,87 +551,38 @@ def run_mock_campaign_send(
mailbox only when send=True.
"""
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
if not campaign:
raise MockCampaignSendError("Campaign not found or not accessible")
wanted_version_id = version_id or campaign.current_version_id
if not wanted_version_id:
raise MockCampaignSendError("Campaign has no current version")
version = session.get(CampaignVersion, wanted_version_id)
if not version or version.campaign_id != campaign.id:
raise MockCampaignSendError("Campaign version not found or not part of campaign")
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
if clear_mailbox and mailbox is not None:
mailbox.clear_records()
files = files_integration()
assert_server_safe_campaign_paths(
version.raw_json if isinstance(version.raw_json, dict) else {},
managed_files_available=files.available,
)
with files.prepared_campaign_snapshot(
campaign, version = _mock_campaign_version(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="govoplan-mock-send-",
) as prepared:
prepared_raw = load_campaign_json(prepared.path)
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
send_batch = _mock_send_batch(
config=config,
built_messages=build_result.built_messages,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
validation_json = validation_report.model_dump(mode="json")
validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count})
build_report = build_result.report
build_json = build_report.model_dump(mode="json")
build_json.update({
"built_count": build_report.built_count,
"queueable_count": build_report.queueable_count,
"needs_review_count": build_report.needs_review_count,
"blocked_count": build_report.blocked_count,
"warning_count": build_report.warning_count,
"ready_count": build_report.ready_count,
"messages": [_message_payload(message) for message in build_report.messages],
})
return {
"campaign_id": campaign.id,
"version_id": version.id,
"version_number": version.version_number,
"send_requested": send,
"include_warnings": include_warnings,
"include_needs_review": include_needs_review,
"append_sent": append_sent,
"steps": [
{"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json},
{"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}},
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if send_batch.failed_count == 0 else "needs_review"), "summary": {"attempted": send_batch.attempted_count, "sent": send_batch.sent_count, "failed": send_batch.failed_count, "skipped": send_batch.skipped_count}},
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if send_batch.imap_failed_count == 0 else "needs_review"), "summary": {"appended": send_batch.imap_appended_count, "failed": send_batch.imap_failed_count}},
],
"validation": validation_json,
"build": build_json,
"send": {
"attempted_count": send_batch.attempted_count,
"sent_count": send_batch.sent_count,
"failed_count": send_batch.failed_count,
"skipped_count": send_batch.skipped_count,
"imap_appended_count": send_batch.imap_appended_count,
"imap_failed_count": send_batch.imap_failed_count,
"results": send_batch.results,
},
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
}
campaign_id=campaign_id,
version_id=version_id,
)
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox)
validation_report, build_result, send_batch = _build_mock_campaign_run(
session,
tenant_id=tenant_id,
campaign=campaign,
version=version,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
check_files=check_files,
)
validation_payload = _mock_validation_payload(validation_report)
build_payload = _mock_build_payload(build_result)
return _mock_campaign_send_response(
campaign=campaign,
version=version,
mailbox=mailbox,
validation_report=validation_report,
validation_payload=validation_payload,
build_result=build_result,
build_payload=build_payload,
send_batch=send_batch,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)

View File

@@ -0,0 +1,755 @@
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",
"campaigns:campaign:create",
"campaigns:campaign:update",
"campaigns:campaign:copy",
"campaigns:campaign:archive",
"campaigns:campaign:delete",
"campaigns:campaign:share",
"campaigns:campaign:validate",
"campaigns:campaign:build",
"campaigns:campaign:review",
"campaigns:campaign:send_test",
"campaigns:campaign:queue",
"campaigns:campaign:control",
"campaigns:campaign:send",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
"campaigns:recipient:export",
"campaigns:report:read",
"campaigns:report:export",
"campaigns:report:send",
)
_CAMPAIGN_HELP_CONTEXTS = (
"campaigns.list",
"campaign.overview",
)
_FILES_INTEGRATION = "files.campaign_attachments"
_MAIL_INTEGRATION = "mail.campaign_delivery"
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
def _workflow_topic(
*,
topic_id: str,
title: str,
summary: str,
body: str,
order: int,
audience: tuple[str, ...],
required_scopes: tuple[str, ...],
route: str,
screen: str,
help_contexts: tuple[str, ...],
prerequisites: tuple[str, ...],
steps: tuple[str, ...],
outcome: str,
verification: str,
related_topic_ids: tuple[str, ...] = (),
required_modules: tuple[str, ...] = ("campaigns",),
required_capabilities: tuple[str, ...] = (),
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
related_modules: tuple[str, ...] = (),
limitations: tuple[str, ...] = (),
) -> DocumentationTopic:
metadata: dict[str, object] = {
"kind": "workflow",
"route": route,
"screen": screen,
"help_contexts": list(help_contexts),
"prerequisites": list(prerequisites),
"steps": list(steps),
"outcome": outcome,
"verification": verification,
"related_topic_ids": list(related_topic_ids),
}
if limitations:
metadata["limitations"] = list(limitations)
return DocumentationTopic(
id=topic_id,
title=title,
summary=summary,
body=body,
layer="configured",
documentation_types=("user",),
audience=audience,
order=order,
conditions=(
DocumentationCondition(
required_modules=required_modules,
required_capabilities=required_capabilities,
required_scopes=required_scopes,
),
),
links=links,
related_modules=related_modules,
unlocks=(outcome,),
source_module_id="campaigns",
metadata=metadata,
)
CAMPAIGN_USER_DOCUMENTATION = (
_workflow_topic(
topic_id="campaigns.workflow.create-campaign",
title="Create a campaign",
summary="Start a governed campaign as an editable draft and complete its purpose and ownership before adding delivery data.",
body="A new campaign starts with one editable working version. Creating it does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
order=30,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
route="/campaigns",
screen="Campaigns",
help_contexts=("campaigns.list",),
prerequisites=("You may create campaigns in the active tenant.",),
steps=(
"Open Campaigns and select New campaign.",
"Use the creation wizard to enter a clear name, identifier, and purpose.",
"Open the new campaign and confirm its owner before adding recipient or delivery data.",
"Continue through the preparation sections and save the editable working version.",
),
outcome="An owned campaign draft with an editable working version.",
verification="The Campaign overview shows the new campaign as a draft and identifies its current working version.",
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.import-recipients"),
),
_workflow_topic(
topic_id="campaigns.workflow.create-editable-successor",
title="Create an editable successor",
summary="Continue work after a permanent or delivery-final lock without rewriting the preserved version.",
body="Create editable copy means creating the campaign's next working version. Validation locks and temporary user locks are removed in place instead; they must not create parallel drafts.",
order=31,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"),
route="/campaigns/{campaign_id}",
screen="Campaign workspace",
help_contexts=("campaign.overview", "campaign.audit"),
prerequisites=(
"You have write access to the selected campaign.",
"Its current version is permanently user-locked or delivery-final.",
),
steps=(
"Open the locked current version and review the reason it is read-only.",
"Select Create editable copy in the locked-version notice.",
"If the notice instead offers an unlock action, unlock that validation or temporary user lock rather than creating a successor.",
"Review the new working version, reselect any Mail profile when requested, and validate and build again before delivery.",
),
outcome="A new editable working version while the source version and its evidence remain unchanged.",
verification="The Campaign overview identifies a new current version number and the earlier version remains in history.",
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.mail-profile-user-journey"),
),
_workflow_topic(
topic_id="campaigns.workflow.import-recipients",
title="Import recipients into a campaign",
summary="Turn a text, CSV, or spreadsheet table into reviewed campaign-local recipient rows with source provenance.",
body="Import copies valid rows into the editable campaign version. Invalid rows stay visible during preview instead of disappearing, and later changes to the source file do not silently change the saved campaign.",
order=33,
audience=("campaign_manager", "campaign_author"),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
),
route="/campaigns/{campaign_id}/recipients",
screen="Recipient data",
help_contexts=("campaign.recipients", "campaign.recipient-data"),
prerequisites=(
"The current campaign version is editable and you have write access to it.",
"The source is tabular and contains only data needed for this campaign.",
),
steps=(
"Open Recipient data and select Import.",
"Upload or paste the table, then confirm its encoding, sheet, header rows, and separator where applicable.",
"Map address and campaign fields, choose append or replace, and review every invalid or excluded row.",
"Select Import valid rows, inspect the resulting recipient table, and save the campaign page.",
),
outcome="A campaign-local recipient snapshot with mapping and source evidence.",
verification="Reopen Recipient data, confirm the expected row count and addressing, then validate before building messages.",
related_topic_ids=("campaigns.workflow.import-address-source", "campaigns.workflow.prepare-validate-and-build"),
),
_workflow_topic(
topic_id="campaigns.workflow.import-address-source",
title="Import a recipient source from Addresses",
summary="Copy a permitted reusable address book or list into the campaign as a traceable versioned snapshot.",
body="Campaign does not follow the address source live. It records the selected source revision and warns when a newer source revision is available, so re-import is always an explicit author action.",
order=32,
audience=("campaign_manager", "campaign_author"),
required_modules=("campaigns", "addresses"),
required_capabilities=(_ADDRESSES_SOURCE_INTEGRATION,),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
),
route="/campaigns/{campaign_id}/recipients",
screen="Recipient data",
help_contexts=("campaign.recipients", "campaign.recipient-data"),
prerequisites=(
"Addresses offers at least one source visible to you.",
"The current campaign version is editable and you have write access to it.",
),
steps=(
"Open Recipient data and select Import address book/list.",
"Choose the permitted source and review the returned snapshot and its revision.",
"Choose append or replace, import the snapshot, inspect the recipient rows, and save.",
"When a stale-source warning appears later, compare the source and re-import deliberately if the campaign should use the new revision.",
),
outcome="A campaign-local recipient snapshot whose Addresses source and revision can be traced.",
verification="Recipient data records the source provenance, and a later source revision does not alter the saved rows until you re-import it.",
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
related_modules=("addresses",),
),
_workflow_topic(
topic_id="campaigns.workflow.use-managed-attachments",
title="Use managed files as campaign attachments",
summary="Select governed file versions, preview rule matches, and preserve exactly which files were used for the campaign build.",
body="Managed attachments remain owned by Files. Campaign stores governed references and frozen build evidence; it does not copy Files administration authority or accept arbitrary server paths.",
order=34,
audience=("campaign_manager", "campaign_author"),
required_modules=("campaigns", "files"),
required_capabilities=(_FILES_INTEGRATION,),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:campaign:validate",
"campaigns:recipient:read",
"files:file:read",
"files:file:share",
),
route="/campaigns/{campaign_id}/files",
screen="Attachments",
help_contexts=("campaign.attachments",),
prerequisites=(
"The current campaign version is editable and you have write access to it.",
"The required file versions exist in Files and may be shared with this campaign.",
),
steps=(
"Open Attachments and choose managed files or patterns from Files.",
"Define campaign-wide and recipient-specific rules, including unmatched-file and archive behavior.",
"Preview the matches and link the exact managed versions to the campaign.",
"Save, validate, and build, then inspect the resolved attachment evidence in Review and send.",
),
outcome="Governed file versions linked to the campaign and frozen into the exact build evidence.",
verification="Confirm the expected filenames, paths, matches, and link state in the Campaign UI. Exact managed versions and checksums remain retained in build evidence for authorized supporting tools.",
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Files", href="/files", kind="runtime"),
),
related_modules=("files",),
),
_workflow_topic(
topic_id="campaigns.workflow.queue-delivery",
title="Queue a campaign for controlled delivery",
summary="Place an exact reviewed build into the worker queue while preserving recipient-level state and retry protection.",
body="Queueing is a controlled state change, not proof of delivery. Ordinary batches should use background workers, and accepted or outcome-unknown effects remain protected from blind retry.",
order=35,
audience=("campaign_sender", "campaign_operator"),
required_modules=("campaigns", "mail"),
required_capabilities=(_MAIL_INTEGRATION,),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:queue",
"campaigns:recipient:read",
"mail:profile:use",
),
route="/campaigns/{campaign_id}/review",
screen="Review and send",
help_contexts=(),
prerequisites=(
"The selected version is validated, locked, built, and reviewed.",
"Its Mail profile remains available and authorized for the current campaign context.",
"You have write access to the selected campaign.",
),
steps=(
"Open Review and send and confirm the selected version, build, recipients, warnings, and review completion.",
"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"),
),
_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="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"),
required_capabilities=(_MAIL_INTEGRATION,),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:send",
"campaigns:recipient:read",
"mail:profile:use",
),
route="/campaigns/{campaign_id}/review",
screen="Review and send",
help_contexts=("campaign.review-send",),
prerequisites=(
"The selected version is validated, locked, built, and reviewed.",
"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.",
"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.",
),
outcome="A synchronous real delivery run with recipient-level attempt and outcome evidence.",
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",),
),
_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 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",
screen="Campaign Report",
help_contexts=("campaign.report",),
prerequisites=("You may read the selected campaign and its report.",),
steps=(
"Open the campaign and select Report.",
"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.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=39,
audience=("campaign_report_exporter", "campaign_auditor"),
required_scopes=(
"campaigns:campaign:read",
"campaigns:report:read",
"campaigns:report:export",
"campaigns:recipient:read",
"campaigns:recipient:export",
),
route="/campaigns/{campaign_id}/report",
screen="Campaign Report",
help_contexts=("campaign.report", "campaign.audit"),
prerequisites=(
"You may export both campaign reports and recipient data.",
"The export has an approved purpose and destination.",
),
steps=(
"Open the campaign Report and select the intended version.",
"Confirm that recipient-level export is necessary for the task.",
"Select Download CSV and store the result only in the approved location.",
"Verify the selected version and row counts, then apply the required retention or deletion rule to the downloaded copy.",
),
outcome="A point-in-time CSV export of authorized campaign job results.",
verification="The downloaded file identifies the intended campaign/version and contains business evidence without credentials or ordinary-reader infrastructure details.",
related_topic_ids=("campaigns.workflow.view-delivery-report",),
),
_workflow_topic(
topic_id="campaigns.workflow.share-campaign",
title="Share a campaign",
summary="Grant a user or group explicit view or write access to one campaign without widening their platform permissions.",
body="A share can only narrow access to the selected campaign within the recipient's existing role. It never grants Mail profile use, Files authority, tenant-wide recipient access, or a missing Campaign action.",
order=39,
audience=("campaign_owner", "campaign_access_manager"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:share"),
route="/campaigns/{campaign_id}/global-settings",
screen="Ownership and sharing",
help_contexts=("campaign.overview", "campaign.global-settings"),
prerequisites=(
"You have write access to the selected campaign.",
"The target user or group already has an appropriate Campaign role for the intended actions.",
),
steps=(
"Open Global settings and find Ownership and sharing.",
"Select Share, choose a user or group, and choose Can view or Can edit and operate.",
"Save the share and verify the target appears in the sharing table with the intended level.",
"Use the row action to revoke the explicit share when it is no longer needed.",
),
outcome="Explicit campaign access for the selected subject, bounded by that subject's existing permissions.",
verification="The sharing table shows the active target and level; revocation removes its explicit access while preserving the audit record.",
related_topic_ids=("campaigns.reference.composition-assurance",),
),
_workflow_topic(
topic_id="campaigns.workflow.archive-campaign",
title="Archive a campaign",
summary="Remove a completed campaign from active work while preserving its versions, outcomes, and audit evidence.",
body="Archive only after queued, sending, and outcome-unknown work has been resolved. Archiving preserves evidence and is the correct lifecycle action for any campaign with build, lock, or delivery history.",
order=40,
audience=("campaign_owner", "campaign_records_manager"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:archive"),
route="/campaigns/{campaign_id}",
screen="Campaign lifecycle",
help_contexts=("campaign.overview", "campaign.report", "campaign.audit"),
prerequisites=(
"You have write access to the selected campaign.",
"No delivery job is queued, sending, or awaiting uncertain-outcome reconciliation.",
),
steps=(
"Open Report and resolve every active or outcome-unknown delivery state.",
"Confirm that the campaign should leave active work while its evidence remains retained.",
"Invoke the authorized Archive action from a supporting client.",
"Reopen or query the campaign and confirm its state is Archived.",
),
outcome="An archived campaign whose versions, reports, and audit evidence remain preserved.",
verification="The campaign state is Archived and its report remains available. Ask an authorized audit reader to verify the platform audit event.",
related_topic_ids=("campaigns.workflow.view-delivery-report", "campaigns.workflow.delete-untouched-draft"),
limitations=(
"The current Campaign Web UI does not yet expose the archive action; use an authorized supporting client or API.",
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
),
),
_workflow_topic(
topic_id="campaigns.workflow.delete-untouched-draft",
title="Delete an untouched campaign draft",
summary="Immediately remove a draft that has no protected build, lock, publication, snapshot, or delivery evidence.",
body="Deletion is deliberately narrower than archive. It marks an eligible draft deleted and emits an audit record; it cannot erase a campaign that already carries evidence that must be retained.",
order=41,
audience=("campaign_owner", "campaign_records_manager"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:delete"),
route="/campaigns/{campaign_id}",
screen="Campaign lifecycle",
help_contexts=("campaign.overview", "campaign.audit"),
prerequisites=(
"You have write access to the selected campaign.",
"The campaign is still a draft and has no jobs, locks, published version, or execution snapshot.",
),
steps=(
"Confirm that the draft is not needed and contains no evidence that should be retained.",
"Invoke the authorized Delete action from a supporting client and confirm the destructive action.",
"If deletion is refused because protected evidence exists, archive the campaign after resolving any active delivery state.",
"Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.",
),
outcome="An eligible untouched draft removed from active Campaigns with attributable deletion evidence.",
verification="The draft is no longer returned as an active campaign. Ask an authorized audit reader to verify who deleted it and when through the platform audit surface.",
related_topic_ids=("campaigns.workflow.archive-campaign",),
limitations=(
"The current Campaign Web UI does not yet expose the delete action; use an authorized supporting client or API.",
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
),
),
)
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
"""Describe the current Campaign composition without resolving module data.
The provider deliberately uses only the actor's permission evaluator and the
registry's declared contracts. Resource access, policy decisions, and
campaign state remain authoritative at the point where an action is used.
"""
if context.documentation_type != "user":
return ()
principal = context.principal
if principal is None or not _has_any_scope(principal, _CAMPAIGN_USER_SCOPES):
return ()
mail_available = _integration_available(context.registry, _MAIL_INTEGRATION)
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 (
DocumentationTopic(
id="campaigns.current-composition",
title="Your Campaign role and installed composition",
summary="This guide separates actions authorized by your role from optional services connected to Campaign in this installation.",
body=_composition_body(current_configuration, limitations),
layer="configured",
documentation_types=("user",),
audience=("campaign_user",),
order=29,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
any_scopes=_CAMPAIGN_USER_SCOPES,
),
),
links=(DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
related_modules=("mail", "files", "addresses", "notifications"),
source_module_id="campaigns",
metadata={
"kind": "reference",
"route": "/campaigns",
"screen": "Campaigns",
"help_contexts": list(_CAMPAIGN_HELP_CONTEXTS),
"current_configuration": current_configuration,
"limitations": limitations,
},
),
)
def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str, ...]:
capabilities: list[str] = []
_append_if(capabilities, principal, ("campaigns:campaign:read",), "Open campaigns shared with you and inspect their business state.")
_append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.")
_append_if(capabilities, principal, ("campaigns:campaign:update",), "Edit eligible working campaign versions.")
_append_if(capabilities, principal, ("campaigns:campaign:copy",), "Create an editable successor from an eligible existing version.")
_append_if(capabilities, principal, ("campaigns:recipient:read",), "Inspect recipients and recipient-specific campaign data.")
_append_if(capabilities, principal, ("campaigns:recipient:write",), "Add and edit recipient rows.")
_append_if(capabilities, principal, ("campaigns:recipient:import",), "Import recipient snapshots.")
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
if mail_available:
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
_append_if(capabilities, principal, ("campaigns:campaign:control",), "Pause, resume, or cancel eligible delivery jobs.")
if mail_available:
_append_if(capabilities, principal, ("campaigns:campaign:send",), "Start an eligible real delivery run.")
_append_if(capabilities, principal, ("campaigns:campaign:retry",), "Retry delivery jobs whose recorded state permits a retry.")
_append_if(capabilities, principal, ("campaigns:campaign:reconcile",), "Reconcile delivery effects whose outcome is uncertain.")
_append_if(capabilities, principal, ("campaigns:report:read",), "View authorized campaign delivery reports.")
_append_if(
capabilities,
principal,
("campaigns:report:export", "campaigns:recipient:export"),
"Export authorized delivery and recipient report data.",
require_all=True,
)
_append_if(capabilities, principal, ("campaigns:campaign:share",), "Manage explicit access to eligible campaigns.")
_append_if(capabilities, principal, ("campaigns:campaign:archive",), "Archive eligible campaigns while retaining their evidence.")
_append_if(capabilities, principal, ("campaigns:campaign:delete",), "Delete eligible untouched drafts.")
if mail_available:
_append_if(
capabilities,
principal,
("campaigns:report:send", "campaigns:report:read", "mail:profile:use"),
"Send an authorized campaign report through a Mail profile.",
require_all=True,
)
return tuple(capabilities)
def _integration_summary(registry: object, principal: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
configured: list[str] = []
limitations: list[str] = []
mail_available = _integration_available(registry, _MAIL_INTEGRATION)
can_select_mail_profile = _has_all_scopes(
principal,
("campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"),
)
can_deliver_with_mail = _has_scope(principal, "mail:profile:use") and _has_any_scope(
principal,
("campaigns:campaign:queue", "campaigns:campaign:send", "campaigns:campaign:retry"),
)
if mail_available and can_select_mail_profile:
configured.append("Installed composition: Campaign can open Mail's actor-filtered profile picker while you edit; eligible profiles are resolved in the selected campaign context.")
elif mail_available and can_deliver_with_mail:
configured.append("Installed composition: Mail-backed Campaign delivery is connected, and the selected profile is re-authorized for each delivery action.")
elif mail_available:
limitations.append("Mail delivery is configured, but selecting a Mail profile is not included in your current tasks.")
else:
limitations.append("Mail-backed Campaign delivery is not available in the current composition.")
files_available = _integration_available(registry, _FILES_INTEGRATION)
can_preview_files = _has_all_scopes(
principal,
("campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"),
)
can_link_files = _has_all_scopes(
principal,
(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:campaign:validate",
"campaigns:recipient:read",
"files:file:read",
"files:file:share",
),
)
if files_available and can_link_files:
configured.append("Installed composition: Managed file versions can be selected, previewed, and linked as governed campaign attachments when the selected campaign and files pass their resource checks.")
elif files_available and can_preview_files:
configured.append("Installed composition: Managed campaign attachments can be previewed; linking a version requires campaign-update, validation, and attachment-sharing authority.")
elif files_available:
limitations.append("Managed attachments are configured, but they are not included in your current Campaign tasks.")
else:
limitations.append("Managed file attachments are not available in the current composition; campaign-owned uploads remain separate.")
lookup_available = _integration_available(registry, _ADDRESSES_LOOKUP_INTEGRATION)
source_available = _integration_available(registry, _ADDRESSES_SOURCE_INTEGRATION)
if lookup_available and _has_scope(principal, "campaigns:recipient:read"):
configured.append("Installed composition: Address records can be looked up while preparing recipients.")
elif lookup_available:
limitations.append("Address lookup is configured, but recipient inspection is not included in your current Campaign tasks.")
else:
limitations.append("Connected address lookup is not available in the current composition.")
can_import_source = _has_all_scopes(
principal,
(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
),
)
if source_available and can_import_source:
configured.append("Installed composition: The actor-filtered Addresses source picker can copy a selected source into a campaign as a traceable recipient snapshot.")
elif source_available:
limitations.append("Connected recipient sources are configured, but source import is not included in your current Campaign tasks.")
else:
limitations.append("Connected recipient-source snapshots are not available; direct campaign imports remain available when authorized.")
if _integration_available(registry, _NOTIFICATIONS_INTEGRATION):
configured.append("Installed composition: In-app notifications can report important Campaign delivery status changes.")
else:
limitations.append("Automatic in-app Campaign status notifications are not configured.")
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,
scopes: tuple[str, ...],
text: str,
*,
require_all: bool = False,
) -> None:
allowed = _has_all_scopes(principal, scopes) if require_all else _has_any_scope(principal, scopes)
if allowed:
target.append(f"Role authorization: {text}")
def _has_scope(principal: object, scope: str) -> bool:
checker = getattr(principal, "has", None)
if not callable(checker):
return False
try:
return bool(checker(scope))
except Exception:
return False
def _has_any_scope(principal: object, scopes: tuple[str, ...]) -> bool:
return any(_has_scope(principal, scope) for scope in scopes)
def _has_all_scopes(principal: object, scopes: tuple[str, ...]) -> bool:
return all(_has_scope(principal, scope) for scope in scopes)
def _integration_available(registry: object, interface_name: str) -> bool:
return _registry_has_interface(registry, interface_name) and _registry_has_capability(registry, interface_name)
def _registry_has_interface(registry: object, interface_name: str) -> bool:
manifests_provider = getattr(registry, "manifests", None)
if not callable(manifests_provider):
return False
try:
manifests = tuple(manifests_provider())
except Exception:
return False
return any(
getattr(provider, "name", None) == interface_name
for manifest in manifests
for provider in (getattr(manifest, "provides_interfaces", ()) or ())
)
def _registry_has_capability(registry: object, capability_name: str) -> bool:
capability_checker = getattr(registry, "has_capability", None)
if not callable(capability_checker):
return False
try:
return bool(capability_checker(capability_name))
except Exception:
return False
def _composition_body(current_configuration: list[str], limitations: list[str]) -> str:
del current_configuration, limitations
return "The facts below are calculated for the current actor and installed module contracts. They do not bypass campaign ownership, sharing, state, or operation-time resource and policy checks."

View File

@@ -34,9 +34,16 @@ class ImapConfigurationError(RuntimeError):
class ImapAppendError(RuntimeError):
def __init__(self, message: str, *, temporary: bool | None = None) -> None:
def __init__(
self,
message: str,
*,
temporary: bool | None = None,
outcome_unknown: bool = False,
) -> None:
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
class MailProfileError(OptionalModuleUnavailable):
@@ -139,17 +146,6 @@ class MailCampaignIntegration:
raise MailProfileError("Mail module is not available")
return self._delegate
def materialize_campaign_mail_profile_config(self, session: Any, **kwargs: Any) -> dict[str, Any]:
if self._delegate is None:
raw_json = kwargs.get("raw_json")
if self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {}):
raise MailProfileError("Campaign mail-server profiles require the mail module")
return dict(raw_json) if isinstance(raw_json, dict) else {}
try:
return self._delegate.materialize_campaign_mail_profile_config(session, **kwargs)
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def assert_campaign_mail_policy_allows_json(self, session: Any, **kwargs: Any) -> None:
if self._delegate is None:
raw_json = kwargs.get("raw_json")
@@ -162,72 +158,50 @@ class MailCampaignIntegration:
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def assert_mail_policy_allows_send(self, session: Any, **kwargs: Any) -> None:
delegate = self._require()
try:
return delegate.assert_mail_policy_allows_send(session, **kwargs)
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def mail_profile_id_from_campaign_json(self, raw_json: dict[str, Any]) -> str | None:
if self._delegate is not None:
return self._delegate.mail_profile_id_from_campaign_json(raw_json)
server = raw_json.get("server") if isinstance(raw_json, dict) else None
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
if profile_id is None and isinstance(server, dict):
profile_id = server.get("profile_id")
return str(profile_id).strip() if profile_id else None
def ensure_mail_profile_allowed_for_campaign(self, session: Any, **kwargs: Any) -> Any:
def campaign_profile_delivery_summary(self, session: Any, **kwargs: Any) -> dict[str, Any]:
delegate = self._require()
try:
return delegate.ensure_mail_profile_allowed_for_campaign(session, **kwargs)
return delegate.campaign_profile_delivery_summary(session, **kwargs)
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def smtp_config_from_profile(self, profile: Any) -> Any:
return self._require().smtp_config_from_profile(profile)
def imap_config_from_profile(self, profile: Any) -> Any:
return self._require().imap_config_from_profile(profile)
def effective_profile_credentials_inherited(self, session: Any, **kwargs: Any) -> bool:
return self._require().effective_profile_credentials_inherited(session, **kwargs)
def apply_campaign_credentials(self, profile_payload: dict[str, Any], server: dict[str, Any], protocol: str) -> dict[str, Any]:
return self._require().apply_campaign_credentials(profile_payload, server, protocol)
def wait_for_rate_limit(self, **kwargs: Any) -> None:
if self._delegate is None:
return None
return self._delegate.wait_for_rate_limit(**kwargs)
def send_email_bytes(self, *args: Any, **kwargs: Any) -> Any:
def send_campaign_email_bytes(self, *args: Any, **kwargs: Any) -> Any:
delegate = self._require()
try:
return delegate.send_email_bytes(*args, **kwargs)
return delegate.send_campaign_email_bytes(*args, **kwargs)
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
raise SmtpConfigurationError(str(exc)) from exc
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def append_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
delegate = self._require()
try:
return delegate.append_message_to_sent(*args, **kwargs)
return delegate.append_campaign_message_to_sent(*args, **kwargs)
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
raise ImapAppendError(str(exc), temporary=getattr(exc, "temporary", None)) from exc
raise ImapAppendError(
str(exc),
temporary=getattr(exc, "temporary", None),
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
) from exc
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
raise ImapConfigurationError(str(exc)) from exc
def send_email_message(self, *args: Any, **kwargs: Any) -> Any:
delegate = self._require()
try:
return delegate.send_email_message(*args, **kwargs)
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
raise SmtpConfigurationError(str(exc)) from exc
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def mock_mailbox(self) -> Any | None:
if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"):

View File

@@ -12,6 +12,9 @@ from govoplan_core.core.campaigns import (
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
@@ -25,6 +28,7 @@ from govoplan_core.core.modules import (
from govoplan_core.db.base import Base
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
register_campaign_change_tracking()
@@ -53,13 +57,14 @@ PERMISSIONS = (
_permission("campaigns:campaign:share", "Share campaigns", "Grant or revoke explicit campaign access.", "Campaigns"),
_permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"),
_permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"),
_permission("campaigns:campaign:review", "Approve campaign review", "Approve or reject built messages and review conditions.", "Campaigns"),
_permission("campaigns:campaign:review", "Complete campaign review", "Record review completion and the exact built messages inspected.", "Campaigns"),
_permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
_permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
_permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"),
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP or IMAP attempts after inspection.", "Campaigns"),
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
@@ -70,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",
@@ -90,7 +103,7 @@ ROLE_TEMPLATES = (
RoleTemplate(
slug="campaign_reviewer",
name="Campaign reviewer",
description="Inspect and approve prepared campaign messages.",
description="Inspect prepared campaign messages and record review completion.",
permissions=(
"campaigns:campaign:read",
"campaigns:campaign:validate",
@@ -111,6 +124,7 @@ ROLE_TEMPLATES = (
"campaigns:campaign:send",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:diagnostic:read",
"campaigns:recipient:read",
"campaigns:report:read",
"campaigns:report:send",
@@ -142,7 +156,7 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.8",
version="0.1.11",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("files", "mail", "notifications", "addresses"),
provides_interfaces=(
@@ -161,8 +175,8 @@ manifest = ModuleManifest(
),
ModuleInterfaceRequirement(
name="mail.campaign_delivery",
version_min="0.1.0",
version_max_exclusive="0.2.0",
version_min="0.2.0",
version_max_exclusive="0.3.0",
optional=True,
),
ModuleInterfaceRequirement(
@@ -188,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,
),
@@ -208,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,
),
@@ -256,6 +270,307 @@ manifest = ModuleManifest(
label="Campaigns",
),
),
documentation=(
*CAMPAIGN_USER_DOCUMENTATION,
DocumentationTopic(
id="campaigns.mail-profile-user-journey",
title="Choose a Mail profile for campaign delivery",
summary="Campaigns reference an authorized Mail profile and never store SMTP/IMAP settings or credentials.",
body="Open the campaign Mail settings, select an available profile, test it through Mail, and save. Validation and delivery recheck profile authorization. A changed transport identity requires a new validation and build.",
layer="available",
documentation_types=("user",),
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
order=46,
conditions=(
DocumentationCondition(
required_modules=("campaigns", "mail"),
required_scopes=("campaigns:campaign:update", "mail:profile:use"),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
),
related_modules=("mail",),
unlocks=("Profile-backed SMTP delivery and optional IMAP append-to-Sent.",),
metadata={
"kind": "workflow",
"route": "/campaigns/{campaign_id}/mail-settings",
"screen": "Campaign Mail settings",
"help_contexts": ["campaign.server-settings"],
"prerequisites": [
"Campaign and Mail are installed.",
"You may edit the current campaign version and use at least one authorized Mail profile.",
],
"steps": [
"Open the campaign and go to Mail settings.",
"Select an available Mail profile; Campaign stores only its stable identifier.",
"Test SMTP and, when configured, IMAP through the Mail module.",
"Save, validate, and build the campaign before queueing delivery.",
],
"outcome": "The editable campaign version references an authorized Mail-owned delivery profile without copying transport settings or credentials.",
"verification": "Reopen Mail settings, confirm the selected profile, then run validation and verify that the build completes without profile-drift errors.",
"related_topic_ids": [
"campaigns.mail-profile-governance",
"campaigns.mail-profile-operations",
"mail.profile-ownership-and-consumers",
],
},
),
DocumentationTopic(
id="campaigns.mail-profile-governance",
title="Govern Campaign-to-Mail profile references",
summary="Mail owns transport definitions and encrypted credentials; Campaign owns only the selected profile reference and delivery evidence.",
body="Grant mail:profile:use to campaign authors, constrain profile availability through Mail policy, and keep effective credential inheritance enabled. Inline transport fields are rejected. Legacy records remain unchanged until an explicit, audited profile migration creates or updates an editable version.",
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "mail_admin", "campaign_admin"),
order=47,
conditions=(
DocumentationCondition(
required_modules=("campaigns", "mail"),
any_scopes=("mail:profile:write", "admin:policies:read", "system:settings:read"),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"),
DocumentationLink(label="Mail profile boundary", href="govoplan-campaign/docs/MAIL_PROFILE_BOUNDARY.md", kind="repository"),
),
related_modules=("mail", "access"),
unlocks=("Auditable, reusable transport configuration across campaigns.",),
metadata={
"kind": "reference",
"route": "/campaigns/{campaign_id}/mail-policy",
"screen": "Campaign Mail policy",
"section": "Profile authorization and credential inheritance",
"related_topic_ids": [
"campaigns.mail-profile-user-journey",
"campaigns.mail-profile-operations",
"mail.profile-ownership-and-consumers",
],
},
),
DocumentationTopic(
id="campaigns.mail-profile-operations",
title="Operate profile-backed campaign delivery",
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation.",
layer="configured",
documentation_types=("admin",),
audience=("campaign_sender", "campaign_operator", "mail_admin"),
order=48,
conditions=(
DocumentationCondition(
required_modules=("campaigns", "mail"),
any_scopes=("campaigns:diagnostic:read", "campaigns:campaign:reconcile", "mail:profile:test"),
),
),
links=(
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
DocumentationLink(label="Campaign reports", href="/reports", kind="runtime"),
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
),
related_modules=("mail", "audit"),
unlocks=("Fail-closed recovery without exposing Mail credentials.",),
metadata={
"kind": "reference",
"route": "/operator",
"screen": "Campaign operator queue",
"section": "Profile-backed delivery recovery",
"related_topic_ids": [
"campaigns.mail-profile-user-journey",
"campaigns.mail-profile-governance",
"mail.profile-ownership-and-consumers",
],
},
),
DocumentationTopic(
id="campaigns.workflow.prepare-validate-and-build",
title="Prepare, validate, and build a campaign",
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build.",
layer="configured",
documentation_types=("user",),
audience=("campaign_manager", "campaign_author"),
order=49,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
required_scopes=("campaigns:campaign:update", "campaigns:campaign:validate", "campaigns:campaign:build"),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Recipient import guide", href="govoplan-campaign/docs/RECIPIENT_IMPORT_GUIDE.md", kind="repository"),
),
related_modules=("addresses", "files", "mail"),
unlocks=("A reviewable build whose exact recipient-specific effects can be inspected before delivery.",),
metadata={
"kind": "workflow",
"route": "/campaigns/{campaign_id}/global-settings",
"screen": "Campaign workspace",
"help_contexts": [
"campaign.overview",
"campaign.settings",
"campaign.fields",
"campaign.template",
"campaign.attachments",
"campaign.recipients",
"campaign.recipient-data",
"campaign.server-settings",
"campaign.global-settings",
"campaign.review-send",
"campaign.json",
],
"prerequisites": [
"The campaign has an owner and a clear communication purpose.",
"You may edit, validate, and build the selected campaign version.",
"Optional source modules needed by this campaign are installed and authorized.",
],
"steps": [
"Set campaign-wide fields and purpose, then define the recipient fields and templates.",
"Import or select recipients and inspect provenance, exclusions, and review-required rows.",
"Select managed attachment versions and an authorized Mail profile when those capabilities are used.",
"Validate the relevant sections and resolve every blocker without hiding warnings.",
"Build the selected version and inspect representative and exceptional rendered messages.",
],
"outcome": "The selected version has exact built messages and frozen source evidence ready for an independent review.",
"verification": "Open Review, confirm the build belongs to the intended version, and inspect counts, warnings, recipients, addressing, rendered content, and attachment evidence.",
"related_topic_ids": [
"campaigns.workflow.complete-review",
"campaigns.mail-profile-user-journey",
"files.workflow.import-managed-snapshot",
],
},
),
DocumentationTopic(
id="campaigns.workflow.complete-review",
title="Inspect built messages and complete review",
summary="Review the exact immutable candidate and record which built messages were inspected before delivery is enabled.",
body="Review completion records the inspected message keys for the selected build. The current baseline does not persist a separate approve/reject decision or review reason, so do not present completion as a richer decision record. Any material input or non-secret transport-identity change requires validation and a new build.",
layer="configured",
documentation_types=("user",),
audience=("campaign_reviewer",),
order=50,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:review"),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
),
related_modules=("files", "mail"),
unlocks=("An attributable review-completion record for the exact built messages inspected.",),
metadata={
"kind": "workflow",
"route": "/campaigns/{campaign_id}/review",
"screen": "Review and send",
"help_contexts": ["campaign.review-send"],
"prerequisites": [
"The selected campaign version is validated and built.",
"You may read the campaign and complete its review.",
],
"steps": [
"Confirm the campaign, owner, selected version, recipient count, warnings, and exclusions.",
"Inspect representative and exceptional messages, addressing, templates, and attachment evidence.",
"Confirm that the selected Mail profile is suitable and authorized for the current context.",
"Record review completion for the exact message keys inspected.",
],
"outcome": "The reviewed build is eligible for a separately authorized queue or send action.",
"verification": "Reload Review and confirm completion is tied to the same version and message build; changed inputs must invalidate or supersede it.",
"related_topic_ids": [
"campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.retry-and-reconcile",
],
},
),
DocumentationTopic(
id="campaigns.workflow.retry-and-reconcile",
title="Retry only known failures and reconcile uncertain effects",
summary="Keep safe-to-retry failures separate from SMTP or IMAP effects whose outcome is unknown.",
body="A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown SMTP or IMAP effect. Inspect external evidence, reconcile SMTP as accepted or not sent, and reconcile IMAP as appended or not appended; repairing Sent never resends accepted SMTP mail.",
layer="evidence",
documentation_types=("admin", "user"),
audience=("campaign_sender", "campaign_operator"),
order=51,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
any_scopes=("campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:diagnostic:read"),
),
),
links=(
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
),
related_modules=("mail", "audit"),
unlocks=("Evidence-backed recovery without accidental duplicate external effects.",),
metadata={
"kind": "workflow",
"route": "/operator",
"screen": "Campaign operator queue",
"help_contexts": ["campaign.review-send", "campaign.report", "campaign.audit"],
"prerequisites": [
"You may perform the selected retry or reconciliation action.",
"Provider, mailbox, worker, and campaign evidence has been preserved.",
],
"steps": [
"Classify the job and latest SMTP and IMAP attempts independently.",
"Retry only an explicitly temporary, permanent-with-override, or unattempted eligible state.",
"For an unknown effect, inspect provider or mailbox evidence and record the factual reconciliation with a note.",
"Verify the resulting protected state before allowing more work for that job.",
],
"outcome": "Every investigated job is either protected as effected, explicitly retryable, or still visibly unresolved.",
"verification": "Confirm the previous attempt remains in history, a retry has a new attempt number, and no accepted SMTP effect was repeated to repair IMAP state.",
"related_topic_ids": [
"campaigns.mail-profile-operations",
"campaigns.reference.composition-assurance",
],
},
),
DocumentationTopic(
id="campaigns.reference.composition-assurance",
title="Assure the Campaign reference composition",
summary="Release Campaign only with aligned contracts, role-safe surfaces, durable effect evidence, optional-module isolation, and recoverable data.",
body="Campaign is a reference composition only when Core, Mail, Files, Addresses, workers, storage, policies, and documentation are tested in the exact installed combination. Normal readers see business state rather than paths, storage keys, worker claims, or raw provider diagnostics; diagnostic and export authority remain separate.",
layer="evidence",
documentation_types=("admin",),
audience=("platform_operator", "security_reviewer", "release_reviewer", "integrator"),
order=52,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
any_scopes=("campaigns:diagnostic:read", "campaigns:report:read", "system:settings:read", "admin:modules:read"),
),
),
links=(
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Reference examples and release checklist", href="govoplan-campaign/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md", kind="repository"),
),
related_modules=("mail", "files", "addresses", "audit"),
unlocks=("A repeatable, supportable Campaign demonstration rather than an unverified module assembly.",),
metadata={
"kind": "reference",
"route": "/campaigns",
"screen": "Campaign reference composition",
"section": "Release, security, integration, and recovery assurance",
"verification": "Run the maintained examples, module-permutation tests, migration and restore drills, target SMTP/IMAP checks, version-alignment gate, WebUI/i18n checks, and full security audit for the pinned composition.",
"related_topic_ids": [
"campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.complete-review",
"campaigns.workflow.retry-and-reconcile",
"mail.reference.credentials-egress-retirement",
],
},
),
),
documentation_providers=(documentation_topics,),
capability_factories={
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
"govoplan_campaign.backend.capabilities",

View File

@@ -36,6 +36,10 @@ from govoplan_campaign.backend.campaign.models import (
)
from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.services.zip_service import create_zip_archive
from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders as _find_unresolved_placeholders,
render_template as _render_template,
)
from .models import (
CampaignBuildReport,
@@ -47,28 +51,6 @@ from .models import (
MessageValidationStatus,
)
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
@dataclass(slots=True)
class BuiltMessage:
draft: MessageDraft
@@ -123,29 +105,6 @@ def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str =
return path.read_text(encoding=encoding)
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0) if keep_missing else ""
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _find_unresolved_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return {
_normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
if recipient is None:
return None
@@ -554,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(
@@ -631,6 +596,25 @@ def _validate_required_recipients(
return MessageValidationStatus.EXCLUDED
def _validate_required_sender(
senders: list[RecipientConfig],
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
if senders:
return validation_status
issues.append(
MessageIssue(
severity="error",
code="missing_sender",
message="No effective From address is configured; Campaign must resolve the sender before building.",
behavior="block",
source="recipients",
)
)
return MessageValidationStatus.BLOCKED
def _render_message_template(
config: CampaignConfig,
campaign_file: str | Path,
@@ -825,6 +809,11 @@ def build_entry_message(
if not entry.active:
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
context.validation_status = _validate_required_sender(
context.senders,
context.issues,
context.validation_status,
)
context.validation_status = _validate_required_recipients(
config,
context.recipients,

View File

@@ -0,0 +1,67 @@
"""add durable IMAP append claim and attempt idempotency
Revision ID: 3c4d5e6f8192
Revises: 2c3d4e5f7081
Create Date: 2026-07-21 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "3c4d5e6f8192"
down_revision = "2c3d4e5f7081"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("campaign_jobs") as batch:
batch.add_column(sa.Column("imap_claimed_at", sa.DateTime(timezone=True), nullable=True))
batch.add_column(sa.Column("imap_claim_token", sa.String(length=36), nullable=True))
batch.create_index("ix_campaign_jobs_imap_claim_token", ["imap_claim_token"], unique=False)
with op.batch_alter_table("imap_append_attempts") as batch:
batch.add_column(sa.Column("claim_token", sa.String(length=36), nullable=True))
_renumber_attempts()
with op.batch_alter_table("imap_append_attempts") as batch:
batch.create_unique_constraint(
"uq_imap_append_attempts_job_attempt",
["job_id", "attempt_number"],
)
def downgrade() -> None:
with op.batch_alter_table("imap_append_attempts") as batch:
batch.drop_constraint("uq_imap_append_attempts_job_attempt", type_="unique")
batch.drop_column("claim_token")
with op.batch_alter_table("campaign_jobs") as batch:
batch.drop_index("ix_campaign_jobs_imap_claim_token")
batch.drop_column("imap_claim_token")
batch.drop_column("imap_claimed_at")
def _renumber_attempts() -> None:
"""Make historical attempt numbers unique per job before constraining them."""
bind = op.get_bind()
rows = list(
bind.execute(
sa.text(
"SELECT id, job_id FROM imap_append_attempts "
"ORDER BY job_id, created_at, id"
)
).mappings()
)
per_job: dict[str, int] = {}
for row in rows:
job_id = str(row["job_id"])
attempt_number = per_job.get(job_id, 0) + 1
per_job[job_id] = attempt_number
bind.execute(
sa.text(
"UPDATE imap_append_attempts SET attempt_number = :attempt_number "
"WHERE id = :attempt_id"
),
{"attempt_number": attempt_number, "attempt_id": row["id"]},
)

View File

@@ -0,0 +1,26 @@
"""seal each campaign delivery job's immutable execution input
Revision ID: 4d5e6f7a9203
Revises: 3c4d5e6f8192
Create Date: 2026-07-21 00:00:01.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4d5e6f7a9203"
down_revision = "3c4d5e6f8192"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("campaign_jobs") as batch:
batch.add_column(sa.Column("execution_input_sha256", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("campaign_jobs") as batch:
batch.drop_column("execution_input_sha256")

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,67 @@
"""add durable IMAP append claim and attempt idempotency
Revision ID: 3c4d5e6f8192
Revises: 2c3d4e5f7081
Create Date: 2026-07-21 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "3c4d5e6f8192"
down_revision = "2c3d4e5f7081"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("campaign_jobs") as batch:
batch.add_column(sa.Column("imap_claimed_at", sa.DateTime(timezone=True), nullable=True))
batch.add_column(sa.Column("imap_claim_token", sa.String(length=36), nullable=True))
batch.create_index("ix_campaign_jobs_imap_claim_token", ["imap_claim_token"], unique=False)
with op.batch_alter_table("imap_append_attempts") as batch:
batch.add_column(sa.Column("claim_token", sa.String(length=36), nullable=True))
_renumber_attempts()
with op.batch_alter_table("imap_append_attempts") as batch:
batch.create_unique_constraint(
"uq_imap_append_attempts_job_attempt",
["job_id", "attempt_number"],
)
def downgrade() -> None:
with op.batch_alter_table("imap_append_attempts") as batch:
batch.drop_constraint("uq_imap_append_attempts_job_attempt", type_="unique")
batch.drop_column("claim_token")
with op.batch_alter_table("campaign_jobs") as batch:
batch.drop_index("ix_campaign_jobs_imap_claim_token")
batch.drop_column("imap_claim_token")
batch.drop_column("imap_claimed_at")
def _renumber_attempts() -> None:
"""Make historical attempt numbers unique per job before constraining them."""
bind = op.get_bind()
rows = list(
bind.execute(
sa.text(
"SELECT id, job_id FROM imap_append_attempts "
"ORDER BY job_id, created_at, id"
)
).mappings()
)
per_job: dict[str, int] = {}
for row in rows:
job_id = str(row["job_id"])
attempt_number = per_job.get(job_id, 0) + 1
per_job[job_id] = attempt_number
bind.execute(
sa.text(
"UPDATE imap_append_attempts SET attempt_number = :attempt_number "
"WHERE id = :attempt_id"
),
{"attempt_number": attempt_number, "attempt_id": row["id"]},
)

View File

@@ -0,0 +1,26 @@
"""seal each campaign delivery job's immutable execution input
Revision ID: 4d5e6f7a9203
Revises: 3c4d5e6f8192
Create Date: 2026-07-21 00:00:01.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4d5e6f7a9203"
down_revision = "3c4d5e6f8192"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("campaign_jobs") as batch:
batch.add_column(sa.Column("execution_input_sha256", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("campaign_jobs") as batch:
batch.drop_column("execution_input_sha256")

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

@@ -1,13 +1,14 @@
from __future__ import annotations
import copy
import hashlib
import json
import os
from datetime import UTC, datetime
from email import policy
from email.parser import BytesParser
from pathlib import Path
from typing import Any
import copy
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import func
@@ -26,11 +27,15 @@ from govoplan_campaign.backend.db.models import (
JobValidationStatus,
)
from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_id,
)
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
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
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions
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
@@ -44,8 +49,9 @@ class CampaignPersistenceError(RuntimeError):
def _ensure_dirs() -> None:
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
for directory in (CAMPAIGN_SNAPSHOT_DIR, BUILD_OUTPUT_DIR):
directory.mkdir(parents=True, mode=0o700, exist_ok=True)
directory.chmod(0o700)
@@ -59,22 +65,44 @@ def load_campaign_config_from_json(
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> CampaignConfig:
materialized = mail_integration().materialize_campaign_mail_profile_config(
session,
tenant_id=tenant_id,
raw_json=raw_json,
campaign_id=campaign_id,
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
validate_against_schema(materialized)
# Validate the persisted Campaign-to-Mail contract before asking Mail for a
# non-secret capability summary. Campaign never receives resolved transport
# settings, account identities, or credentials.
assert_campaign_uses_mail_profile_reference(raw_json)
validate_against_schema(raw_json)
materialized = copy.deepcopy(raw_json)
profile_id = campaign_mail_profile_id(raw_json)
if profile_id:
summary = mail_integration().campaign_profile_delivery_summary(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
profile_id=profile_id,
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
materialized.setdefault("server", {})["profile_capabilities"] = {
"smtp_available": bool(summary.get("smtp_available")),
"imap_available": bool(summary.get("imap_available")),
}
return CampaignConfig.model_validate(materialized)
def _write_campaign_snapshot(version: CampaignVersion) -> Path:
_ensure_dirs()
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o600)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
descriptor = -1
json.dump(version.raw_json, stream, ensure_ascii=False, indent=2)
finally:
if descriptor >= 0:
os.close(descriptor)
return path
@@ -129,6 +157,7 @@ def create_campaign_version_from_json(
raw_json: dict[str, Any],
source_filename: str | None = None,
source_base_path: str | None = None,
commit: bool = True,
) -> tuple[Campaign, CampaignVersion]:
assert_server_safe_campaign_paths(
raw_json,
@@ -183,8 +212,11 @@ def create_campaign_version_from_json(
session.flush()
campaign.current_version_id = version.id
session.add(campaign)
_write_campaign_snapshot(version)
session.commit()
if commit:
_write_campaign_snapshot(version)
session.commit()
else:
session.flush()
return campaign, version
@@ -374,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,
@@ -472,13 +508,19 @@ def build_campaign_version(
[job for job, _message in job_build_pairs],
stage="built",
)
runtime_smtp = managed_config.server.runtime_smtp_config()
if not runtime_smtp:
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created")
if not managed_config.server.profile_capabilities.smtp_available:
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created")
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
if profile_id is None:
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages")
revisions = profile_transport_revisions(session, version)
if not revisions["smtp"]:
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision")
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
version,
smtp=runtime_smtp,
imap=managed_config.server.runtime_imap_config(),
mail_profile_id=profile_id,
smtp_transport_revision=revisions["smtp"],
imap_transport_revision=revisions["imap"],
delivery=managed_config.delivery,
jobs=[job for job, _message in job_build_pairs],
build_summary=report_json,

View File

@@ -19,6 +19,14 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus,
)
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_editor_state_for_edit,
campaign_mail_profile_boundary_violations,
campaign_mail_profile_id,
assert_campaign_uses_mail_profile_reference,
public_campaign_mail_server,
validate_campaign_editor_state,
)
from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
@@ -106,25 +114,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
},
"fields": [],
"global_values": {},
"server": {
"inherit_smtp_credentials": True,
"inherit_imap_credentials": True,
"smtp": {
"host": "",
"port": 587,
"security": "starttls",
},
"imap": {
"host": "",
"port": 993,
"security": "tls",
"sent_folder": "auto",
},
"credentials": {
"smtp": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
"imap": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
},
},
"server": {},
"recipients": {
"from": [],
"allow_individual_from": False,
@@ -218,6 +208,7 @@ def create_minimal_campaign(
description: str | None = None,
current_flow: str = CampaignVersionFlow.CREATE.value,
current_step: str = "basics",
commit: bool = True,
) -> tuple[Campaign, CampaignVersion]:
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
if existing:
@@ -251,8 +242,11 @@ def create_minimal_campaign(
session.flush()
campaign.current_version_id = version.id
session.add(campaign)
_write_campaign_snapshot(version)
session.commit()
if commit:
_write_campaign_snapshot(version)
session.commit()
else:
session.flush()
return campaign, version
@@ -347,6 +341,8 @@ def fork_campaign_version_for_edit(
source_filename: str | None = None,
source_base_path: str | None = None,
autosave: bool = True,
migrate_legacy_mail_settings: bool = False,
commit: bool = True,
) -> CampaignVersion:
"""Create the next sole working version from immutable campaign history.
@@ -371,7 +367,16 @@ def fork_campaign_version_for_edit(
"Create the next working copy from the campaign's current immutable version."
)
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
source_requires_mail_migration = bool(campaign_mail_profile_boundary_violations(source_json))
if source_requires_mail_migration and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Create the editable copy from "
"the Mail settings migration action so the audit record is preserved and the copy uses a Mail profile."
)
base_json = raw_json if raw_json is not None else copy.deepcopy(source_json)
if source_requires_mail_migration and raw_json is None:
base_json["server"] = public_campaign_mail_server(source_json)
assert_server_safe_campaign_paths(
base_json,
source_filename=source_filename,
@@ -379,6 +384,7 @@ def fork_campaign_version_for_edit(
managed_files_available=files_integration().available,
)
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
assert_campaign_uses_mail_profile_reference(runtime_json)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
new_version = CampaignVersion(
@@ -392,7 +398,11 @@ def fork_campaign_version_for_edit(
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
current_step=current_step if current_step is not None else source.current_step,
is_complete=False,
editor_state=editor_state if editor_state is not None else copy.deepcopy(source.editor_state or {}),
editor_state=(
validate_campaign_editor_state(editor_state)
if editor_state is not None
else campaign_editor_state_for_edit(source.editor_state)
),
autosaved_at=datetime.now(UTC) if autosave else None,
)
session.add(new_version)
@@ -402,8 +412,11 @@ def fork_campaign_version_for_edit(
campaign.current_version_id = new_version.id
campaign.status = CampaignStatus.DRAFT.value
session.add(campaign)
_write_campaign_snapshot(new_version)
session.commit()
if commit:
_write_campaign_snapshot(new_version)
session.commit()
else:
session.flush()
return new_version
@@ -461,6 +474,7 @@ def unlock_validated_campaign_version(
tenant_id: str,
campaign_id: str,
version_id: str,
commit: bool = True,
) -> CampaignVersion:
"""Unlock a validation snapshot so it can be edited again.
@@ -510,7 +524,10 @@ def unlock_validated_campaign_version(
campaign.status = CampaignStatus.DRAFT.value
session.add(version)
session.add(campaign)
session.commit()
if commit:
session.commit()
else:
session.flush()
return version
def update_campaign_version(
@@ -528,6 +545,8 @@ def update_campaign_version(
source_filename: str | None = None,
source_base_path: str | None = None,
autosave: bool = False,
migrate_legacy_mail_settings: bool = False,
commit: bool = True,
) -> CampaignVersion:
if raw_json is not None or source_filename is not None or source_base_path is not None:
assert_server_safe_campaign_paths(
@@ -547,6 +566,18 @@ def update_campaign_version(
if raw_json is not None:
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
if campaign_mail_profile_boundary_violations(version.raw_json) and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
"will not be changed automatically."
)
assert_campaign_uses_mail_profile_reference(runtime_json)
if campaign_mail_profile_boundary_violations(version.raw_json) and campaign_mail_profile_id(runtime_json) is None:
raise CampaignPersistenceError(
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
"Select a Mail profile before saving."
)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
version.raw_json = runtime_json
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
@@ -561,7 +592,7 @@ def update_campaign_version(
if is_complete is not None:
version.is_complete = is_complete
if editor_state is not None:
version.editor_state = editor_state
version.editor_state = validate_campaign_editor_state(editor_state)
if source_filename is not None:
version.source_filename = source_filename
if source_base_path is not None:
@@ -584,8 +615,11 @@ def update_campaign_version(
session.add(version)
session.add(campaign)
session.flush()
_write_campaign_snapshot(version)
session.commit()
if commit:
_write_campaign_snapshot(version)
session.commit()
else:
session.flush()
return version
@@ -598,6 +632,7 @@ def update_campaign_review_state(
inspection_complete: bool,
reviewed_message_keys: list[str],
user_id: str | None,
commit: bool = True,
) -> CampaignVersion:
"""Persist review acknowledgement without mutating the locked campaign data.
@@ -628,7 +663,10 @@ def update_campaign_review_state(
user_id=user_id,
)
session.add(version)
session.commit()
if commit:
session.commit()
else:
session.flush()
return version
@@ -710,6 +748,7 @@ def lock_campaign_version_temporarily(
campaign_id: str,
version_id: str,
user_id: str | None,
commit: bool = True,
) -> CampaignVersion:
"""Apply a reversible user-requested lock without changing workflow state."""
@@ -734,7 +773,10 @@ def lock_campaign_version_temporarily(
version.user_locked_at = datetime.now(UTC)
version.user_locked_by_user_id = user_id
session.add(version)
session.commit()
if commit:
session.commit()
else:
session.flush()
return version
@@ -744,6 +786,7 @@ def unlock_user_locked_campaign_version(
tenant_id: str,
campaign_id: str,
version_id: str,
commit: bool = True,
) -> CampaignVersion:
"""Remove a reversible user lock without invalidating campaign data."""
@@ -767,7 +810,10 @@ def unlock_user_locked_campaign_version(
version.user_locked_at = None
version.user_locked_by_user_id = None
session.add(version)
session.commit()
if commit:
session.commit()
else:
session.flush()
return version
@@ -778,6 +824,7 @@ def permanently_lock_campaign_version(
campaign_id: str,
version_id: str,
user_id: str | None,
commit: bool = True,
) -> CampaignVersion:
"""Apply an irreversible user lock.
@@ -805,7 +852,10 @@ def permanently_lock_campaign_version(
# Retain published_at as a compatibility marker for existing integrations.
version.published_at = version.published_at or now
session.add(version)
session.commit()
if commit:
session.commit()
else:
session.flush()
return version
@@ -816,6 +866,7 @@ def publish_campaign_version(
campaign_id: str,
version_id: str,
user_id: str | None = None,
commit: bool = True,
) -> CampaignVersion:
"""Backwards-compatible alias for the permanent user lock."""
@@ -825,6 +876,7 @@ def publish_campaign_version(
campaign_id=campaign_id,
version_id=version_id,
user_id=user_id,
commit=commit,
)
@@ -839,6 +891,7 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
_validate_partial_basics(collector, _dict_value(raw_json, "campaign"))
recipients = _dict_value(raw_json, "recipients")
_validate_partial_sender(collector, recipients)
_validate_partial_mail_profile(collector, raw_json)
_validate_partial_recipients(collector, _dict_value(raw_json, "entries"))
_validate_partial_template(collector, _dict_value(raw_json, "template"))
_validate_partial_attachments(collector, _dict_value(raw_json, "attachments"))
@@ -864,6 +917,26 @@ def _validate_partial_sender(collector: _PartialValidationCollector, recipients:
collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_json: dict[str, Any]) -> None:
violations = campaign_mail_profile_boundary_violations(raw_json)
if violations:
collector.issue(
"error",
"sender",
"server",
"campaign_local_mail_transport_forbidden",
"Campaign-local SMTP/IMAP settings are not supported. Select or migrate to an authorized Mail-module profile.",
)
elif campaign_mail_profile_id(raw_json) is None:
collector.issue(
"warning",
"sender",
"server.mail_profile_id",
"missing_mail_profile",
"Select an authorized Mail-module profile before validating or delivering this campaign.",
)
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
has_inline = bool(entries.get("inline"))
has_source = isinstance(entries.get("source"), dict)

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

@@ -2,6 +2,7 @@ from __future__ import annotations
import csv
import io
import logging
import math
from collections import Counter
from datetime import datetime, timezone
@@ -20,12 +21,20 @@ from govoplan_campaign.backend.db.models import (
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
public_delivery_result_message,
public_source_filename,
)
class CampaignReportError(RuntimeError):
pass
logger = logging.getLogger(__name__)
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -55,23 +64,42 @@ def _selected_version(
return version
def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
def _version_info(
version: CampaignVersion | None,
*,
include_diagnostics: bool = False,
) -> dict[str, Any] | None:
if not version:
return None
return {
"id": version.id,
"version_number": version.version_number,
"schema_version": version.schema_version,
"source_filename": version.source_filename,
"source_filename": public_source_filename(version.source_filename),
"created_at": version.created_at.isoformat() if version.created_at else None,
"validation_summary": version.validation_summary,
"build_summary": version.build_summary,
"validation_summary": public_campaign_payload(
version.validation_summary,
include_diagnostics=include_diagnostics,
),
"build_summary": public_campaign_payload(
version.build_summary,
include_diagnostics=include_diagnostics,
),
"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
),
}
def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, Any]:
def _load_delivery_info(
version: CampaignVersion | None,
jobs: list[CampaignJob],
*,
include_diagnostics: bool = False,
) -> dict[str, Any]:
"""Read deterministic delivery settings from the immutable execution snapshot."""
default = {
@@ -81,25 +109,37 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
"execution_snapshot_hash": None,
"execution_snapshot_at": None,
"snapshot_version": None,
"build_token": None, # nosec B105 - absent report value, not a credential.
"built_at": None,
"job_manifest_sha256": None,
"job_count": 0,
"queueable_job_count": 0,
"effective_policy_sha256": None,
"smtp_config_fingerprint": None,
"imap_config_fingerprint": None,
"estimated_remaining_send_seconds": None,
"estimated_remaining_send_human": None,
"background_workers_enabled": bool(core_settings.celery_enabled),
"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(
{
"build_token": None, # nosec B105 - absent report value, not a credential.
"job_manifest_sha256": None,
"effective_policy_sha256": None,
"smtp_transport_revision": None,
"imap_transport_revision": None,
"background_workers_enabled": bool(core_settings.celery_enabled),
}
)
if not version or not isinstance(version.execution_snapshot, dict):
default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery."
return default
try:
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
except Exception as exc: # pragma: no cover - reporting should remain available
default["load_error"] = str(exc)
except Exception: # pragma: no cover - reporting should remain available
logger.warning("Campaign execution snapshot could not be loaded for a report")
default["load_error"] = "Execution snapshot is invalid; rebuild the selected version before delivery."
return default
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
@@ -108,7 +148,7 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
if messages_per_minute and pending:
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60))
return {
result = {
"rate_limit": {
"messages_per_minute": messages_per_minute,
"concurrency": snapshot.delivery.rate_limit.concurrency,
@@ -124,18 +164,28 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
"execution_snapshot_hash": version.execution_snapshot_hash,
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
"snapshot_version": snapshot.snapshot_version,
"build_token": snapshot.build_token,
"built_at": snapshot.built_at,
"job_manifest_sha256": snapshot.job_manifest_sha256,
"job_count": snapshot.job_count,
"queueable_job_count": snapshot.queueable_job_count,
"effective_policy_sha256": snapshot.effective_policy_sha256,
"smtp_config_fingerprint": snapshot.smtp_config_fingerprint,
"imap_config_fingerprint": snapshot.imap_config_fingerprint,
"estimated_remaining_send_seconds": estimated_seconds,
"estimated_remaining_send_human": _human_duration(estimated_seconds),
"background_workers_enabled": bool(core_settings.celery_enabled),
"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(
{
"build_token": snapshot.build_token,
"job_manifest_sha256": snapshot.job_manifest_sha256,
"effective_policy_sha256": snapshot.effective_policy_sha256,
"smtp_transport_revision": snapshot.smtp_transport_revision,
"imap_transport_revision": snapshot.imap_transport_revision,
"background_workers_enabled": bool(core_settings.celery_enabled),
}
)
return result
def _human_duration(seconds: int | None) -> str | None:
@@ -222,15 +272,19 @@ def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[s
"send_status": job.send_status,
"imap_status": job.imap_status,
"attempt_count": job.attempt_count,
"last_error": job.last_error,
"last_error": public_delivery_result_message(
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
),
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
}
for job in failed[:limit]
]
def _job_row(job: CampaignJob) -> dict[str, Any]:
return {
def _job_row(job: CampaignJob, *, include_diagnostics: bool = False) -> dict[str, Any]:
row = {
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
@@ -243,17 +297,27 @@ def _job_row(job: CampaignJob) -> dict[str, Any]:
"imap_status": job.imap_status,
"attempt_count": job.attempt_count,
"queued_at": job.queued_at.isoformat() if job.queued_at else None,
"claimed_at": job.claimed_at.isoformat() if job.claimed_at else None,
"smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None,
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None,
"sent_at": job.sent_at.isoformat() if job.sent_at else None,
"last_error": job.last_error,
"last_error": public_delivery_result_message(
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
),
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"issues_count": len(job.issues_snapshot or []),
"attachment_config_count": len(job.resolved_attachments or []),
"matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)),
}
if include_diagnostics:
row.update(
{
"claimed_at": job.claimed_at.isoformat() if job.claimed_at else None,
"smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None,
}
)
return row
def _address_summary(value: Any) -> str:
@@ -316,15 +380,14 @@ def _job_evidence_row(
*,
latest_smtp: SendAttempt | None = None,
latest_imap: ImapAppendAttempt | None = None,
include_diagnostics: bool = False,
) -> dict[str, Any]:
row = _job_row(job)
row = _job_row(job, include_diagnostics=include_diagnostics)
recipients = job.resolved_recipients or {}
row.update({
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"message_id_header": job.message_id_header,
"eml_storage_key": job.eml_storage_key,
"eml_local_path": job.eml_local_path,
"from": _address_summary(recipients.get("from")),
"to": _address_summary(recipients.get("to")),
"cc": _address_summary(recipients.get("cc")),
@@ -334,15 +397,11 @@ def _job_evidence_row(
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None,
"latest_smtp_response": latest_smtp.smtp_response if latest_smtp else None,
"latest_smtp_error_type": latest_smtp.error_type if latest_smtp else None,
"latest_smtp_error_message": latest_smtp.error_message if latest_smtp else None,
"latest_smtp_started_at": latest_smtp.started_at.isoformat() if latest_smtp and latest_smtp.started_at else None,
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None,
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap else None,
"latest_imap_status": latest_imap.status if latest_imap else None,
"latest_imap_folder": latest_imap.folder if latest_imap else None,
"latest_imap_error_message": latest_imap.error_message if latest_imap else None,
"latest_imap_created_at": latest_imap.created_at.isoformat() if latest_imap and latest_imap.created_at else None,
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None,
})
@@ -356,7 +415,8 @@ def generate_campaign_report(
campaign_id: str,
version_id: str | None = None,
include_jobs: bool = False,
include_recent_failures: bool = True,
include_recent_failures: bool = False,
include_diagnostics: bool = False,
) -> dict[str, Any]:
"""Generate a dashboard/report payload for one campaign.
@@ -375,9 +435,13 @@ def generate_campaign_report(
jobs=jobs,
tenant_id=tenant_id,
include_recent_failures=include_recent_failures,
include_diagnostics=include_diagnostics,
)
if include_jobs:
report["jobs"] = [_job_row(job) for job in jobs]
report["jobs"] = [
_job_row(job, include_diagnostics=include_diagnostics)
for job in jobs
]
return report
@@ -407,12 +471,13 @@ def _campaign_report_payload(
jobs: list[CampaignJob],
tenant_id: str,
include_recent_failures: bool,
include_diagnostics: bool,
) -> dict[str, Any]:
job_ids = [job.id for job in jobs]
report = {
"generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign),
"current_version": _version_info(version),
"current_version": _version_info(version, include_diagnostics=include_diagnostics),
"selected_version_id": version.id if version else None,
"cards": _campaign_report_cards(version, jobs),
"status_counts": _campaign_report_status_counts(version, jobs),
@@ -427,7 +492,11 @@ def _campaign_report_payload(
},
"attachments": _attachment_summary(jobs),
"attempts": _campaign_report_attempt_counts(session, job_ids),
"delivery": _load_delivery_info(version, jobs),
"delivery": _load_delivery_info(
version,
jobs,
include_diagnostics=include_diagnostics,
),
}
if include_recent_failures:
report["recent_failures"] = _recent_failures(jobs)
@@ -489,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
@@ -500,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)
@@ -507,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)
@@ -532,6 +637,7 @@ def generate_jobs_csv(
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
include_diagnostics: bool = False,
) -> str:
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
version = _selected_version(session, campaign, version_id)
@@ -572,6 +678,7 @@ def generate_jobs_csv(
job,
latest_smtp=latest_smtp.get(job.id),
latest_imap=latest_imap.get(job.id),
include_diagnostics=include_diagnostics,
)
for job in jobs
]
@@ -596,15 +703,11 @@ def generate_jobs_csv(
"imap_status",
"attempt_count",
"queued_at",
"claimed_at",
"smtp_started_at",
"outcome_unknown_at",
"sent_at",
"last_error",
"eml_size_bytes",
"eml_sha256",
"eml_storage_key",
"eml_local_path",
"issues_count",
"attachment_config_count",
"matched_file_count",
@@ -612,18 +715,17 @@ def generate_jobs_csv(
"latest_smtp_attempt_number",
"latest_smtp_status",
"latest_smtp_status_code",
"latest_smtp_response",
"latest_smtp_error_type",
"latest_smtp_error_message",
"latest_smtp_started_at",
"latest_smtp_finished_at",
"latest_imap_attempt_number",
"latest_imap_status",
"latest_imap_folder",
"latest_imap_error_message",
"latest_imap_created_at",
"latest_imap_updated_at",
]
if include_diagnostics:
sent_at_index = fieldnames.index("outcome_unknown_at")
fieldnames[sent_at_index:sent_at_index] = ["claimed_at", "smtp_started_at"]
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
writer.writeheader()

View File

@@ -9,11 +9,12 @@ from typing import Any
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_campaign.backend.campaign.loader import load_campaign_config
from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig
from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
from govoplan_campaign.backend.persistence.campaigns import load_version_config
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.integrations import SmtpConfigurationError, mail_integration
from govoplan_campaign.backend.integrations import SmtpConfigurationError
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
class CampaignReportEmailError(RuntimeError):
@@ -30,8 +31,6 @@ class CampaignReportEmailResult:
sent: bool
attached_jobs_csv: bool
attached_report_json: bool
smtp_host: str | None = None
smtp_port: int | None = None
accepted_count: int | None = None
def as_dict(self) -> dict[str, Any]:
@@ -44,8 +43,6 @@ class CampaignReportEmailResult:
"sent": self.sent,
"attached_jobs_csv": self.attached_jobs_csv,
"attached_report_json": self.attached_report_json,
"smtp_host": self.smtp_host,
"smtp_port": self.smtp_port,
"accepted_count": self.accepted_count,
}
@@ -62,18 +59,15 @@ def _selected_version(
return version
def _load_config(version: CampaignVersion) -> CampaignConfig:
snapshot_path = _write_campaign_snapshot(version)
return load_campaign_config(snapshot_path)
def _load_config(session: Session, version: CampaignVersion) -> CampaignConfig:
_campaign, _version, config = load_version_config(session, version.id)
return config
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
if config.recipients.from_:
return config.recipients.from_[0].email, config.recipients.from_[0].name
smtp_config = config.server.runtime_smtp_config()
if smtp_config and smtp_config.username and "@" in smtp_config.username:
return smtp_config.username, None
raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address")
raise SmtpConfigurationError("Report email requires a Campaign-owned recipients.from address")
def _text_summary(report: dict[str, Any]) -> str:
@@ -94,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', {})}",
@@ -150,7 +146,7 @@ def send_campaign_report_email(
version_id: str | None = None,
to: list[str],
include_jobs: bool = False,
attach_jobs_csv: bool = True,
attach_jobs_csv: bool = False,
attach_report_json: bool = False,
dry_run: bool = False,
) -> CampaignReportEmailResult:
@@ -161,10 +157,28 @@ def send_campaign_report_email(
raise CampaignReportEmailError("At least one report recipient is required")
version = _selected_version(session, campaign, version_id)
config = _load_config(version)
smtp_config: SmtpConfig | None = config.server.runtime_smtp_config()
if smtp_config is None:
config = _load_config(session, version)
if not config.server.profile_capabilities.smtp_available:
raise SmtpConfigurationError("Campaign has no SMTP configuration")
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
if not profile_id:
raise SmtpConfigurationError("Campaign has no Mail profile reference")
if not isinstance(version.execution_snapshot, dict):
raise CampaignReportEmailError(
"Report email requires a validated and built campaign version with stored Mail-profile evidence."
)
try:
snapshot = ensure_execution_snapshot(session, version)
except ExecutionSnapshotError as exc:
raise CampaignReportEmailError(
"Report email requires a validated and built campaign version with current Mail-profile evidence."
) from exc
if not snapshot.smtp_transport_revision:
raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision")
if not dry_run:
raise CampaignReportEmailError(
"Report email delivery is disabled until it uses a durable, idempotent Mail-owned outbox with unknown-outcome reconciliation."
)
report = generate_campaign_report(
session,
@@ -172,6 +186,7 @@ def send_campaign_report_email(
campaign_id=campaign_id,
version_id=version.id,
include_jobs=include_jobs,
include_recent_failures=include_jobs,
)
jobs_csv = (
generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id)
@@ -187,38 +202,13 @@ def send_campaign_report_email(
jobs_csv=jobs_csv,
report_json=report_json,
)
envelope_from, _ = _effective_from(config)
if dry_run:
return CampaignReportEmailResult(
campaign_id=campaign.id,
version_id=version.id,
to=to,
subject=str(message["Subject"]),
dry_run=True,
sent=False,
attached_jobs_csv=jobs_csv is not None,
attached_report_json=report_json is not None,
smtp_host=smtp_config.host,
smtp_port=smtp_config.port,
)
result = mail_integration().send_email_message(
message,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=to,
)
return CampaignReportEmailResult(
campaign_id=campaign.id,
version_id=version.id,
to=to,
subject=str(message["Subject"]),
dry_run=False,
sent=True,
dry_run=True,
sent=False,
attached_jobs_csv=jobs_csv is not None,
attached_report_json=report_json is not None,
smtp_host=result.host,
smtp_port=result.port,
accepted_count=result.accepted_count,
)

View File

@@ -0,0 +1,266 @@
from __future__ import annotations
import copy
from pathlib import Path, PureWindowsPath
from typing import Any
from govoplan_campaign.backend.campaign.mail_profile_boundary import public_campaign_mail_server
# These fields locate process-local or storage-backend resources, or authorize
# a worker claim. They are useful for tightly controlled diagnostics but are
# not part of the campaign business-data contract.
CAMPAIGN_INTERNAL_RESPONSE_KEYS = frozenset(
{
"campaign_file",
"claim_token",
"eml_local_path",
"eml_path",
"eml_storage_key",
"local_path",
"source_base_path",
"storage_bucket",
"storage_key",
}
)
CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS = frozenset(
{
"background_workers_enabled",
"build_token",
"celery_enabled",
"claimed_at",
"effective_policy_sha256",
"execution_input_sha256",
"imap_claimed_at",
"imap_transport_revision",
"job_manifest_sha256",
"smtp_started_at",
"smtp_transport_revision",
}
)
_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."""
if isinstance(value, dict):
blocked_keys = CAMPAIGN_INTERNAL_RESPONSE_KEYS
if not include_diagnostics:
blocked_keys = blocked_keys | CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS
return {
key: public_campaign_payload(item, include_diagnostics=include_diagnostics)
for key, item in value.items()
if key not in blocked_keys
}
if isinstance(value, list):
return [public_campaign_payload(item, include_diagnostics=include_diagnostics) for item in value]
if isinstance(value, tuple):
return tuple(public_campaign_payload(item, include_diagnostics=include_diagnostics) for item in value)
return copy.deepcopy(value)
def public_delivery_result_message(
*,
last_error: Any,
send_status: Any,
imap_status: Any,
) -> str | None:
"""Map persisted provider text to a stable business-safe explanation."""
if not last_error:
return None
clean_send_status = str(send_status or "")
clean_imap_status = str(imap_status or "")
if clean_send_status == "outcome_unknown":
return "SMTP delivery outcome requires operator reconciliation."
if clean_send_status in {"failed_temporary", "failed_permanent"}:
return "SMTP delivery failed; an operator can inspect restricted diagnostics."
if clean_imap_status in {"outcome_unknown", "appending"}:
return "Sent-folder append outcome requires operator reconciliation."
if clean_imap_status in {"failed", "skipped"}:
return "Sent-folder append did not complete; an operator can inspect restricted diagnostics."
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.
Password-named business fields are intentionally retained. Only the
schema-defined SMTP/IMAP credential paths under ``server`` are secrets.
"""
payload = public_campaign_payload(value)
if not isinstance(payload, dict):
return payload
if "server" in payload:
payload["server"] = public_campaign_mail_server(payload)
_sanitize_configuration_paths(payload)
return payload
def public_source_filename(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return text
windows_path = PureWindowsPath(text)
return windows_path.name if windows_path.is_absolute() or "\\" in text else Path(text).name
def _sanitize_configuration_paths(payload: dict[str, Any]) -> None:
template = payload.get("template")
source = template.get("source") if isinstance(template, dict) else None
if isinstance(source, dict):
for key in ("subject_path", "text_path", "html_path"):
if key in source:
source[key] = _public_configuration_path(source[key])
entries = payload.get("entries")
entry_source = entries.get("source") if isinstance(entries, dict) else None
if isinstance(entry_source, dict) and "path" in entry_source:
entry_source["path"] = _public_configuration_path(entry_source["path"])
attachments = payload.get("attachments")
if isinstance(attachments, dict):
if "base_path" in attachments:
attachments["base_path"] = _public_configuration_path(attachments["base_path"])
base_paths = attachments.get("base_paths")
if isinstance(base_paths, list):
for item in base_paths:
if not isinstance(item, dict):
continue
for key in ("path", "source"):
if key in item:
item[key] = _public_configuration_path(item[key])
_sanitize_attachment_rules(attachments.get("global"))
if isinstance(entries, dict):
inline_entries = entries.get("inline")
if isinstance(inline_entries, list):
for entry in inline_entries:
if isinstance(entry, dict):
_sanitize_attachment_rules(entry.get("attachments"))
defaults = entries.get("defaults")
if isinstance(defaults, dict):
_sanitize_attachment_rules(defaults.get("attachments"))
def _sanitize_attachment_rules(value: Any) -> None:
if not isinstance(value, list):
return
for rule in value:
if isinstance(rule, dict) and "base_dir" in rule:
rule["base_dir"] = _public_configuration_path(rule["base_dir"])
def _public_configuration_path(value: Any) -> Any:
if not isinstance(value, str) or not value.strip():
return value
text = value.strip()
windows_path = PureWindowsPath(text)
path = Path(text)
if windows_path.is_absolute():
return windows_path.name
if path.is_absolute() or text.startswith("~"):
return path.name
return value

View File

@@ -127,7 +127,11 @@ def _apply_eml_retention(
if job.queue_status in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value} or job.send_status not in FINAL_EML_SEND_STATUSES:
result["skipped_not_final"] += 1
continue
if job.imap_status == JobImapStatus.PENDING.value:
if job.imap_status in {
JobImapStatus.PENDING.value,
JobImapStatus.APPENDING.value,
JobImapStatus.OUTCOME_UNKNOWN.value,
}:
result["skipped_not_final"] += 1
continue
result["eligible"] += 1

File diff suppressed because it is too large Load Diff

View File

@@ -90,125 +90,9 @@
"type": "object",
"properties": {
"mail_profile_id": {
"type": "string"
},
"inherit_smtp_credentials": {
"type": "boolean",
"default": true
},
"inherit_imap_credentials": {
"type": "boolean",
"default": true
},
"smtp": {
"type": "object",
"properties": {
"host": {
"type": "string"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"username": {
"type": "string"
},
"password": {
"type": "string"
},
"security": {
"type": "string",
"enum": [
"plain",
"tls",
"starttls"
],
"default": "starttls"
},
"timeout_seconds": {
"type": "integer",
"minimum": 1,
"default": 30
}
},
"additionalProperties": false
},
"imap": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"host": {
"type": "string"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"username": {
"type": "string"
},
"password": {
"type": "string"
},
"security": {
"type": "string",
"enum": [
"plain",
"tls",
"starttls"
],
"default": "tls"
},
"sent_folder": {
"type": "string",
"default": "auto"
},
"timeout_seconds": {
"type": "integer",
"minimum": 1,
"default": 30
}
},
"additionalProperties": false
},
"credentials": {
"type": "object",
"properties": {
"smtp": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"additionalProperties": false
},
"imap": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"default": {
"smtp": {},
"imap": {}
}
"type": "string",
"minLength": 1,
"description": "Stable reference to an authorized profile owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials."
}
},
"additionalProperties": false

View File

@@ -3,10 +3,18 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.mail.config import ImapConfig, SmtpConfig
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
public_campaign_editor_state,
validate_campaign_editor_state,
)
from govoplan_campaign.backend.response_security import (
public_campaign_configuration,
public_campaign_payload,
public_source_filename,
)
class CampaignCreateRequest(BaseModel):
@@ -49,6 +57,12 @@ class CampaignVersionUpdateRequest(BaseModel):
editor_state: dict[str, Any] | None = None
source_filename: str | None = None
source_base_path: str | None = None
migrate_legacy_mail_settings: bool = False
@field_validator("editor_state")
@classmethod
def validate_editor_state(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
return validate_campaign_editor_state(value) if value is not None else None
class CampaignVersionSetStepRequest(BaseModel):
@@ -80,7 +94,6 @@ class CampaignVersionResponse(BaseModel):
version_number: int
schema_version: str
source_filename: str | None = None
source_base_path: str | None = None
workflow_state: str = "editing"
current_flow: str = "manual"
current_step: str | None = None
@@ -99,10 +112,39 @@ 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
def remove_unsupported_editor_state(cls, value: Any, info: ValidationInfo) -> dict[str, Any]:
return public_campaign_editor_state(
value,
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
)
@field_validator("source_filename", mode="before")
@classmethod
def remove_source_directory(cls, value: Any) -> str | None:
return public_source_filename(value)
@field_validator("validation_summary", "build_summary", mode="before")
@classmethod
def remove_internal_summary_fields(cls, value: Any, info: ValidationInfo) -> Any:
return public_campaign_payload(
value,
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
)
class CampaignVersionDetailResponse(CampaignVersionResponse):
raw_json: dict[str, Any]
mail_profile_migration_required: bool = False
@field_validator("raw_json", mode="before")
@classmethod
def remove_internal_configuration_fields(cls, value: Any) -> Any:
return public_campaign_configuration(value)
class CampaignPartialValidationResponse(BaseModel):
@@ -351,6 +393,15 @@ class CampaignJobDetailResponse(BaseModel):
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignJobDiagnosticsResponse(BaseModel):
job_id: str
campaign_id: str
campaign_version_id: str
storage: dict[str, Any] = Field(default_factory=dict)
worker_claim: dict[str, Any] = Field(default_factory=dict)
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignRetryJobsRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -383,8 +434,20 @@ class CampaignSendJobRequest(BaseModel):
class CampaignResolveOutcomeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
decision: Literal["smtp_accepted", "not_sent"]
note: str | None = None
decision: Literal[
"smtp_accepted",
"not_sent",
"imap_appended",
"imap_not_appended",
]
note: str | None = Field(default=None, max_length=2000)
@model_validator(mode="after")
def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest":
self.note = (self.note or "").strip()
if not self.note:
raise ValueError("Reconciliation requires an evidence note")
return self
class ValidateCampaignRequest(BaseModel):
@@ -400,125 +463,6 @@ class BuildCampaignRequest(BaseModel):
write_eml: bool = True
class MailSmtpTestRequest(SmtpConfig):
"""SMTP settings supplied directly from the WebUI mail settings form."""
class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form."""
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
class MailCredentialPolicyPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
inherit: bool | None = None
allow_override: bool | None = None
class MailProfilePolicyPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
allowed_profile_ids: list[str] = Field(default_factory=list)
allow_user_profiles: bool | None = None
allow_group_profiles: bool | None = None
allow_campaign_profiles: bool | None = None
smtp_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
whitelist: dict[str, list[str]] = Field(default_factory=dict)
blacklist: dict[str, list[str]] = Field(default_factory=dict)
class MailProfilePolicyUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
class MailProfilePolicyResponse(BaseModel):
scope_type: MailProfileScope
scope_id: str | None = None
policy: dict[str, Any]
effective_policy: dict[str, Any] | None = None
class MailServerProfileCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
slug: str | None = Field(default=None, max_length=100)
description: str | None = None
is_active: bool = True
scope_type: MailProfileScope = "tenant"
scope_id: str | None = None
smtp: SmtpConfig
imap: ImapConfig | None = None
class MailServerProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, max_length=255)
slug: str | None = Field(default=None, max_length=100)
description: str | None = None
is_active: bool | None = None
smtp: SmtpConfig | None = None
imap: ImapConfig | None = None
clear_imap: bool = False
class MailServerProfileResponse(BaseModel):
id: str
tenant_id: str | None = None
scope_type: MailProfileScope = "tenant"
scope_id: str | None = None
name: str
slug: str
description: str | None = None
is_active: bool
smtp: dict[str, Any]
imap: dict[str, Any] | None = None
smtp_password_configured: bool = False
imap_password_configured: bool = False
created_at: datetime
updated_at: datetime
class MailServerProfileListResponse(BaseModel):
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]
host: str | None = None
port: int | None = None
security: str | None = None
message: str
details: dict[str, Any] = Field(default_factory=dict)
class MailImapFolderResponse(BaseModel):
name: str
flags: list[str] = Field(default_factory=list)
class MailImapFolderListResponse(BaseModel):
ok: bool
protocol: Literal["imap"] = "imap"
host: str | None = None
port: int | None = None
security: str | None = None
message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class ApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -550,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
@@ -570,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")
@@ -601,13 +554,60 @@ class CampaignActionResponse(BaseModel):
class ReportEmailRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
to: list[str]
to: list[str] = Field(min_length=1, max_length=50)
version_id: str | None = None
include_jobs: bool = False
attach_jobs_csv: bool = True
attach_jobs_csv: bool = False
attach_report_json: bool = False
dry_run: bool = False
@field_validator("to", mode="before")
@classmethod
def normalize_and_validate_recipients(cls, value: Any) -> Any:
if not isinstance(value, list):
return value
if not 1 <= len(value) <= 50:
raise ValueError("report email requires between 1 and 50 recipients")
recipients: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
raise ValueError("report recipients must be email-address strings")
recipient = item.strip()
if len(recipient) > 320:
raise ValueError("report recipient addresses must be at most 320 characters")
if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
raise ValueError("report recipient addresses must not contain control characters")
if recipient.count("@") != 1:
raise ValueError("report recipients must be email addresses")
local, domain = recipient.split("@", 1)
if (
not local
or not domain
or any(character.isspace() for character in recipient)
or any(character in ',;:<>[]()\\"' for character in recipient)
or local.startswith(".")
or local.endswith(".")
or ".." in local
or domain.startswith(".")
or domain.endswith(".")
or ".." in domain
or any(
not label
or label.startswith("-")
or label.endswith("-")
or not all(character.isalnum() or character == "-" for character in label)
for label in domain.split(".")
)
):
raise ValueError("report recipients must be email addresses")
key = recipient.casefold()
if key in seen:
continue
seen.add(key)
recipients.append(recipient)
return recipients
class ReportEmailResponse(BaseModel):
result: dict[str, Any]

View File

@@ -9,11 +9,16 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_id,
)
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
SNAPSHOT_VERSION = "3"
SNAPSHOT_VERSION = "5"
class ExecutionSnapshotError(RuntimeError):
@@ -25,9 +30,9 @@ class ExecutionSnapshot(BaseModel):
Rendered messages and attachment evidence remain normalized in
``CampaignJob`` and ``CampaignAttachmentUse``. This record freezes the
mutable transport/runtime configuration and cryptographically binds it to
the build and the exact set of persisted jobs without duplicating all
recipient data into one large JSON value.
Mail-profile reference, delivery policy, and opaque transport revisions and
cryptographically binds them to the build and exact persisted jobs. Mail
owns the resolved transport configuration and credentials.
"""
model_config = ConfigDict(extra="forbid")
@@ -35,6 +40,7 @@ class ExecutionSnapshot(BaseModel):
snapshot_version: str = SNAPSHOT_VERSION
campaign_version_id: str
campaign_json_sha256: str
mail_profile_id: str
created_at: str
build_token: str | None = None
built_at: str | None = None
@@ -42,10 +48,8 @@ class ExecutionSnapshot(BaseModel):
queueable_job_count: int = 0
job_manifest_sha256: str | None = None
effective_policy_sha256: str | None = None
smtp_config_fingerprint: str | None = None
imap_config_fingerprint: str | None = None
smtp: SmtpConfig
imap: ImapConfig | None = None
smtp_transport_revision: str | None = None
imap_transport_revision: str | None = None
delivery: DeliveryConfig
@@ -61,110 +65,50 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
return _sha256(payload)
def _transport_fingerprint(config: SmtpConfig | ImapConfig | None) -> str | None:
if config is None:
return None
payload = config.model_dump(mode="json")
# The fingerprint is safe to expose in reports. It identifies the effective
# account/transport settings without incorporating or revealing the secret.
if "password" in payload:
payload["password"] = "<configured>" if payload.get("password") else None
return _sha256(payload)
def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpConfig | ImapConfig | None:
if config is None:
return None
payload = config.model_dump(mode="json")
payload["password"] = None
if isinstance(config, SmtpConfig):
return SmtpConfig.model_validate(payload)
return ImapConfig.model_validate(payload)
def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
credentials = server.get("credentials") if isinstance(server, dict) and isinstance(server.get("credentials"), dict) else None
config = credentials.get(name) if isinstance(credentials, dict) and isinstance(credentials.get(name), dict) else None
password = config.get("password") if isinstance(config, dict) else None
if password is None:
legacy_config = server.get(name) if isinstance(server, dict) else None
password = legacy_config.get("password") if isinstance(legacy_config, dict) else None
if password is None:
return None
return str(password)
def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any]:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
return server if isinstance(server, dict) else {}
def _profile_for_version(session: Session, version: CampaignVersion):
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json)
mail = mail_integration()
profile_id = mail.mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
if not profile_id:
return None
profile_id = campaign_mail_profile_id(raw_json)
if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
try:
return mail.ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
return mail.campaign_profile_delivery_summary(
session,
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
profile_id=profile_id,
)
except MailProfileError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> SmtpConfig:
payload = snapshot.smtp.model_dump(mode="json")
if not payload.get("password"):
server = _server_from_campaign_json(version.raw_json)
profile = _profile_for_version(session, version)
if profile is not None:
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
mail = mail_integration()
profile_payload = mail.smtp_config_from_profile(profile).model_dump(mode="json")
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
return SmtpConfig.model_validate(profile_payload)
return SmtpConfig.model_validate(mail.apply_campaign_credentials(profile_payload, server, "smtp"))
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp")
return SmtpConfig.model_validate(payload)
def profile_transport_revisions(session: Session, version: CampaignVersion) -> dict[str, str | None]:
summary = profile_delivery_summary(session, version)
return {
"smtp": summary.get("smtp_transport_revision"),
"imap": summary.get("imap_transport_revision"),
}
def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> ImapConfig | None:
server = _server_from_campaign_json(version.raw_json)
if snapshot.imap is None:
profile = _profile_for_version(session, version)
if profile is None:
return None
mail = mail_integration()
imap = mail.imap_config_from_profile(profile)
if imap is None:
return None
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
imap_payload = imap.model_dump(mode="json")
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
return ImapConfig.model_validate(imap_payload)
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
payload = snapshot.imap.model_dump(mode="json")
if not payload.get("password"):
profile = _profile_for_version(session, version)
if profile is not None:
mail = mail_integration()
imap = mail.imap_config_from_profile(profile)
if imap is not None:
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
imap_payload = imap.model_dump(mode="json")
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
return ImapConfig.model_validate(imap_payload)
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap")
return ImapConfig.model_validate(payload)
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json)
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
raise ExecutionSnapshotError(
"The campaign's Mail profile reference differs from the built execution snapshot. "
"Revalidate and rebuild the campaign before delivery."
)
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None:
try:
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True)
except CampaignMailProfileBoundaryError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
@@ -180,70 +124,143 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
)
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
return {
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"subject": job.subject,
"message_id_header": job.message_id_header,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"build_status": job.build_status,
"validation_status": job.validation_status,
"resolved_recipients_sha256": _sha256(job.resolved_recipients or {}),
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []),
}
def job_execution_input_hash(job: CampaignJob) -> str:
return _sha256(_job_execution_input_payload(job))
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
"""Hash the immutable per-message execution records in stable order."""
payload: list[dict[str, Any]] = []
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)):
payload.append(
{
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"subject": job.subject,
"message_id_header": job.message_id_header,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"build_status": job.build_status,
"validation_status": job.validation_status,
"resolved_recipients_sha256": _sha256(job.resolved_recipients or {}),
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []),
}
)
payload = [
_job_execution_input_payload(job)
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
]
return _sha256(payload)
def create_execution_snapshot(
version: CampaignVersion,
*,
smtp: SmtpConfig,
imap: ImapConfig | None,
mail_profile_id: str,
smtp_transport_revision: str,
imap_transport_revision: str | None,
delivery: DeliveryConfig,
jobs: Iterable[CampaignJob] = (),
build_summary: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs)
for job in job_list:
job.execution_input_sha256 = job_execution_input_hash(job)
summary = build_summary if isinstance(build_summary, dict) else {}
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
redacted_smtp = _redacted_transport_config(smtp)
redacted_imap = _redacted_transport_config(imap)
if not isinstance(redacted_smtp, SmtpConfig):
raise ExecutionSnapshotError("Redacted SMTP configuration is invalid.")
if redacted_imap is not None and not isinstance(redacted_imap, ImapConfig):
raise ExecutionSnapshotError("Redacted IMAP configuration is invalid.")
payload = ExecutionSnapshot(
campaign_version_id=version.id,
campaign_json_sha256=_sha256(raw_json),
mail_profile_id=mail_profile_id,
build_token=str(summary.get("build_token") or "") or None,
built_at=str(summary.get("built_at") or "") or None,
job_count=len(job_list),
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
smtp_config_fingerprint=_transport_fingerprint(smtp),
imap_config_fingerprint=_transport_fingerprint(imap),
smtp_transport_revision=smtp_transport_revision,
imap_transport_revision=imap_transport_revision,
created_at=datetime.now(timezone.utc).isoformat(),
smtp=redacted_smtp,
imap=redacted_imap,
delivery=delivery,
).model_dump(mode="json")
return payload, snapshot_hash(payload)
def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> ExecutionSnapshot:
def _assert_snapshot_matches_persisted_inputs(
session: Session,
version: CampaignVersion,
snapshot: ExecutionSnapshot,
*,
effect_job: CampaignJob | None = None,
) -> None:
"""Fail closed when any build-bound input drifted after snapshot creation."""
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
if snapshot.campaign_version_id != version.id:
raise ExecutionSnapshotError("Execution snapshot campaign version mismatch")
if snapshot.campaign_json_sha256 != _sha256(raw_json):
raise ExecutionSnapshotError(
"Campaign inputs changed after this execution snapshot was built. "
"Revalidate and rebuild the campaign before delivery."
)
if not snapshot.smtp_transport_revision:
raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision")
if not snapshot.job_manifest_sha256:
raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum")
if not snapshot.effective_policy_sha256:
raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum")
if snapshot.effective_policy_sha256 != _policy_fingerprint(raw_json, snapshot.delivery):
raise ExecutionSnapshotError(
"Campaign delivery policy changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
)
if effect_job is not None:
if effect_job.campaign_version_id != version.id:
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
if not getattr(effect_job, "execution_input_sha256", None):
raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery")
if effect_job.execution_input_sha256 != job_execution_input_hash(effect_job):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
)
return
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
.all()
)
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
if snapshot.job_count != len(jobs):
raise ExecutionSnapshotError(
"Built campaign jobs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
)
queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses)
if (
snapshot.queueable_job_count != queueable_count
or snapshot.job_manifest_sha256 != job_manifest_hash(jobs)
or any(getattr(job, "execution_input_sha256", None) != job_execution_input_hash(job) for job in jobs)
):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
)
def ensure_execution_snapshot(
session: Session,
version: CampaignVersion,
*,
effect_job: CampaignJob | None = None,
) -> ExecutionSnapshot:
"""Return a validated snapshot, creating one for pre-migration builds.
New builds create the snapshot after persisting their jobs. The fallback is
@@ -251,27 +268,46 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
without a manual data migration.
"""
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
try:
assert_server_safe_campaign_paths(
version.raw_json if isinstance(version.raw_json, dict) else {},
raw_json,
managed_files_available=files_integration().available,
)
except CampaignPathSecurityError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
_assert_version_mail_profile_boundary(raw_json)
if isinstance(version.execution_snapshot, dict):
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION:
raise ExecutionSnapshotError(
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. "
"It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
"and rebuild a new campaign version."
)
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
expected = snapshot_hash(snapshot.model_dump(mode="json"))
if version.execution_snapshot_hash and version.execution_snapshot_hash != expected:
if not version.execution_snapshot_hash:
raise ExecutionSnapshotError("Execution snapshot checksum is missing")
if version.execution_snapshot_hash != expected:
raise ExecutionSnapshotError("Execution snapshot checksum mismatch")
_assert_snapshot_profile_matches_version(version, snapshot)
_assert_snapshot_matches_persisted_inputs(
session,
version,
snapshot,
effect_job=effect_job,
)
return snapshot
from govoplan_campaign.backend.persistence.campaigns import load_version_config
_, _, config = load_version_config(session, version.id)
runtime_smtp = config.server.runtime_smtp_config()
if not runtime_smtp:
raise ExecutionSnapshotError("Campaign has no SMTP configuration")
profile_id = campaign_mail_profile_id(raw_json)
if not config.server.profile_capabilities.smtp_available:
raise ExecutionSnapshotError("The selected Mail profile has no SMTP configuration")
if profile_id is None:
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
@@ -280,10 +316,14 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
)
if not jobs:
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
revisions = profile_transport_revisions(session, version)
if not revisions["smtp"]:
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
payload, digest = create_execution_snapshot(
version,
smtp=runtime_smtp,
imap=config.server.runtime_imap_config(),
mail_profile_id=profile_id,
smtp_transport_revision=revisions["smtp"],
imap_transport_revision=revisions["imap"],
delivery=config.delivery,
jobs=jobs,
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def render_template(
template: str,
values: Mapping[str, Any],
*,
keep_missing: bool = True,
) -> str:
def replace(match: re.Match[str]) -> str:
key = normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0) if keep_missing else ""
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def find_unresolved_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return {
normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}

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

@@ -44,7 +44,10 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
"campaign": {"id": f"no-attachment-{behavior or legacy_allow}", "name": "No attachment policy", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Subject", "text": "Body"},
"attachments": attachments,
@@ -120,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, [])
@@ -140,7 +146,10 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
"campaign": {"id": "zip-missing-pattern", "name": "ZIP missing pattern", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Subject", "text": "Body"},
"attachments": {

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from govoplan_campaign.backend import router
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
def _principal() -> SimpleNamespace:
return SimpleNamespace(
tenant_id="tenant-1",
user=SimpleNamespace(id="user-1"),
api_key=None,
)
def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() -> None:
session = MagicMock()
principal = _principal()
version = SimpleNamespace(
id="version-1",
raw_json={},
current_flow="manual",
current_step="recipients",
)
def mutate(*_args, **kwargs):
assert kwargs["commit"] is False
session.flush()
return version
with (
patch.object(router, "_get_campaign_for_principal"),
patch.object(router, "_get_version_for_tenant", return_value=version),
patch.object(router, "update_campaign_version", side_effect=mutate),
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
):
with pytest.raises(HTTPException, match="audit unavailable") as captured:
router._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
session,
principal, # type: ignore[arg-type]
"campaign-1",
"version-1",
CampaignVersionUpdateRequest(current_step="recipients"),
autosave=True,
audit_action="campaign.version_autosaved",
)
assert captured.value.status_code == 422
session.commit.assert_not_called()
session.rollback.assert_called_once_with()
def test_version_fork_rolls_back_when_its_audit_record_cannot_be_written() -> None:
session = MagicMock()
principal = _principal()
campaign = SimpleNamespace(id="campaign-1")
source = SimpleNamespace(id="version-1", campaign_id="campaign-1", raw_json={})
forked = SimpleNamespace(id="version-2", campaign_id="campaign-1", version_number=2)
def mutate(*_args, **kwargs):
assert kwargs["commit"] is False
session.flush()
return forked
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_require_permission"),
patch.object(router, "_get_version_for_tenant", return_value=source),
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
patch.object(router, "fork_campaign_version_for_edit", side_effect=mutate),
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
):
with pytest.raises(RuntimeError, match="audit unavailable"):
router.fork_version_for_edit(
"campaign-1",
"version-1",
CampaignVersionUpdateRequest(),
session=session,
principal=principal, # type: ignore[arg-type]
)
session.commit.assert_not_called()
session.rollback.assert_called_once_with()
def test_metadata_update_rolls_back_when_its_audit_record_cannot_be_written() -> None:
session = MagicMock()
principal = _principal()
campaign = SimpleNamespace(
id="campaign-1",
tenant_id="tenant-1",
external_id="C-1",
name="Old name",
description=None,
status="draft",
current_version_id=None,
)
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_sync_campaign_metadata_to_current_version"),
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
):
with pytest.raises(RuntimeError, match="audit unavailable"):
router.update_campaign_metadata_endpoint(
"campaign-1",
CampaignUpdateRequest(name="New name"),
session=session,
principal=principal, # type: ignore[arg-type]
)
session.flush.assert_called_once_with()
session.commit.assert_not_called()
session.rollback.assert_called_once_with()

View File

@@ -2,8 +2,15 @@ from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
from govoplan_campaign.backend.reports.campaigns import (
_job_evidence_row,
_latest_by_job_id,
_load_delivery_info,
generate_campaign_report,
)
def _dt() -> datetime:
@@ -86,9 +93,14 @@ def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
assert "bundle.zip" in row["attachment_names"]
assert "notice.pdf" in row["attachment_names"]
assert row["latest_smtp_status_code"] == 250
assert row["latest_smtp_response"] == "2.0.0 queued"
assert "latest_smtp_response" not in row
assert "latest_smtp_error_type" not in row
assert "latest_smtp_error_message" not in row
assert row["latest_imap_status"] == "appended"
assert row["latest_imap_folder"] == "Sent"
assert "latest_imap_error_message" not in row
assert "eml_storage_key" not in row
assert "eml_local_path" not in row
def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
@@ -102,3 +114,60 @@ def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
assert latest["job-1"].attempt_number == 3
assert latest["job-2"].attempt_number == 2
def test_invalid_legacy_snapshot_never_echoes_validation_details() -> None:
version = SimpleNamespace(
execution_snapshot={
"snapshot_version": "legacy",
"smtp": {"host": "smtp.internal.example", "password": "provider-secret"},
},
execution_snapshot_hash=None,
execution_snapshot_at=None,
)
delivery = _load_delivery_info(version, [])
assert delivery["load_error"] == "Execution snapshot is invalid; rebuild the selected version before delivery."
assert "provider-secret" not in repr(delivery)
assert "smtp.internal.example" not in repr(delivery)
assert "background_workers_enabled" not in delivery
assert "smtp_transport_revision" not in delivery
operator_delivery = _load_delivery_info(version, [], include_diagnostics=True)
assert "background_workers_enabled" in operator_delivery
assert "smtp_transport_revision" in operator_delivery
def test_aggregate_report_omits_recipient_level_failures_by_default() -> None:
campaign = SimpleNamespace(id="campaign-1")
version = SimpleNamespace(id="version-1")
with (
patch("govoplan_campaign.backend.reports.campaigns._get_campaign", return_value=campaign),
patch("govoplan_campaign.backend.reports.campaigns._selected_version", return_value=version),
patch("govoplan_campaign.backend.reports.campaigns._report_jobs", return_value=[]),
patch(
"govoplan_campaign.backend.reports.campaigns._campaign_report_payload",
return_value={"cards": {}},
) as payload,
):
report = generate_campaign_report(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
)
assert report == {"cards": {}}
assert payload.call_args.kwargs["include_recent_failures"] is False
class CampaignReportProjectionTests(unittest.TestCase):
def test_evidence_projection(self) -> None:
test_job_evidence_row_contains_transport_and_message_evidence()
def test_latest_attempt_projection(self) -> None:
test_latest_by_job_id_keeps_highest_attempt_number()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,314 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
import sys
import tempfile
from unittest import mock
import pytest
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
RUNNER_PATH = (
REPOSITORY_ROOT
/ "dev"
/ "mail-testbed"
/ "run_celery_redelivery_acceptance.py"
)
COMPOSE_PATH = REPOSITORY_ROOT / "dev" / "mail-testbed" / "docker-compose.yml"
FIXTURE_PATH = REPOSITORY_ROOT / "examples" / "greenmail-delivery" / "campaign.json"
TASK_ID = "12345678-1234-4234-8234-123456789abc"
def _load_runner():
spec = importlib.util.spec_from_file_location(
"govoplan_campaign_celery_redelivery_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) -> None:
self.status_code = status_code
self._payload = payload
def json(self) -> dict:
return self._payload
class _Client:
def post(self, path: str, **_kwargs) -> _Response:
assert path.endswith("/queue")
return _Response(
200,
{
"queued_count": 1,
"skipped_count": 0,
"blocked_count": 0,
"enqueued_count": 1,
"delivery_mode": "worker_queue",
"worker_queue_available": True,
"dry_run": False,
},
)
def get(self, path: str, **_kwargs) -> _Response:
assert path.endswith("/report")
return _Response(
200,
{
"cards": {
"jobs_total": 1,
"outcome_unknown": 1,
"needs_attention": 1,
},
"status_counts": {
"send": {"outcome_unknown": 1},
"imap": {"pending": 1},
},
},
)
class _Endpoint:
host = "127.0.0.1"
port = 4025
def __init__(self) -> None:
self.release_count = 0
def wait_for_data(self, _timeout_seconds: int) -> bool:
return True
def release_held_connection(self) -> None:
self.release_count += 1
def evidence(self) -> dict[str, int]:
return {
"connection_count": 1,
"accepted_rcpt_commands": 1,
"refused_rcpt_commands": 0,
"data_transactions": 1,
}
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,
)
def test_compose_redis_is_isolated_durable_and_health_checked() -> None:
compose = COMPOSE_PATH.read_text(encoding="utf-8")
assert "redis:7-alpine" in compose
assert '"--appendonly", "yes"' in compose
assert "127.0.0.1:${GOVOPLAN_CAMPAIGN_TEST_REDIS_PORT:-36379}:6379" in compose
assert 'test: ["CMD", "redis-cli", "ping"]' in compose
assert "campaign-redis-data:/data" in compose
def test_runbook_keeps_local_redelivery_distinct_from_production_supervision() -> 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 "run_celery_redelivery_acceptance.py" in testbed
assert "same Celery task identity must be redelivered" in testbed
assert "production daemon supervision" in testbed
assert "empty broker queue/unacked set" in runbook
assert "production process manager" in runbook
def test_compose_lifecycle_targets_only_isolated_redis_service() -> None:
up = runner._compose_command(
compose_file=COMPOSE_PATH,
project_name="govoplan-campaign-redelivery-test",
operation="up",
)
down = runner._compose_command(
compose_file=COMPOSE_PATH,
project_name="govoplan-campaign-redelivery-test",
operation="down",
)
assert up[-3:] == ["up", "--detach", "redis"]
assert down[-3:] == ["down", "--volumes", "--remove-orphans"]
assert "greenmail" not in up
assert "--project-name" in up
def test_worker_bootstrap_uses_real_late_ack_solo_celery_worker() -> None:
source = runner.WORKER_BOOTSTRAP
assert "celery.worker_main" in source
assert '"--pool=solo"' in source
assert '"--queues=send_email"' in source
assert '"visibility_timeout"' in source
assert '"polling_interval"' in source
assert "send_email.run" not in source
def test_runtime_root_uses_platform_temp_selection() -> None:
with mock.patch(
"govoplan_campaign_celery_redelivery_acceptance.tempfile.mkdtemp",
return_value="/selected-temp/govoplan-campaign-celery-redelivery-test",
) as mkdtemp:
runtime_root = runner._create_runtime_root()
assert runtime_root == Path(
"/selected-temp/govoplan-campaign-celery-redelivery-test"
)
mkdtemp.assert_called_once_with(prefix="govoplan-campaign-celery-redelivery-")
def test_worker_log_projection_matches_redelivered_task_without_retaining_id() -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
log_path = Path(temporary_directory) / "worker.log"
log_path.write_text(
"\n".join(
[
f"Task govoplan.campaigns.send_email[{TASK_ID}] received",
f"Task govoplan.campaigns.send_email[{TASK_ID}] succeeded in 0.1s",
]
),
encoding="utf-8",
)
with log_path.open("ab") as handle:
worker = runner.WorkerProcess(
process=SimpleNamespace(),
log_path=log_path,
log_handle=handle,
)
assert worker.received_task_ids() == (TASK_ID,)
assert worker.succeeded_task_ids() == (TASK_ID,)
def test_queue_projection_fails_closed_if_no_task_was_published() -> None:
with pytest.raises(runner.AcceptanceError, match="one Celery task"):
runner._queue_evidence(
{
"queued_count": 1,
"skipped_count": 0,
"blocked_count": 0,
"enqueued_count": 0,
"delivery_mode": "database_queue",
"worker_queue_available": False,
"dry_run": False,
}
)
def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
monkeypatch,
) -> None:
first_worker = mock.Mock()
first_worker.received_task_ids.return_value = (TASK_ID,)
replacement_worker = mock.Mock()
replacement_worker.received_task_ids.return_value = (TASK_ID,)
workers = iter([first_worker, replacement_worker])
endpoint = _Endpoint()
durable_states = iter(
[
{
"job_count": 1,
"send_status_counts": {"sending": 1},
"attempt_status_counts": {"smtp_in_progress": 1},
"unfinished_attempt_count": 1,
},
{
"job_count": 1,
"send_status_counts": {"outcome_unknown": 1},
"attempt_status_counts": {"outcome_unknown": 1},
"unfinished_attempt_count": 0,
},
]
)
prepared = SimpleNamespace(
campaign_id="campaign-internal",
version_id="version-internal",
public_evidence=lambda: {
"validation": {"ok": True},
"build": {"built_count": 1},
"campaign_mail_boundary": {
"profile_reference_only": True,
"smtp_revision_frozen": True,
"imap_revision_frozen": True,
"resolved_transport_material_present": False,
},
},
)
monkeypatch.setattr(runner, "create_mail_profile", lambda *args, **kwargs: "profile-internal")
monkeypatch.setattr(runner, "prepare_campaign_scenario", lambda *args, **kwargs: prepared)
monkeypatch.setattr(runner, "_start_worker", lambda *args, **kwargs: next(workers))
monkeypatch.setattr(runner, "_wait_for_worker_ready", lambda *args, **kwargs: None)
received = iter([TASK_ID, TASK_ID])
monkeypatch.setattr(runner, "_wait_for_received_task", lambda *args, **kwargs: next(received))
monkeypatch.setattr(runner, "_wait_for_task_success", lambda *args, **kwargs: None)
monkeypatch.setattr(runner, "_kill_worker", lambda *args, **kwargs: -9)
monkeypatch.setattr(runner, "_stop_worker", lambda *args, **kwargs: None)
monkeypatch.setattr(
runner,
"_wait_for_broker_drained",
lambda *args, **kwargs: runner.RedisBrokerState(0, 0, 0),
)
evidence = runner.execute_redelivery_scenario(
_Client(),
{"Authorization": "not-retained"},
fixture_path=FIXTURE_PATH,
settings=_settings(),
endpoint=endpoint,
redis_url="redis://127.0.0.1:36379/0",
runtime_root=Path("/not-used"),
snapshot_probe=lambda _version_id: ({}, {}),
audit_probe=lambda _campaign_id, _version_id: {
"campaign.created": 1,
"campaign.validated": 1,
"campaign.messages_built": 1,
"campaign.queued": 1,
},
delivery_probe=lambda _campaign_id, _version_id: next(durable_states),
)
assert evidence["broker"] == {
"transport": "redis",
"same_task_identity_redelivered": True,
"first_worker_received_count": 1,
"replacement_worker_received_count": 1,
"queue_depth": 0,
"unacked_hash_count": 0,
"unacked_index_count": 0,
}
assert evidence["protocol"]["connection_count"] == 1
assert evidence["protocol"]["data_transactions"] == 1
assert evidence["recovered_durable_state"]["send_status_counts"] == {
"outcome_unknown": 1
}
assert TASK_ID not in json.dumps(evidence, sort_keys=True)
assert endpoint.release_count >= 1

434
tests/test_documentation.py Normal file
View File

@@ -0,0 +1,434 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
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)
return scope in self.scopes or f"{namespace}:{resource}:*" in self.scopes or f"{namespace}:*" in self.scopes or "*:*" in self.scopes
class _Registry:
def __init__(self, integrations: set[str], *, interface_only: set[str] | None = None, capability_only: set[str] | None = None) -> None:
interfaces = integrations | (interface_only or set())
self._capabilities = integrations | (capability_only or set())
self._manifests = (
SimpleNamespace(
provides_interfaces=tuple(SimpleNamespace(name=name) for name in sorted(interfaces)),
),
)
def manifests(self):
return self._manifests
def has_capability(self, name: str) -> bool:
return name in self._capabilities
def _topics(scopes: set[str], integrations: set[str] | None = None, *, documentation_type: str = "user"):
return documentation_topics(
DocumentationContext(
registry=_Registry(integrations or set()),
principal=_Principal(frozenset(scopes)),
documentation_type=documentation_type, # type: ignore[arg-type]
)
)
def test_campaign_runtime_documentation_provider_is_registered() -> None:
from govoplan_campaign.backend.manifest import get_manifest
assert documentation_topics in get_manifest().documentation_providers
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
assert _topics({"docs:documentation:read"}) == ()
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
def test_runtime_documentation_reflects_actor_authority_without_exposing_scopes() -> None:
topic = _topics(
{
"campaigns:campaign:read",
"campaigns:campaign:create",
"campaigns:campaign:update",
"campaigns:campaign:validate",
"campaigns:campaign:build",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
}
)[0]
configuration = topic.metadata["current_configuration"]
assert "Role authorization: Create new campaigns." in configuration
assert "Role authorization: Build exact recipient messages for review." in configuration
assert not any("queue" in item.lower() for item in configuration)
assert not any("campaigns:" in item or "files:" in item or "mail:" in item for item in configuration)
def test_runtime_documentation_requires_both_interface_and_capability() -> None:
integration = "mail.campaign_delivery"
scopes = {"campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"}
for registry in (
_Registry(set(), interface_only={integration}),
_Registry(set(), capability_only={integration}),
):
topic = documentation_topics(
DocumentationContext(registry=registry, principal=_Principal(frozenset(scopes)), documentation_type="user")
)[0]
assert not any("profile picker" in item for item in topic.metadata["current_configuration"])
topic = _topics(scopes, {integration})[0]
assert any("Mail's actor-filtered profile picker" in item for item in topic.metadata["current_configuration"])
def test_mail_delivery_actions_are_not_presented_without_the_mail_contract() -> None:
scopes = {
"campaigns:campaign:read",
"campaigns:campaign:queue",
"campaigns:campaign:send",
"campaigns:campaign:retry",
}
without_mail = _topics(scopes)[0]
with_mail = _topics(scopes, {"mail.campaign_delivery"})[0]
assert not any("Queue an eligible" in item for item in without_mail.metadata["current_configuration"])
assert any("Queue an eligible" in item for item in with_mail.metadata["current_configuration"])
@pytest.mark.parametrize(
("integrations", "scopes", "expected"),
[
(
{"files.campaign_attachments"},
{"campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"},
"Managed campaign attachments can be previewed",
),
(
{"addresses.lookup"},
{"campaigns:campaign:read", "campaigns:recipient:read"},
"Address records can be looked up",
),
(
{"addresses.recipient_source"},
{
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
},
"traceable recipient snapshot",
),
(
{"notifications.dispatch"},
{"campaigns:campaign:read"},
"status changes",
),
],
)
def test_runtime_documentation_reflects_optional_composition_permutations(
integrations: set[str],
scopes: set[str],
expected: str,
) -> None:
topic = _topics(scopes, integrations)[0]
assert any(expected in item for item in topic.metadata["current_configuration"])
def test_runtime_documentation_full_composition_uses_only_user_facing_names() -> None:
integrations = {
"mail.campaign_delivery",
"files.campaign_attachments",
"addresses.lookup",
"addresses.recipient_source",
"notifications.dispatch",
}
topic = _topics({"*:*"}, integrations)[0]
rendered = "\n".join(
(
topic.title,
topic.summary,
topic.body,
*topic.metadata["current_configuration"],
*topic.metadata["limitations"],
)
)
assert "Mail's actor-filtered profile picker" in rendered
assert "Managed file versions" in rendered
assert "Address records" in rendered
assert "In-app notifications" in rendered
assert topic.metadata["help_contexts"] == ["campaigns.list", "campaign.overview"]
for technical_name in integrations:
assert technical_name not in rendered
assert "0.1." not in rendered
assert "0.2." not in rendered
assert "hostname" not in rendered.lower()
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],
*,
modules: set[str] | None = None,
capabilities: set[str] | None = None,
) -> set[str]:
installed = modules or {"campaigns"}
available_capabilities = capabilities or set()
principal = _Principal(frozenset(scopes))
visible: set[str] = set()
for topic in CAMPAIGN_USER_DOCUMENTATION:
condition = topic.conditions[0]
if not set(condition.required_modules).issubset(installed):
continue
if not set(condition.required_capabilities).issubset(available_capabilities):
continue
if not all(principal.has(scope) for scope in condition.required_scopes):
continue
visible.add(topic.id)
return visible
def test_campaign_manager_sees_only_authoring_tasks_from_the_static_handbook() -> None:
visible = _visible_static_topics(
{
"campaigns:campaign:read",
"campaigns:campaign:create",
"campaigns:campaign:update",
"campaigns:campaign:copy",
"campaigns:campaign:validate",
"campaigns:campaign:build",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
"campaigns:report:read",
}
)
assert {
"campaigns.workflow.create-campaign",
"campaigns.workflow.create-editable-successor",
"campaigns.workflow.import-recipients",
"campaigns.workflow.view-delivery-report",
}.issubset(visible)
assert "campaigns.workflow.queue-delivery" not in visible
assert "campaigns.workflow.send-small-controlled-run" not in visible
assert "campaigns.workflow.export-delivery-report" not in visible
assert "campaigns.workflow.share-campaign" not in visible
assert "campaigns.workflow.archive-campaign" not in visible
assert "campaigns.workflow.delete-untouched-draft" not in visible
assert "campaigns.workflow.create-campaign" not in _visible_static_topics({"campaigns:campaign:create"})
def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_authority() -> None:
scopes = {
"campaigns:campaign:read",
"campaigns:campaign:queue",
"campaigns:campaign:send",
"campaigns:recipient:read",
"campaigns:report:read",
"mail:profile:use",
}
without_mail = _visible_static_topics(scopes)
with_mail = _visible_static_topics(
scopes,
modules={"campaigns", "mail"},
capabilities={"mail.campaign_delivery"},
)
assert "campaigns.workflow.queue-delivery" not in without_mail
assert "campaigns.workflow.send-small-controlled-run" not in without_mail
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 = {
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:campaign:validate",
"campaigns:recipient:read",
"files:file:read",
"files:file:share",
}
source_scopes = {
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
}
files_visible = _visible_static_topics(
attachment_scopes,
modules={"campaigns", "files"},
capabilities={"files.campaign_attachments"},
)
addresses_visible = _visible_static_topics(
source_scopes,
modules={"campaigns", "addresses"},
capabilities={"addresses.recipient_source"},
)
assert "campaigns.workflow.use-managed-attachments" in files_visible
assert "campaigns.workflow.import-address-source" in addresses_visible
assert "campaigns.workflow.use-managed-attachments" not in _visible_static_topics(attachment_scopes)
assert "campaigns.workflow.import-address-source" not in _visible_static_topics(source_scopes)
def test_report_export_and_lifecycle_tasks_are_independently_permission_gated() -> None:
exporter = _visible_static_topics(
{
"campaigns:campaign:read",
"campaigns:report:read",
"campaigns:report:export",
"campaigns:recipient:read",
"campaigns:recipient:export",
}
)
custodian = _visible_static_topics(
{
"campaigns:campaign:read",
"campaigns:campaign:share",
"campaigns:campaign:archive",
"campaigns:campaign:delete",
}
)
assert "campaigns.workflow.export-delivery-report" in exporter
assert "campaigns.workflow.view-delivery-report" in exporter
assert "campaigns.workflow.share-campaign" in custodian
assert "campaigns.workflow.archive-campaign" in custodian
assert "campaigns.workflow.delete-untouched-draft" in custodian
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
topics = get_manifest().documentation
ids = [topic.id for topic in topics]
rendered_static = "\n".join(
(topic.title + "\n" + topic.summary + "\n" + topic.body).lower()
for topic in CAMPAIGN_USER_DOCUMENTATION
)
known_help_contexts = {
"campaigns.list",
"campaign.overview",
"campaign.settings",
"campaign.fields",
"campaign.template",
"campaign.attachments",
"campaign.recipients",
"campaign.recipient-data",
"campaign.server-settings",
"campaign.global-settings",
"campaign.review-send",
"campaign.report",
"campaign.audit",
"campaign.json",
}
assert len(ids) == len(set(ids))
assert "single resend" not in rendered_static
for topic in CAMPAIGN_USER_DOCUMENTATION:
assert topic.metadata["kind"] == "workflow"
assert topic.metadata["prerequisites"]
assert topic.metadata["steps"]
assert topic.metadata["outcome"]
assert topic.metadata["verification"]
assert set(topic.metadata["help_contexts"]).issubset(known_help_contexts)
existing_user_workflows = {
topic.id: topic
for topic in topics
if topic.id
in {
"campaigns.mail-profile-user-journey",
"campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.complete-review",
"campaigns.workflow.retry-and-reconcile",
}
}
assert set(existing_user_workflows) == {
"campaigns.mail-profile-user-journey",
"campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.complete-review",
"campaigns.workflow.retry-and-reconcile",
}
for topic in existing_user_workflows.values():
assert topic.metadata["help_contexts"]

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_editor_state_for_edit,
)
from govoplan_campaign.backend.schemas import (
CampaignVersionResponse,
CampaignVersionUpdateRequest,
)
@pytest.mark.parametrize(
"editor_state",
[
{"smtp": {"host": "smtp.example.test", "password": "secret"}},
{"transport": {"imap_password": "secret"}},
{
"review_send": {
"build_token": "forged",
"inspection_complete": True,
"reviewed_message_keys": [],
"updated_at": "2026-07-21T00:00:00+00:00",
"updated_by_user_id": "user-1",
}
},
],
)
def test_version_updates_reject_arbitrary_or_server_owned_editor_state(
editor_state: dict[str, object],
) -> None:
with pytest.raises(ValidationError, match="unsupported or transport-owned"):
CampaignVersionUpdateRequest(editor_state=editor_state)
def test_version_response_omits_legacy_secret_bearing_editor_state() -> None:
response = CampaignVersionResponse.model_validate(
{
"id": "version-1",
"campaign_id": "campaign-1",
"version_number": 1,
"schema_version": "1.0",
"editor_state": {
"opt_ins": {"inline_guidance": True},
"smtp": {"host": "smtp.internal.example", "password": "provider-secret"},
},
"created_at": datetime.now(UTC),
"updated_at": datetime.now(UTC),
}
)
assert response.editor_state == {"opt_ins": {"inline_guidance": True}}
assert "provider-secret" not in repr(response)
assert "internal.example" not in repr(response)
def test_review_build_token_is_visible_only_in_operator_diagnostics() -> None:
value = {
"id": "version-1",
"campaign_id": "campaign-1",
"version_number": 1,
"schema_version": "1.0",
"editor_state": {
"review_send": {
"build_token": "internal-build-token",
"inspection_complete": True,
"reviewed_message_keys": ["entry-1"],
"updated_at": "2026-07-21T00:00:00+00:00",
"updated_by_user_id": "reviewer-1",
}
},
"created_at": datetime.now(UTC),
"updated_at": datetime.now(UTC),
}
public = CampaignVersionResponse.model_validate(value)
operator = CampaignVersionResponse.model_validate(
value,
context={"include_diagnostics": True},
)
assert "build_token" not in public.editor_state["review_send"]
assert operator.editor_state["review_send"]["build_token"] == "internal-build-token"
def test_fork_copy_keeps_only_client_owned_bounded_metadata() -> None:
copied = campaign_editor_state_for_edit(
{
"created_from": "minimal_campaign",
"field_overrides": {"department": False},
"review_send": {
"build_token": "build-1",
"inspection_complete": True,
"reviewed_message_keys": ["entry-1"],
"updated_at": "2026-07-21T00:00:00+00:00",
"updated_by_user_id": "reviewer-1",
},
"credentials": {"password": "legacy-secret"},
}
)
assert copied == {
"created_from": "minimal_campaign",
"field_overrides": {"department": False},
}
assert "legacy-secret" not in repr(copied)

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,139 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.sending.execution import (
ExecutionSnapshotError,
create_execution_snapshot,
ensure_execution_snapshot,
)
def _raw_campaign() -> dict[str, object]:
return {
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"server": {"mail_profile_id": "profile-1"},
"recipients": {"from": [{"email": "sender@example.test"}]},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"entries": {"inline": []},
}
def _job() -> SimpleNamespace:
return SimpleNamespace(
id="job-1",
campaign_version_id="version-1",
entry_index=0,
entry_id="entry-1",
recipient_email="recipient@example.test",
subject="Subject",
message_id_header="<message@example.test>",
eml_size_bytes=123,
eml_sha256="eml-digest",
build_status="built",
validation_status="ready",
resolved_recipients={"to": ["recipient@example.test"]},
resolved_attachments=[],
issues_snapshot=[],
)
class _Query:
def __init__(self, jobs: list[SimpleNamespace]):
self.jobs = jobs
def filter(self, *_args):
return self
def order_by(self, *_args):
return self
def all(self):
return list(self.jobs)
class _Session:
def __init__(self, jobs: list[SimpleNamespace]):
self.jobs = jobs
def query(self, _model):
return _Query(self.jobs)
def _snapshotted_version(job: SimpleNamespace):
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
raw_json=_raw_campaign(),
build_summary={"build_token": "build-1", "built_at": "2026-07-21T00:00:00+00:00"},
)
payload, digest = create_execution_snapshot(
version, # type: ignore[arg-type]
mail_profile_id="profile-1",
smtp_transport_revision="smtp-revision",
imap_transport_revision="imap-revision",
delivery=DeliveryConfig(),
jobs=[job], # type: ignore[list-item]
build_summary=version.build_summary,
)
version.execution_snapshot = payload
version.execution_snapshot_hash = digest
return version
def _ensure(session: _Session, version) -> None:
with patch(
"govoplan_campaign.backend.sending.execution.files_integration",
return_value=SimpleNamespace(available=False),
):
ensure_execution_snapshot(session, version) # type: ignore[arg-type]
def test_v5_snapshot_requires_its_persisted_checksum() -> None:
job = _job()
version = _snapshotted_version(job)
version.execution_snapshot_hash = None
with pytest.raises(ExecutionSnapshotError, match="checksum is missing"):
_ensure(_Session([job]), version)
def test_campaign_json_drift_is_rejected_before_delivery() -> None:
job = _job()
version = _snapshotted_version(job)
version.raw_json["template"] = {"subject": "Changed", "text": "Body"}
with pytest.raises(ExecutionSnapshotError, match="Campaign inputs changed"):
_ensure(_Session([job]), version)
def test_post_build_recipient_drift_cannot_redirect_delivery() -> None:
job = _job()
version = _snapshotted_version(job)
job.resolved_recipients = {"to": ["attacker@example.test"]}
with pytest.raises(ExecutionSnapshotError, match="job inputs changed"):
_ensure(_Session([job]), version)
def test_effect_check_verifies_only_the_claimed_job_in_constant_time() -> None:
job = _job()
version = _snapshotted_version(job)
session = SimpleNamespace(
query=lambda *_args: pytest.fail("per-effect validation must not rescan every campaign job")
)
with patch(
"govoplan_campaign.backend.sending.execution.files_integration",
return_value=SimpleNamespace(available=False),
):
ensure_execution_snapshot(
session, # type: ignore[arg-type]
version,
effect_job=job, # type: ignore[arg-type]
)

View File

@@ -0,0 +1,433 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine, text
from govoplan_campaign.backend import router
from govoplan_campaign.backend.db.models import (
CampaignJob,
ImapAppendAttempt,
JobImapStatus,
JobSendStatus,
)
from govoplan_campaign.backend.retention import _apply_eml_retention
from govoplan_campaign.backend.schemas import CampaignResolveOutcomeRequest
from govoplan_campaign.backend.sending.jobs import (
AppendSentResult,
QueueingError,
_claim_job_for_imap_append,
_record_imap_append_success,
append_sent_for_job,
reconcile_job_outcome,
)
@pytest.mark.parametrize(
("imap_status", "expected_status"),
[
(JobImapStatus.APPENDING.value, "append_in_progress"),
(JobImapStatus.OUTCOME_UNKNOWN.value, JobImapStatus.OUTCOME_UNKNOWN.value),
],
)
def test_in_progress_and_unknown_jobs_never_reinvoke_mail_provider(
imap_status: str,
expected_status: str,
) -> None:
job = SimpleNamespace(
id="job-1",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=imap_status,
)
session = SimpleNamespace(get=lambda _model, _id: job)
with (
patch("govoplan_campaign.backend.sending.jobs._imap_attempt_count", return_value=1),
patch("govoplan_campaign.backend.sending.jobs.mail_integration") as mail,
):
result = append_sent_for_job(
session, # type: ignore[arg-type]
job_id="job-1",
)
assert result.status == expected_status
mail.assert_not_called()
def test_imap_claim_is_a_single_atomic_state_transition() -> None:
session = MagicMock()
query = session.query.return_value
query.filter.return_value.update.return_value = 1
job = SimpleNamespace(id="job-1")
claim_token = _claim_job_for_imap_append(
session,
job, # type: ignore[arg-type]
)
assert claim_token
changes = query.filter.return_value.update.call_args.args[0]
assert changes[CampaignJob.imap_status] == JobImapStatus.APPENDING.value
assert changes[CampaignJob.imap_claim_token] == claim_token
session.commit.assert_called_once_with()
session.expire_all.assert_called_once_with()
def test_late_provider_acknowledgement_cannot_overwrite_a_changed_claim() -> None:
session = MagicMock()
session.query.return_value.filter.return_value.update.return_value = 0
job = SimpleNamespace(id="job-1")
attempt = SimpleNamespace(id="attempt-1", attempt_number=1)
expected = AppendSentResult(
job_id="job-1",
status=JobImapStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._imap_result_after_lost_claim",
return_value=expected,
) as lost_claim,
patch("govoplan_campaign.backend.sending.jobs.files_integration") as files,
):
result = _record_imap_append_success(
session,
job=job, # type: ignore[arg-type]
attempt=attempt, # type: ignore[arg-type]
claim_token="claim-1",
folder="Sent",
)
assert result is expected
lost_claim.assert_called_once_with(
session,
job_id="job-1",
attempt=attempt,
provider_succeeded=True,
)
files.assert_not_called()
def test_post_provider_persistence_failure_freezes_imap_retry() -> None:
job = SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=JobImapStatus.PENDING.value,
)
version = SimpleNamespace(id="version-1")
snapshot = SimpleNamespace(
mail_profile_id="profile-1",
smtp_transport_revision="smtp-revision",
imap_transport_revision="imap-revision",
delivery=SimpleNamespace(
imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"),
),
)
attempt = SimpleNamespace(id="attempt-1", attempt_number=1)
class Session:
def get(self, model, _object_id):
return version if model.__name__ == "CampaignVersion" else job
class Mail:
def append_campaign_message_to_sent(self, *_args, **_kwargs):
return SimpleNamespace(folder="Sent")
expected = AppendSentResult(
job_id="job-1",
status=JobImapStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
session = Session()
with (
patch("govoplan_campaign.backend.sending.jobs.ensure_execution_snapshot", return_value=snapshot),
patch("govoplan_campaign.backend.sending.jobs._load_eml_bytes_for_job", return_value=b"message"),
patch("govoplan_campaign.backend.sending.jobs._claim_job_for_imap_append", return_value="claim-1"),
patch("govoplan_campaign.backend.sending.jobs._record_imap_attempt_start", return_value=attempt),
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
patch(
"govoplan_campaign.backend.sending.jobs._record_imap_append_success",
side_effect=OSError("database unavailable"),
),
patch(
"govoplan_campaign.backend.sending.jobs._mark_imap_append_outcome_unknown_after_effect",
return_value=expected,
) as mark_unknown,
):
result = append_sent_for_job(
session, # type: ignore[arg-type]
job_id="job-1",
)
assert result is expected
assert "Automatic retry is stopped" in mark_unknown.call_args.kwargs["reason"]
def test_attempt_numbers_are_unique_per_job_in_the_model_contract() -> None:
constraints = {
constraint.name
for constraint in ImapAppendAttempt.__table__.constraints
}
assert "uq_imap_append_attempts_job_attempt" in constraints
def test_imap_claim_migration_preserves_and_renumbers_duplicate_attempts() -> None:
migration = importlib.import_module(
"govoplan_campaign.backend.migrations.versions.3c4d5e6f8192_v019_imap_append_claim"
)
engine = create_engine("sqlite+pysqlite:///:memory:")
with engine.begin() as connection:
connection.execute(
text(
"CREATE TABLE imap_append_attempts ("
"id VARCHAR(36) PRIMARY KEY, job_id VARCHAR(36) NOT NULL, "
"attempt_number INTEGER NOT NULL, created_at DATETIME NOT NULL)"
)
)
connection.execute(
text(
"INSERT INTO imap_append_attempts "
"(id, job_id, attempt_number, created_at) VALUES "
"('a2', 'job-1', 1, '2026-01-02'), "
"('a1', 'job-1', 1, '2026-01-01'), "
"('b1', 'job-2', 7, '2026-01-01')"
)
)
with patch.object(migration.op, "get_bind", return_value=connection):
migration._renumber_attempts()
rows = connection.execute(
text(
"SELECT id, job_id, attempt_number FROM imap_append_attempts "
"ORDER BY job_id, attempt_number"
)
).all()
assert rows == [
("a1", "job-1", 1),
("a2", "job-1", 2),
("b1", "job-2", 1),
]
@pytest.mark.parametrize("decision", ["smtp_accepted", "not_sent", "imap_appended", "imap_not_appended"])
def test_reconciliation_requires_an_evidence_note(decision: str) -> None:
with pytest.raises(ValueError, match="evidence note"):
CampaignResolveOutcomeRequest(
decision=decision, # type: ignore[arg-type]
note=" ",
)
@pytest.mark.parametrize(
("decision", "expected_status", "attempt_status"),
[
(
"imap_appended",
JobImapStatus.APPENDED.value,
"reconciled_imap_appended",
),
(
"imap_not_appended",
JobImapStatus.FAILED.value,
"reconciled_imap_not_appended",
),
],
)
def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryable(
decision: str,
expected_status: str,
attempt_status: str,
) -> None:
campaign = SimpleNamespace(id="campaign-1")
job = SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=JobImapStatus.OUTCOME_UNKNOWN.value,
imap_claimed_at=datetime.now(timezone.utc),
imap_claim_token="claim-1",
last_error="unknown",
)
attempt = SimpleNamespace(
id="attempt-1",
status=JobImapStatus.OUTCOME_UNKNOWN.value,
error_message="unknown",
)
session = MagicMock()
session.get.return_value = job
session.query.return_value.filter.return_value.order_by.return_value.first.return_value = attempt
with (
patch(
"govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant",
return_value=campaign,
),
patch("govoplan_campaign.backend.sending.jobs.files_integration") as files,
):
result = reconcile_job_outcome(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
decision=decision,
note="Mailbox UID evidence checked by operator 42.",
commit=False,
)
assert result["channel"] == "imap"
assert job.imap_status == expected_status
assert job.imap_claimed_at is None
assert job.imap_claim_token is None
assert attempt.status == attempt_status
assert attempt.error_message == "Mailbox UID evidence checked by operator 42."
assert attempt in session.add.call_args_list[0].args
session.flush.assert_called_once_with()
session.commit.assert_not_called()
if decision == "imap_appended":
files().mark_job_attachment_uses_sent.assert_called_once_with(session, job)
else:
files().mark_job_attachment_uses_sent.assert_not_called()
@pytest.mark.parametrize(
"imap_status",
[JobImapStatus.APPENDING.value, JobImapStatus.FAILED.value, JobImapStatus.APPENDED.value],
)
def test_imap_reconciliation_rejects_live_retryable_or_completed_state(imap_status: str) -> None:
campaign = SimpleNamespace(id="campaign-1")
job = SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=imap_status,
)
session = MagicMock()
session.get.return_value = job
with patch(
"govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant",
return_value=campaign,
):
with pytest.raises(QueueingError, match="does not require reconciliation"):
reconcile_job_outcome(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
decision="imap_not_appended",
note="Checked mailbox.",
)
@pytest.mark.parametrize("send_status", [JobSendStatus.CLAIMED.value, JobSendStatus.SENDING.value])
def test_smtp_reconciliation_rejects_a_live_worker_state(send_status: str) -> None:
campaign = SimpleNamespace(id="campaign-1")
job = SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
send_status=send_status,
)
session = MagicMock()
session.get.return_value = job
with patch(
"govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant",
return_value=campaign,
):
with pytest.raises(QueueingError, match="does not require reconciliation"):
reconcile_job_outcome(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
decision="not_sent",
note="Checked provider logs.",
)
@pytest.mark.parametrize(
("decision", "note"),
[
("smtp_accepted", "SMTP provider evidence checked."),
("imap_appended", "Mailbox evidence checked."),
],
)
def test_reconciliation_rolls_back_state_when_audit_fails(
decision: str,
note: str | None,
) -> None:
payload = CampaignResolveOutcomeRequest(decision=decision, note=note) # type: ignore[arg-type]
principal = SimpleNamespace(tenant_id="tenant-1")
session = MagicMock()
def mutate_without_commit(*_args, **kwargs):
assert kwargs["commit"] is False
session.flush()
return {"decision": decision, "job_id": "job-1"}
with (
patch("govoplan_campaign.backend.router._get_campaign_for_principal"),
patch("govoplan_campaign.backend.router._require_permission"),
patch(
"govoplan_campaign.backend.router.reconcile_job_outcome",
side_effect=mutate_without_commit,
),
patch(
"govoplan_campaign.backend.router.audit_from_principal",
side_effect=RuntimeError("audit unavailable"),
),
):
with pytest.raises(RuntimeError, match="audit unavailable"):
router.resolve_campaign_job_outcome(
"campaign-1",
"job-1",
payload,
session=session,
principal=principal, # type: ignore[arg-type]
)
session.commit.assert_not_called()
session.rollback.assert_called_once_with()
def test_retention_preserves_eml_for_unknown_imap_outcome(tmp_path: Path) -> None:
eml_path = tmp_path / "message.eml"
eml_path.write_bytes(b"message")
job = SimpleNamespace(
campaign_id="campaign-1",
updated_at=datetime.now(timezone.utc) - timedelta(days=10),
queue_status="draft",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=JobImapStatus.OUTCOME_UNKNOWN.value,
eml_local_path=str(eml_path),
eml_storage_key=None,
)
query = MagicMock()
query.filter.return_value.order_by.return_value.all.return_value = [job]
session = MagicMock()
session.query.return_value = query
policy = SimpleNamespace(generated_eml_retention_days=1)
result = _apply_eml_retention(
session,
dry_run=False,
now=datetime.now(timezone.utc),
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["skipped_not_final"] == 1
assert eml_path.exists()
session.add.assert_not_called()

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,248 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import call, patch
import pytest
from fastapi import HTTPException
from govoplan_campaign.backend import router
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_boundary_violations,
campaign_mail_profile_id,
)
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError, load_campaign_config_from_json
from govoplan_campaign.backend.persistence.versions import update_campaign_version
from govoplan_campaign.backend.integrations import MailCampaignIntegration
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot, ensure_execution_snapshot
from govoplan_campaign.backend.schemas import CampaignVersionUpdateRequest
def _campaign_json(server: dict[str, object] | None = None) -> dict[str, object]:
return {
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"server": server or {},
"recipients": {"from": [{"email": "sender@example.test"}]},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"entries": {"inline": []},
}
def test_campaign_mail_contract_accepts_only_a_stable_profile_reference() -> None:
raw = _campaign_json({"mail_profile_id": " profile-1 "})
assert_campaign_uses_mail_profile_reference(raw)
assert campaign_mail_profile_id(raw) == "profile-1"
assert campaign_mail_profile_boundary_violations(raw) == ()
def test_mail_profile_documentation_is_classified_for_adaptive_views() -> None:
from govoplan_campaign.backend.manifest import get_manifest
manifest = get_manifest()
topics = {topic.id: topic for topic in manifest.documentation}
workflow = topics["campaigns.mail-profile-user-journey"]
assert workflow.metadata["kind"] == "workflow"
assert workflow.metadata["route"] == "/campaigns/{campaign_id}/mail-settings"
assert workflow.conditions[0].required_scopes == (
"campaigns:campaign:update",
"mail:profile:use",
)
assert workflow.metadata["prerequisites"]
assert workflow.metadata["steps"]
assert workflow.metadata["outcome"]
assert workflow.metadata["verification"]
assert "campaigns.mail-profile-governance" in workflow.metadata["related_topic_ids"]
assert topics["campaigns.mail-profile-governance"].metadata["kind"] == "reference"
assert topics["campaigns.mail-profile-operations"].metadata["kind"] == "reference"
assert topics["campaigns.workflow.prepare-validate-and-build"].metadata["kind"] == "workflow"
assert topics["campaigns.workflow.complete-review"].metadata["kind"] == "workflow"
assert topics["campaigns.workflow.retry-and-reconcile"].metadata["kind"] == "workflow"
assert topics["campaigns.reference.composition-assurance"].metadata["kind"] == "reference"
@pytest.mark.parametrize("legacy_key", ["smtp", "imap", "credentials", "inherit_smtp_credentials", "profile_id"])
def test_campaign_mail_contract_rejects_every_legacy_server_field(legacy_key: str) -> None:
raw = _campaign_json({"mail_profile_id": "profile-1", legacy_key: {}})
with pytest.raises(CampaignMailProfileBoundaryError, match="select an authorized Mail profile"):
assert_campaign_uses_mail_profile_reference(raw)
def test_persisted_schema_rejects_inline_transport_even_without_a_secret() -> None:
with pytest.raises(CampaignSchemaError, match="Additional properties are not allowed"):
validate_against_schema(_campaign_json({"smtp": {"host": "smtp.example.test"}}))
def test_loader_rejects_inline_transport_before_optional_mail_summary() -> None:
integration = SimpleNamespace(campaign_profile_delivery_summary=lambda *_args, **_kwargs: pytest.fail("must not resolve"))
with patch("govoplan_campaign.backend.persistence.campaigns.mail_integration", return_value=integration):
with pytest.raises(CampaignMailProfileBoundaryError, match="remove campaign-local SMTP/IMAP settings"):
load_campaign_config_from_json(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
raw_json=_campaign_json({"smtp": {"password": "secret"}}),
)
def test_loader_uses_only_non_secret_mail_profile_capabilities() -> None:
raw = _campaign_json({"mail_profile_id": "profile-1"})
def summary(_session, **kwargs):
assert kwargs["profile_id"] == "profile-1"
return {
"mail_profile_id": "profile-1",
"smtp_available": True,
"imap_available": False,
"smtp_transport_revision": "opaque-smtp",
"imap_transport_revision": None,
# Even a broken/malicious provider cannot inject extra material into
# Campaign's strict in-memory ServerConfig.
"host": "smtp.example.test",
"password": "secret",
}
integration = SimpleNamespace(campaign_profile_delivery_summary=summary)
with patch("govoplan_campaign.backend.persistence.campaigns.mail_integration", return_value=integration):
config = load_campaign_config_from_json(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
raw_json=raw,
)
assert raw["server"] == {"mail_profile_id": "profile-1"}
assert config.server.mail_profile_id == "profile-1"
assert config.server.profile_capabilities.smtp_available is True
assert config.server.profile_capabilities.imap_available is False
assert "smtp.example.test" not in repr(config.server)
assert "secret" not in repr(config.server)
def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_material() -> None:
raw = _campaign_json({"mail_profile_id": "profile-1"})
version = SimpleNamespace(id="version-1", raw_json=raw)
payload, _digest = create_execution_snapshot(
version, # type: ignore[arg-type]
mail_profile_id="profile-1",
smtp_transport_revision="opaque-smtp-evidence",
imap_transport_revision="opaque-imap-evidence",
delivery=DeliveryConfig(),
)
assert payload["snapshot_version"] == "5"
assert payload["mail_profile_id"] == "profile-1"
assert "smtp" not in payload
assert "imap" not in payload
assert payload["smtp_transport_revision"] == "opaque-smtp-evidence"
assert payload["imap_transport_revision"] == "opaque-imap-evidence"
def test_legacy_execution_snapshot_is_preserved_but_fails_closed() -> None:
version = SimpleNamespace(
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
execution_snapshot={"snapshot_version": "3", "smtp": {"host": "legacy.example.test"}},
execution_snapshot_hash=None,
)
with patch(
"govoplan_campaign.backend.sending.execution.files_integration",
return_value=SimpleNamespace(available=False),
):
with pytest.raises(ExecutionSnapshotError, match="preserved for audit only"):
ensure_execution_snapshot(object(), version) # type: ignore[arg-type]
assert version.execution_snapshot["smtp"]["host"] == "legacy.example.test"
def test_campaign_mail_adapter_does_not_expose_raw_transport_helpers() -> None:
integration = MailCampaignIntegration(SimpleNamespace())
for name in (
"smtp_config_from_profile",
"imap_config_from_profile",
"send_email_bytes",
"send_email_message",
"materialize_campaign_mail_profile_config",
):
assert not hasattr(integration, name)
def test_editing_a_legacy_record_requires_an_explicit_profile_migration() -> None:
legacy_raw = _campaign_json({"smtp": {"host": "smtp.example.test", "password": "secret"}})
version = SimpleNamespace(id="version-1", campaign_id="campaign-1", raw_json=legacy_raw)
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
with (
patch("govoplan_campaign.backend.persistence.versions.get_campaign_version_for_tenant", return_value=version),
patch("govoplan_campaign.backend.persistence.versions._require_campaign", return_value=campaign),
patch("govoplan_campaign.backend.persistence.versions.ensure_current_working_version"),
patch("govoplan_campaign.backend.persistence.versions.is_version_locked", return_value=False),
):
with pytest.raises(CampaignPersistenceError, match="explicitly save the migration"):
update_campaign_version(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
version_id="version-1",
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
)
assert version.raw_json is legacy_raw
assert legacy_raw["server"]["smtp"]["password"] == "secret" # type: ignore[index]
def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
principal = SimpleNamespace(
tenant_id="tenant-1",
user=SimpleNamespace(id="user-1"),
)
campaign = SimpleNamespace(id="campaign-1")
source = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
raw_json={"server": {"mail_profile_id": "profile-1"}},
)
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_require_permission"),
patch.object(router, "_get_version_for_tenant", return_value=source),
patch.object(router, "has_scope", return_value=False),
patch.object(router, "fork_campaign_version_for_edit") as fork,
):
with pytest.raises(HTTPException) as captured:
router.fork_version_for_edit(
"campaign-1",
"version-1",
CampaignVersionUpdateRequest(),
session=object(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
assert captured.value.status_code == 403
fork.assert_not_called()
def test_campaign_wide_effect_authorizes_every_affected_version() -> None:
session = object()
principal = SimpleNamespace(tenant_id="tenant-1")
with patch.object(router, "_require_campaign_profile_use_if_needed") as require_profile:
router._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
session, # type: ignore[arg-type]
principal, # type: ignore[arg-type]
"campaign-1",
{"version-2", "version-1"},
)
assert require_profile.call_args_list == [
call(session, principal, "campaign-1", "version-1"),
call(session, principal, "campaign-1", "version-2"),
]

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,66 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from govoplan_campaign.backend.dev.mock_campaign import (
_MockSendBatch,
_mock_send_steps,
)
class MockCampaignStepTests(unittest.TestCase):
def test_preview_marks_delivery_steps_as_skipped(self):
validation = SimpleNamespace(ok=True)
build = SimpleNamespace(
built_count=2,
queueable_count=2,
needs_review_count=0,
blocked_count=0,
)
steps = _mock_send_steps(
validation_report=validation,
validation_payload={"ok": True},
build_report=build,
send_batch=_MockSendBatch(results=[]),
send=False,
append_sent=True,
)
self.assertEqual(
[step["status"] for step in steps],
["ok", "ok", "skipped", "skipped"],
)
def test_delivery_failures_require_review(self):
validation = SimpleNamespace(ok=False)
build = SimpleNamespace(
built_count=1,
queueable_count=0,
needs_review_count=1,
blocked_count=0,
)
batch = _MockSendBatch(
results=[],
failed_count=1,
imap_failed_count=1,
)
steps = _mock_send_steps(
validation_report=validation,
validation_payload={"ok": False},
build_report=build,
send_batch=batch,
send=True,
append_sent=True,
)
self.assertEqual(
[step["status"] for step in steps],
["needs_review", "needs_review", "needs_review", "needs_review"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -80,6 +80,30 @@ class CampaignPartialValidationTests(unittest.TestCase):
class CampaignSemanticValidationTests(unittest.TestCase):
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {"allow_individual_from": True},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"entries": {
"inline": [
{"id": "ready", "from": [{"email": "sender@example.local"}]},
{"id": "missing"},
]
},
})
report = validate_campaign_config(config)
missing_sender = [issue for issue in report.issues if issue.code == "missing_sender"]
self.assertEqual(1, len(missing_sender))
self.assertEqual("/entries/inline/1/from", missing_sender[0].path)
def test_semantic_validation_reports_zip_delivery_and_external_mapping_issues(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -139,8 +163,7 @@ class CampaignSemanticValidationTests(unittest.TestCase):
self.assertIn("unknown_global_value", codes)
self.assertIn("zip_global_password_value_missing", codes)
self.assertIn("zip_archive_unknown", codes)
self.assertIn("delivery_imap_enabled_without_server_imap", codes)
self.assertIn("missing_smtp_config", codes)
self.assertIn("missing_mail_profile", codes)
self.assertIn("unknown_mapping_target", codes)
self.assertIn("mapping_target_not_overridable", codes)
self.assertIn("mapping_columns_missing", codes)

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

@@ -0,0 +1,175 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from govoplan_campaign.backend import router
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from govoplan_campaign.backend.schemas import ReportEmailRequest
def test_report_email_recipient_schema_normalizes_and_has_safe_attachment_default() -> None:
request = ReportEmailRequest.model_validate({
"to": [" First@Example.test ", "first@example.test", "second@example.test"],
})
assert request.to == ["First@Example.test", "second@example.test"]
assert request.attach_jobs_csv is False
@pytest.mark.parametrize(
"recipients",
[
[],
[f"recipient-{index}@example.test" for index in range(51)],
["a" * 310 + "@example.test"],
["victim@example.test\r\nBcc: attacker@example.test"],
["missing-at.example.test"],
["@example.test"],
["local@"],
["local @example.test"],
["one@two@example.test"],
["victim@example.test,Bcc:attacker"],
["Display<a@example.test>"],
],
)
def test_report_email_recipient_schema_rejects_unsafe_addresses(recipients: list[str]) -> None:
with pytest.raises(ValidationError):
ReportEmailRequest.model_validate({"to": recipients})
def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"], attach_jobs_csv=True)
principal = SimpleNamespace(tenant_id="tenant-1")
with (
patch.object(
router,
"_get_campaign_for_principal",
return_value=SimpleNamespace(id="campaign-1", current_version_id=None),
),
patch.object(
router,
"_require_permission",
side_effect=HTTPException(status_code=403, detail="missing export"),
) as require_permission,
patch.object(router, "send_campaign_report_email") as send_report,
):
with pytest.raises(HTTPException) as captured:
router.email_campaign_report(
"campaign-1",
payload,
session=object(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
assert captured.value.status_code == 403
require_permission.assert_called_once_with(principal, "campaigns:recipient:export")
send_report.assert_not_called()
def test_report_email_requires_permission_to_use_selected_mail_profile() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"])
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",
campaign_id="campaign-1",
raw_json={"server": {"mail_profile_id": "profile-1"}},
)
session = SimpleNamespace(get=lambda _model, _id: version)
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(
router,
"_require_mail_profile_use_if_needed",
side_effect=HTTPException(status_code=403, detail="missing profile use"),
) as require_profile_use,
patch.object(router, "send_campaign_report_email") as send_report,
):
with pytest.raises(HTTPException) as captured:
router.email_campaign_report(
"campaign-1",
payload,
session=session, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
assert captured.value.status_code == 403
require_profile_use.assert_called_once_with(principal, version.raw_json)
send_report.assert_not_called()
def test_unexpected_report_failure_does_not_leak_internal_details() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"])
principal = SimpleNamespace(
tenant_id="tenant-1",
has=lambda scope: scope == "campaigns:recipient:export",
)
with (
patch.object(
router,
"_get_campaign_for_principal",
return_value=SimpleNamespace(id="campaign-1", current_version_id=None),
),
patch.object(
router,
"send_campaign_report_email",
side_effect=RuntimeError("smtp.internal.example provider-secret"),
),
):
with pytest.raises(HTTPException) as captured:
router.email_campaign_report(
"campaign-1",
payload,
session=object(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
assert captured.value.status_code == 500
assert captured.value.detail == "Campaign report email could not be completed."
def test_report_send_fails_closed_until_durable_mail_outbox_exists() -> None:
campaign = SimpleNamespace(
id="campaign-1",
tenant_id="tenant-1",
name="Campaign",
external_id="campaign-1",
)
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
raw_json={"server": {"mail_profile_id": "profile-1"}},
execution_snapshot={"snapshot_version": "5"},
)
config = SimpleNamespace(
server=SimpleNamespace(profile_capabilities=SimpleNamespace(smtp_available=True)),
)
snapshot = SimpleNamespace(smtp_transport_revision="frozen-build-revision")
class Session:
def get(self, _model, _id):
return campaign
with (
patch("govoplan_campaign.backend.reports.emailing._selected_version", return_value=version),
patch("govoplan_campaign.backend.reports.emailing._load_config", return_value=config),
patch("govoplan_campaign.backend.reports.emailing.ensure_execution_snapshot", return_value=snapshot),
patch("govoplan_campaign.backend.reports.emailing.generate_campaign_report") as generate_report,
):
with pytest.raises(CampaignReportEmailError, match="durable, idempotent Mail-owned outbox"):
send_campaign_report_email(
Session(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
to=["recipient@example.test"],
)
generate_report.assert_not_called()

View File

@@ -0,0 +1,292 @@
from __future__ import annotations
import stat
import unittest
from datetime import UTC, datetime
from types import SimpleNamespace
from govoplan_campaign.backend.response_security import public_campaign_payload
from govoplan_campaign.backend.router import (
_job_attempts_payload,
_job_detail_payload,
_job_diagnostics_payload,
)
from govoplan_campaign.backend.schemas import CampaignVersionDetailResponse, CampaignVersionResponse
def _now() -> datetime:
return datetime(2026, 7, 21, 12, 0, tzinfo=UTC)
def _job() -> SimpleNamespace:
return SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=1,
entry_id="recipient-1",
recipient_email="person@example.test",
subject="Subject",
message_id_header="<message@example.test>",
build_status="built",
validation_status="ready",
queue_status="claimed",
send_status="sending",
imap_status="pending",
eml_size_bytes=123,
eml_sha256="sha256",
eml_local_path="/runtime/campaign/job-1.eml",
eml_storage_key="campaign/job-1.eml",
claim_token="job-claim-secret",
attempt_count=1,
last_error="smtp.internal.example /srv/private provider-secret",
queued_at=_now(),
claimed_at=_now(),
smtp_started_at=_now(),
outcome_unknown_at=None,
sent_at=None,
created_at=_now(),
updated_at=_now(),
issues_snapshot=[],
resolved_attachments=[
{
"filename": "public.pdf",
"local_path": "/tmp/materialized/public.pdf",
"storage_key": "private/blob-key",
}
],
resolved_recipients={"to": [{"email": "person@example.test"}]},
)
def _smtp_attempt() -> SimpleNamespace:
return SimpleNamespace(
id="smtp-attempt-1",
attempt_number=1,
status="started",
claim_token="attempt-claim-secret",
smtp_status_code=None,
smtp_response="smtp.internal.example provider-secret",
error_type="/srv/private/ProviderError",
error_message="credential=provider-secret",
started_at=_now(),
finished_at=None,
)
def _imap_attempt() -> SimpleNamespace:
return SimpleNamespace(
id="imap-attempt-1",
attempt_number=1,
status="claimed",
claim_token="imap-claim-secret",
folder="Sent",
error_message="imap.internal.example /srv/private provider-secret",
created_at=_now(),
updated_at=_now(),
)
def test_public_payload_recursively_removes_infrastructure_locators() -> None:
source = {
"campaign_file": "/tmp/campaign.json",
"messages": [
{
"subject": "Public",
"eml_path": "/tmp/message.eml",
"managed": {"storage_bucket": "private", "storage_key": "object"},
}
],
}
result = public_campaign_payload(source)
assert result == {"messages": [{"subject": "Public", "managed": {}}]}
assert source["messages"][0]["eml_path"] == "/tmp/message.eml"
def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> None:
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
version_number=1,
schema_version="1",
source_filename="/srv/govoplan/imports/campaign.json",
source_base_path="/private/import/path",
workflow_state="editing",
current_flow="manual",
current_step=None,
is_complete=False,
editor_state={},
autosaved_at=None,
published_at=None,
locked_at=None,
locked_by_user_id=None,
user_lock_state=None,
user_locked_at=None,
user_locked_by_user_id=None,
created_at=_now(),
updated_at=_now(),
validation_summary={"campaign_file": "/tmp/campaign.json", "ok": True},
build_summary={
"build_token": "internal-build-token",
"smtp_transport_revision": "internal-smtp-revision",
"messages": [{"eml_path": "/tmp/message.eml", "subject": "Public"}],
},
execution_snapshot_hash=None,
execution_snapshot_at=None,
)
payload = CampaignVersionResponse.model_validate(version).model_dump()
assert "source_base_path" not in payload
assert payload["source_filename"] == "campaign.json"
assert payload["validation_summary"] == {"ok": True}
assert payload["build_summary"] == {"messages": [{"subject": "Public"}]}
operator_payload = CampaignVersionResponse.model_validate(
version,
context={"include_diagnostics": True},
).model_dump()
assert operator_payload["build_summary"]["build_token"] == "internal-build-token"
assert operator_payload["build_summary"]["smtp_transport_revision"] == "internal-smtp-revision"
detail = CampaignVersionDetailResponse.model_validate(
SimpleNamespace(
**version.__dict__,
raw_json={
"campaign": {"title": "Public"},
"files": [{"storage_key": "private/object", "filename": "public.pdf"}],
"server": {
"mail_profile_id": "profile-1",
"smtp": {"host": "smtp.example.invalid", "password": "smtp-secret"},
"imap": {"host": "imap.example.invalid", "password": "imap-secret"},
"credentials": {
"smtp": {"username": "sender", "password": "legacy-smtp-secret"},
"imap": {"username": "archive", "password": "legacy-imap-secret"},
},
},
"archives": [{"password": "business-zip-password"}],
"template": {
"source": {
"subject_path": "/srv/govoplan/templates/subject.txt",
"html_path": "templates/body.html",
}
},
"entries": {"source": {"type": "csv", "path": "C:\\imports\\recipients.csv"}},
"attachments": {
"base_path": "/srv/govoplan/attachments",
"base_paths": [
{"name": "Shared", "path": "/mnt/shared/campaign", "source": "/mnt/shared"}
],
"global": [{"base_dir": "/srv/govoplan/attachments/global", "file_filter": "*.pdf"}],
},
},
)
).model_dump()
assert detail["raw_json"] == {
"campaign": {"title": "Public"},
"files": [{"filename": "public.pdf"}],
"server": {"mail_profile_id": "profile-1"},
"archives": [{"password": "business-zip-password"}],
"template": {
"source": {
"subject_path": "subject.txt",
"html_path": "templates/body.html",
}
},
"entries": {"source": {"type": "csv", "path": "recipients.csv"}},
"attachments": {
"base_path": "attachments",
"base_paths": [{"name": "Shared", "path": "campaign", "source": "shared"}],
"global": [{"base_dir": "global", "file_filter": "*.pdf"}],
},
}
def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
job_payload = _job_detail_payload(_job()) # type: ignore[arg-type]
attempts = _job_attempts_payload([_smtp_attempt()], [_imap_attempt()]) # type: ignore[list-item]
assert "eml_local_path" not in job_payload
assert "eml_storage_key" not in job_payload
assert "claimed_at" not in job_payload
assert "smtp_started_at" not in job_payload
assert job_payload["attachments"] == [{"filename": "public.pdf"}]
assert "claim_token" not in attempts["smtp"][0]
assert "claim_token" not in attempts["imap"][0]
assert "smtp_response" not in attempts["smtp"][0]
assert "error_type" not in attempts["smtp"][0]
assert "error_message" not in attempts["smtp"][0]
assert "error_message" not in attempts["imap"][0]
ordinary = repr({"job": job_payload, "attempts": attempts})
assert "internal.example" not in ordinary
assert "/srv/private" not in ordinary
assert "provider-secret" not in ordinary
def test_operator_diagnostics_include_claim_and_storage_details() -> None:
diagnostics = _job_diagnostics_payload( # type: ignore[arg-type, list-item]
_job(),
[_smtp_attempt()],
[_imap_attempt()],
).model_dump()
assert diagnostics["storage"]["eml_local_path"] == "/runtime/campaign/job-1.eml"
assert diagnostics["storage"]["eml_storage_key"] == "campaign/job-1.eml"
assert diagnostics["worker_claim"]["claim_token"] == "job-claim-secret"
assert diagnostics["attempts"]["smtp"][0]["claim_token"] == "attempt-claim-secret"
assert diagnostics["attempts"]["imap"][0]["claim_token"] == "imap-claim-secret"
assert "smtp.internal.example" in diagnostics["attempts"]["smtp"][0]["smtp_response"]
assert "imap.internal.example" in diagnostics["attempts"]["imap"][0]["error_message"]
assert "provider-secret" in diagnostics["worker_claim"]["last_error"]
def test_diagnostics_permission_is_operator_only_by_default() -> None:
from govoplan_campaign.backend.manifest import PERMISSIONS, ROLE_TEMPLATES
permission_scopes = {permission.scope for permission in PERMISSIONS}
role_permissions = {role.slug: set(role.permissions) for role in ROLE_TEMPLATES}
assert "campaigns:diagnostic:read" in permission_scopes
assert "campaigns:diagnostic:read" in role_permissions["campaign_sender"]
assert "campaigns:diagnostic:read" not in role_permissions["campaign_manager"]
assert "campaigns:diagnostic:read" not in role_permissions["campaign_reviewer"]
def test_campaign_snapshot_with_inline_credentials_is_owner_only(tmp_path, monkeypatch) -> None:
from govoplan_campaign.backend.persistence import campaigns as persistence
snapshots = tmp_path / "snapshots"
output = tmp_path / "generated"
monkeypatch.setattr(persistence, "CAMPAIGN_SNAPSHOT_DIR", snapshots)
monkeypatch.setattr(persistence, "BUILD_OUTPUT_DIR", output)
path = persistence._write_campaign_snapshot( # type: ignore[arg-type]
SimpleNamespace(id="version-secret", raw_json={"server": {"smtp": {"password": "secret"}}})
)
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert stat.S_IMODE(snapshots.stat().st_mode) == 0o700
assert stat.S_IMODE(output.stat().st_mode) == 0o700
class ResponseSecurityTests(unittest.TestCase):
def test_public_payload(self) -> None:
test_public_payload_recursively_removes_infrastructure_locators()
def test_version_response(self) -> None:
test_version_response_omits_source_base_path_and_sanitizes_summaries()
def test_ordinary_job_response(self) -> None:
test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics()
def test_operator_diagnostics(self) -> None:
test_operator_diagnostics_include_claim_and_storage_details()
def test_operator_permission(self) -> None:
test_diagnostics_permission_is_operator_only_by_default()
if __name__ == "__main__":
unittest.main()

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

207
tests/test_sending_jobs.py Normal file
View File

@@ -0,0 +1,207 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_campaign.backend.db.models import (
JobBuildStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
)
from govoplan_campaign.backend.capabilities import delivery_tasks_capability
from govoplan_campaign.backend.sending.jobs import (
SendJobResult,
_queue_validation_statuses,
_select_campaign_jobs_for_queue,
_send_claimed_campaign_job,
)
class FakeSession:
def __init__(self) -> None:
self.added: list[object] = []
def add(self, value: object) -> None:
self.added.append(value)
def _job(entry_id: str, **overrides):
values = {
"entry_id": entry_id,
"entry_index": int(entry_id),
"send_status": JobSendStatus.NOT_QUEUED.value,
"queue_status": JobQueueStatus.DRAFT.value,
"validation_status": JobValidationStatus.READY.value,
"build_status": JobBuildStatus.BUILT.value,
"eml_local_path": f"{entry_id}.eml",
"eml_storage_key": None,
"last_error": "old error",
"queued_at": None,
"claimed_at": "claimed",
"claim_token": "token",
"smtp_started_at": "started",
"outcome_unknown_at": "unknown",
}
values.update(overrides)
return SimpleNamespace(**values)
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)
blocked_validation = _job(
"3",
validation_status=JobValidationStatus.WARNING.value,
)
blocked_missing_eml = _job(
"4",
eml_local_path=None,
eml_storage_key=None,
)
ready = _job("5")
reviewed = _job(
"6",
validation_status=JobValidationStatus.NEEDS_REVIEW.value,
)
session = FakeSession()
queued, skipped_count, blocked_count = _select_campaign_jobs_for_queue(
session,
jobs=[
skipped_send,
skipped_queue,
blocked_validation,
blocked_missing_eml,
ready,
reviewed,
],
allowed_validation=_queue_validation_statuses(include_warnings=False),
reviewed_needs_review_keys={"6"},
dry_run=False,
)
self.assertEqual(queued, [ready, reviewed])
self.assertEqual(skipped_count, 2)
self.assertEqual(blocked_count, 2)
self.assertIn("generated EML", blocked_missing_eml.last_error)
self.assertEqual(session.added, [ready, reviewed])
for job in queued:
self.assertEqual(job.queue_status, JobQueueStatus.QUEUED.value)
self.assertEqual(job.send_status, JobSendStatus.QUEUED.value)
self.assertIsNotNone(job.queued_at)
self.assertIsNone(job.claimed_at)
self.assertIsNone(job.claim_token)
self.assertIsNone(job.smtp_started_at)
self.assertIsNone(job.outcome_unknown_at)
self.assertIsNone(job.last_error)
def test_dry_run_does_not_mutate_queueable_job(self):
warning = _job(
"1",
validation_status=JobValidationStatus.WARNING.value,
)
session = FakeSession()
queued, skipped_count, blocked_count = _select_campaign_jobs_for_queue(
session,
jobs=[warning],
allowed_validation=_queue_validation_statuses(include_warnings=True),
reviewed_needs_review_keys=set(),
dry_run=True,
)
self.assertEqual(queued, [warning])
self.assertEqual((skipped_count, blocked_count), (0, 0))
self.assertEqual(warning.queue_status, JobQueueStatus.DRAFT.value)
self.assertEqual(warning.send_status, JobSendStatus.NOT_QUEUED.value)
self.assertEqual(session.added, [])
def test_post_smtp_persistence_failure_is_outcome_unknown_not_retryable_failure(self):
job = SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
imap_status="not_requested",
resolved_recipients={"from": {"email": "sender@example.test"}},
)
snapshot = SimpleNamespace(
mail_profile_id="profile-1",
smtp_transport_revision="frozen",
delivery=SimpleNamespace(
rate_limit=SimpleNamespace(messages_per_minute=60),
),
)
context = SimpleNamespace(
snapshot=snapshot,
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
)
current = SimpleNamespace(id="job-1")
class Session:
def __init__(self) -> None:
self.rolled_back = False
def rollback(self) -> None:
self.rolled_back = True
def get(self, _model, _id):
return current
class Mail:
def wait_for_rate_limit(self, **_kwargs):
return None
def send_campaign_email_bytes(self, *_args, **_kwargs):
return SimpleNamespace(accepted_count=1)
session = Session()
expected = SendJobResult(
job_id="job-1",
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
with (
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
patch("govoplan_campaign.backend.sending.jobs._record_attempt_start", return_value=object()),
patch(
"govoplan_campaign.backend.sending.jobs._record_smtp_send_success",
side_effect=OSError("storage unavailable"),
),
patch(
"govoplan_campaign.backend.sending.jobs.mark_job_outcome_unknown",
return_value=expected,
) as mark_unknown,
):
result = _send_claimed_campaign_job(
session, # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=context, # type: ignore[arg-type]
use_rate_limit=False,
enqueue_imap_task=False,
)
self.assertIs(result, expected)
self.assertTrue(session.rolled_back)
self.assertIn("Automatic retry is stopped", mark_unknown.call_args.kwargs["reason"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,405 @@
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,
) as queue,
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
assert queue.call_args.kwargs["commit_queue"] is False
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 persist.call_args.kwargs["commit"] is True
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()
def test_rejected_synchronous_preflight_rolls_back_staged_queue_before_audit() -> None:
session = Mock()
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
version = SimpleNamespace(
id="version-1",
raw_json={},
locked_at=object(),
validation_summary={"ok": True},
build_summary={"built_count": 1},
)
rejection = SynchronousSendRejected(
"Preflight rejected the staged send.",
reason="batch_preflight_failed",
eligible_count=1,
)
with (
patch.object(router, "_get_campaign_for_principal"),
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", side_effect=rejection),
patch.object(router, "audit_from_principal") as audit,
pytest.raises(HTTPException) as rejected,
):
router.send_campaign_now_endpoint(
"campaign-1",
session=session,
principal=_Principal(
"campaigns:campaign:send", "campaigns:recipient:read"
), # type: ignore[arg-type]
)
assert rejected.value.status_code == 422
session.rollback.assert_called_once_with()
audit.assert_called_once()
assert audit.call_args.kwargs["action"] == "campaign.send_now_rejected"
assert audit.call_args.kwargs["commit"] is True

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.attachments.resolver import resolve_entry_attachments
from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders,
normalize_template_key,
render_template,
)
class CampaignTemplateRenderingTests(unittest.TestCase):
def test_template_key_aliases_have_shared_normalization(self) -> None:
cases = {
" name ": "name",
"fields.name": "name",
"local.name": "local::name",
"local:name": "local::name",
"local::name": "local::name",
"global.name": "global::name",
"global:name": "global::name",
"global::name": "global::name",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(normalize_template_key(raw), expected)
def test_rendering_preserves_both_syntaxes_none_and_missing_values(self) -> None:
values = {
"name": "Ada",
"local::reference": 42,
"global::empty": None,
}
template = "${fields.name}|{{ local.reference }}|${global:empty}|${missing}|{{ global.absent }}"
self.assertEqual(
render_template(template, values),
"Ada|42||${missing}|{{ global.absent }}",
)
self.assertEqual(render_template(template, values, keep_missing=False), "Ada|42|||")
def test_rendering_unescapes_literal_dollar_placeholders(self) -> None:
self.assertEqual(
render_template(r"\${literal\}|${known}", {"known": "resolved"}, keep_missing=False),
"${literal}|resolved",
)
def test_unresolved_placeholders_are_normalized_and_deduplicated(self) -> None:
unresolved = find_unresolved_placeholders(
r"${fields.missing} {{ local:name }} ${global.other} {{local::name}} \${escaped\}"
)
self.assertEqual(unresolved, {"missing", "local::name", "global::other"})
self.assertEqual(find_unresolved_placeholders(None), set())
def test_attachment_resolution_keeps_missing_placeholders(self) -> None:
config = CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {"id": "template-parity", "name": "Template parity", "mode": "test"},
"template": {"subject": "Subject", "text": "Body"},
"attachments": {
"base_path": ".",
"global": [
{
"base_dir": ".",
"file_filter": "${missing}.pdf",
"required": False,
"missing_behavior": "continue",
}
],
},
"entries": {
"inline": [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.test", "type": "to"}],
}
]
},
}
)
with tempfile.TemporaryDirectory() as temp_dir:
resolution = resolve_entry_attachments(
config=config,
campaign_file=Path(temp_dir) / "campaign.json",
entry=config.entries.inline[0], # type: ignore[index]
entry_index=1,
)
self.assertEqual(resolution.attachments[0].file_filter, "${missing}.pdf")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.8",
"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.8",
"@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,12 @@
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,
ResourceAccessExplanationUser as AccessExplanationUser,
ResourceAccessExplanationResponse
} from "@govoplan/core-webui";
export type CampaignListResponse =
CampaignListItem[] |
@@ -22,27 +29,6 @@ export type CampaignShare = {
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
export type AccessExplanationUser = {
id: string;
account_id?: string | null;
email?: string | null;
display_name?: string | null;
};
export type AccessDecisionProvenanceItem = {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details?: Record<string, unknown>;
};
export type ResourceAccessExplanationResponse = {
user: AccessExplanationUser;
resource_type: string;
resource_id: string;
action: string;
provenance: AccessDecisionProvenanceItem[];
};
export type CampaignUpdatePayload = {
external_id?: string | null;
@@ -91,11 +77,14 @@ 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 & {
raw_json: Record<string, unknown>;
campaign_json?: Record<string, unknown>;
mail_profile_migration_required?: boolean;
};
export type CampaignWorkspaceResponse = {
@@ -224,6 +213,7 @@ export type CampaignVersionUpdatePayload = {
editor_state?: Record<string, unknown> | null;
source_filename?: string | null;
source_base_path?: string | null;
migrate_legacy_mail_settings?: boolean;
};
export type CampaignPartialValidationPayload = {
@@ -252,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>;
@@ -284,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;
@@ -399,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>[];
@@ -454,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");
@@ -464,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;} = {})
@@ -796,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}`);
}
@@ -814,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}`);
}
@@ -847,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,
@@ -909,7 +961,7 @@ export async function resolveCampaignJobOutcome(
settings: ApiSettings,
campaignId: string,
jobId: string,
decision: "smtp_accepted" | "not_sent",
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended",
note?: string)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
@@ -941,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,
@@ -995,20 +1056,6 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
}
export function fetchResourceAccessExplanation(
settings: ApiSettings,
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
: Promise<ResourceAccessExplanationResponse> {
const params = new URLSearchParams({
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action
});
if (options.tenantId) params.set("tenant_id", options.tenantId);
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
}
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
return response.shares;

View File

@@ -2,17 +2,10 @@ import type {
ApiSettings,
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapTestPayload,
MailProfilePolicy,
MailProfilePolicyResponse,
MailProfileScope,
MailSecurity,
MailServerProfile,
MailServerProfilePayload,
MailSmtpTestPayload,
MockMailboxMessageResponse
} from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
import { apiFetch, apiGetList, apiPost } from "./client";
const profileActionEndpoints = {
smtp: "test-smtp",
@@ -20,12 +13,6 @@ const profileActionEndpoints = {
folders: "list-imap-folders"
} as const;
const rawSettingsEndpoints = {
smtp: "/api/v1/mail/test-smtp",
imap: "/api/v1/mail/test-imap",
folders: "/api/v1/mail/list-imap-folders"
} as const;
function runProfileAction<TResponse>(
settings: ApiSettings,
profileId: string,
@@ -37,14 +24,6 @@ function runProfileAction<TResponse>(
);
}
function runRawSettingsAction<TResponse, TPayload>(
settings: ApiSettings,
payload: TPayload,
action: keyof typeof rawSettingsEndpoints
): Promise<TResponse> {
return apiPostJson<TResponse>(settings, rawSettingsEndpoints[action], payload);
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
include_inactive: includeInactive ? true : undefined,
@@ -52,25 +31,6 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact
});
}
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function getMailProfilePolicy(
settings: ApiSettings,
scopeType: MailProfileScope,
scopeId?: string | null,
campaignId?: string | null
): Promise<MailProfilePolicyResponse> {
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
scope_id: scopeId,
campaign_id: campaignId
}));
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
}
@@ -83,22 +43,8 @@ export async function listMailProfileImapFolders(settings: ApiSettings, profileI
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
}
export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise<MailConnectionTestResponse> {
return runRawSettingsAction<MailConnectionTestResponse, MailSmtpTestPayload>(settings, payload, "smtp");
}
export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailConnectionTestResponse> {
return runRawSettingsAction<MailConnectionTestResponse, MailImapTestPayload>(settings, payload, "imap");
}
export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailImapFolderListResponse> {
return runRawSettingsAction<MailImapFolderListResponse, MailImapTestPayload>(settings, payload, "folders");
}
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
}
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
export type { MailConnectionTestResponse, MailCredentialPolicy, MailImapFolderListResponse, MailImapFolderResponse, MailImapTestPayload, MailProfilePatternKey, MailProfilePatternRules, MailProfilePolicy, MailProfilePolicyLimitKey, MailProfilePolicyLimitPermissions, MailProfilePolicyResponse, MailProfileScope, MailSecurity, MailServerProfile, MailServerProfileCredentialsPayload, MailServerProfileListResponse, MailServerProfilePayload, MailSmtpTestPayload, MailTransportCredentialsPayload, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";
export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui";
export type { MailConnectionTestResponse, MailImapFolderListResponse, MailImapFolderResponse, MailServerProfile, MailServerProfileListResponse, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";

View File

@@ -1,4 +0,0 @@
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
export { DataGridEmptyAction, DataGridRowActions };
export default DataGrid;

View File

@@ -12,7 +12,7 @@ import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";

View File

@@ -13,7 +13,7 @@ import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor";
import FieldValueInput from "./components/FieldValueInput";
import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {

View File

@@ -8,8 +8,8 @@ import { Button } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
@@ -126,15 +126,13 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
width: 70,
sticky: "end",
align: "right",
render: (campaign) =>
<Link
to={`/campaigns/${campaign.id}`}
className="btn btn-primary admin-icon-button"
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}>
<ExternalLink aria-hidden="true" />
</Link>
render: (campaign) => <TableActionGroup actions={[{
id: "open",
label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id }),
icon: <ExternalLink aria-hidden="true" />,
variant: "primary",
onClick: () => navigate(`/campaigns/${campaign.id}`)
}]} />
}];

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, LockKeyhole } from "lucide-react";
import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
@@ -10,8 +10,8 @@ import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import {
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
@@ -40,6 +40,7 @@ type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const navigate = useGuardedNavigate();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const campaign = data.campaign;
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
@@ -204,17 +205,17 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
</div>
<div className="summary-grid overview-summary-grid">
<SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
<div className="metric-grid inside">
<MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
</div>
<div className="admin-table-surface version-history-table-surface">
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
@@ -296,7 +297,7 @@ function textValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
return [
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
@@ -307,36 +308,18 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 260,
width: 150,
sticky: "end",
render: (version) => {
const isCurrent = version.id === currentVersionId;
return (
<div className="button-row compact-actions">
<Link
to={`send?version=${version.id}`}
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}>
<ExternalLink aria-hidden="true" />
</Link>
{isCurrent && (isTemporaryUserLockedVersion(version) ?
<>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>i18n:govoplan-campaign.lock_permanently.cc0ce9e7</Button>
</> :
!isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ?
<Button
className="admin-icon-button"
onClick={() => setPendingLockAction({ version, action: "temporary" })}
aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
<LockKeyhole aria-hidden="true" />
</Button> :
null)}
</div>);
const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version);
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
return <TableActionGroup actions={[
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) },
{ id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
{ id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
{ id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) }
]} />;
}
}];
@@ -392,12 +375,3 @@ function lockDialogLabel(pending: PendingLockAction): string {
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "i18n:govoplan-campaign.confirm.04a21221";
}
function SummaryTile({ label, value }: {label: string;value: string | number;}) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</div>);
}

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, RotateCcw, Search, X } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
downloadCampaignJobsCsv,
emailCampaignReport,
getCampaignJobDetail,
getCampaignJobsDelta,
getCampaignJobs,
resolveCampaignJobOutcome,
retryCampaignJobs,
sendCampaignJob,
@@ -15,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 "../../components/table/DataGrid";
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, 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",
@@ -37,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);
@@ -59,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("");
@@ -80,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, {
versionId: version?.id,
page,
pageSize: 50,
cursor: pageCursor,
sendStatus: sendStatus ? [sendStatus] : undefined,
imapStatus: imapStatus ? [imapStatus] : undefined,
query: appliedQuery || undefined,
since: nextWatermark
});
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);
const response = await getCampaignJobs(settings, campaignId, {
versionId: version?.id,
page,
pageSize,
query: appliedQuery || undefined,
sortBy: campaignJobSortColumn(appliedJobGridQuery.sort?.columnId),
sortDirection: appliedJobGridQuery.sort?.direction ?? "asc",
filters: appliedJobGridQuery.filters
});
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()]);
}
@@ -326,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",
@@ -350,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 ?? "—")
},
@@ -358,18 +382,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 250,
width: 190,
sticky: "end",
render: (row) => {
const id = String(row.id ?? "");
const status = String(row.send_status ?? "");
return (
<div className="button-row compact-actions">
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
</div>);
return <TableActionGroup actions={[
{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
{ id: "retry", label: busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now", icon: <RotateCcw aria-hidden="true" />, applicable: retryableFailedStatus(status), disabled: !id || Boolean(busyAction), onClick: () => void retryFailedSynchronously([row]) },
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "smtp_accepted" }) },
{ id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "not_sent" }) }
]} />;
}
}],
@@ -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>
@@ -473,8 +523,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
</label>
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label>
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label>
<ToggleSwitch label="i18n:govoplan-campaign.attach_job_csv.adb76197" checked={attachCsv} onChange={setAttachCsv} />
<ToggleSwitch label="i18n:govoplan-campaign.attach_json_report.d70883b5" checked={attachJson} onChange={setAttachJson} />
</Dialog>
<Dialog
@@ -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 ?? []} />
@@ -526,43 +576,71 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
}
const columns: DataGridColumn<Record<string, unknown>>[] = [
{ id: "attempt", header: "#", width: 72, sortable: true, value: (row, index) => Number(row.attempt_number ?? index + 1), render: (row, index) => String(row.attempt_number ?? index + 1) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
kind === "imap" ?
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? "")}>{String(row.smtp_response ?? row.error_message ?? "—")}</span> }
];
return (
<section className="attempt-history-section">
<h3>{title}</h3>
<div className="attempt-history-wrap">
<table className="attempt-history-table">
<thead>
<tr>
<th>#</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
<th>i18n:govoplan-campaign.finished.355bcc57</th>
<th>i18n:govoplan-campaign.result.5faa59d4</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) =>
<tr key={String(row.id ?? `${kind}-${index}`)}>
<td>{String(row.attempt_number ?? index + 1)}</td>
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
{String(row.smtp_response ?? row.error_message ?? "—")}
</td>
</tr>
)}
</tbody>
</table>
</div>
<DataGrid id={`campaign-${kind}-attempt-history`} rows={rows} columns={columns} getRowKey={(row, index) => String(row.id ?? `${kind}-${index}`)} />
</section>);
}
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

@@ -13,7 +13,7 @@ import CampaignAccessCard from "./components/CampaignAccessCard";
import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui";
import { hasScope } from "@govoplan/core-webui";
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
import { RetentionPolicyEditor } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";

View File

@@ -1,35 +1,35 @@
import { useEffect, useMemo, useState } from "react";
import { MailServerFolderLookupResultView, MailServerSettingsPanel, ToggleSwitch, addressesFromValue, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTransportCredentialsPayloadFromRecords, usePlatformModuleInstalled, usePlatformUiCapability, type MailProfilesUiCapability, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import { useEffect, useState } from "react";
import {
Button,
Card,
DismissibleAlert,
FormField,
LoadingFrame,
MailServerFolderLookupResultView,
MetricCard,
PageTitle,
ToggleSwitch,
usePlatformModuleInstalled,
usePlatformUiCapability,
type MailProfilesUiCapability,
type MailServerConnectionTestResult,
type MailServerFolderLookupResult
} from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import {
createMailServerProfile,
getMailProfilePolicy,
listImapFolders,
listMailProfileImapFolders,
listMailServerProfiles,
testImapSettings,
testMailProfileImap,
testMailProfileSmtp,
testSmtpSettings,
type MailProfilePolicy,
type MailSecurity,
type MailServerProfile } from
"../../api/mail";
type MailServerProfile
} from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getNumber, getText } from "./utils/draftEditor";
import { campaignMailSettingsPolicyState } from "./policyUi";
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool, getText } from "./utils/draftEditor";
import { campaignMailProfileReferenceOnly } from "./utils/mailProfileReference";
type MailSettingsView = "settings" | "policy";
@@ -41,24 +41,22 @@ type MailSettingsPageProps = {
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
const mailModuleInstalled = usePlatformModuleInstalled("mail");
const isPolicyView = view === "policy";
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const MailProfilePolicyEditor = mailProfilesUi?.MailProfilePolicyEditor ?? null;
const isPolicyView = view === "policy";
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
const [profilesLoading, setProfilesLoading] = useState(false);
const [profileError, setProfileError] = useState("");
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
const [effectiveMailPolicy, setEffectiveMailPolicy] = useState<MailProfilePolicy | null>(null);
const [profilesLoading, setProfilesLoading] = useState(false);
const [profileName, setProfileName] = useState("");
const [profileMessage, setProfileMessage] = useState("");
const [profileError, setProfileError] = useState("");
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const migrationRequired = version?.mail_profile_migration_required === true;
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, discardDraft, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
@@ -68,76 +66,29 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
setError,
currentStep: isPolicyView ? "mail-policy" : "mail-settings",
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_mail_policy_changes.c9327491" : "i18n:govoplan-campaign.unsaved_mail_settings.38e1536b",
unsavedMessage: isPolicyView ?
"i18n:govoplan-campaign.mail_policy_changes_have_unsaved_draft_changes_s.5aee7d4e" :
"i18n:govoplan-campaign.mail_settings_have_unsaved_changes_save_them_bef.52644559",
transformDraftBeforeSave: normalizeMailSettingsBeforeSave
unsavedMessage: isPolicyView
? "i18n:govoplan-campaign.mail_policy_changes_have_unsaved_draft_changes_s.5aee7d4e"
: "i18n:govoplan-campaign.mail_settings_have_unsaved_changes_save_them_bef.52644559",
transformDraftBeforeSave: campaignMailProfileReferenceOnly,
extraPayload: () => ({
migrate_legacy_mail_settings: migrationRequired && Boolean(selectedProfileId)
})
});
const server = asRecord(displayDraft.server);
const smtp = asRecord(server.smtp);
const imap = asRecord(server.imap);
const credentials = asRecord(server.credentials);
const smtpCredentials = asRecord(credentials.smtp);
const imapCredentials = asRecord(credentials.imap);
const selectedProfileId = getText(server, "mail_profile_id");
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
const delivery = asRecord(displayDraft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
const selectedProfileId = mailModuleInstalled ? getText(server, "mail_profile_id") : "";
const selectedProfile = mailModuleInstalled ? mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null : null;
const usingMailProfile = mailModuleInstalled && Boolean(selectedProfileId);
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
const imapUnavailable = usingMailProfile && !selectedProfileHasImap;
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
const effectiveMailPolicyForState: MailProfilePolicy | null = mailModuleInstalled ? effectiveMailPolicy : { allow_campaign_profiles: true };
const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicyForState, selectedProfileId, locked });
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || usingMailProfile && smtpCredentialsInherited;
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile;
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || usingMailProfile && imapCredentialsInherited;
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
const imapAppendEnabled = getBool(imapAppend, "enabled");
const imapAppendFolder = getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"));
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
const inlinePolicyMessages = useMemo(() => {
const validateMailPolicy = mailProfilesUi?.validateMailPolicy;
if (!mailModuleInstalled || usingMailProfile || !validateMailPolicy || !effectiveMailPolicy) return [];
const recipients = asRecord(displayDraft.recipients);
const fromEmail = firstAddressEmail(recipients.from);
return validateMailPolicy(effectiveMailPolicy, {
smtpHost: getText(smtp, "host"),
imapHost: getText(imap, "host"),
envelopeSender: fromEmail || getText(smtpCredentials, "username", getText(smtp, "username")),
fromHeader: fromEmail,
recipientDomains: collectRecipientDomains(displayDraft)
});
}, [displayDraft, effectiveMailPolicy, imap, mailModuleInstalled, mailProfilesUi, smtp, smtpCredentials, usingMailProfile]);
const displayedSmtp = {
host: usingMailProfile && selectedProfile ? stringOrEmpty(selectedProfile.smtp.host) : getText(smtp, "host"),
port: usingMailProfile && selectedProfile ? selectedProfile.smtp.port ?? 587 : getNumber(smtp, "port", 587),
username: usingMailProfile && smtpCredentialsInherited ? profileUsername(selectedProfile, "smtp") : getText(smtpCredentials, "username", getText(smtp, "username")),
password: usingMailProfile && smtpCredentialsInherited ? "" : getText(smtpCredentials, "password", getText(smtp, "password")),
security: usingMailProfile && selectedProfile ? selectedProfile.smtp.security ?? "starttls" : getText(smtp, "security", "starttls"),
timeout_seconds: usingMailProfile && selectedProfile ? selectedProfile.smtp.timeout_seconds ?? 30 : getNumber(smtp, "timeout_seconds", 30)
};
const displayedImap = {
host: usingMailProfile && selectedProfile?.imap ? stringOrEmpty(selectedProfile.imap.host) : getText(imap, "host"),
port: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.port ?? 993 : getNumber(imap, "port", 993),
username: usingMailProfile && imapCredentialsInherited ? profileUsername(selectedProfile, "imap") : getText(imapCredentials, "username", getText(imap, "username")),
password: usingMailProfile && imapCredentialsInherited ? "" : getText(imapCredentials, "password", getText(imap, "password")),
security: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.security ?? "tls" : getText(imap, "security", "tls"),
sent_folder: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.sent_folder ?? "auto" : getText(imap, "sent_folder", "auto"),
timeout_seconds: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.timeout_seconds ?? 30 : getNumber(imap, "timeout_seconds", 30)
};
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || selectedProfileHasImap && !imapCredentialsInherited);
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId));
useEffect(() => {
if (!mailModuleInstalled) {
setMailProfiles([]);
setPolicyProfiles([]);
setEffectiveMailPolicy({ allow_campaign_profiles: true });
setProfileError("");
setProfilesLoading(false);
return;
}
@@ -149,230 +100,55 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
setProfilesLoading(true);
setProfileError("");
try {
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
listMailServerProfiles(settings, false, campaignId),
listMailServerProfiles(settings, true),
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)]
);
const [allowedProfiles, visibleProfiles] = await Promise.all([
listMailServerProfiles(settings, false, campaignId),
listMailServerProfiles(settings, true)
]);
setMailProfiles(allowedProfiles);
setPolicyProfiles(visibleProfiles);
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
} catch (err) {
setMailProfiles([]);
setPolicyProfiles([]);
setEffectiveMailPolicy(null);
setProfileError(err instanceof Error ? err.message : String(err));
} finally {
setProfilesLoading(false);
}
}
function normalizeMailSettingsBeforeSave(value: Record<string, unknown>): Record<string, unknown> {
if (mailModuleInstalled === false) return value;
const next = cloneJson(value);
const nextServer = { ...asRecord(next.server) };
const profileId = getText(nextServer, "mail_profile_id");
if (profileId.length === 0) return next;
const nextCredentials = { ...asRecord(nextServer.credentials) };
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "smtp", effectiveMailPolicy?.smtp_credentials?.inherit === false ? false : true);
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "imap", effectiveMailPolicy?.imap_credentials?.inherit === false ? false : true);
nextServer.credentials = nextCredentials;
next.server = nextServer;
return next;
}
function normalizeProfileCredentialProtocol(
serverValue: Record<string, unknown>,
credentialsValue: Record<string, unknown>,
protocol: "smtp" | "imap",
inherit: boolean)
{
serverValue["inherit_" + protocol + "_credentials"] = inherit;
if (inherit === false) return;
const transport = { ...asRecord(serverValue[protocol]) };
delete transport.username;
delete transport.password;
serverValue[protocol] = transport;
credentialsValue[protocol] = {};
}
function selectMailProfile(profileId: string) {
if (!mailModuleInstalled || locked) return;
if (!profileId && !campaignProfilesAllowed) {
setProfileError(mailPolicyState.inlineBlockedMessage);
return;
}
patch(["server", "mail_profile_id"], profileId);
setProfileMessage("");
patch(["server"], profileId ? { mail_profile_id: profileId } : {});
setSmtpTestResult(null);
setImapTestResult(null);
setFolderResult(null);
setProfileError("");
if (profileId) {
patch(["server", "smtp"], {});
patch(["server", "imap"], {});
patch(["server", "credentials"], {});
setSmtpTestResult(null);
setImapTestResult(null);
setFolderResult(null);
}
}
async function saveCurrentSettingsAsProfile() {
if (!mailModuleInstalled || locked || usingMailProfile || !campaignProfilesAllowed) return;
setProfilesLoading(true);
setProfileMessage("");
setProfileError("");
try {
const created = await createMailServerProfile(settings, {
name: profileName.trim() || `${data.campaign?.name || "Campaign"} mail profile`,
scope_type: "campaign",
scope_id: campaignId,
smtp: smtpServerPayload(),
imap: hasInlineImapSettings() ? imapServerPayload() : null,
credentials: mailProfileCredentialsPayload(false),
is_active: true
});
setMailProfiles((current) => [...current.filter((profile) => profile.id !== created.id), created].sort((a, b) => a.name.localeCompare(b.name)));
patch(["server", "mail_profile_id"], created.id);
patch(["server", "smtp"], {});
patch(["server", "imap"], {});
patch(["server", "credentials"], {});
setProfileName("");
setProfileMessage(`Saved profile ${created.name}.`);
} catch (err) {
setProfileError(err instanceof Error ? err.message : String(err));
} finally {
setProfilesLoading(false);
}
}
function patchSmtpSettings(patchValue: Partial<MailServerSmtpSettings>) {
if (patchValue.host !== undefined) patch(["server", "smtp", "host"], String(patchValue.host ?? ""));
if (patchValue.port !== undefined) patch(["server", "smtp", "port"], mailNumberOrNull(patchValue.port));
if (patchValue.security !== undefined) patch(["server", "smtp", "security"], String(patchValue.security || "starttls"));
if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
}
function patchImapSettings(patchValue: Partial<MailServerImapSettings>) {
if (patchValue.host !== undefined) patch(["server", "imap", "host"], String(patchValue.host ?? ""));
if (patchValue.port !== undefined) patch(["server", "imap", "port"], mailNumberOrNull(patchValue.port));
if (patchValue.security !== undefined) patch(["server", "imap", "security"], String(patchValue.security || "tls"));
if (patchValue.sent_folder !== undefined) patch(["server", "imap", "sent_folder"], String(patchValue.sent_folder ?? ""));
if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
}
function patchSmtpCredentials(patchValue: Partial<MailServerCredentialSettings>) {
if (patchValue.username !== undefined) patch(["server", "credentials", "smtp", "username"], String(patchValue.username ?? ""));
if (patchValue.password !== undefined) patch(["server", "credentials", "smtp", "password"], String(patchValue.password ?? ""));
}
function patchImapCredentials(patchValue: Partial<MailServerCredentialSettings>) {
if (patchValue.username !== undefined) patch(["server", "credentials", "imap", "username"], String(patchValue.username ?? ""));
if (patchValue.password !== undefined) patch(["server", "credentials", "imap", "password"], String(patchValue.password ?? ""));
}
function profileScopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "system") return "system";
if (profile.scope_type === "tenant") return "tenant";
if (profile.scope_type === "user") return "user";
if (profile.scope_type === "group") return "group";
return "campaign";
}
function smtpServerPayload() {
return mailSmtpSettingsPayload<MailSecurity>(
{ host: getText(smtp, "host"), port: getNumber(smtp, "port", 587), security: getText(smtp, "security", "starttls"), timeout_seconds: getNumber(smtp, "timeout_seconds", 30) },
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions }
);
}
function imapServerPayload() {
return mailImapSettingsPayload<MailSecurity>(
{ host: getText(imap, "host"), port: getNumber(imap, "port", 993), security: getText(imap, "security", "tls"), sent_folder: getText(imap, "sent_folder", "auto"), timeout_seconds: getNumber(imap, "timeout_seconds", 30) },
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
);
}
function mailProfileCredentialsPayload(preserveBlankPassword: boolean) {
return {
smtp: mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, preserveBlankPassword),
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword)
};
}
function rawSmtpPayload() {
const serverPayload = selectedProfile && !smtpCredentialsInherited ? selectedProfile.smtp : smtpServerPayload();
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, false) };
}
function rawImapPayload() {
const serverPayload = selectedProfile?.imap && !imapCredentialsInherited ? selectedProfile.imap : imapServerPayload();
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, false) };
}
function hasInlineImapSettings(): boolean {
return hasMailImapSettings([getText(imap, "host"), getText(imapCredentials, "username", getText(imap, "username")), getText(imapCredentials, "password", getText(imap, "password"))]);
}
function profileUsername(profile: MailServerProfile | null, protocol: "smtp" | "imap"): string {
if (!profile) return "";
if (protocol === "smtp") return stringOrEmpty(profile.credentials?.smtp?.username ?? profile.smtp.username);
return stringOrEmpty(profile.credentials?.imap?.username ?? profile.imap?.username);
}
async function runSmtpTest() {
if (!mailModuleInstalled) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_smtp_.a7ce04e1", details: {} });
return;
}
if (locked || inlineMailSettingsBlocked) return;
setMailActionState("smtp");
async function runProfileTest(protocol: "smtp" | "imap") {
if (!selectedProfileId || locked) return;
setMailActionState(protocol);
setLocalError("");
try {
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited ?
await testMailProfileSmtp(settings, selectedProfileId) :
await testSmtpSettings(settings, rawSmtpPayload()));
if (protocol === "smtp") {
setSmtpTestResult(await testMailProfileSmtp(settings, selectedProfileId));
} else {
setImapTestResult(await testMailProfileImap(settings, selectedProfileId));
}
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runImapTest() {
if (!mailModuleInstalled) {
setImapTestResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_imap_.d6537dfd", details: {} });
return;
}
if (imapDisabled) return;
setMailActionState("imap");
setLocalError("");
try {
setImapTestResult(selectedProfileId && imapCredentialsInherited ?
await testMailProfileImap(settings, selectedProfileId) :
await testImapSettings(settings, rawImapPayload()));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
const result = { ok: false, protocol, message: err instanceof Error ? err.message : String(err), details: {} };
if (protocol === "smtp") setSmtpTestResult(result);
else setImapTestResult(result);
} finally {
setMailActionState(null);
}
}
async function runFolderLookup() {
if (!mailModuleInstalled) {
setFolderResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_inspect_im.52535774", folders: [], details: {} });
return;
}
if (appendTargetFolderDisabled) return;
if (!selectedProfileId || locked || !selectedProfileHasImap) return;
setMailActionState("folders");
setLocalError("");
try {
setFolderResult(selectedProfileId && imapCredentialsInherited ?
await listMailProfileImapFolders(settings, selectedProfileId) :
await listImapFolders(settings, rawImapPayload()));
setFolderResult(await listMailProfileImapFolders(settings, selectedProfileId));
} catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally {
@@ -382,8 +158,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || appendTargetFolderDisabled) return;
patch(["delivery", "imap_append_sent", "folder"], folder);
if (folder && !locked) patch(["delivery", "imap_append_sent", "folder"], folder);
}
return (
@@ -394,8 +169,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
{!isPolicyView && <Button variant="primary" onClick={() => void saveDraft("manual")} disabled={!canSave}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
<Button onClick={() => void discardDraft()} disabled={loading}>i18n:govoplan-campaign.discard.36fff63c</Button>
</div>
</div>
@@ -405,14 +180,13 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
{!mailModuleInstalled &&
<DismissibleAlert tone="info" dismissible={false}>
i18n:govoplan-campaign.the_mail_module_is_not_installed_inline_smtp_and.8e0f802e
</DismissibleAlert>
}
{!mailModuleInstalled && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.install_and_enable_the_mail_module_to_select_a_d.01c75fc4</DismissibleAlert>}
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor &&
<MailProfilePolicyEditor
{migrationRequired && <DismissibleAlert tone="warning" dismissible={false}>
i18n:govoplan-campaign.this_version_contains_legacy_campaign_local_mail.44c7a6fd
</DismissibleAlert>}
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && <MailProfilePolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
@@ -423,144 +197,72 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
canWrite={!locked}
locked={locked}
title="i18n:govoplan-campaign.campaign_local_mail_policy.94f59da0"
description="i18n:govoplan-campaign.campaign_local_mail_limits_applied_after_system_.e7f9bb31"
onSaved={refreshMailProfiles} />
description="i18n:govoplan-campaign.mail_policy_limits_which_mail_owned_profiles_thi.824b1b2e"
onSaved={refreshMailProfiles} />}
}
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_mail_module_did_not_expose_profile_managemen.2cdb57e1</DismissibleAlert>}
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor &&
<DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_mail_module_did_not_expose_profile_managemen.2cdb57e1</DismissibleAlert>
}
{!isPolicyView && mailModuleInstalled &&
<Card
{!isPolicyView && mailModuleInstalled && <Card
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
actions={
<div className="button-row compact-actions">
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save_current_settings_as_profile.7578a50b"}</Button>
</div>
}>
actions={<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>}>
<p className="muted small-note">i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809</p>
<div className="form-grid compact responsive-form-grid">
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>i18n:govoplan-campaign.inline_smtp_imap_settings.cf16c421</option>
<option value="">i18n:govoplan-campaign.select_a_mail_profile.76480af0</option>
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-campaign.new_profile_name.393313b6">
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? i18nMessage("i18n:govoplan-campaign.value_campaign_local_profile.70cd9d43", { value0: data.campaign.name }) : "i18n:govoplan-campaign.campaign_local_profile.c24cf2d1"} />
</FormField>
</div>
{!campaignProfilesAllowed && <p className="muted small-note">i18n:govoplan-campaign.campaign_local_mail_settings_are_blocked_by_the_.0b2510aa</p>}
{selectedProfile &&
<p className="muted small-note">i18n:govoplan-campaign.using.c25de2e8 {selectedProfile.name} ({profileScopeLabel(selectedProfile)}i18n:govoplan-campaign.smtp_credentials.10f75c8a {smtpCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c"}i18n:govoplan-campaign.imap_credentials.7442b238 {selectedProfile.imap ? imapCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c" : "i18n:govoplan-campaign.not_configured.67f2141f"}.</p>
}
{selectedProfileNeedsLocalCredentials &&
<p className="muted small-note">i18n:govoplan-campaign.the_selected_profile_supplies_the_server_setting.bdc830db</p>
}
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>}
</Card>
}
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
{selectedProfile && <div className="metric-grid inside">
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
<MetricCard label="i18n:govoplan-campaign.scope.4651a34e" value={profileScopeLabel(selectedProfile)} />
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value="i18n:govoplan-campaign.configured.668c5fff" tone="good" />
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedProfile.imap ? "i18n:govoplan-campaign.configured.668c5fff" : "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedProfile.imap ? "good" : "neutral"} />
</div>}
<div className="button-row compact-actions">
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedProfile || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
<Button onClick={() => void runProfileTest("imap")} disabled={!selectedProfileHasImap || locked || Boolean(mailActionState)}>{mailActionState === "imap" ? "i18n:govoplan-campaign.testing_imap.13d255cf" : "i18n:govoplan-campaign.test_profile_imap.e1cec0e0"}</Button>
</div>
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
{imapTestResult && <DismissibleAlert tone={imapTestResult.ok ? "success" : "danger"} resetKey={`${imapTestResult.protocol}:${imapTestResult.message}`} floating>{imapTestResult.message}</DismissibleAlert>}
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
</Card>}
{!isPolicyView &&
<Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
{!isPolicyView && <Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
<ToggleSwitch
label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8"
checked={imapAppendEnabled}
disabled={imapDisabled}
disabled={locked || !selectedProfileHasImap}
onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help="i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9">
<div className="field-with-action mail-server-folder-field">
<input
value={imapAppendFolder}
disabled={appendTargetFolderDisabled}
onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)}
placeholder="i18n:govoplan-core.auto.0d612c12" />
<Button type="button" variant="primary" onClick={() => void runFolderLookup()} disabled={appendTargetFolderDisabled || mailActionState === "folders"}>
value={getText(imapAppend, "folder", "auto")}
disabled={locked || !imapAppendEnabled || !selectedProfileHasImap}
onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)} />
<Button type="button" variant="primary" onClick={() => void runFolderLookup()} disabled={locked || !imapAppendEnabled || !selectedProfileHasImap || Boolean(mailActionState)}>
{mailActionState === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : "i18n:govoplan-core.folders.c603ab65"}
</Button>
</div>
</FormField>
<MailServerFolderLookupResultView result={folderResult} disabled={appendTargetFolderDisabled} onUseDetected={useDetectedSentFolder} floatingFailures />
<MailServerFolderLookupResultView result={folderResult} disabled={locked || !imapAppendEnabled} onUseDetected={useDetectedSentFolder} floatingFailures />
</div>
</Card>
}
{!isPolicyView &&
<Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
{inlinePolicyMessages.length > 0 &&
<DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
<strong>i18n:govoplan-campaign.effective_mail_policy_blocks_the_current_inline_.99c34c6b</strong>
<ul>{inlinePolicyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
</DismissibleAlert>
}
<MailServerSettingsPanel
smtp={displayedSmtp}
imap={displayedImap}
onSmtpChange={patchSmtpSettings}
onImapChange={patchImapSettings}
smtpCredentials={{ username: displayedSmtp.username, password: displayedSmtp.password }}
imapCredentials={{ username: displayedImap.username, password: displayedImap.password }}
onSmtpCredentialsChange={patchSmtpCredentials}
onImapCredentialsChange={patchImapCredentials}
smtpDisabled={smtpDisabled}
smtpCredentialDisabled={smtpCredentialDisabled}
smtpPasswordSaved={Boolean(usingMailProfile && smtpCredentialsInherited && selectedProfile?.smtp_password_configured)}
smtpActionDisabled={locked || inlineMailSettingsBlocked || !mailModuleInstalled}
imapServerDisabled={imapServerDisabled}
imapCredentialDisabled={imapCredentialDisabled}
imapPasswordSaved={Boolean(usingMailProfile && imapCredentialsInherited && selectedProfile?.imap_password_configured)}
imapActionDisabled={imapDisabled || !mailModuleInstalled}
smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
busyAction={mailActionState}
onTestSmtp={runSmtpTest}
onTestImap={runImapTest}
smtpTestResult={smtpTestResult}
imapTestResult={imapTestResult}
floatingResults />
</Card>
}
</Card>}
</>
</LoadingFrame>
</div>);
</div>
);
}
function stringOrEmpty(value: unknown): string {
return value === null || value === undefined ? "" : String(value);
}
function firstAddressEmail(value: unknown): string {
return addressesFromValue(value)[0]?.email ?? "";
}
function collectRecipientDomains(draft: Record<string, unknown>): string[] {
const domains = new Set<string>();
const recipients = asRecord(draft.recipients);
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, recipients[key]);
const entries = asArray(asRecord(draft.entries).inline).map(asRecord);
for (const entry of entries) {
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, entry[key]);
addAddressDomains(domains, entry.recipient);
addAddressDomains(domains, entry);
}
return [...domains].sort();
}
function addAddressDomains(domains: Set<string>, value: unknown) {
for (const address of addressesFromValue(value)) {
const domain = address.email.split("@").pop()?.trim().toLowerCase();
if (domain) domains.add(domain);
}
function profileScopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "system") return "i18n:govoplan-campaign.system.bc0792d8";
if (profile.scope_type === "tenant") return "i18n:govoplan-campaign.tenant.3ca93c78";
if (profile.scope_type === "user") return "i18n:govoplan-campaign.user.9f8a2389";
if (profile.scope_type === "group") return "i18n:govoplan-campaign.group.171a0606";
return "i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505";
}

View File

@@ -21,7 +21,8 @@ import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { SegmentedControl } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { TableActionGroup } from "@govoplan/core-webui";
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -582,24 +583,10 @@ function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, on
<span>{translateText(emptyText)}</span>
}
</div>
<Button
type="button"
className="admin-icon-button recipient-address-inline-button"
disabled={disabled}
aria-label="Edit addresses"
title="Edit addresses"
onClick={onEdit}>
<Pencil aria-hidden="true" />
</Button>
<Button
type="button"
className="admin-icon-button recipient-address-inline-button"
disabled={!copiedText}
aria-label="Copy addresses"
title="Copy addresses"
onClick={onCopy}>
<Copy aria-hidden="true" />
</Button>
<TableActionGroup actions={[
{ id: "edit", label: "Edit addresses", icon: <Pencil aria-hidden="true" />, disabled, onClick: onEdit },
{ id: "copy", label: "Copy addresses", icon: <Copy aria-hidden="true" />, applicable: Boolean(copiedText), onClick: onCopy }
]} />
</div>
</div>);
}
@@ -783,18 +770,17 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
{column.allowMultiple && draftAddresses.length === 0 &&
<div className="recipient-address-empty-row">
<p className="muted recipient-address-empty">{column.emptyText}</p>
<div className="data-grid-row-actions data-grid-empty-row-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedAddLabel}
title={translatedAddLabel}
disabled={!canAdd}
onClick={() => addAddress()}>
<Plus size={16} aria-hidden="true" />
</Button>
</div>
<TableActionGroup
className="data-grid-empty-row-actions"
actions={[{
id: "add",
label: translatedAddLabel,
icon: <Plus size={16} aria-hidden="true" />,
variant: "primary",
disabled: !canAdd,
onClick: () => addAddress()
}]}
/>
</div>
}
{draftAddresses.map((address, addressIndex) =>
@@ -813,48 +799,15 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
aria-label={`${column.label} email ${addressIndex + 1}`}
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
{column.allowMultiple &&
<div className="data-grid-row-actions recipient-address-line-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedAddLabel}
title={translatedAddLabel}
disabled={!canAdd}
onClick={() => addAddress(addressIndex)}>
<Plus size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel}
title={translatedMoveUpLabel}
disabled={locked || addressIndex === 0}
onClick={() => moveAddress(addressIndex, addressIndex - 1)}>
<ArrowUp size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel}
title={translatedMoveDownLabel}
disabled={locked || addressIndex >= draftAddresses.length - 1}
onClick={() => moveAddress(addressIndex, addressIndex + 1)}>
<ArrowDown size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="danger"
className="data-grid-row-action is-remove"
aria-label={translatedRemoveLabel}
title={translatedRemoveLabel}
disabled={locked}
onClick={() => removeAddress(addressIndex)}>
<Trash2 size={16} aria-hidden="true" />
</Button>
</div>
<TableActionGroup
className="recipient-address-line-actions"
actions={[
{ id: "add", label: translatedAddLabel, icon: <Plus size={16} aria-hidden="true" />, variant: "primary", disabled: !canAdd, onClick: () => addAddress(addressIndex) },
{ id: "move-up", label: translatedMoveUpLabel, icon: <ArrowUp size={16} aria-hidden="true" />, disabled: locked || addressIndex === 0, onClick: () => moveAddress(addressIndex, addressIndex - 1) },
{ id: "move-down", label: translatedMoveDownLabel, icon: <ArrowDown size={16} aria-hidden="true" />, disabled: locked || addressIndex >= draftAddresses.length - 1, onClick: () => moveAddress(addressIndex, addressIndex + 1) },
{ id: "remove", label: translatedRemoveLabel, icon: <Trash2 size={16} aria-hidden="true" />, variant: "danger", disabled: locked, onClick: () => removeAddress(addressIndex) }
]}
/>
}
</div>
)}
@@ -1080,34 +1033,25 @@ function AddressSourceImportDialog({ settings, campaignId, sources, initialSourc
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
</dl>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Email</th>
<th>Fields</th>
</tr>
</thead>
<tbody>
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
<tr key={`${recipient.contact_id}-${recipient.email}`}>
<td>{index + 1}</td>
<td>{recipient.display_name}</td>
<td>{recipient.email}</td>
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
</tr>
)}
</tbody>
</table>
</div>
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
</>
}
</Dialog>);
}
type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number];
function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) {
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
{ id: "row", header: "#", width: 64, value: (_recipient, index) => index + 1, render: (_recipient, index) => index + 1 },
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.display_name },
{ id: "email", header: "Email", width: "minmax(220px, 1.2fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.email },
{ id: "fields", header: "Fields", width: 100, sortable: true, value: (recipient) => Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length }
];
return <DataGrid id="campaign-address-source-import-preview" rows={recipients} columns={columns} getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`} />;
}
type RecipientImportDialogProps = {
settings: ApiSettings;
campaignId: string;
@@ -1451,6 +1395,30 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
replaceColumnMapping(nextMapping);
}
const mappingRows = (table?.headers ?? []).map((header, columnIndex) => ({
id: `${columnIndex}-${header}`,
header,
columnIndex,
mapping: mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const }
}));
type MappingRow = (typeof mappingRows)[number];
const mappingColumns: DataGridColumn<MappingRow>[] = [
{ id: "column", header: "i18n:govoplan-campaign.column.65ba00e9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.header, render: (row) => <strong>{row.header}</strong> },
{ id: "sample", header: "i18n:govoplan-campaign.sample.58fabfa7", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (row) => table ? sampleColumnValue(table.dataRows, row.columnIndex) : "", render: (row) => <span className="mono-small">{table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""}</span> },
{ id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => <select value={row.mapping.kind} onChange={(event) => changeColumnKind(row.columnIndex, event.target.value as RecipientColumnKind)}>{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select> },
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(200px, .9fr)", minWidth: 180, resizable: true, value: (row) => row.mapping.fieldName ?? row.mapping.newFieldName ?? "", render: (row) => <>{row.mapping.kind === "field" && <select value={row.mapping.fieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, fieldName: event.target.value, newFieldName: undefined })}><option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}</select>}{row.mapping.kind === "new_field" && <input value={row.mapping.newFieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />}</> }
];
type PreviewRow = RecipientImportPreview["rows"][number];
const previewColumns: DataGridColumn<PreviewRow>[] = [
{ id: "row", header: "#", width: 68, sortable: true, value: (row) => row.rowNumber },
{ id: "to", header: "i18n:govoplan-campaign.to.ae79ea1e", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => formatAddressList(row.addresses.to) },
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.name },
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 100, sortable: true, value: (row) => Object.keys(row.fields).length },
{ id: "patterns", header: "i18n:govoplan-campaign.patterns.4d34f7a2", width: 110, sortable: true, value: (row) => row.patterns.length },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552", render: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552" }
];
const stepContent = activeStep === "upload" ?
<>
<div className="campaign-header-grid recipient-import-upload-grid">
@@ -1569,51 +1537,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
</select>
</FormField>
</div>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-mapping-table">
<thead>
<tr>
<th>i18n:govoplan-campaign.column.65ba00e9</th>
<th>i18n:govoplan-campaign.sample.58fabfa7</th>
<th>i18n:govoplan-campaign.content.4f9be057</th>
<th>i18n:govoplan-campaign.field.c326a466</th>
</tr>
</thead>
<tbody>
{table?.headers.map((header, columnIndex) => {
const mapping = mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const };
return (
<tr key={`${columnIndex}-${header}`}>
<td><strong>{header}</strong></td>
<td className="mono-small">{sampleColumnValue(table.dataRows, columnIndex)}</td>
<td>
<select value={mapping.kind} onChange={(event) => changeColumnKind(columnIndex, event.target.value as RecipientColumnKind)}>
{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</td>
<td>
{mapping.kind === "field" &&
<select
value={mapping.fieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, fieldName: event.target.value, newFieldName: undefined })}>
<option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>
{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
}
{mapping.kind === "new_field" &&
<input
value={mapping.newFieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, newFieldName: event.target.value, fieldName: undefined })} />
}
</td>
</tr>);
})}
</tbody>
</table>
</div>
<DataGrid id="campaign-recipient-import-mapping" rows={mappingRows} columns={mappingColumns} getRowKey={(row) => row.id} />
</> :
activeStep === "preview" ?
<>
@@ -1628,32 +1552,14 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
{preview.fieldNamesToCreate.length > 0 &&
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>i18n:govoplan-campaign.to.ae79ea1e</th>
<th>i18n:govoplan-campaign.name.709a2322</th>
<th>i18n:govoplan-campaign.fields.e8b68527</th>
<th>i18n:govoplan-campaign.patterns.4d34f7a2</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{preview.rows.slice(0, 20).map((row) =>
<tr key={row.rowNumber} className={row.issues.length ? "is-invalid" : undefined}>
<td>{row.rowNumber}</td>
<td>{formatAddressList(row.addresses.to)}</td>
<td>{row.name}</td>
<td>{Object.keys(row.fields).length}</td>
<td>{row.patterns.length}</td>
<td>{row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552"}</td>
</tr>
)}
</tbody>
</table>
</div>
<DataGrid
id="campaign-recipient-import-preview"
className="recipient-import-preview-grid"
rows={preview.rows.slice(0, 20)}
columns={previewColumns}
getRowKey={(row) => String(row.rowNumber)}
rowClassName={(row) => row.issues.length ? "is-invalid" : undefined}
/>
</>
}
</> :
@@ -1743,6 +1649,14 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
}
type ResolvedFile = ImportFileLinkResolution["files"][number];
const fileColumns: DataGridColumn<ResolvedFile>[] = [
{ id: "file", header: "i18n:govoplan-campaign.file.2c3cafa4", width: "minmax(200px, .8fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (file) => file.filename, render: (file) => <strong>{file.filename}</strong> },
{ id: "path", header: "i18n:govoplan-campaign.path.519e3913", width: "minmax(260px, 1.3fr)", minWidth: 220, resizable: true, sortable: true, filterable: true, value: (file) => file.display_path },
{ id: "size", header: "i18n:govoplan-campaign.size.b7152342", width: 120, sortable: true, value: (file) => file.size_bytes, render: (file) => formatImportBytes(file.size_bytes) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (file) => linkableIds.has(file.id) ? "needs-linking" : "linked", render: (file) => linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600" }
];
return (
<div className="recipient-import-file-step">
<div className="recipient-import-file-actions">
@@ -1781,30 +1695,13 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
</div>
}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-file-link-table">
<thead>
<tr>
<th>i18n:govoplan-campaign.file.2c3cafa4</th>
<th>i18n:govoplan-campaign.path.519e3913</th>
<th>i18n:govoplan-campaign.size.b7152342</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{resolution.files.length === 0 ?
<tr><td colSpan={4}>i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b</td></tr> :
resolution.files.map((file) =>
<tr key={file.id}>
<td><strong>{file.filename}</strong></td>
<td>{file.display_path}</td>
<td>{formatImportBytes(file.size_bytes)}</td>
<td>{linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600"}</td>
</tr>
)}
</tbody>
</table>
</div>
<DataGrid
id="campaign-recipient-import-file-links"
rows={resolution.files}
columns={fileColumns}
getRowKey={(file) => file.id}
emptyText="i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b"
/>
</>
}
</div>);
@@ -1820,27 +1717,31 @@ type RecipientImportRawTableProps = {
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
const visibleRows = tableRows.slice(0, 15);
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
const rows = visibleRows.map((cells, rowIndex) => ({ rowIndex, cells }));
type RawRow = (typeof rows)[number];
const columns: DataGridColumn<RawRow>[] = [
{ id: "row", header: "#", width: 64, sortable: true, value: (row) => row.rowIndex + 1 },
...Array.from({ length: safeColumnCount }, (_value, columnIndex): DataGridColumn<RawRow> => ({
id: `column-${columnIndex + 1}`,
header: String(columnIndex + 1),
width: "minmax(140px, 1fr)",
minWidth: 120,
resizable: true,
filterable: true,
value: (row) => row.cells[columnIndex] ?? ""
}))
];
return (
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-raw-table">
<thead>
<tr>
<th>#</th>
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)}
</tr>
</thead>
<tbody>
{visibleRows.length === 0 ?
<tr><td colSpan={safeColumnCount + 1}>i18n:govoplan-campaign.no_rows_parsed.a7ccc3de</td></tr> :
visibleRows.map((row, rowIndex) =>
<tr key={rowIndex} className={rowIndex < headerRowCount ? "is-header-row" : undefined}>
<td>{rowIndex + 1}</td>
{Array.from({ length: safeColumnCount }, (_value, columnIndex) => <td key={columnIndex}>{row[columnIndex] ?? ""}</td>)}
</tr>
)}
</tbody>
</table>
</div>);
<DataGrid
id="campaign-recipient-import-raw"
className="recipient-import-raw-grid"
rows={rows}
columns={columns}
getRowKey={(row) => String(row.rowIndex)}
rowClassName={(row) => row.rowIndex < headerRowCount ? "is-header-row" : undefined}
emptyText="i18n:govoplan-campaign.no_rows_parsed.a7ccc3de"
initialFit="content"
/>);
}

View File

@@ -7,6 +7,7 @@ import {
Link2,
LockKeyhole,
PackageCheck,
Search,
Send,
ShieldCheck,
X,
@@ -16,28 +17,35 @@ 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 DataGrid, { type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "../../components/table/DataGrid";
import { DismissibleAlert } 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";
import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
@@ -62,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";
@@ -96,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)",
@@ -120,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();
@@ -133,14 +142,15 @@ 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 smtpServer = asRecord(server.smtp);
const imapServer = asRecord(server.imap);
const serverCredentials = asRecord(server.credentials);
const smtpCredentials = asRecord(serverCredentials.smtp);
const imapCredentials = asRecord(serverCredentials.imap);
const selectedMailProfileId = getText(server, "mail_profile_id", getText(server, "profile_id"));
const selectedMailProfileId = getText(server, "mail_profile_id");
const inlineEntries = useMemo(
() => asArray(asRecord(campaignJson.entries).inline).map(asRecord),
[campaignJson]
@@ -181,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());
@@ -204,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]);
@@ -238,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);
@@ -246,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;
@@ -259,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"]);
@@ -276,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 &&
@@ -329,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]);
@@ -397,7 +448,9 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
const imapAppendResultRows = asArray(imapAppendResult?.results).map(asRecord);
const imapDiagnosticRows = imapDiagnostics.jobs.map(asRecord);
const imapDiagnosticsPending = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "pending").length;
const imapDiagnosticsFailed = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "failed").length;
const imapDiagnosticsFailed = imapDiagnosticRows.filter((job) =>
["failed", "outcome_unknown"].includes(String(job.imap_status ?? "").toLowerCase()),
).length;
const imapPendingForDisplay = Math.max(imapPending, imapDiagnosticsPending);
const imapFailedForDisplay = Math.max(imapFailed, imapDiagnosticsFailed);
const canAppendPendingImap = Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && !historicalVersion && !userLockedVersion;
@@ -459,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" :
@@ -697,6 +750,12 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
reviewJobsRef.current = mergedResult;
setReviewJobs(mergedResult);
setBuiltReviewRows(jobs);
setReviewedMessageKeys(new Set(
jobs
.filter((row) => row.reviewed === true)
.map((row, index) => builtMessageKey(row, index))
));
setMessageReviewComplete(result.review?.inspection_complete === true);
setReviewPage(1);
setJobsLoadedKey(reviewKey);
if (!silent) {
@@ -760,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 ?
@@ -793,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));
@@ -1031,13 +1180,12 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]);
const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
const smtpConfigured = Boolean(selectedMailProfileId || getText(smtpServer, "host") || getText(smtpCredentials, "username"));
const imapConfigured = Boolean(selectedMailProfileId || getText(imapServer, "host") || getText(imapCredentials, "username"));
const mailProfileSelected = Boolean(selectedMailProfileId);
const deliverabilityPreflightItems: DeliverabilityPreflightItem[] = [
{
label: "Transport",
detail: smtpConfigured ? selectedMailProfileId ? "Reusable mail profile selected." : "Inline SMTP settings are present." : "Select a reusable mail profile or configure SMTP before live delivery.",
state: smtpConfigured ? "ready" : "blocked"
detail: mailProfileSelected ? "i18n:govoplan-campaign.mail_owned_delivery_profile_selected.4f44778e" : "i18n:govoplan-campaign.select_an_authorized_mail_profile_before_live_de.45c80a42",
state: mailProfileSelected ? "ready" : "blocked"
},
{
label: "Policy",
@@ -1059,10 +1207,31 @@ 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) ? imapConfigured ? `IMAP append enabled for ${String(imapAppend.folder ?? "auto")}.` : "IMAP append is enabled, but no IMAP server/profile is visible." : "IMAP append is disabled for this campaign.",
state: Boolean(imapAppend.enabled) ? imapConfigured ? "ready" : "blocked" : "info"
detail: Boolean(imapAppend.enabled) ? mailProfileSelected ? i18nMessage("i18n:govoplan-campaign.imap_append_requested_for_value0_validation_chec.51527a20", { value0: String(imapAppend.folder ?? "auto") }) : "i18n:govoplan-campaign.imap_append_is_enabled_but_no_mail_profile_is_se.cf50419e" : "i18n:govoplan-campaign.imap_append_is_disabled_for_this_campaign.7757f7f1",
state: Boolean(imapAppend.enabled) ? mailProfileSelected ? "ready" : "blocked" : "info"
}];
const canCompleteInspection = blockingReviewCount === 0 &&
reviewRequiredCount > 0 &&
@@ -1300,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} />
@@ -1324,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" :
@@ -1336,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>
@@ -1358,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} />
@@ -1368,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>
}
@@ -1473,15 +1706,43 @@ 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}
@@ -1940,7 +2201,7 @@ reviewedKeys: Set<string>)
},
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => openMessage(row)}>i18n:govoplan-campaign.review.e29a79fe</Button> }];
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
}
@@ -2132,7 +2393,7 @@ function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): Da
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>i18n:govoplan-campaign.details.dc3decbb</Button> }];
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
}
@@ -2146,7 +2407,7 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")}${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openMessage(String(row.id ?? ""))}>i18n:govoplan-campaign.review.e29a79fe</Button> }];
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => void openMessage(String(row.id ?? "")) }]} /> }];
}
@@ -2345,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

@@ -4,7 +4,7 @@ import type { ApiSettings } from "../../../types";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
import { KeyRound } from "lucide-react";
import { KeyRound, Trash2 } from "lucide-react";
import {
fetchResourceAccessExplanation,
getCampaignShares,
@@ -12,13 +12,13 @@ import {
type CampaignShareTargets,
type ResourceAccessExplanationResponse } from
"../../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Button, ResourceAccessExplanation } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { StatusBadge, TableActionGroup, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
type TargetType = "user" | "group";
export default function CampaignAccessCard({
@@ -196,7 +196,7 @@ export default function CampaignAccessCard({
}
},
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }],
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, resizable: false, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "remove", label: "i18n:govoplan-campaign.remove.e963907d", icon: <Trash2 aria-hidden="true" />, variant: "danger", onClick: () => setRevokeTarget(row) }]} /> }],
[targetMap]);
if (available === false) return null;
@@ -221,76 +221,11 @@ export default function CampaignAccessCard({
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
</Dialog>
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<ResourceAccessExplanationContent loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<ResourceAccessExplanation loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
</Dialog>
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
</>);
}
function ResourceAccessExplanationContent({
loading,
explanation,
fallbackResourceLabel
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
if (loading) return <p className="muted small-note">i18n:govoplan-campaign.loading_access_explanation.04a7c934</p>;
if (!explanation) return null;
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
return (
<>
<div className="form-grid compact responsive-form-grid">
<div><span className="form-label">i18n:govoplan-campaign.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-campaign.resource.d1c626a9</span><p>{resourceLabel}</p></div>
<div><span className="form-label">i18n:govoplan-campaign.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
<div><span className="form-label">i18n:govoplan-campaign.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
</div>
{explanation.provenance.length === 0 ?
<p className="muted small-note">i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e</p> :
<div className="admin-assignment-grid">
{explanation.provenance.map((item, index) =>
<div key={`${item.kind}:${item.id ?? index}`}>
<strong>{provenanceKindLabel(item.kind)}</strong>
<div className="muted small-note">
{item.source || "i18n:govoplan-campaign.no_source.6dcf9723"}
{item.id && <> · <code>{item.id}</code></>}
</div>
{item.label && <p>{item.label}</p>}
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
</div>
)}
</div>
}
</>);
}
function provenanceKindLabel(kind: string): string {
switch (kind) {
case "resource":
return "i18n:govoplan-campaign.resource.d1c626a9";
case "owner":
return "i18n:govoplan-campaign.owner.89ff3122";
case "share":
return "i18n:govoplan-campaign.share.09ca55ca";
case "policy":
return "i18n:govoplan-campaign.policy.0b779a05";
case "role":
return "i18n:govoplan-campaign.role.b5b4a5a2";
case "right":
return "i18n:govoplan-campaign.permission.2f81a22d";
default:
return kind;
}
}
function formatProvenanceDetails(details: Record<string, unknown>): string {
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
}
function formatProvenanceValue(value: unknown): string {
if (value === null || value === undefined) return "i18n:govoplan-campaign.none.6eef6648";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}

View File

@@ -43,6 +43,9 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
const permanentUserLock = isPermanentUserLockedVersion(version);
const finalLock = isFinalLockedVersion(version);
const canCreateEditableCopy = !historicalVersion && (permanentUserLock || finalLock);
const mailProfileMigrationRequired = Boolean(
version && "mail_profile_migration_required" in version && version.mail_profile_migration_required
);
const presentation = lockPresentation(version, {
historicalVersion,
validationLock,
@@ -83,7 +86,8 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
try {
const result = await forkCampaignVersion(settings, campaignId, version.id, {
current_flow: "manual",
current_step: version.current_step ?? null
current_step: version.current_step ?? null,
migrate_legacy_mail_settings: mailProfileMigrationRequired
});
setLocalMessage(`Created editable version #${result.version.version_number}.`);
await reload();
@@ -100,6 +104,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
<strong>{presentation.title}</strong>{" "}
<span>{presentation.description}</span>
{message && <span className="locked-version-context"> {message}</span>}
{mailProfileMigrationRequired && <span className="locked-version-context"> i18n:govoplan-campaign.the_editable_copy_will_preserve_this_audit_recor.aac956f1</span>}
{presentation.info && <span className="locked-version-reason"> {presentation.info}</span>}
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
{localError && <span className="locked-version-error"> {localError}</span>}
@@ -232,4 +237,4 @@ function confirmDialogLabel(action: ConfirmAction): string {
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock.1526a17e";
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "i18n:govoplan-campaign.confirm.04a21221";
}
}

View File

@@ -1,36 +0,0 @@
export const INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE = "i18n:govoplan-campaign.inline_smtp_imap_settings_are_blocked_by_the_eff.90c94538";
export type CampaignMailPolicy = {
allow_campaign_profiles?: boolean | null;
};
export type CampaignMailSettingsPolicyState = {
campaignProfilesAllowed: boolean;
usingMailProfile: boolean;
inlineMailSettingsBlocked: boolean;
inlineOptionDisabled: boolean;
canSelectInlineSettings: boolean;
inlineBlockedMessage: string;
};
export function campaignMailSettingsPolicyState({
effectivePolicy,
selectedProfileId,
locked = false
}: {effectivePolicy: CampaignMailPolicy | null | undefined;selectedProfileId: string | null | undefined;locked?: boolean;}): CampaignMailSettingsPolicyState {
const campaignProfilesAllowed = effectivePolicy?.allow_campaign_profiles === true;
const usingMailProfile = Boolean(selectedProfileId);
const inlineMailSettingsBlocked = !campaignProfilesAllowed && !usingMailProfile;
return {
campaignProfilesAllowed,
usingMailProfile,
inlineMailSettingsBlocked,
inlineOptionDisabled: !campaignProfilesAllowed,
canSelectInlineSettings: !locked && campaignProfilesAllowed,
inlineBlockedMessage: INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE
};
}

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,13 @@
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
const server = isRecord(value.server) ? value.server : {};
const rawProfileId = server.mail_profile_id;
const profileId = typeof rawProfileId === "string" ? rawProfileId.trim() : "";
return {
...value,
server: profileId ? { mail_profile_id: profileId } : {}
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

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;
}

View File

@@ -49,8 +49,6 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
const globalCc = addressesFromValue(recipients.cc);
const globalBcc = addressesFromValue(recipients.bcc);
const globalReplyTo = addressesFromValue(recipients.reply_to);
const server = asRecord(draft.server);
const smtp = asRecord(server.smtp);
const delivery = asRecord(draft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
return (
@@ -106,8 +104,7 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))} />
</FormField>
<FormField label="i18n:govoplan-campaign.smtp_host.2d4a434b"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.smtp_port.65b5a108"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<p className="muted small-note">i18n:govoplan-campaign.select_the_delivery_profile_on_the_mail_settings.a89cbb5f</p>
<ToggleSwitch label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>);
@@ -232,4 +229,4 @@ function JsonEditor({ value, onValid }: {value: unknown;onValid: (value: unknown
{Array.isArray(value) && value.length > 0 && <p className="form-help">i18n:govoplan-campaign.preview.4bf30626 {stringifyPreview(asArray(value)[0], 140)}</p>}
</div>);
}
}

Some files were not shown because too many files have changed in this diff Show More