17 Commits

Author SHA1 Message Date
dd9592a192 Refactor campaign delivery decision paths 2026-07-29 17:06:15 +02:00
5240749ae1 feat: add governed postbox delivery and report hardening 2026-07-29 14:16:28 +02:00
f11c56e890 fix: make direct campaign pages scrollable 2026-07-28 22:50:11 +02:00
8d0a2608ed Declare campaign route View surfaces 2026-07-28 21:04:55 +02:00
89ae14c032 Integrate campaign delivery with hierarchical mail profiles 2026-07-28 19:33:12 +02:00
38af25ee88 docs: update GovOPlaN repository links 2026-07-27 15:46:51 +02:00
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
77 changed files with 11421 additions and 766 deletions

View File

@@ -94,7 +94,7 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives. - [Campaign 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. - [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. - 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. - 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/GovOPlaN/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. - [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 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. - [Recipient and address boundary](docs/RECIPIENT_ADDRESS_BOUNDARY.md) defines the split between campaign-local recipients and future reusable address management.

View File

@@ -9,3 +9,5 @@ GOVOPLAN_MAIL_TEST_IMAP_PORT=3143
GOVOPLAN_MAIL_TEST_SENT_FOLDER=Sent GOVOPLAN_MAIL_TEST_SENT_FOLDER=Sent
GOVOPLAN_MAIL_TEST_ZIP_PASSWORD=zip-test-password GOVOPLAN_MAIL_TEST_ZIP_PASSWORD=zip-test-password
GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS=45 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 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`. 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 With A Campaign
Use the same settings in a campaign mail profile: 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_SMTP_PORT:-3025}:3025"
- "${GOVOPLAN_MAIL_TEST_IMAP_PORT:-3143}:3143" - "${GOVOPLAN_MAIL_TEST_IMAP_PORT:-3143}:3143"
- "127.0.0.1:38080:8080" - "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

@@ -104,6 +104,36 @@ bed where possible:
- IMAP append failure after SMTP acceptance. - IMAP append failure after SMTP acceptance.
- Worker restart with queued, claimed, and sending jobs. - 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 ## Reporting Checks
- Partial delivery must show accepted, failed, and unknown counts separately. - Partial delivery must show accepted, failed, and unknown counts separately.

View File

@@ -144,10 +144,14 @@ paths, storage keys, worker claim tokens, and raw provider diagnostics require
the dedicated diagnostic permission and must not leak through ordinary campaign, the dedicated diagnostic permission and must not leak through ordinary campaign,
version, job, or report responses. version, job, or report responses.
The current Campaign Report Web UI additionally requires recipient-read access Campaign now provides a separate aggregate **Reports** surface for readers with
and does not yet hide every action control that the actor lacks. The server `campaigns:report:read` and access to the campaign. It loads only the safe
still authorizes each action, but an aggregate-only reader UI remains open aggregate projections, applies small-cell suppression, and offers no recipient
work; do not promise that experience from `campaigns:report:read` alone. 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 ### Deliver and resolve outcomes
@@ -465,7 +469,7 @@ the current baseline:
- the final audited **test / single send / single resend** semantics; - the final audited **test / single send / single resend** semantics;
- reusable SMTP batch sessions and their measured throughput benefit; - reusable SMTP batch sessions and their measured throughput benefit;
- durable, idempotent Campaign report delivery through a Mail-owned outbox - durable, idempotent Campaign report delivery through a Mail-owned outbox
([`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17)); ([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17));
- a fully packaged one-command Campaign reference composition with production - a fully packaged one-command Campaign reference composition with production
policy presets and target-provider certification; policy presets and target-provider certification;
- function-bound Postbox delivery (stage 2 of the reference program); - function-bound Postbox delivery (stage 2 of the reference program);

View File

@@ -10,7 +10,11 @@ scenario catalogue lives in `examples/README.md`; committed fixture files should
be added under `examples/` only when they validate against the current campaign be added under `examples/` only when they validate against the current campaign
schema and are safe to run in non-production environments. 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 - multi-recipient message with To, CC, BCC, Reply-To, bounce, and disposition
notification fields notification fields
- campaign with global attachments and recipient-specific attachment rules - campaign with global attachments and recipient-specific attachment rules
@@ -22,7 +26,9 @@ schema and are safe to run in non-production environments.
- campaign with blocked recipients or attachment errors that must not be sent - 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 - mock delivery campaign that captures SMTP and IMAP append messages in the mail
development mailbox 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 ## Fixture Rules
@@ -41,12 +47,31 @@ Before tagging a campaign release:
- Review `examples/README.md` and update the scenario catalogue when a release - Review `examples/README.md` and update the scenario catalogue when a release
adds or removes delivery behavior. 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 - Run core module permutation tests with campaign installed both with and
without files/mail. without files/mail.
- Validate and build each maintained example campaign. - Validate and build each maintained example campaign.
- Run the mock delivery example when the mail development mailbox capability is - Run the mock delivery example when the mail development mailbox capability is
enabled. enabled.
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path. - 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 - Confirm reusable mail profile selection is revalidated after campaign owner
transfer. transfer.
- Confirm every inline SMTP/IMAP field is rejected on import/write, omitted - Confirm every inline SMTP/IMAP field is rejected on import/write, omitted

View File

@@ -49,7 +49,7 @@ 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 durable job/effect model through a direct SMTP call. Re-enabling it requires the
Mail-owned idempotent outbox, attempt, unknown-outcome, and reconciliation path Mail-owned idempotent outbox, attempt, unknown-outcome, and reconciliation path
tracked in tracked in
[`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17). [`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17).
Report generation and dry-run validation remain separate from an external Report generation and dry-run validation remain separate from an external
effect; recipient-level exports require recipient-export authorization. effect; recipient-level exports require recipient-export authorization.

View File

@@ -9,7 +9,7 @@ campaign schema and do not require production data.
| Scenario | Required Modules | Release Check | | 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. | | `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. | | `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. | | `recipient-attachment-rules` | core, access, campaigns; optional files | Match recipient-specific attachment rules and verify per-recipient evidence. |
@@ -19,7 +19,7 @@ campaign schema and do not require production data.
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. | | `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. | | `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. | | `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 ## Fixture Rules
@@ -37,9 +37,14 @@ campaign schema and do not require production data.
Before a release tag: Before a release tag:
1. Run module permutation startup checks from core. 1. Run module permutation startup checks from core.
2. Validate every committed example fixture against the current campaign schema. 2. Run `python -m unittest discover -s tests -p 'test_example_campaigns.py'`
3. Build exact messages for each fixture. from this repository. The acceptance test copies each maintained fixture to
4. Run the mock-delivery example when the dev mailbox capability is enabled. an unrelated temporary workspace before using Campaign's public loader,
5. Run `dev/mail-testbed/run_transport_smoke.py`. validator, and message builder.
6. Execute the delivery checklist in 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`. `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", "name": "@govoplan/campaign-webui",
"version": "0.1.10", "version": "0.1.12",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",

View File

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

View File

@@ -44,6 +44,7 @@ def _parse_scalar_for_target(target: str, value: Any) -> Any:
"merge_reply_to", "merge_reply_to",
"merge_bounce_to", "merge_bounce_to",
"merge_disposition_notification_to", "merge_disposition_notification_to",
"merge_postbox_targets",
"combine_to", "combine_to",
"combine_cc", "combine_cc",
"combine_bcc", "combine_bcc",

View File

@@ -4,7 +4,15 @@ import copy
from typing import Any from typing import Any
CAMPAIGN_MAIL_SERVER_KEYS = frozenset({"mail_profile_id"}) CAMPAIGN_MAIL_SERVER_KEYS = frozenset(
{
"mail_profile_id",
"smtp_server_id",
"smtp_credential_id",
"imap_server_id",
"imap_credential_id",
}
)
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"}) CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"})
CAMPAIGN_OPT_IN_KEYS = frozenset( CAMPAIGN_OPT_IN_KEYS = frozenset(
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"} {"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
@@ -24,8 +32,8 @@ class CampaignMailProfileBoundaryError(ValueError):
"""Raised when campaign JSON owns mail transport configuration. """Raised when campaign JSON owns mail transport configuration.
SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON 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 may select Mail-owned profile, server, and credential identifiers, but it
profile's transport configuration. must never copy or override transport configuration.
""" """
@@ -218,16 +226,53 @@ def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None:
return normalized or None return normalized or None
def campaign_mail_resource_ids(
raw_json: dict[str, Any] | None,
) -> dict[str, str | None]:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return {
"mail_profile_id": None,
"smtp_server_id": None,
"smtp_credential_id": None,
"imap_server_id": None,
"imap_credential_id": None,
}
return {
key: (
value.strip()
if isinstance((value := server.get(key)), str) and value.strip()
else None
)
for key in CAMPAIGN_MAIL_SERVER_KEYS
}
def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]: 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 server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict): if not isinstance(server, dict):
return () return ()
violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS] violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS]
if "mail_profile_id" in server: for key in CAMPAIGN_MAIL_SERVER_KEYS:
profile_id = server["mail_profile_id"] if key not in server:
if not isinstance(profile_id, str) or not profile_id.strip(): continue
value = server[key]
if not isinstance(value, str) or not value.strip():
violations.append(f"/server/{key}")
references = campaign_mail_resource_ids(raw_json)
if references["mail_profile_id"] is None and any(
references[key]
for key in references
if key != "mail_profile_id"
):
violations.append("/server/mail_profile_id") violations.append("/server/mail_profile_id")
for protocol in ("smtp", "imap"):
if (
references[f"{protocol}_credential_id"]
and not references[f"{protocol}_server_id"]
):
violations.append(f"/server/{protocol}_server_id")
return tuple(violations) return tuple(violations)
@@ -240,9 +285,9 @@ def assert_campaign_uses_mail_profile_reference(
if violations: if violations:
fields = ", ".join(violations) fields = ", ".join(violations)
raise CampaignMailProfileBoundaryError( raise CampaignMailProfileBoundaryError(
"Campaign JSON may only reference a Mail-module profile through " "Campaign JSON may only reference Mail-owned profiles, servers, and credentials; "
f"server.mail_profile_id; remove campaign-local SMTP/IMAP settings ({fields}), " f"remove campaign-local SMTP/IMAP settings or invalid references ({fields}), "
"select an authorized Mail profile, and save a new campaign version." "select authorized Mail resources, and save a new campaign version."
) )
if require_profile and campaign_mail_profile_id(raw_json) is None: if require_profile and campaign_mail_profile_id(raw_json) is None:
raise CampaignMailProfileBoundaryError( raise CampaignMailProfileBoundaryError(
@@ -254,5 +299,8 @@ def assert_campaign_uses_mail_profile_reference(
def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]: def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]:
"""Return the complete public/persisted Campaign-to-Mail contract.""" """Return the complete public/persisted Campaign-to-Mail contract."""
profile_id = campaign_mail_profile_id(raw_json) return {
return {"mail_profile_id": profile_id} if profile_id else {} key: value
for key, value in campaign_mail_resource_ids(raw_json).items()
if value
}

View File

@@ -23,6 +23,8 @@ class FieldType(StrEnum):
DOUBLE = "double" DOUBLE = "double"
DATE = "date" DATE = "date"
PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary. PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary.
ORGANIZATION_UNIT = "organization_unit"
ORGANIZATION_FUNCTION = "organization_function"
class RecipientType(StrEnum): class RecipientType(StrEnum):
@@ -92,6 +94,104 @@ class SendStatus(StrEnum):
SKIPPED = "skipped" SKIPPED = "skipped"
class DeliveryChannelPolicy(StrEnum):
MAIL = "mail"
POSTBOX = "postbox"
MAIL_AND_POSTBOX = "mail_and_postbox"
MAIL_THEN_POSTBOX = "mail_then_postbox"
POSTBOX_THEN_MAIL = "postbox_then_mail"
@property
def uses_mail(self) -> bool:
return self in {
DeliveryChannelPolicy.MAIL,
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
}
@property
def uses_postbox(self) -> bool:
return self != DeliveryChannelPolicy.MAIL
class PostboxTargetMode(StrEnum):
DIRECT = "direct"
DERIVED = "derived"
class PostboxTargetMatch(StrEnum):
ID = "id"
SLUG = "slug"
class PostboxTargetConfig(StrictModel):
id: str = Field(min_length=1, max_length=120)
mode: PostboxTargetMode = PostboxTargetMode.DIRECT
label: str | None = Field(default=None, max_length=500)
postbox_id: str | None = Field(default=None, max_length=36)
address_key: str | None = Field(default=None, max_length=500)
template_id: str | None = Field(default=None, max_length=36)
organization_unit_id: str | None = Field(default=None, max_length=36)
organization_unit_field: str | None = Field(default=None, max_length=255)
organization_unit_match: PostboxTargetMatch = PostboxTargetMatch.ID
function_id: str | None = Field(default=None, max_length=36)
function_field: str | None = Field(default=None, max_length=255)
function_match: PostboxTargetMatch = PostboxTargetMatch.ID
context_key: str | None = Field(default=None, max_length=255)
context_field: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_target_shape(self) -> "PostboxTargetConfig":
direct_values = [self.postbox_id, self.address_key]
if self.mode == PostboxTargetMode.DIRECT:
if sum(bool(value) for value in direct_values) != 1:
raise ValueError(
"A direct Postbox target requires exactly one postbox_id "
"or address_key."
)
if any(
(
self.template_id,
self.organization_unit_id,
self.organization_unit_field,
self.function_id,
self.function_field,
self.context_key,
self.context_field,
)
):
raise ValueError(
"A direct Postbox target cannot contain derived target fields."
)
return self
if any(direct_values):
raise ValueError(
"A derived Postbox target cannot contain postbox_id or address_key."
)
if not self.template_id:
raise ValueError("A derived Postbox target requires template_id.")
if bool(self.organization_unit_id) == bool(self.organization_unit_field):
raise ValueError(
"A derived Postbox target requires exactly one fixed or "
"field-derived organization unit."
)
if bool(self.function_id) == bool(self.function_field):
raise ValueError(
"A derived Postbox target requires exactly one fixed or "
"field-derived function."
)
if self.context_key and self.context_field:
raise ValueError(
"A derived Postbox target may use a fixed context or a context "
"field, not both."
)
return self
class CampaignMeta(StrictModel): class CampaignMeta(StrictModel):
id: str id: str
name: str name: str
@@ -114,6 +214,10 @@ class MailProfileCapabilities(StrictModel):
class ServerConfig(StrictModel): class ServerConfig(StrictModel):
mail_profile_id: str | None = None mail_profile_id: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
imap_server_id: str | None = None
imap_credential_id: str | None = None
profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities) profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities)
@@ -446,6 +550,13 @@ class EntryConfig(StrictModel):
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list) disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
merge_disposition_notification_to: bool = True merge_disposition_notification_to: bool = True
channel_policy: DeliveryChannelPolicy | None = None
postbox_targets: list[PostboxTargetConfig] = Field(
default_factory=list,
max_length=50,
)
merge_postbox_targets: bool = True
attachments: list[AttachmentConfig] = Field(default_factory=list) attachments: list[AttachmentConfig] = Field(default_factory=list)
combine_attachments: bool = True combine_attachments: bool = True
@@ -559,7 +670,17 @@ class RetryConfig(StrictModel):
return values return values
class PostboxDeliveryConfig(StrictModel):
targets: list[PostboxTargetConfig] = Field(default_factory=list, max_length=50)
classification: str = Field(default="internal", min_length=1, max_length=50)
unresolved_target: Behavior = Behavior.BLOCK
vacant_target: Behavior = Behavior.WARN
duplicate_target: Behavior = Behavior.WARN
class DeliveryConfig(StrictModel): class DeliveryConfig(StrictModel):
channel_policy: DeliveryChannelPolicy = DeliveryChannelPolicy.MAIL
postbox: PostboxDeliveryConfig = Field(default_factory=PostboxDeliveryConfig)
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig) rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig) imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
retry: RetryConfig = Field(default_factory=RetryConfig) retry: RetryConfig = Field(default_factory=RetryConfig)
@@ -602,3 +723,23 @@ class CampaignConfig(StrictModel):
if path.is_absolute(): if path.is_absolute():
return path return path
return (campaign_file.parent / path).resolve() return (campaign_file.parent / path).resolve()
def effective_delivery_channel_policy(
config: CampaignConfig,
entry: EntryConfig,
) -> DeliveryChannelPolicy:
return entry.channel_policy or config.delivery.channel_policy
def effective_postbox_targets(
config: CampaignConfig,
entry: EntryConfig,
) -> list[PostboxTargetConfig]:
global_targets = list(config.delivery.postbox.targets)
individual_targets = list(entry.postbox_targets)
if not individual_targets:
return global_targets
if entry.merge_postbox_targets:
return [*global_targets, *individual_targets]
return individual_targets

View File

@@ -0,0 +1,321 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDirectoryEntryRef,
PostboxTargetRef,
)
from govoplan_campaign.backend.campaign.field_values import (
effective_entry_field_values,
)
from govoplan_campaign.backend.campaign.models import (
Behavior,
CampaignConfig,
EntryConfig,
PostboxTargetConfig,
PostboxTargetMatch,
PostboxTargetMode,
effective_postbox_targets,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.messages.models import (
MessageIssue,
MessageValidationStatus,
)
def _apply_behavior(
current: MessageValidationStatus,
behavior: Behavior,
) -> MessageValidationStatus:
if behavior == Behavior.BLOCK:
return MessageValidationStatus.BLOCKED
if behavior == Behavior.DROP:
return MessageValidationStatus.EXCLUDED
if behavior == Behavior.ASK and current not in {
MessageValidationStatus.BLOCKED,
MessageValidationStatus.EXCLUDED,
}:
return MessageValidationStatus.NEEDS_REVIEW
if behavior == Behavior.WARN and current == MessageValidationStatus.READY:
return MessageValidationStatus.WARNING
return current
def _issue(
*,
code: str,
message: str,
behavior: Behavior,
) -> MessageIssue:
return MessageIssue(
severity="error" if behavior == Behavior.BLOCK else "warning",
code=code,
message=message,
behavior=behavior.value,
source="postbox",
)
def _field_value(
values: dict[str, Any],
field_name: str | None,
) -> str | None:
if not field_name:
return None
value = values.get(field_name)
if value is None:
return None
text = str(value).strip()
return text or None
def _match_unit(
catalog: PostboxDeliveryCatalogRef,
value: str | None,
match: PostboxTargetMatch,
):
if not value:
return None
return next(
(
unit
for unit in catalog.organization_units
if (unit.id if match == PostboxTargetMatch.ID else unit.slug) == value
),
None,
)
def _match_function(unit, value: str | None, match: PostboxTargetMatch):
if unit is None or not value:
return None
return next(
(
function
for function in unit.functions
if (
function.id
if match == PostboxTargetMatch.ID
else function.slug
)
== value
),
None,
)
def _target_ref(
target: PostboxTargetConfig,
*,
values: dict[str, Any],
catalog: PostboxDeliveryCatalogRef,
) -> tuple[PostboxTargetRef | None, str | None]:
if target.mode == PostboxTargetMode.DIRECT:
return (
PostboxTargetRef(
postbox_id=target.postbox_id,
address_key=target.address_key,
),
None,
)
unit_value = target.organization_unit_id or _field_value(
values,
target.organization_unit_field,
)
unit_match = (
PostboxTargetMatch.ID
if target.organization_unit_id
else target.organization_unit_match
)
unit = _match_unit(catalog, unit_value, unit_match)
if unit is None:
return None, (
f"Organization unit {unit_value!r} could not be resolved by "
f"{unit_match.value}."
)
function_value = target.function_id or _field_value(
values,
target.function_field,
)
function_match = (
PostboxTargetMatch.ID
if target.function_id
else target.function_match
)
function = _match_function(unit, function_value, function_match)
if function is None:
return None, (
f"Organization function {function_value!r} could not be resolved "
f"inside {unit.name!r} by {function_match.value}."
)
context_key = target.context_key or _field_value(
values,
target.context_field,
)
return (
PostboxTargetRef(
template_id=target.template_id,
organization_unit_id=unit.id,
function_id=function.id,
context_key=context_key,
),
None,
)
def _resolved_target_payload(
target: PostboxTargetConfig,
entry: PostboxDirectoryEntryRef,
*,
position: int,
) -> dict[str, Any]:
return {
"target_id": target.id,
"position": position,
"mode": target.mode.value,
"requested": target.model_dump(mode="json", exclude_none=True),
"postbox_id": entry.id,
"address": entry.address,
"address_key": entry.address_key,
"name": entry.name,
"status": entry.status,
"classification": entry.classification,
"organization_unit_id": entry.organization_unit_id,
"organization_unit_name": entry.organization_unit_name,
"function_id": entry.function_id,
"function_name": entry.function_name,
"context_key": entry.context_key,
"template_revision_id": entry.template_revision_id,
"holder_count": entry.holder_count,
"vacant": entry.vacant,
}
def resolve_entry_postbox_targets(
session: Session,
*,
tenant_id: str,
config: CampaignConfig,
entry: EntryConfig,
validation_status: MessageValidationStatus,
materialize: bool,
) -> tuple[
list[dict[str, Any]],
list[MessageIssue],
MessageValidationStatus,
]:
integration = postbox_integration()
policy = config.delivery.postbox
targets = effective_postbox_targets(config, entry)
if not targets:
issue = _issue(
code="postbox_target_missing",
message="Postbox delivery requires at least one target.",
behavior=policy.unresolved_target,
)
return (
[],
[issue],
_apply_behavior(validation_status, policy.unresolved_target),
)
try:
catalog = integration.delivery_catalog(session, tenant_id=tenant_id)
except Exception as exc:
issue = _issue(
code="postbox_unavailable",
message=str(exc),
behavior=Behavior.BLOCK,
)
return [], [issue], MessageValidationStatus.BLOCKED
values = effective_entry_field_values(config, entry)
resolved: list[dict[str, Any]] = []
issues: list[MessageIssue] = []
status = validation_status
seen_postbox_ids: set[str] = set()
for position, target in enumerate(targets):
target_ref, resolution_error = _target_ref(
target,
values=values,
catalog=catalog,
)
if target_ref is None:
issue = _issue(
code="postbox_target_unresolved",
message=resolution_error or "Postbox target could not be resolved.",
behavior=policy.unresolved_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.unresolved_target)
continue
try:
entry_ref = integration.resolve_postbox(
session,
tenant_id=tenant_id,
target=target_ref,
materialize=materialize,
)
except Exception as exc:
issue = _issue(
code="postbox_target_unresolved",
message=f"Postbox target {target.id!r} could not be resolved: {exc}",
behavior=policy.unresolved_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.unresolved_target)
continue
if entry_ref is None:
issue = _issue(
code="postbox_target_unresolved",
message=f"Postbox target {target.id!r} does not exist.",
behavior=policy.unresolved_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.unresolved_target)
continue
if entry_ref.id in seen_postbox_ids:
issue = _issue(
code="postbox_target_duplicate",
message=(
f"Postbox {entry_ref.address!r} is selected more than once; "
"it will receive one message."
),
behavior=policy.duplicate_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.duplicate_target)
continue
seen_postbox_ids.add(entry_ref.id)
resolved.append(
_resolved_target_payload(
target,
entry_ref,
position=position,
)
)
if entry_ref.vacant:
issue = _issue(
code="postbox_target_vacant",
message=(
f"Postbox {entry_ref.address!r} currently has no function "
"holder."
),
behavior=policy.vacant_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.vacant_target)
return resolved, issues, status
def delivery_catalog_payload(catalog: PostboxDeliveryCatalogRef) -> dict[str, Any]:
return asdict(catalog)

View File

@@ -10,7 +10,21 @@ from pydantic import BaseModel, ConfigDict, Field
from .addressing import effective_address_lists from .addressing import effective_address_lists
from .field_values import ignored_entry_field_overrides from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode from .models import (
AttachmentConfig,
CampaignConfig,
DeliveryChannelPolicy,
EntryConfig,
FieldType,
PostboxTargetConfig,
SourceType,
ZipArchiveConfig,
ZipPasswordMode,
ZipPasswordScope,
ZipRuleMode,
effective_delivery_channel_policy,
effective_postbox_targets,
)
from ..attachments.resolver import resolve_campaign_attachments from ..attachments.resolver import resolve_campaign_attachments
@@ -90,6 +104,8 @@ def _mapping_target_known(target: str, field_names: set[str]) -> bool:
"merge_reply_to", "merge_reply_to",
"merge_bounce_to", "merge_bounce_to",
"merge_disposition_notification_to", "merge_disposition_notification_to",
"merge_postbox_targets",
"channel_policy",
"combine_to", "combine_to",
"combine_cc", "combine_cc",
"combine_bcc", "combine_bcc",
@@ -337,10 +353,148 @@ def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> li
] ]
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]: def _active_delivery_entries(config: CampaignConfig) -> list[EntryConfig]:
if config.entries.is_inline:
return [
entry
for entry in (config.entries.inline or [])
if entry.active
]
return [config.entries.defaults or EntryConfig()]
def _delivery_policies(config: CampaignConfig) -> set[DeliveryChannelPolicy]:
return {
effective_delivery_channel_policy(config, entry)
for entry in _active_delivery_entries(config)
}
def _postbox_target_field_issues(
config: CampaignConfig,
target: PostboxTargetConfig,
path: str,
) -> list[SemanticIssue]:
definitions = {field.name: field for field in config.fields}
checks = (
(
target.organization_unit_field,
FieldType.ORGANIZATION_UNIT,
"organization unit",
"organization_unit_field",
),
(
target.function_field,
FieldType.ORGANIZATION_FUNCTION,
"organization function",
"function_field",
),
(target.context_field, None, "context", "context_field"),
)
issues: list[SemanticIssue] = [] issues: list[SemanticIssue] = []
for field_name, expected_type, label, key in checks:
if not field_name:
continue
definition = definitions.get(field_name)
if definition is None:
issues.append(
_issue(
Severity.ERROR,
"postbox_target_field_missing",
f"Postbox {label} field {field_name!r} is not declared.",
f"{path}/{key}",
)
)
elif expected_type is not None and definition.type != expected_type:
issues.append(
_issue(
Severity.WARNING,
"postbox_target_field_type",
(
f"Postbox {label} field {field_name!r} should use "
f"field type {expected_type.value!r}."
),
f"{path}/{key}",
)
)
return issues
def _postbox_delivery_issues(
config: CampaignConfig,
*,
postbox_available: bool,
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
policies = _delivery_policies(config)
if not any(policy.uses_postbox for policy in policies):
return issues
if not postbox_available:
issues.append(
_issue(
Severity.ERROR,
"postbox_unavailable",
(
"This campaign uses Postbox delivery, but the Postbox "
"module and its delivery directory are not active."
),
"/delivery/channel_policy",
)
)
for entry_index, entry in enumerate(_active_delivery_entries(config)):
policy = effective_delivery_channel_policy(config, entry)
if not policy.uses_postbox:
continue
targets = effective_postbox_targets(config, entry)
if not targets:
issues.append(
_issue(
Severity.ERROR,
"postbox_target_missing",
"Postbox delivery requires at least one target.",
f"/entries/inline/{entry_index}/postbox_targets",
)
)
continue
seen_ids: set[str] = set()
for target_index, target in enumerate(targets):
target_path = (
f"/entries/inline/{entry_index}/postbox_targets/"
f"{target_index}"
)
if target.id in seen_ids:
issues.append(
_issue(
Severity.WARNING,
"postbox_target_id_duplicate",
f"Postbox target id {target.id!r} is repeated.",
f"{target_path}/id",
)
)
seen_ids.add(target.id)
issues.extend(
_postbox_target_field_issues(config, target, target_path)
)
return issues
def _delivery_issues(
config: CampaignConfig,
*,
postbox_available: bool,
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
policies = _delivery_policies(config)
uses_mail = any(policy.uses_mail for policy in policies)
profile_id = (config.server.mail_profile_id or "").strip() 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: if (
(
config.campaign.mode == "send"
and uses_mail
or config.delivery.imap_append_sent.enabled
)
and not profile_id
):
issues.append( issues.append(
_issue( _issue(
Severity.ERROR, Severity.ERROR,
@@ -350,7 +504,12 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
) )
) )
capabilities = config.server.profile_capabilities capabilities = config.server.profile_capabilities
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available: if (
config.campaign.mode == "send"
and uses_mail
and profile_id
and not capabilities.smtp_available
):
issues.append( issues.append(
_issue( _issue(
Severity.ERROR, Severity.ERROR,
@@ -368,13 +527,22 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
"/server/mail_profile_id", "/server/mail_profile_id",
) )
) )
issues.extend(
_postbox_delivery_issues(
config,
postbox_available=postbox_available,
)
)
return issues return issues
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]: def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
"""Require Campaign-owned sender data before a send-mode build.""" """Require Campaign-owned sender data before a send-mode build."""
if config.campaign.mode != "send": if (
config.campaign.mode != "send"
or not any(policy.uses_mail for policy in _delivery_policies(config))
):
return [] return []
if config.entries.is_inline: if config.entries.is_inline:
return [ return [
@@ -385,7 +553,11 @@ def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
f"/entries/inline/{index}/from", f"/entries/inline/{index}/from",
) )
for index, entry in enumerate(config.entries.inline or []) for index, entry in enumerate(config.entries.inline or [])
if entry.active and not effective_address_lists(config, entry)["from"] if (
entry.active
and effective_delivery_channel_policy(config, entry).uses_mail
and not effective_address_lists(config, entry)["from"]
)
] ]
if config.recipients.from_: if config.recipients.from_:
return [] return []
@@ -611,6 +783,7 @@ def validate_campaign_config(
*, *,
campaign_file: str | Path | None = None, campaign_file: str | Path | None = None,
check_files: bool = False, check_files: bool = False,
postbox_available: bool = False,
) -> SemanticReport: ) -> SemanticReport:
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json" campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
issues: list[SemanticIssue] = [] issues: list[SemanticIssue] = []
@@ -622,7 +795,12 @@ def validate_campaign_config(
issues.extend(_global_value_issues(config, declared_names)) issues.extend(_global_value_issues(config, declared_names))
issues.extend(_attachment_path_issues(config)) issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config)) issues.extend(_zip_configuration_issues(config))
issues.extend(_delivery_issues(config)) issues.extend(
_delivery_issues(
config,
postbox_available=postbox_available,
)
)
issues.extend(_sender_issues(config)) issues.extend(_sender_issues(config))
entries = _entries_validation( entries = _entries_validation(

View File

@@ -279,6 +279,12 @@ class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider):
def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService: 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() return CampaignDeliveryTaskService()

View File

@@ -21,6 +21,7 @@ from govoplan_campaign.backend.db.models import (
CampaignShare, CampaignShare,
CampaignVersion, CampaignVersion,
ImapAppendAttempt, ImapAppendAttempt,
PostboxDeliveryAttempt,
SendAttempt, SendAttempt,
new_uuid, new_uuid,
) )
@@ -55,7 +56,10 @@ def _record_campaign_changes(session: OrmSession, _flush_context: object, _insta
_record_job_change(session, obj) _record_job_change(session, obj)
elif isinstance(obj, CampaignIssue): elif isinstance(obj, CampaignIssue):
_record_issue_change(session, obj) _record_issue_change(session, obj)
elif isinstance(obj, (SendAttempt, ImapAppendAttempt)): elif isinstance(
obj,
(SendAttempt, ImapAppendAttempt, PostboxDeliveryAttempt),
):
_record_attempt_change(session, obj) _record_attempt_change(session, obj)
@@ -182,8 +186,11 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"validation_status", "validation_status",
"queue_status", "queue_status",
"send_status", "send_status",
"delivery_channel_policy",
"postbox_status",
"imap_status", "imap_status",
"attempt_count", "attempt_count",
"postbox_attempt_count",
"last_error", "last_error",
"queued_at", "queued_at",
"claimed_at", "claimed_at",
@@ -191,6 +198,7 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"outcome_unknown_at", "outcome_unknown_at",
"sent_at", "sent_at",
"resolved_recipients", "resolved_recipients",
"resolved_postbox_targets",
"resolved_attachments", "resolved_attachments",
"issues_snapshot", "issues_snapshot",
), ),
@@ -217,6 +225,8 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"validation_status": job.validation_status, "validation_status": job.validation_status,
"queue_status": job.queue_status, "queue_status": job.queue_status,
"send_status": job.send_status, "send_status": job.send_status,
"delivery_channel_policy": job.delivery_channel_policy,
"postbox_status": job.postbox_status,
"imap_status": job.imap_status, "imap_status": job.imap_status,
}, },
) )
@@ -247,10 +257,25 @@ def _record_issue_change(session: OrmSession, issue: CampaignIssue) -> None:
) )
def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppendAttempt) -> None: def _record_attempt_change(
session: OrmSession,
attempt: SendAttempt | ImapAppendAttempt | PostboxDeliveryAttempt,
) -> None:
operation = _operation_for_object( operation = _operation_for_object(
attempt, attempt,
changed_attrs=("status", "claim_token", "smtp_status_code", "smtp_response", "error_type", "error_message", "folder"), changed_attrs=(
"status",
"claim_token",
"smtp_status_code",
"smtp_response",
"error_type",
"error_message",
"folder",
"provider_delivery_id",
"provider_message_id",
"postbox_id",
"evidence",
),
) )
if operation is None: if operation is None:
return return
@@ -270,7 +295,13 @@ def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppen
payload={ payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}), **(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
"attempt_id": attempt_id, "attempt_id": attempt_id,
"attempt_kind": "imap" if isinstance(attempt, ImapAppendAttempt) else "smtp", "attempt_kind": (
"postbox"
if isinstance(attempt, PostboxDeliveryAttempt)
else "imap"
if isinstance(attempt, ImapAppendAttempt)
else "smtp"
),
"job_id": job.id, "job_id": job.id,
"version_id": job.campaign_version_id, "version_id": job.campaign_version_id,
}, },

View File

@@ -83,6 +83,9 @@ class JobSendStatus(StrEnum):
CLAIMED = "claimed" CLAIMED = "claimed"
SENDING = "sending" SENDING = "sending"
SMTP_ACCEPTED = "smtp_accepted" SMTP_ACCEPTED = "smtp_accepted"
POSTBOX_ACCEPTED = "postbox_accepted"
DELIVERED = "delivered"
PARTIALLY_ACCEPTED = "partially_accepted"
SENT = "sent" # legacy value retained for existing databases/reports SENT = "sent" # legacy value retained for existing databases/reports
OUTCOME_UNKNOWN = "outcome_unknown" OUTCOME_UNKNOWN = "outcome_unknown"
FAILED_TEMPORARY = "failed_temporary" FAILED_TEMPORARY = "failed_temporary"
@@ -90,6 +93,19 @@ class JobSendStatus(StrEnum):
CANCELLED = "cancelled" CANCELLED = "cancelled"
class JobPostboxStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
DELIVERING = "delivering"
ACCEPTED = "accepted"
ACCEPTED_VACANT = "accepted_vacant"
PARTIALLY_ACCEPTED = "partially_accepted"
REJECTED_TEMPORARY = "rejected_temporary"
REJECTED_PERMANENT = "rejected_permanent"
OUTCOME_UNKNOWN = "outcome_unknown"
SKIPPED = "skipped"
class JobImapStatus(StrEnum): class JobImapStatus(StrEnum):
NOT_REQUESTED = "not_requested" NOT_REQUESTED = "not_requested"
PENDING = "pending" PENDING = "pending"
@@ -249,9 +265,26 @@ class CampaignJob(Base, TimestampMixin):
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True) validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True) queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True)
send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True) send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True)
delivery_channel_policy: Mapped[str] = mapped_column(
String(30),
default="mail",
nullable=False,
index=True,
)
postbox_status: Mapped[str] = mapped_column(
String(50),
default=JobPostboxStatus.NOT_REQUESTED.value,
nullable=False,
index=True,
)
imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True) imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
postbox_attempt_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
)
last_error: Mapped[str | None] = mapped_column(Text) last_error: Mapped[str | None] = mapped_column(Text)
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -263,6 +296,11 @@ class CampaignJob(Base, TimestampMixin):
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
resolved_postbox_targets: Mapped[list[dict[str, Any]]] = mapped_column(
JSON,
default=list,
nullable=False,
)
resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
@@ -342,6 +380,79 @@ class ImapAppendAttempt(Base, TimestampMixin):
error_message: Mapped[str | None] = mapped_column(Text) error_message: Mapped[str | None] = mapped_column(Text)
class PostboxDeliveryAttempt(Base, TimestampMixin):
__tablename__ = "campaign_postbox_delivery_attempts"
__table_args__ = (
UniqueConstraint(
"job_id",
"target_key",
"attempt_number",
name="uq_campaign_postbox_attempt_target_number",
),
Index(
"ix_campaign_postbox_attempt_idempotency",
"tenant_id",
"idempotency_key",
),
Index(
"ix_campaign_postbox_attempt_job_status",
"job_id",
"status",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=new_uuid,
)
tenant_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
index=True,
)
job_id: Mapped[str] = mapped_column(
ForeignKey("campaign_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
target_key: Mapped[str] = mapped_column(String(64), nullable=False)
target_index: Mapped[int] = mapped_column(Integer, nullable=False)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True,
)
target_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
provider_delivery_id: Mapped[str | None] = mapped_column(String(36))
provider_message_id: Mapped[str | None] = mapped_column(String(36))
postbox_id: Mapped[str | None] = mapped_column(String(36), index=True)
address: Mapped[str | None] = mapped_column(String(500))
holder_count: Mapped[int | None] = mapped_column(Integer)
vacant: Mapped[bool | None] = mapped_column(Boolean)
duplicate: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
error_type: Mapped[str | None] = mapped_column(String(255))
error_code: Mapped[str | None] = mapped_column(String(100))
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
)
__all__ = [ __all__ = [
@@ -359,8 +470,10 @@ __all__ = [
"IssueSeverity", "IssueSeverity",
"JobBuildStatus", "JobBuildStatus",
"JobImapStatus", "JobImapStatus",
"JobPostboxStatus",
"JobQueueStatus", "JobQueueStatus",
"JobSendStatus", "JobSendStatus",
"JobValidationStatus", "JobValidationStatus",
"SendAttempt", "SendAttempt",
"PostboxDeliveryAttempt",
] ]

View File

@@ -7,11 +7,24 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any, Iterator
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_DELIVERY,
PostboxDeliveryCatalogRef,
PostboxDeliveryProvider,
PostboxDeliveryRequest,
PostboxDeliveryResult,
PostboxDirectoryEntryRef,
PostboxDirectoryProvider,
PostboxTargetRef,
)
from govoplan_campaign.backend.runtime import capability from govoplan_campaign.backend.runtime import capability
FILES_CAPABILITY = "files.campaign_attachments" FILES_CAPABILITY = "files.campaign_attachments"
MAIL_CAPABILITY = "mail.campaign_delivery" MAIL_CAPABILITY = "mail.campaign_delivery"
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
class OptionalModuleUnavailable(RuntimeError): class OptionalModuleUnavailable(RuntimeError):
@@ -50,6 +63,10 @@ class MailProfileError(OptionalModuleUnavailable):
pass pass
class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
pass
class _PreparedCampaignSnapshot: class _PreparedCampaignSnapshot:
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None: def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
self._directory = directory self._directory = directory
@@ -209,9 +226,89 @@ class MailCampaignIntegration:
return self._delegate.mock_mailbox() return self._delegate.mock_mailbox()
class PostboxCampaignIntegration:
def __init__(
self,
delivery_delegate: object | None = None,
directory_delegate: object | None = None,
) -> None:
self._delivery_delegate = (
delivery_delegate
if isinstance(delivery_delegate, PostboxDeliveryProvider)
else None
)
self._directory_delegate = (
directory_delegate
if isinstance(directory_delegate, PostboxDirectoryProvider)
else None
)
@property
def available(self) -> bool:
return (
self._delivery_delegate is not None
and self._directory_delegate is not None
)
def delivery_catalog(
self,
session: object,
*,
tenant_id: str,
) -> PostboxDeliveryCatalogRef:
if self._directory_delegate is None:
raise PostboxDeliveryUnavailable(
"Postbox targets are unavailable because the Postbox module "
"is not active."
)
return self._directory_delegate.delivery_catalog(
session,
tenant_id=tenant_id,
)
def resolve_postbox(
self,
session: object,
*,
tenant_id: str,
target: PostboxTargetRef,
materialize: bool = False,
) -> PostboxDirectoryEntryRef | None:
if self._directory_delegate is None:
raise PostboxDeliveryUnavailable(
"Postbox targets are unavailable because the Postbox module "
"is not active."
)
return self._directory_delegate.resolve_postbox(
session,
tenant_id=tenant_id,
target=target,
materialize=materialize,
)
def deliver(
self,
session: object,
request: PostboxDeliveryRequest,
) -> PostboxDeliveryResult:
if self._delivery_delegate is None:
raise PostboxDeliveryUnavailable(
"Postbox delivery is unavailable because the Postbox module "
"is not active."
)
return self._delivery_delegate.deliver(session, request)
def files_integration() -> FilesCampaignIntegration: def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY)) return FilesCampaignIntegration(capability(FILES_CAPABILITY))
def mail_integration() -> MailCampaignIntegration: def mail_integration() -> MailCampaignIntegration:
return MailCampaignIntegration(capability(MAIL_CAPABILITY)) return MailCampaignIntegration(capability(MAIL_CAPABILITY))
def postbox_integration() -> PostboxCampaignIntegration:
return PostboxCampaignIntegration(
capability(POSTBOX_CAPABILITY),
capability(POSTBOX_DIRECTORY_CAPABILITY),
)

View File

@@ -16,6 +16,7 @@ from govoplan_core.core.modules import (
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute,
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
@@ -25,7 +26,12 @@ from govoplan_core.core.modules import (
PermissionDefinition, PermissionDefinition,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
)
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking 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.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
@@ -61,9 +67,9 @@ PERMISSIONS = (
_permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "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: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: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:send", "Send campaigns", "Start real Mail or Postbox delivery.", "Campaigns"),
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"), _permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP or IMAP attempts after inspection.", "Campaigns"), _permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown Mail, Postbox, 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: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: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:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
@@ -156,9 +162,15 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="campaigns", id="campaigns",
name="Campaigns", name="Campaigns",
version="0.1.10", version="0.1.12",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("files", "mail", "notifications", "addresses"), optional_dependencies=(
"files",
"mail",
"notifications",
"addresses",
"postbox",
),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
@@ -191,6 +203,18 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0", version_max_exclusive="0.2.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_DELIVERY,
version_min="0.1.1",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_DIRECTORY,
version_min="0.1.1",
version_max_exclusive="0.2.0",
optional=True,
),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_campaigns_router, route_factory=_campaigns_router,
@@ -216,6 +240,39 @@ manifest = ModuleManifest(
frontend=FrontendModule( frontend=FrontendModule(
module_id="campaigns", module_id="campaigns",
package_name="@govoplan/campaign-webui", package_name="@govoplan/campaign-webui",
routes=(
FrontendRoute(
path="/campaigns",
component="CampaignListPage",
required_any=("campaigns:campaign:read",),
order=20,
),
FrontendRoute(
path="/campaigns/:campaignId/*",
component="CampaignWorkspace",
required_any=("campaigns:campaign:read",),
order=21,
),
FrontendRoute(
path="/operator",
component="OperatorQueuePage",
required_all=("campaigns:campaign:read",),
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:campaign:control",
),
order=30,
),
FrontendRoute(
path="/reports",
component="AggregateReportsPage",
required_any=("campaigns:report:read",),
order=70,
),
FrontendRoute(path="/templates", component="TemplatesPage", order=90),
),
nav_items=( nav_items=(
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20),
NavItem( NavItem(
@@ -234,6 +291,15 @@ manifest = ModuleManifest(
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70), NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
NavItem(path="/templates", label="Templates", icon="layout-template", order=90), NavItem(path="/templates", label="Templates", icon="layout-template", order=90),
), ),
view_surfaces=(
ViewSurface(
id="campaigns.widget.activity",
module_id="campaigns",
kind="section",
label="Campaign activity widget",
order=50,
),
),
), ),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id="campaigns", module_id="campaigns",
@@ -251,6 +317,7 @@ manifest = ModuleManifest(
campaign_models.AttachmentInstance, campaign_models.AttachmentInstance,
campaign_models.SendAttempt, campaign_models.SendAttempt,
campaign_models.ImapAppendAttempt, campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt,
label="Campaigns", label="Campaigns",
), ),
retirement_notes="Destructive retirement drops campaign-owned database tables after the installer captures a database snapshot.", retirement_notes="Destructive retirement drops campaign-owned database tables after the installer captures a database snapshot.",
@@ -267,11 +334,57 @@ manifest = ModuleManifest(
campaign_models.AttachmentInstance, campaign_models.AttachmentInstance,
campaign_models.SendAttempt, campaign_models.SendAttempt,
campaign_models.ImapAppendAttempt, campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt,
label="Campaigns", label="Campaigns",
), ),
), ),
documentation=( documentation=(
*CAMPAIGN_USER_DOCUMENTATION, *CAMPAIGN_USER_DOCUMENTATION,
DocumentationTopic(
id="campaigns.postbox-delivery",
title="Deliver Campaign messages to Postboxes",
summary="Target one or more exact or organization-derived Postboxes per recipient row, independently or alongside Mail.",
body="Configure campaign-wide targets and optional per-row additions or replacements. Derived targets resolve a published Postbox template with organization unit, function, and optional context values, including values sourced from Campaign fields. Targets are frozen during build. Fallback crosses to the second channel only after a confirmed pre-acceptance rejection; accepted or outcome-unknown effects never trigger fallback.",
layer="available",
documentation_types=("user", "admin"),
audience=("campaign_manager", "campaign_reviewer", "campaign_sender", "campaign_operator"),
order=45,
conditions=(
DocumentationCondition(
required_modules=("campaigns", "postbox"),
any_scopes=("campaigns:recipient:write", "campaigns:campaign:send", "campaigns:campaign:reconcile"),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Postboxes", href="/postbox", kind="runtime"),
DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"),
),
related_modules=("postbox", "organizations", "idm", "mail", "audit"),
unlocks=("Audited direct, derived, combined, and pre-acceptance fallback delivery to Postboxes.",),
metadata={
"kind": "workflow",
"route": "/campaigns/{campaign_id}/global-settings",
"screen": "Campaign delivery defaults and recipient data",
"prerequisites": [
"Campaign and Postbox are installed and active.",
"At least one exact Postbox or published Postbox template is available.",
],
"steps": [
"Choose Postbox, Mail and Postbox, or an ordered fallback policy.",
"Configure one or more campaign-wide targets.",
"Optionally add or replace targets for individual recipient rows.",
"Validate and build to freeze the resolved Postbox addresses before review.",
"Review, queue, and inspect channel-specific delivery evidence in the report.",
],
"outcome": "Each active row resolves an auditable set of Postbox targets without introducing a hard Campaign dependency on Postbox.",
"verification": "Confirm the frozen target list in the built job and verify accepted, rejected, or outcome-unknown attempts in Campaign reporting.",
"related_topic_ids": [
"campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.retry-and-reconcile",
],
},
),
DocumentationTopic( DocumentationTopic(
id="campaigns.mail-profile-user-journey", id="campaigns.mail-profile-user-journey",
title="Choose a Mail profile for campaign delivery", title="Choose a Mail profile for campaign delivery",
@@ -493,8 +606,8 @@ manifest = ModuleManifest(
DocumentationTopic( DocumentationTopic(
id="campaigns.workflow.retry-and-reconcile", id="campaigns.workflow.retry-and-reconcile",
title="Retry only known failures and reconcile uncertain effects", 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.", summary="Keep safe-to-retry failures separate from Mail, Postbox, 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.", body="A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown Mail, Postbox, or IMAP effect. Inspect external evidence and reconcile the affected channel before continuing. Accepted Mail attempts and accepted Postbox targets are immutable during partial retries, and repairing Sent never resends accepted Mail.",
layer="evidence", layer="evidence",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("campaign_sender", "campaign_operator"), audience=("campaign_sender", "campaign_operator"),
@@ -553,14 +666,14 @@ manifest = ModuleManifest(
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"), 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"), 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"), related_modules=("mail", "postbox", "files", "addresses", "audit"),
unlocks=("A repeatable, supportable Campaign demonstration rather than an unverified module assembly.",), unlocks=("A repeatable, supportable Campaign demonstration rather than an unverified module assembly.",),
metadata={ metadata={
"kind": "reference", "kind": "reference",
"route": "/campaigns", "route": "/campaigns",
"screen": "Campaign reference composition", "screen": "Campaign reference composition",
"section": "Release, security, integration, and recovery assurance", "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.", "verification": "Run the maintained examples, module-permutation tests, migration and restore drills, target Mail/Postbox/IMAP checks, version-alignment gate, WebUI/i18n checks, and full security audit for the pinned composition.",
"related_topic_ids": [ "related_topic_ids": [
"campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.complete-review", "campaigns.workflow.complete-review",

View File

@@ -33,6 +33,7 @@ from govoplan_campaign.backend.campaign.models import (
ZipArchiveConfig, ZipArchiveConfig,
ZipPasswordMode, ZipPasswordMode,
ZipPasswordScope, ZipPasswordScope,
effective_delivery_channel_policy,
) )
from govoplan_campaign.backend.campaign.template_values import build_template_values 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.services.zip_service import create_zip_archive
@@ -425,8 +426,14 @@ def _attach_files(
return attached_count return attached_count
def _imap_initial_status(config: CampaignConfig) -> ImapStatus: def _imap_initial_status(
if config.delivery.imap_append_sent.enabled: config: CampaignConfig,
entry: EntryConfig,
) -> ImapStatus:
if (
effective_delivery_channel_policy(config, entry).uses_mail
and config.delivery.imap_append_sent.enabled
):
return ImapStatus.PENDING return ImapStatus.PENDING
return ImapStatus.NOT_REQUESTED return ImapStatus.NOT_REQUESTED
@@ -520,7 +527,11 @@ def _message_draft(
send_status = SendStatus.SKIPPED send_status = SendStatus.SKIPPED
imap_status = ImapStatus.SKIPPED imap_status = ImapStatus.SKIPPED
if imap_status is None: if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED imap_status = (
_imap_initial_status(config, entry)
if build_status == BuildStatus.BUILT
else ImapStatus.SKIPPED
)
return MessageDraft( return MessageDraft(
entry_index=entry_index, entry_index=entry_index,
entry_id=entry.id, entry_id=entry.id,
@@ -529,6 +540,10 @@ def _message_draft(
validation_status=validation_status, validation_status=validation_status,
send_status=send_status, send_status=send_status,
imap_status=imap_status, imap_status=imap_status,
delivery_channel_policy=effective_delivery_channel_policy(
config,
entry,
).value,
subject=subject, subject=subject,
from_=_message_address(context.sender), from_=_message_address(context.sender),
from_all=_message_addresses(context.senders), from_all=_message_addresses(context.senders),
@@ -809,6 +824,8 @@ def build_entry_message(
if not entry.active: if not entry.active:
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context) return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
channel_policy = effective_delivery_channel_policy(config, entry)
if channel_policy.uses_mail:
context.validation_status = _validate_required_sender( context.validation_status = _validate_required_sender(
context.senders, context.senders,
context.issues, context.issues,

View File

@@ -78,6 +78,7 @@ class MessageDraft(BaseModel):
validation_status: MessageValidationStatus validation_status: MessageValidationStatus
send_status: SendStatus send_status: SendStatus
imap_status: ImapStatus imap_status: ImapStatus
delivery_channel_policy: str = "mail"
subject: str | None = None subject: str | None = None
from_: MessageAddress | None = Field(default=None, alias="from") from_: MessageAddress | None = Field(default=None, alias="from")

View File

@@ -0,0 +1,32 @@
"""repair a missing IMAP append attempt claim token
Revision ID: e9f0a1b2c3d4
Revises: d8b3e2c1f4a5
Create Date: 2026-07-28 23:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f0a1b2c3d4"
down_revision = "d8b3e2c1f4a5"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
columns = {column["name"] for column in sa.inspect(bind).get_columns("imap_append_attempts")}
if "claim_token" not in columns:
op.add_column(
"imap_append_attempts",
sa.Column("claim_token", sa.String(length=36), nullable=True),
)
def downgrade() -> None:
# The column belongs to revision 3c4d5e6f8192. This repair revision only
# restores drift, so downgrading to d8b3e2c1f4a5 must retain it.
pass

View File

@@ -0,0 +1,22 @@
"""add governed campaign Postbox delivery
Revision ID: f0a1b2c3d4e5
Revises: e9f0a1b2c3d4
Create Date: 2026-07-29 02:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
_migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"f0a1b2c3d4e5_v0115_postbox_delivery"
)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
upgrade = _migration.upgrade
downgrade = _migration.downgrade

View File

@@ -0,0 +1,32 @@
"""repair a missing IMAP append attempt claim token
Revision ID: e9f0a1b2c3d4
Revises: d8b3e2c1f4a5
Create Date: 2026-07-28 23:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f0a1b2c3d4"
down_revision = "d8b3e2c1f4a5"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
columns = {column["name"] for column in sa.inspect(bind).get_columns("imap_append_attempts")}
if "claim_token" not in columns:
op.add_column(
"imap_append_attempts",
sa.Column("claim_token", sa.String(length=36), nullable=True),
)
def downgrade() -> None:
# The column belongs to revision 3c4d5e6f8192. This repair revision only
# restores drift, so downgrading to d8b3e2c1f4a5 must retain it.
pass

View File

@@ -0,0 +1,153 @@
"""add governed campaign Postbox delivery
Revision ID: f0a1b2c3d4e5
Revises: e9f0a1b2c3d4
Create Date: 2026-07-29 02:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f0a1b2c3d4e5"
down_revision = "e9f0a1b2c3d4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaign_jobs",
sa.Column(
"delivery_channel_policy",
sa.String(length=30),
nullable=False,
server_default="mail",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"postbox_status",
sa.String(length=50),
nullable=False,
server_default="not_requested",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"postbox_attempt_count",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"resolved_postbox_targets",
sa.JSON(),
nullable=False,
server_default="[]",
),
)
op.create_index(
op.f("ix_campaign_jobs_delivery_channel_policy"),
"campaign_jobs",
["delivery_channel_policy"],
unique=False,
)
op.create_index(
op.f("ix_campaign_jobs_postbox_status"),
"campaign_jobs",
["postbox_status"],
unique=False,
)
op.create_table(
"campaign_postbox_delivery_attempts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("job_id", sa.String(length=36), nullable=False),
sa.Column("target_key", sa.String(length=64), nullable=False),
sa.Column("target_index", sa.Integer(), nullable=False),
sa.Column("attempt_number", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("target_snapshot", sa.JSON(), nullable=False),
sa.Column("provider_delivery_id", sa.String(length=36), nullable=True),
sa.Column("provider_message_id", sa.String(length=36), nullable=True),
sa.Column("postbox_id", sa.String(length=36), nullable=True),
sa.Column("address", sa.String(length=500), nullable=True),
sa.Column("holder_count", sa.Integer(), nullable=True),
sa.Column("vacant", sa.Boolean(), nullable=True),
sa.Column(
"duplicate",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("error_type", sa.String(length=255), nullable=True),
sa.Column("error_code", sa.String(length=100), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["job_id"],
["campaign_jobs.id"],
name=op.f(
"fk_campaign_postbox_delivery_attempts_job_id_campaign_jobs"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_campaign_postbox_delivery_attempts"),
),
sa.UniqueConstraint(
"job_id",
"target_key",
"attempt_number",
name="uq_campaign_postbox_attempt_target_number",
),
)
for column in ("tenant_id", "job_id", "status", "postbox_id"):
op.create_index(
op.f(f"ix_campaign_postbox_delivery_attempts_{column}"),
"campaign_postbox_delivery_attempts",
[column],
unique=False,
)
op.create_index(
"ix_campaign_postbox_attempt_job_status",
"campaign_postbox_delivery_attempts",
["job_id", "status"],
unique=False,
)
op.create_index(
"ix_campaign_postbox_attempt_idempotency",
"campaign_postbox_delivery_attempts",
["tenant_id", "idempotency_key"],
unique=False,
)
def downgrade() -> None:
op.drop_table("campaign_postbox_delivery_attempts")
op.drop_index(
op.f("ix_campaign_jobs_postbox_status"),
table_name="campaign_jobs",
)
op.drop_index(
op.f("ix_campaign_jobs_delivery_channel_policy"),
table_name="campaign_jobs",
)
op.drop_column("campaign_jobs", "resolved_postbox_targets")
op.drop_column("campaign_jobs", "postbox_attempt_count")
op.drop_column("campaign_jobs", "postbox_status")
op.drop_column("campaign_jobs", "delivery_channel_policy")

View File

@@ -22,6 +22,7 @@ from govoplan_campaign.backend.db.models import (
CampaignVersion, CampaignVersion,
CampaignVersionWorkflowState, CampaignVersionWorkflowState,
JobImapStatus, JobImapStatus,
JobPostboxStatus,
JobQueueStatus, JobQueueStatus,
JobSendStatus, JobSendStatus,
JobValidationStatus, JobValidationStatus,
@@ -30,13 +31,26 @@ from govoplan_campaign.backend.campaign.loader import load_campaign_json, valida
from govoplan_campaign.backend.campaign.mail_profile_boundary import ( from govoplan_campaign.backend.campaign.mail_profile_boundary import (
assert_campaign_uses_mail_profile_reference, assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_id, campaign_mail_profile_id,
campaign_mail_resource_ids,
) )
from govoplan_campaign.backend.campaign.validation import validate_campaign_config from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.postbox_targets import (
resolve_entry_postbox_targets,
)
from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_delivery_summary
from govoplan_campaign.backend.campaign.models import CampaignConfig, SendStatus from govoplan_campaign.backend.campaign.models import (
from govoplan_campaign.backend.integrations import files_integration, mail_integration CampaignConfig,
DeliveryChannelPolicy,
SendStatus,
)
from govoplan_campaign.backend.integrations import (
files_integration,
mail_integration,
postbox_integration,
)
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime" RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
@@ -73,6 +87,7 @@ def load_campaign_config_from_json(
materialized = copy.deepcopy(raw_json) materialized = copy.deepcopy(raw_json)
profile_id = campaign_mail_profile_id(raw_json) profile_id = campaign_mail_profile_id(raw_json)
if profile_id: if profile_id:
references = campaign_mail_resource_ids(raw_json)
summary = mail_integration().campaign_profile_delivery_summary( summary = mail_integration().campaign_profile_delivery_summary(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -80,6 +95,10 @@ def load_campaign_config_from_json(
profile_id=profile_id, profile_id=profile_id,
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
owner_group_id=owner_group_id, owner_group_id=owner_group_id,
smtp_server_id=references["smtp_server_id"],
smtp_credential_id=references["smtp_credential_id"],
imap_server_id=references["imap_server_id"],
imap_credential_id=references["imap_credential_id"],
) )
materialized.setdefault("server", {})["profile_capabilities"] = { materialized.setdefault("server", {})["profile_capabilities"] = {
"smtp_available": bool(summary.get("smtp_available")), "smtp_available": bool(summary.get("smtp_available")),
@@ -323,9 +342,19 @@ def validate_campaign_version(
) as prepared: ) as prepared:
managed_raw = load_campaign_json(prepared.path) managed_raw = load_campaign_json(prepared.path)
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id) managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
report = validate_campaign_config(managed_config, campaign_file=prepared.path, check_files=True) report = validate_campaign_config(
managed_config,
campaign_file=prepared.path,
check_files=True,
postbox_available=postbox_integration().available,
)
else: else:
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=False) report = validate_campaign_config(
config,
campaign_file=snapshot_path,
check_files=False,
postbox_available=postbox_integration().available,
)
report_json = report.model_dump(mode="json") report_json = report.model_dump(mode="json")
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count}) report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
version.validation_summary = report_json version.validation_summary = report_json
@@ -388,6 +417,7 @@ def _job_from_message(
campaign_id: str, campaign_id: str,
version_id: str, version_id: str,
message: MessageDraft, message: MessageDraft,
resolved_postbox_targets: list[dict[str, Any]] | None = None,
) -> CampaignJob: ) -> CampaignJob:
recipient_email = message.to[0].email if message.to else None recipient_email = message.to[0].email if message.to else None
eml_sha256, message_id_header = _eml_evidence(message.eml_path) eml_sha256, message_id_header = _eml_evidence(message.eml_path)
@@ -411,6 +441,12 @@ def _job_from_message(
if message.send_status == SendStatus.SKIPPED if message.send_status == SendStatus.SKIPPED
else JobSendStatus.NOT_QUEUED.value else JobSendStatus.NOT_QUEUED.value
), ),
delivery_channel_policy=message.delivery_channel_policy,
postbox_status=(
JobPostboxStatus.PENDING.value
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
else JobPostboxStatus.NOT_REQUESTED.value
),
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value, imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
resolved_recipients={ resolved_recipients={
"from": message.from_.model_dump(mode="json") if message.from_ else None, "from": message.from_.model_dump(mode="json") if message.from_ else None,
@@ -422,12 +458,184 @@ def _job_from_message(
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to], "bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to], "disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
}, },
resolved_postbox_targets=resolved_postbox_targets or [],
resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments], resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments],
issues_snapshot=[item.model_dump(mode="json") for item in message.issues], issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None, last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
) )
def _resolve_built_postbox_targets(
session: Session,
*,
tenant_id: str,
config: CampaignConfig,
built_messages: list[Any],
entries_by_index: dict[int, Any],
) -> dict[int, list[dict[str, Any]]]:
resolved_by_index: dict[int, list[dict[str, Any]]] = {}
for built in built_messages:
if not DeliveryChannelPolicy(built.draft.delivery_channel_policy).uses_postbox:
continue
entry = entries_by_index.get(built.draft.entry_index)
if entry is None:
raise CampaignPersistenceError("Built recipient row is missing from the campaign input.")
resolved, issues, validation_status = resolve_entry_postbox_targets(
session,
tenant_id=tenant_id,
config=config,
entry=entry,
validation_status=built.draft.validation_status,
materialize=True,
)
built.draft.issues.extend(issues)
built.draft.validation_status = validation_status
resolved_by_index[built.draft.entry_index] = resolved
return resolved_by_index
def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
report_json = result.report.model_dump(mode="json", by_alias=True)
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
if isinstance(message_payload, dict):
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
report_json.update({
"built_at": datetime.now(UTC).isoformat(),
"build_token": uuid4().hex,
"built_count": result.report.built_count,
"build_failed_count": result.report.build_failed_count,
"ready_count": result.report.ready_count,
"warning_count": result.report.warning_count,
"needs_review_count": result.report.needs_review_count,
"blocked_count": result.report.blocked_count,
"excluded_count": result.report.excluded_count,
"inactive_count": result.report.inactive_count,
"queueable_count": result.report.queueable_count,
})
return report_json
def _replace_version_jobs(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
built_messages: list[Any],
postbox_targets_by_index: dict[int, list[dict[str, Any]]],
) -> list[tuple[CampaignJob, MessageDraft]]:
session.query(CampaignIssue).filter(
CampaignIssue.campaign_version_id == version_id,
CampaignIssue.job_id.is_not(None),
).delete(synchronize_session=False)
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).delete(synchronize_session=False)
session.flush()
pairs: list[tuple[CampaignJob, MessageDraft]] = []
for built in built_messages:
job = _job_from_message(
tenant_id=tenant_id,
campaign_id=campaign_id,
version_id=version_id,
message=built.draft,
resolved_postbox_targets=postbox_targets_by_index.get(built.draft.entry_index, []),
)
session.add(job)
pairs.append((job, built.draft))
session.flush()
return pairs
def _mail_execution_profile(
session: Session,
*,
version: CampaignVersion,
config: CampaignConfig,
jobs: list[CampaignJob],
) -> tuple[str | None, dict[str, Any]]:
if not any(DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs):
return None, {}
if not 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 that use Mail.")
summary = profile_delivery_summary(session, version)
if not summary.get("smtp_transport_revision"):
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision.")
return profile_id, summary
def _store_execution_snapshot(
session: Session,
*,
version: CampaignVersion,
config: CampaignConfig,
jobs: list[CampaignJob],
build_summary: dict[str, Any],
) -> None:
profile_id, profile = _mail_execution_profile(session, version=version, config=config, jobs=jobs)
snapshot, snapshot_hash = create_execution_snapshot(
version,
mail_profile_id=profile_id,
smtp_server_id=profile.get("smtp_server_id"),
smtp_credential_id=profile.get("smtp_credential_id"),
imap_server_id=profile.get("imap_server_id"),
imap_credential_id=profile.get("imap_credential_id"),
smtp_transport_revision=profile.get("smtp_transport_revision"),
imap_transport_revision=profile.get("imap_transport_revision"),
delivery=config.delivery,
jobs=jobs,
build_summary=build_summary,
)
version.execution_snapshot = snapshot
version.execution_snapshot_hash = snapshot_hash
version.execution_snapshot_at = datetime.now(UTC)
def _store_job_issues(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
job_build_pairs: list[tuple[CampaignJob, MessageDraft]],
) -> None:
for job, message in job_build_pairs:
session.add_all([
CampaignIssue(
tenant_id=tenant_id,
campaign_id=campaign_id,
campaign_version_id=version_id,
job_id=job.id,
severity=issue.severity,
code=issue.code,
message=issue.message,
source=issue.source,
behavior=issue.behavior,
)
for issue in message.issues
])
def _apply_campaign_build_state(
campaign: Campaign,
version: CampaignVersion,
*,
needs_review_count: int,
blocked_count: int,
queueable_count: int,
) -> None:
if needs_review_count or blocked_count:
campaign.status = CampaignStatus.NEEDS_REVIEW.value
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
elif queueable_count > 0:
campaign.status = CampaignStatus.READY_TO_QUEUE.value
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
else:
campaign.status = CampaignStatus.VALIDATED.value
def build_campaign_version( def build_campaign_version(
session: Session, session: Session,
*, *,
@@ -461,98 +669,55 @@ def build_campaign_version(
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id) managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml) result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
files.annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path) files.annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
report_json = result.report.model_dump(mode="json", by_alias=True) entries_by_index = {
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False): index: entry
if isinstance(message_payload, dict): for index, entry in enumerate(
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments] load_campaign_entries(
report_json["built_at"] = datetime.now(UTC).isoformat() managed_config,
report_json["build_token"] = uuid4().hex campaign_file=prepared.path,
report_json.update({ ),
"built_count": result.report.built_count, start=1,
"build_failed_count": result.report.build_failed_count, )
"ready_count": result.report.ready_count, }
"warning_count": result.report.warning_count, resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
"needs_review_count": result.report.needs_review_count, session,
"blocked_count": result.report.blocked_count, tenant_id=tenant_id,
"excluded_count": result.report.excluded_count, config=managed_config,
"inactive_count": result.report.inactive_count, built_messages=result.built_messages,
"queueable_count": result.report.queueable_count, entries_by_index=entries_by_index,
}) )
report_json = _campaign_build_report(result, files)
version.build_summary = report_json version.build_summary = report_json
editor_state = copy.deepcopy(version.editor_state or {}) editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None) editor_state.pop("review_send", None)
version.editor_state = editor_state version.editor_state = editor_state
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds. job_build_pairs = _replace_version_jobs(
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False) session,
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
session.flush()
job_build_pairs: list[tuple[CampaignJob, MessageDraft]] = []
for built in result.built_messages:
job = _job_from_message(
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign.id, campaign_id=campaign.id,
version_id=version.id, version_id=version.id,
message=built.draft, built_messages=result.built_messages,
postbox_targets_by_index=resolved_postbox_targets_by_index,
) )
session.add(job) jobs = [job for job, _message in job_build_pairs]
job_build_pairs.append((job, built.draft)) files.record_campaign_attachment_uses_for_jobs(session, jobs, stage="built")
_store_execution_snapshot(session, version=version, config=managed_config, jobs=jobs, build_summary=report_json)
# Assign all job IDs in one round-trip, then persist exact attachment use _store_job_issues(
# records in bulk. This avoids one flush plus several metadata queries per
# recipient for large campaigns.
session.flush()
files.record_campaign_attachment_uses_for_jobs(
session, session,
[job for job, _message in job_build_pairs],
stage="built",
)
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,
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,
)
version.execution_snapshot = execution_snapshot
version.execution_snapshot_hash = execution_snapshot_hash
version.execution_snapshot_at = datetime.now(UTC)
for job, message in job_build_pairs:
for issue in message.issues:
session.add(
CampaignIssue(
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign.id, campaign_id=campaign.id,
campaign_version_id=version.id, version_id=version.id,
job_id=job.id, job_build_pairs=job_build_pairs,
severity=issue.severity,
code=issue.code,
message=issue.message,
source=issue.source,
behavior=issue.behavior,
) )
_apply_campaign_build_state(
campaign,
version,
needs_review_count=result.report.needs_review_count,
blocked_count=result.report.blocked_count,
queueable_count=result.report.queueable_count,
) )
if result.report.needs_review_count or result.report.blocked_count:
campaign.status = CampaignStatus.NEEDS_REVIEW.value
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
elif result.report.queueable_count > 0:
campaign.status = CampaignStatus.READY_TO_QUEUE.value
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
else:
campaign.status = CampaignStatus.VALIDATED.value
session.add(version) session.add(version)
session.add(campaign) session.add(campaign)
session.commit() session.commit()

View File

@@ -328,6 +328,112 @@ def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> No
campaign.external_id = campaign_meta.get("id") or campaign.external_id campaign.external_id = campaign_meta.get("id") or campaign.external_id
def _assert_fork_source_allowed(session: Session, *, campaign: Campaign, source: CampaignVersion) -> None:
if campaign_has_active_working_version(session, campaign):
current = session.get(CampaignVersion, campaign.current_version_id)
current_number = current.version_number if current else "current"
raise LockedCampaignVersionError(
f"Campaign already has active working version #{current_number}. "
"Unlock or continue editing that version instead of creating a parallel draft."
)
if campaign.current_version_id and source.id != campaign.current_version_id:
raise LockedCampaignVersionError(
"Historical versions remain review-only and cannot become a new branch. "
"Create the next working copy from the campaign's current immutable version."
)
def _fork_runtime_json(
session: Session,
*,
tenant_id: str,
campaign: Campaign,
source: CampaignVersion,
raw_json: dict[str, Any] | None,
source_filename: str | None,
source_base_path: str | None,
migrate_legacy_mail_settings: bool,
) -> dict[str, Any]:
source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
requires_migration = bool(campaign_mail_profile_boundary_violations(source_json))
if requires_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 requires_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,
source_base_path=source_base_path,
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,
)
return runtime_json
def _new_forked_campaign_version(
session: Session,
*,
campaign: Campaign,
source: CampaignVersion,
runtime_json: dict[str, Any],
current_flow: str | None,
current_step: str | None,
editor_state: dict[str, Any] | None,
source_filename: str | None,
source_base_path: str | None,
autosave: bool,
) -> CampaignVersion:
return CampaignVersion(
campaign_id=campaign.id,
version_number=_next_version_number(session, campaign.id),
raw_json=runtime_json,
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")),
source_filename=source_filename if source_filename is not None else source.source_filename,
source_base_path=source_base_path if source_base_path is not None else source.source_base_path,
workflow_state=CampaignVersionWorkflowState.EDITING.value,
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=(
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,
)
def _persist_forked_version(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
commit: bool,
) -> None:
session.add(version)
session.flush()
_apply_campaign_metadata(campaign, version.raw_json)
campaign.current_version_id = version.id
campaign.status = CampaignStatus.DRAFT.value
session.add(campaign)
if commit:
_write_campaign_snapshot(version)
session.commit()
else:
session.flush()
def fork_campaign_version_for_edit( def fork_campaign_version_for_edit(
session: Session, session: Session,
*, *,
@@ -354,69 +460,30 @@ def fork_campaign_version_for_edit(
source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = _require_campaign(session, campaign_id) campaign = _require_campaign(session, campaign_id)
if campaign_has_active_working_version(session, campaign): _assert_fork_source_allowed(session, campaign=campaign, source=source)
current = session.get(CampaignVersion, campaign.current_version_id) runtime_json = _fork_runtime_json(
current_number = current.version_number if current else "current" session,
raise LockedCampaignVersionError( tenant_id=tenant_id,
f"Campaign already has active working version #{current_number}. " campaign=campaign,
"Unlock or continue editing that version instead of creating a parallel draft." source=source,
) raw_json=raw_json,
if campaign.current_version_id and source.id != campaign.current_version_id:
raise LockedCampaignVersionError(
"Historical versions remain review-only and cannot become a new branch. "
"Create the next working copy from the campaign's current immutable version."
)
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, source_filename=source_filename,
source_base_path=source_base_path, source_base_path=source_base_path,
managed_files_available=files_integration().available, migrate_legacy_mail_settings=migrate_legacy_mail_settings,
) )
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json) new_version = _new_forked_campaign_version(
assert_campaign_uses_mail_profile_reference(runtime_json) session,
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id) campaign=campaign,
source=source,
new_version = CampaignVersion( runtime_json=runtime_json,
campaign_id=campaign.id, current_flow=current_flow,
version_number=_next_version_number(session, campaign.id), current_step=current_step,
raw_json=runtime_json, editor_state=editor_state,
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")), source_filename=source_filename,
source_filename=source_filename if source_filename is not None else source.source_filename, source_base_path=source_base_path,
source_base_path=source_base_path if source_base_path is not None else source.source_base_path, autosave=autosave,
workflow_state=CampaignVersionWorkflowState.EDITING.value,
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=(
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) _persist_forked_version(session, campaign=campaign, version=new_version, commit=commit)
session.flush()
_apply_campaign_metadata(campaign, runtime_json)
campaign.current_version_id = new_version.id
campaign.status = CampaignStatus.DRAFT.value
session.add(campaign)
if commit:
_write_campaign_snapshot(new_version)
session.commit()
else:
session.flush()
return new_version return new_version
@@ -530,6 +597,113 @@ def unlock_validated_campaign_version(
session.flush() session.flush()
return version return version
def _assert_update_paths_safe(
raw_json: dict[str, Any] | None,
*,
source_filename: str | None,
source_base_path: str | None,
) -> None:
if raw_json is None and source_filename is None and source_base_path is None:
return
assert_server_safe_campaign_paths(
raw_json if raw_json is not None else {},
source_filename=source_filename,
source_base_path=source_base_path,
managed_files_available=files_integration().available,
)
def _updated_runtime_json(
session: Session,
*,
tenant_id: str,
campaign: Campaign,
version: CampaignVersion,
raw_json: dict[str, Any],
source_base_path: str | None,
migrate_legacy_mail_settings: bool,
) -> dict[str, Any]:
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
requires_migration = bool(campaign_mail_profile_boundary_violations(version.raw_json))
if requires_migration 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 requires_migration 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,
)
return runtime_json
def _apply_version_field_updates(
version: CampaignVersion,
*,
current_flow: str | None,
current_step: str | None,
workflow_state: str | None,
is_complete: bool | None,
editor_state: dict[str, Any] | None,
source_filename: str | None,
source_base_path: str | None,
autosave: bool,
) -> None:
updates = (
("current_flow", current_flow),
("current_step", current_step),
("workflow_state", workflow_state),
("is_complete", is_complete),
("source_filename", source_filename),
("source_base_path", source_base_path),
)
for field_name, value in updates:
if value is not None:
setattr(version, field_name, value)
if editor_state is not None:
version.editor_state = validate_campaign_editor_state(editor_state)
if autosave:
version.autosaved_at = datetime.now(UTC)
def _invalidate_version_content(session: Session, *, campaign: Campaign, version: CampaignVersion) -> None:
version.validation_summary = None
version.build_summary = None
clear_execution_snapshot(version)
version.locked_at = None
version.locked_by_user_id = None
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
campaign.status = CampaignStatus.DRAFT.value
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
def _persist_updated_version(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
commit: bool,
) -> None:
session.add(version)
session.add(campaign)
session.flush()
if commit:
_write_campaign_snapshot(version)
session.commit()
else:
session.flush()
def update_campaign_version( def update_campaign_version(
session: Session, session: Session,
*, *,
@@ -548,13 +722,7 @@ def update_campaign_version(
migrate_legacy_mail_settings: bool = False, migrate_legacy_mail_settings: bool = False,
commit: bool = True, commit: bool = True,
) -> CampaignVersion: ) -> CampaignVersion:
if raw_json is not None or source_filename is not None or source_base_path is not None: _assert_update_paths_safe(raw_json, source_filename=source_filename, source_base_path=source_base_path)
assert_server_safe_campaign_paths(
raw_json if raw_json is not None else {},
source_filename=source_filename,
source_base_path=source_base_path,
managed_files_available=files_integration().available,
)
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = _require_campaign(session, campaign_id) campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="edit") ensure_current_working_version(campaign, version, action="edit")
@@ -565,61 +733,35 @@ def update_campaign_version(
) )
if raw_json is not None: 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) runtime_json = _updated_runtime_json(
if campaign_mail_profile_boundary_violations(version.raw_json) and not migrate_legacy_mail_settings: session,
raise CampaignPersistenceError( tenant_id=tenant_id,
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail " campaign=campaign,
"profile on the Mail settings page and explicitly save the migration; the stored legacy version " version=version,
"will not be changed automatically." raw_json=raw_json,
source_base_path=source_base_path,
migrate_legacy_mail_settings=migrate_legacy_mail_settings,
) )
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.raw_json = runtime_json
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0")) version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
_apply_campaign_metadata(campaign, runtime_json) _apply_campaign_metadata(campaign, runtime_json)
if current_flow is not None: _apply_version_field_updates(
version.current_flow = current_flow version,
if current_step is not None: current_flow=current_flow,
version.current_step = current_step current_step=current_step,
if workflow_state is not None: workflow_state=workflow_state,
version.workflow_state = workflow_state is_complete=is_complete,
if is_complete is not None: editor_state=editor_state,
version.is_complete = is_complete source_filename=source_filename,
if editor_state is not None: source_base_path=source_base_path,
version.editor_state = validate_campaign_editor_state(editor_state) autosave=autosave,
if source_filename is not None: )
version.source_filename = source_filename
if source_base_path is not None:
version.source_base_path = source_base_path
if autosave:
version.autosaved_at = datetime.now(UTC)
# Changes invalidate previous build and validation summaries. # Changes invalidate previous build and validation summaries.
if raw_json is not None: if raw_json is not None:
version.validation_summary = None _invalidate_version_content(session, campaign=campaign, version=version)
version.build_summary = None _persist_updated_version(session, campaign=campaign, version=version, commit=commit)
clear_execution_snapshot(version)
version.locked_at = None
version.locked_by_user_id = None
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
campaign.status = CampaignStatus.DRAFT.value
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
session.add(version)
session.add(campaign)
session.flush()
if commit:
_write_campaign_snapshot(version)
session.commit()
else:
session.flush()
return version return version

View File

@@ -58,6 +58,9 @@ class AggregateOutcomeCounts(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
smtp_accepted: AggregateCount smtp_accepted: AggregateCount
postbox_accepted: AggregateCount
delivered: AggregateCount
partially_accepted: AggregateCount
failed: AggregateCount failed: AggregateCount
outcome_unknown: AggregateCount outcome_unknown: AggregateCount
queued_or_active: AggregateCount queued_or_active: AggregateCount
@@ -105,6 +108,9 @@ class AggregateCampaignReport(BaseModel):
_OUTCOME_KEYS = ( _OUTCOME_KEYS = (
"smtp_accepted", "smtp_accepted",
"postbox_accepted",
"delivered",
"partially_accepted",
"failed", "failed",
"outcome_unknown", "outcome_unknown",
"queued_or_active", "queued_or_active",
@@ -175,7 +181,18 @@ def _query_aggregate_facts(
if version is None: if version is None:
return _AggregateFacts.empty() return _AggregateFacts.empty()
accepted = CampaignJob.send_status.in_({"smtp_accepted", "sent"}) smtp_accepted = CampaignJob.send_status.in_(
{"smtp_accepted", "sent"}
)
accepted = CampaignJob.send_status.in_(
{
"smtp_accepted",
"postbox_accepted",
"delivered",
"partially_accepted",
"sent",
}
)
failed = CampaignJob.send_status.in_({"failed_temporary", "failed_permanent"}) failed = CampaignJob.send_status.in_({"failed_temporary", "failed_permanent"})
unknown = CampaignJob.send_status == "outcome_unknown" unknown = CampaignJob.send_status == "outcome_unknown"
active = CampaignJob.send_status.in_({"queued", "claimed", "sending"}) active = CampaignJob.send_status.in_({"queued", "claimed", "sending"})
@@ -188,7 +205,33 @@ def _query_aggregate_facts(
row = ( row = (
session.query( session.query(
func.count(CampaignJob.id).label("denominator"), func.count(CampaignJob.id).label("denominator"),
func.sum(case((accepted, 1), else_=0)).label("smtp_accepted"), func.sum(case((smtp_accepted, 1), else_=0)).label(
"smtp_accepted"
),
func.sum(
case(
(
CampaignJob.send_status == "postbox_accepted",
1,
),
else_=0,
)
).label("postbox_accepted"),
func.sum(
case(
(CampaignJob.send_status == "delivered", 1),
else_=0,
)
).label("delivered"),
func.sum(
case(
(
CampaignJob.send_status == "partially_accepted",
1,
),
else_=0,
)
).label("partially_accepted"),
func.sum(case((failed, 1), else_=0)).label("failed"), func.sum(case((failed, 1), else_=0)).label("failed"),
func.sum(case((unknown, 1), else_=0)).label("outcome_unknown"), func.sum(case((unknown, 1), else_=0)).label("outcome_unknown"),
func.sum(case((active, 1), else_=0)).label("queued_or_active"), func.sum(case((active, 1), else_=0)).label("queued_or_active"),
@@ -353,6 +396,12 @@ def _outcome_counts(jobs: list[CampaignJob]) -> dict[str, int]:
status = job.send_status status = job.send_status
if status in {"smtp_accepted", "sent"}: if status in {"smtp_accepted", "sent"}:
counts["smtp_accepted"] += 1 counts["smtp_accepted"] += 1
elif status == "postbox_accepted":
counts["postbox_accepted"] += 1
elif status == "delivered":
counts["delivered"] += 1
elif status == "partially_accepted":
counts["partially_accepted"] += 1
elif status in {"failed_temporary", "failed_permanent"}: elif status in {"failed_temporary", "failed_permanent"}:
counts["failed"] += 1 counts["failed"] += 1
elif status == "outcome_unknown": elif status == "outcome_unknown":
@@ -460,10 +509,15 @@ def _completion_state(
return "outcome_unknown" return "outcome_unknown"
if counts["queued_or_active"]: if counts["queued_or_active"]:
return "in_progress" return "in_progress"
accepted = counts["smtp_accepted"] fully_accepted = (
if accepted == total: counts["smtp_accepted"]
+ counts["postbox_accepted"]
+ counts["delivered"]
)
partially_accepted = counts["partially_accepted"]
if fully_accepted == total:
return "completed" return "completed"
if accepted: if fully_accepted or partially_accepted:
return "partially_completed" return "partially_completed"
return "incomplete" return "incomplete"

View File

@@ -5,6 +5,7 @@ import io
import logging import logging
import math import math
from collections import Counter from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@@ -18,6 +19,7 @@ from govoplan_campaign.backend.db.models import (
CampaignJob, CampaignJob,
CampaignVersion, CampaignVersion,
ImapAppendAttempt, ImapAppendAttempt,
PostboxDeliveryAttempt,
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
@@ -33,6 +35,8 @@ class CampaignReportError(RuntimeError):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CAMPAIGN_JSON_JOB_LIMIT = 5_000
CAMPAIGN_CSV_JOB_LIMIT = 100_000
def _utcnow_iso() -> str: def _utcnow_iso() -> str:
@@ -96,8 +100,9 @@ def _version_info(
def _load_delivery_info( def _load_delivery_info(
version: CampaignVersion | None, version: CampaignVersion | None,
jobs: list[CampaignJob], jobs: list[CampaignJob] | None = None,
*, *,
pending_job_count: int | None = None,
include_diagnostics: bool = False, include_diagnostics: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Read deterministic delivery settings from the immutable execution snapshot.""" """Read deterministic delivery settings from the immutable execution snapshot."""
@@ -143,10 +148,17 @@ def _load_delivery_info(
return default return default
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
pending = [job for job in jobs if job.send_status in {"queued", "claimed", "sending"}] if pending_job_count is None:
pending_job_count = sum(
1
for job in jobs or []
if job.send_status in {"queued", "claimed", "sending"}
)
estimated_seconds = None estimated_seconds = None
if messages_per_minute and pending: if messages_per_minute and pending_job_count:
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60)) estimated_seconds = int(
math.ceil((pending_job_count / messages_per_minute) * 60)
)
result = { result = {
"rate_limit": { "rate_limit": {
@@ -259,8 +271,184 @@ def _attachment_summary(jobs: list[CampaignJob]) -> dict[str, Any]:
} }
@dataclass(slots=True)
class _JobReportAggregate:
total: int = 0
pending: int = 0
queueable: int = 0
queueable_unattempted: int = 0
retryable: int = 0
cancellable: int = 0
needs_attention: int = 0
build: Counter[str] = field(default_factory=Counter)
validation: Counter[str] = field(default_factory=Counter)
queue: Counter[str] = field(default_factory=Counter)
send: Counter[str] = field(default_factory=Counter)
postbox: Counter[str] = field(default_factory=Counter)
imap: Counter[str] = field(default_factory=Counter)
issue_total: int = 0
issue_severity: Counter[str] = field(default_factory=Counter)
issue_code: Counter[str] = field(default_factory=Counter)
issue_behavior: Counter[str] = field(default_factory=Counter)
attachment_total: int = 0
attachment_matches: int = 0
attachment_zip_enabled: int = 0
attachment_missing: int = 0
attachment_ambiguous: int = 0
attachment_status: Counter[str] = field(default_factory=Counter)
attachment_behavior: Counter[str] = field(default_factory=Counter)
recent_failures: list[CampaignJob] = field(default_factory=list)
def add(
self,
job: CampaignJob,
*,
retry_max_attempts: int | None,
include_recent_failures: bool,
) -> None:
self.total += 1
self._add_statuses(job)
self._add_delivery_counts(job, retry_max_attempts=retry_max_attempts)
self._add_issues(job)
self._add_attachments(job)
self._add_recent_failure(job, include_recent_failures=include_recent_failures)
def _add_statuses(self, job: CampaignJob) -> None:
self.build[job.build_status or "unknown"] += 1
self.validation[job.validation_status or "unknown"] += 1
self.queue[job.queue_status or "unknown"] += 1
self.send[job.send_status or "unknown"] += 1
self.postbox[job.postbox_status or "unknown"] += 1
self.imap[job.imap_status or "unknown"] += 1
def _add_delivery_counts(self, job: CampaignJob, *, retry_max_attempts: int | None) -> None:
if job.send_status in {"queued", "claimed", "sending"}:
self.pending += 1
if _job_is_queueable(job):
self.queueable += 1
if _job_is_queueable_unattempted(job):
self.queueable_unattempted += 1
if _job_is_retryable(job, retry_max_attempts=retry_max_attempts):
self.retryable += 1
if _job_is_cancellable(job):
self.cancellable += 1
if _job_needs_attention(job):
self.needs_attention += 1
def _add_recent_failure(self, job: CampaignJob, *, include_recent_failures: bool) -> None:
if not include_recent_failures or not _job_is_recent_failure(job):
return
self.recent_failures.append(job)
self.recent_failures.sort(key=lambda item: item.updated_at or item.created_at, reverse=True)
del self.recent_failures[20:]
def _add_issues(self, job: CampaignJob) -> None:
for issue in job.issues_snapshot or []:
if not isinstance(issue, dict):
continue
self.issue_total += 1
self.issue_severity[issue.get("severity") or "unknown"] += 1
self.issue_code[issue.get("code") or "unknown"] += 1
if issue.get("behavior"):
self.issue_behavior[str(issue["behavior"])] += 1
def _add_attachments(self, job: CampaignJob) -> None:
for attachment in job.resolved_attachments or []:
if not isinstance(attachment, dict):
continue
self.attachment_total += 1
status_value = str(attachment.get("status") or "unknown")
self.attachment_status[status_value] += 1
if attachment.get("behavior"):
self.attachment_behavior[str(attachment["behavior"])] += 1
matches = attachment.get("matches")
if isinstance(matches, list):
self.attachment_matches += len(matches)
if attachment.get("zip_enabled"):
self.attachment_zip_enabled += 1
if status_value == "missing":
self.attachment_missing += 1
if status_value == "ambiguous":
self.attachment_ambiguous += 1
NON_CANCELLABLE_SEND_STATUSES = {
"skipped",
"smtp_accepted",
"postbox_accepted",
"delivered",
"partially_accepted",
"sent",
"outcome_unknown",
"claimed",
"sending",
"cancelled",
}
def _job_is_queueable(job: CampaignJob) -> bool:
return job.validation_status in {"ready", "warning"} and job.build_status == "built"
def _job_is_queueable_unattempted(job: CampaignJob) -> bool:
return (
job.attempt_count == 0
and job.postbox_attempt_count == 0
and job.send_status in {"not_queued", "cancelled"}
and _job_is_queueable(job)
)
def _job_is_retryable(job: CampaignJob, *, retry_max_attempts: int | None) -> bool:
attempts_available = retry_max_attempts is None or job.attempt_count < retry_max_attempts
return job.send_status in {"failed_temporary", "partially_accepted"} and attempts_available
def _job_is_cancellable(job: CampaignJob) -> bool:
return job.send_status not in NON_CANCELLABLE_SEND_STATUSES
def _job_needs_attention(job: CampaignJob) -> bool:
return (
job.validation_status in {"needs_review", "blocked"}
or job.send_status
in {
"failed_temporary",
"failed_permanent",
"partially_accepted",
"outcome_unknown",
"claimed",
"sending",
}
or job.postbox_status
in {
"partially_accepted",
"rejected_temporary",
"rejected_permanent",
"outcome_unknown",
}
or job.imap_status == "failed"
)
def _job_is_recent_failure(job: CampaignJob) -> bool:
return bool(
job.last_error
or str(job.send_status).startswith("failed")
or job.send_status == "partially_accepted"
or job.postbox_status
in {
"partially_accepted",
"rejected_temporary",
"rejected_permanent",
"outcome_unknown",
}
or job.imap_status == "failed"
)
def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[str, Any]]: def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[str, Any]]:
failed = [job for job in jobs if job.last_error or str(job.send_status).startswith("failed") or job.imap_status == "failed"] failed = [job for job in jobs if _job_is_recent_failure(job)]
failed.sort(key=lambda job: job.updated_at or job.created_at, reverse=True) failed.sort(key=lambda job: job.updated_at or job.created_at, reverse=True)
return [ return [
{ {
@@ -270,12 +458,15 @@ def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[s
"recipient_email": job.recipient_email, "recipient_email": job.recipient_email,
"validation_status": job.validation_status, "validation_status": job.validation_status,
"send_status": job.send_status, "send_status": job.send_status,
"postbox_status": job.postbox_status,
"imap_status": job.imap_status, "imap_status": job.imap_status,
"attempt_count": job.attempt_count, "attempt_count": job.attempt_count,
"postbox_attempt_count": job.postbox_attempt_count,
"last_error": public_delivery_result_message( "last_error": public_delivery_result_message(
last_error=job.last_error, last_error=job.last_error,
send_status=job.send_status, send_status=job.send_status,
imap_status=job.imap_status, imap_status=job.imap_status,
postbox_status=job.postbox_status,
), ),
"updated_at": job.updated_at.isoformat() if job.updated_at else None, "updated_at": job.updated_at.isoformat() if job.updated_at else None,
} }
@@ -294,8 +485,22 @@ def _job_row(job: CampaignJob, *, include_diagnostics: bool = False) -> dict[str
"validation_status": job.validation_status, "validation_status": job.validation_status,
"queue_status": job.queue_status, "queue_status": job.queue_status,
"send_status": job.send_status, "send_status": job.send_status,
"delivery_channel_policy": getattr(
job,
"delivery_channel_policy",
"mail",
),
"postbox_status": getattr(
job,
"postbox_status",
"not_requested",
),
"imap_status": job.imap_status, "imap_status": job.imap_status,
"attempt_count": job.attempt_count, "attempt_count": job.attempt_count,
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
"postbox_target_count": len(
getattr(job, "resolved_postbox_targets", None) or []
),
"queued_at": job.queued_at.isoformat() if job.queued_at else None, "queued_at": job.queued_at.isoformat() if job.queued_at else None,
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_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, "sent_at": job.sent_at.isoformat() if job.sent_at else None,
@@ -303,6 +508,7 @@ def _job_row(job: CampaignJob, *, include_diagnostics: bool = False) -> dict[str
last_error=job.last_error, last_error=job.last_error,
send_status=job.send_status, send_status=job.send_status,
imap_status=job.imap_status, imap_status=job.imap_status,
postbox_status=getattr(job, "postbox_status", "not_requested"),
), ),
"eml_size_bytes": job.eml_size_bytes, "eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256, "eml_sha256": job.eml_sha256,
@@ -388,24 +594,48 @@ def _job_evidence_row(
"campaign_id": job.campaign_id, "campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id, "campaign_version_id": job.campaign_version_id,
"message_id_header": job.message_id_header, "message_id_header": job.message_id_header,
"from": _address_summary(recipients.get("from")), **_job_evidence_addresses(recipients),
"to": _address_summary(recipients.get("to")), "postbox_targets": _job_postbox_target_summary(job),
"cc": _address_summary(recipients.get("cc")),
"bcc": _address_summary(recipients.get("bcc")),
"reply_to": _address_summary(recipients.get("reply_to")),
"attachment_names": _attachment_names(job.resolved_attachments), "attachment_names": _attachment_names(job.resolved_attachments),
**_job_attempt_evidence(latest_smtp=latest_smtp, latest_imap=latest_imap),
})
return row
def _job_evidence_addresses(recipients: dict[str, Any]) -> dict[str, str]:
return {key: _address_summary(recipients.get(key)) for key in ("from", "to", "cc", "bcc", "reply_to")}
def _job_postbox_target_summary(job: CampaignJob) -> str:
targets = getattr(job, "resolved_postbox_targets", None) or []
return "; ".join(
str(target.get("address") or target.get("name") or target.get("postbox_id") or "")
for target in targets
if isinstance(target, dict)
)
def _iso_timestamp(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _job_attempt_evidence(
*,
latest_smtp: SendAttempt | None,
latest_imap: ImapAppendAttempt | None,
) -> dict[str, Any]:
return {
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None, "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": latest_smtp.status if latest_smtp else None,
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None, "latest_smtp_status_code": latest_smtp.smtp_status_code 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_started_at": _iso_timestamp(latest_smtp.started_at) if latest_smtp else None,
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None, "latest_smtp_finished_at": _iso_timestamp(latest_smtp.finished_at) if latest_smtp else None,
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap 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_status": latest_imap.status if latest_imap else None,
"latest_imap_folder": latest_imap.folder if latest_imap else None, "latest_imap_folder": latest_imap.folder 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_created_at": _iso_timestamp(latest_imap.created_at) if latest_imap else None,
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None, "latest_imap_updated_at": _iso_timestamp(latest_imap.updated_at) if latest_imap else None,
}) }
return row
def generate_campaign_report( def generate_campaign_report(
@@ -427,21 +657,43 @@ def generate_campaign_report(
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id) campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
version = _selected_version(session, campaign, version_id) version = _selected_version(session, campaign, version_id)
jobs = _report_jobs(session, tenant_id=tenant_id, campaign_id=campaign.id, version=version) jobs = _report_jobs(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
)
aggregate = _JobReportAggregate()
job_rows: list[dict[str, Any]] = []
retry_max_attempts = _retry_max_attempts(version)
iterator = jobs.yield_per(500) if hasattr(jobs, "yield_per") else jobs
for job in iterator:
aggregate.add(
job,
retry_max_attempts=retry_max_attempts,
include_recent_failures=include_recent_failures,
)
if include_jobs:
if len(job_rows) >= CAMPAIGN_JSON_JOB_LIMIT:
raise CampaignReportError(
"The recipient-level JSON report exceeds the safe row "
f"limit of {CAMPAIGN_JSON_JOB_LIMIT}; use the CSV export "
"or the paginated recipient table."
)
job_rows.append(
_job_row(job, include_diagnostics=include_diagnostics)
)
report = _campaign_report_payload( report = _campaign_report_payload(
session, session,
campaign=campaign, campaign=campaign,
version=version, version=version,
jobs=jobs, aggregate=aggregate,
tenant_id=tenant_id, tenant_id=tenant_id,
include_recent_failures=include_recent_failures, include_recent_failures=include_recent_failures,
include_diagnostics=include_diagnostics, include_diagnostics=include_diagnostics,
) )
if include_jobs: if include_jobs:
report["jobs"] = [ report["jobs"] = job_rows
_job_row(job, include_diagnostics=include_diagnostics)
for job in jobs
]
return report return report
@@ -451,7 +703,7 @@ def _report_jobs(
tenant_id: str, tenant_id: str,
campaign_id: str, campaign_id: str,
version: CampaignVersion | None, version: CampaignVersion | None,
) -> list[CampaignJob]: ) -> Any:
jobs_query = session.query(CampaignJob).filter( jobs_query = session.query(CampaignJob).filter(
CampaignJob.tenant_id == tenant_id, CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id, CampaignJob.campaign_id == campaign_id,
@@ -460,7 +712,7 @@ def _report_jobs(
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id) jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
else: else:
jobs_query = jobs_query.filter(False) jobs_query = jobs_query.filter(False)
return jobs_query.order_by(CampaignJob.entry_index.asc()).all() return jobs_query.order_by(CampaignJob.entry_index.asc())
def _campaign_report_payload( def _campaign_report_payload(
@@ -468,21 +720,26 @@ def _campaign_report_payload(
*, *,
campaign: Campaign, campaign: Campaign,
version: CampaignVersion | None, version: CampaignVersion | None,
jobs: list[CampaignJob], aggregate: _JobReportAggregate,
tenant_id: str, tenant_id: str,
include_recent_failures: bool, include_recent_failures: bool,
include_diagnostics: bool, include_diagnostics: bool,
) -> dict[str, Any]: ) -> dict[str, Any]:
job_ids = [job.id for job in jobs]
report = { report = {
"generated_at": _utcnow_iso(), "generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign), "campaign": _campaign_report_campaign_payload(campaign),
"current_version": _version_info(version, include_diagnostics=include_diagnostics), "current_version": _version_info(version, include_diagnostics=include_diagnostics),
"selected_version_id": version.id if version else None, "selected_version_id": version.id if version else None,
"cards": _campaign_report_cards(version, jobs), "cards": _campaign_report_cards_from_aggregate(version, aggregate),
"status_counts": _campaign_report_status_counts(version, jobs), "status_counts": _campaign_report_status_counts_from_aggregate(
version,
aggregate,
),
"issues": { "issues": {
**_issue_summary_from_jobs(jobs), "total": aggregate.issue_total,
"by_severity": dict(aggregate.issue_severity),
"by_code": dict(aggregate.issue_code),
"by_behavior": dict(aggregate.issue_behavior),
"persisted_campaign_issue_count": _persisted_campaign_issue_count( "persisted_campaign_issue_count": _persisted_campaign_issue_count(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -490,16 +747,31 @@ def _campaign_report_payload(
version=version, version=version,
), ),
}, },
"attachments": _attachment_summary(jobs), "attachments": {
"attempts": _campaign_report_attempt_counts(session, job_ids), "total_attachment_configs": aggregate.attachment_total,
"total_matched_files": aggregate.attachment_matches,
"zip_enabled_configs": aggregate.attachment_zip_enabled,
"missing_configs": aggregate.attachment_missing,
"ambiguous_configs": aggregate.attachment_ambiguous,
"by_status": dict(aggregate.attachment_status),
"by_behavior": dict(aggregate.attachment_behavior),
},
"attempts": _campaign_report_attempt_counts(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
"delivery": _load_delivery_info( "delivery": _load_delivery_info(
version, version,
jobs, pending_job_count=aggregate.pending,
include_diagnostics=include_diagnostics, include_diagnostics=include_diagnostics,
), ),
} }
if include_recent_failures: if include_recent_failures:
report["recent_failures"] = _recent_failures(jobs) report["recent_failures"] = _recent_failures(
aggregate.recent_failures,
)
return report return report
@@ -515,10 +787,34 @@ def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
} }
def _campaign_report_attempt_counts(session: Session, job_ids: list[str]) -> dict[str, int]: def _campaign_report_attempt_counts(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> dict[str, int]:
def count(model: type[Any]) -> int:
query = (
session.query(model)
.join(CampaignJob, model.job_id == CampaignJob.id)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id,
)
)
if version is not None:
query = query.filter(
CampaignJob.campaign_version_id == version.id,
)
else:
query = query.filter(False)
return int(query.count())
return { return {
"send_attempts": int(session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0, "send_attempts": count(SendAttempt),
"imap_append_attempts": int(session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0, "imap_append_attempts": count(ImapAppendAttempt),
"postbox_delivery_attempts": count(PostboxDeliveryAttempt),
} }
@@ -541,84 +837,109 @@ def _persisted_campaign_issue_count(
def _campaign_report_status_counts(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, dict[str, int]]: def _campaign_report_status_counts(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, dict[str, int]]:
validation_counts = _counter([job.validation_status for job in jobs]) aggregate = _aggregate_job_list(version, jobs)
return _campaign_report_status_counts_from_aggregate(version, aggregate)
def _campaign_report_status_counts_from_aggregate(
version: CampaignVersion | None,
aggregate: _JobReportAggregate,
) -> dict[str, dict[str, int]]:
validation_counts = dict(aggregate.validation)
inactive_entries = _inactive_entry_count(version) inactive_entries = _inactive_entry_count(version)
if inactive_entries: if inactive_entries:
validation_counts["inactive"] = inactive_entries validation_counts["inactive"] = inactive_entries
return { return {
"build": _counter([job.build_status for job in jobs]), "build": dict(aggregate.build),
"validation": validation_counts, "validation": validation_counts,
"queue": _counter([job.queue_status for job in jobs]), "queue": dict(aggregate.queue),
"send": _counter([job.send_status for job in jobs]), "send": dict(aggregate.send),
"imap": _counter([job.imap_status for job in jobs]), "postbox": dict(aggregate.postbox),
"imap": dict(aggregate.imap),
} }
def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]: def _campaign_report_cards_from_aggregate(
send_counts = _counter([job.send_status for job in jobs]) version: CampaignVersion | None,
imap_counts = _counter([job.imap_status for job in jobs]) aggregate: _JobReportAggregate,
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built") ) -> dict[str, object]:
queueable_unattempted = sum( send_counts = aggregate.send
1 imap_counts = aggregate.imap
for job in jobs sent = (
if job.attempt_count == 0 send_counts.get("sent", 0)
and job.send_status in {"not_queued", "cancelled"} + send_counts.get("smtp_accepted", 0)
and job.validation_status in {"ready", "warning"} + send_counts.get("postbox_accepted", 0)
and job.build_status == "built" + send_counts.get("delivered", 0)
+ send_counts.get("partially_accepted", 0)
) )
retry_max_attempts = _retry_max_attempts(version) failed = (
retryable = sum( send_counts.get("failed_temporary", 0)
1 + send_counts.get("failed_permanent", 0)
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
if job.validation_status in {"needs_review", "blocked"}
or job.send_status in {"failed_temporary", "failed_permanent", "outcome_unknown", "claimed", "sending"}
or job.imap_status == "failed"
)
sent = send_counts.get("sent", 0) + send_counts.get("smtp_accepted", 0)
failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0)
outcome_unknown = send_counts.get("outcome_unknown", 0) outcome_unknown = send_counts.get("outcome_unknown", 0)
not_attempted = send_counts.get("not_queued", 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) cancelled = send_counts.get("cancelled", 0)
inactive_entries = _inactive_entry_count(version) inactive_entries = _inactive_entry_count(version)
return { return {
"jobs_total": len(jobs), "jobs_total": aggregate.total,
"inactive": inactive_entries, "inactive": inactive_entries,
"queueable": queueable, "queueable": aggregate.queueable,
"queueable_unattempted": queueable_unattempted, "queueable_unattempted": aggregate.queueable_unattempted,
"retryable": retryable, "retryable": aggregate.retryable,
"cancellable": cancellable, "cancellable": aggregate.cancellable,
"needs_attention": needs_attention, "needs_attention": aggregate.needs_attention,
"sent": sent, "sent": sent,
"smtp_accepted": sent, "smtp_accepted": (
send_counts.get("sent", 0)
+ send_counts.get("smtp_accepted", 0)
),
"postbox_accepted": send_counts.get("postbox_accepted", 0),
"delivered": send_counts.get("delivered", 0),
"partially_accepted": send_counts.get("partially_accepted", 0),
"failed": failed, "failed": failed,
"outcome_unknown": outcome_unknown, "outcome_unknown": outcome_unknown,
"not_attempted": not_attempted, "not_attempted": not_attempted,
"skipped": skipped, "skipped": send_counts.get("skipped", 0),
"queued_or_active": queued, "queued_or_active": aggregate.pending,
"cancelled": cancelled, "cancelled": cancelled,
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)), "partially_completed": bool(
send_counts.get("partially_accepted", 0)
or sent
and (failed or outcome_unknown or not_attempted or cancelled)
),
"imap_appended": imap_counts.get("appended", 0), "imap_appended": imap_counts.get("appended", 0),
"imap_failed": imap_counts.get("failed", 0), "imap_failed": imap_counts.get("failed", 0),
"imap_skipped": imap_counts.get("skipped", 0), "imap_skipped": imap_counts.get("skipped", 0),
} }
def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]:
return _campaign_report_cards_from_aggregate(
version,
_aggregate_job_list(version, jobs),
)
def _aggregate_job_list(
version: CampaignVersion | None,
jobs: list[CampaignJob],
) -> _JobReportAggregate:
aggregate = _JobReportAggregate()
retry_max_attempts = _retry_max_attempts(version)
for job in jobs:
aggregate.add(
job,
retry_max_attempts=retry_max_attempts,
include_recent_failures=False,
)
return aggregate
def _retry_max_attempts(version: CampaignVersion | None) -> int | None: def _retry_max_attempts(version: CampaignVersion | None) -> int | None:
if version is None or not isinstance(version.execution_snapshot, dict): if version is None or not isinstance(
getattr(version, "execution_snapshot", None),
dict,
):
return None return None
try: try:
return ExecutionSnapshot.model_validate(version.execution_snapshot).delivery.retry.max_attempts return ExecutionSnapshot.model_validate(version.execution_snapshot).delivery.retry.max_attempts
@@ -652,27 +973,25 @@ def generate_jobs_csv(
jobs = ( jobs = (
jobs_query jobs_query
.order_by(CampaignJob.entry_index.asc()) .order_by(CampaignJob.entry_index.asc())
.limit(CAMPAIGN_CSV_JOB_LIMIT + 1)
.all() .all()
) )
if len(jobs) > CAMPAIGN_CSV_JOB_LIMIT:
raise CampaignReportError(
"The campaign CSV export exceeds the safe row limit of "
f"{CAMPAIGN_CSV_JOB_LIMIT}. Split the export by recipient filter."
)
job_ids = [job.id for job in jobs] job_ids = [job.id for job in jobs]
smtp_attempts = ( latest_smtp = _latest_attempts_for_jobs(
session.query(SendAttempt) session,
.filter(SendAttempt.job_id.in_(job_ids)) SendAttempt,
.order_by(SendAttempt.job_id.asc(), SendAttempt.attempt_number.asc()) job_ids,
.all()
if job_ids
else []
) )
imap_attempts = ( latest_imap = _latest_attempts_for_jobs(
session.query(ImapAppendAttempt) session,
.filter(ImapAppendAttempt.job_id.in_(job_ids)) ImapAppendAttempt,
.order_by(ImapAppendAttempt.job_id.asc(), ImapAppendAttempt.attempt_number.asc()) job_ids,
.all()
if job_ids
else []
) )
latest_smtp = _latest_by_job_id(smtp_attempts)
latest_imap = _latest_by_job_id(imap_attempts)
rows = [ rows = [
_job_evidence_row( _job_evidence_row(
job, job,
@@ -700,8 +1019,13 @@ def generate_jobs_csv(
"validation_status", "validation_status",
"queue_status", "queue_status",
"send_status", "send_status",
"delivery_channel_policy",
"postbox_status",
"imap_status", "imap_status",
"attempt_count", "attempt_count",
"postbox_attempt_count",
"postbox_target_count",
"postbox_targets",
"queued_at", "queued_at",
"outcome_unknown_at", "outcome_unknown_at",
"sent_at", "sent_at",
@@ -731,3 +1055,30 @@ def generate_jobs_csv(
writer.writeheader() writer.writeheader()
writer.writerows(rows) writer.writerows(rows)
return buffer.getvalue() return buffer.getvalue()
def _latest_attempts_for_jobs(
session: Session,
model: type[Any],
job_ids: list[str],
*,
batch_size: int = 500,
) -> dict[str, Any]:
latest: dict[str, Any] = {}
for offset in range(0, len(job_ids), batch_size):
batch = job_ids[offset : offset + batch_size]
attempts = (
session.query(model)
.filter(model.job_id.in_(batch))
.order_by(model.job_id.asc(), model.attempt_number.asc())
.yield_per(batch_size)
)
for attempt in attempts:
current = latest.get(attempt.job_id)
if (
current is None
or (attempt.attempt_number or 0)
>= (current.attempt_number or 0)
):
latest[attempt.job_id] = attempt
return latest

View File

@@ -110,6 +110,7 @@ def public_delivery_result_message(
last_error: Any, last_error: Any,
send_status: Any, send_status: Any,
imap_status: Any, imap_status: Any,
postbox_status: Any = None,
) -> str | None: ) -> str | None:
"""Map persisted provider text to a stable business-safe explanation.""" """Map persisted provider text to a stable business-safe explanation."""
@@ -117,10 +118,22 @@ def public_delivery_result_message(
return None return None
clean_send_status = str(send_status or "") clean_send_status = str(send_status or "")
clean_imap_status = str(imap_status or "") clean_imap_status = str(imap_status or "")
clean_postbox_status = str(postbox_status or "")
if clean_postbox_status == "outcome_unknown":
return "Postbox delivery outcome requires operator reconciliation."
if clean_send_status == "outcome_unknown": if clean_send_status == "outcome_unknown":
return "SMTP delivery outcome requires operator reconciliation." return "Delivery outcome requires operator reconciliation."
if clean_postbox_status in {
"rejected_temporary",
"rejected_permanent",
}:
return "Postbox delivery was rejected; an operator can inspect restricted diagnostics."
if clean_postbox_status == "partially_accepted":
return "Some Postbox targets accepted the message and others rejected it."
if clean_send_status in {"failed_temporary", "failed_permanent"}: if clean_send_status in {"failed_temporary", "failed_permanent"}:
if clean_postbox_status in {"", "not_requested"}:
return "SMTP delivery failed; an operator can inspect restricted diagnostics." return "SMTP delivery failed; an operator can inspect restricted diagnostics."
return "Delivery failed; an operator can inspect restricted diagnostics."
if clean_imap_status in {"outcome_unknown", "appending"}: if clean_imap_status in {"outcome_unknown", "appending"}:
return "Sent-folder append outcome requires operator reconciliation." return "Sent-folder append outcome requires operator reconciliation."
if clean_imap_status in {"failed", "skipped"}: if clean_imap_status in {"failed", "skipped"}:

View File

@@ -4,7 +4,7 @@ import copy
import dataclasses import dataclasses
import json import json
import logging import logging
from collections.abc import Callable from collections.abc import Callable, Sequence
from typing import Any, Literal from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
@@ -29,6 +29,7 @@ from govoplan_campaign.backend.schemas import (
CampaignOwnerUpdateRequest, CampaignOwnerUpdateRequest,
CampaignAddressLookupCandidate, CampaignAddressLookupCandidate,
CampaignAddressLookupResponse, CampaignAddressLookupResponse,
CampaignPostboxCatalogResponse,
CampaignRecipientAddressSource, CampaignRecipientAddressSource,
CampaignRecipientAddressSourcesResponse, CampaignRecipientAddressSourcesResponse,
CampaignRecipientAddressSourceSnapshotRequest, CampaignRecipientAddressSourceSnapshotRequest,
@@ -98,12 +99,21 @@ from govoplan_campaign.backend.db.models import (
CampaignVersionWorkflowState, CampaignVersionWorkflowState,
ImapAppendAttempt, ImapAppendAttempt,
JobImapStatus, JobImapStatus,
JobPostboxStatus,
JobQueueStatus, JobQueueStatus,
JobSendStatus, JobSendStatus,
JobValidationStatus, JobValidationStatus,
RecipientImportMappingProfile, RecipientImportMappingProfile,
PostboxDeliveryAttempt,
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.campaign.postbox_targets import (
delivery_catalog_payload,
)
from govoplan_campaign.backend.integrations import (
PostboxDeliveryUnavailable,
postbox_integration,
)
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.report_privacy_policy import CampaignReportPrivacyPolicyError from govoplan_campaign.backend.report_privacy_policy import CampaignReportPrivacyPolicyError
@@ -136,7 +146,10 @@ from govoplan_campaign.backend.integrations import (
) )
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
from govoplan_campaign.backend.campaign.loader import load_campaign_json from govoplan_campaign.backend.campaign.loader import load_campaign_json
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CAMPAIGN_MAIL_SERVER_KEYS,
campaign_mail_profile_id,
)
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
from govoplan_campaign.backend.persistence.versions import ( from govoplan_campaign.backend.persistence.versions import (
@@ -581,7 +594,8 @@ def _clear_current_version_mail_profile_for_owner_transfer(session: Session, cam
) )
next_server = dict(server) next_server = dict(server)
next_server.pop("mail_profile_id", None) for key in CAMPAIGN_MAIL_SERVER_KEYS:
next_server.pop(key, None)
next_server.pop("profile_id", None) next_server.pop("profile_id", None)
raw_json["server"] = next_server raw_json["server"] = next_server
@@ -715,14 +729,38 @@ def create_minimal_campaign_endpoint(
@router.get("", response_model=CampaignListResponse) @router.get("", response_model=CampaignListResponse)
def list_campaigns( def list_campaigns(
limit: int = 200,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
): ):
campaigns = _campaign_query_for_principal(session, principal).order_by(Campaign.updated_at.desc()).all() campaigns = (
_campaign_query_for_principal(session, principal)
.order_by(Campaign.updated_at.desc())
.limit(max(1, min(limit, 500)))
.all()
)
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns]) return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
def _bounded_query_rows(query, *, limit: int, label: str):
rows = query.limit(limit + 1).all()
if len(rows) > limit:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
f"{label} exceeds the maximum response size of {limit} rows. "
"Narrow the request or use a paginated/delta endpoint."
),
)
return rows
def _job_attempt_rows(query, *, label: str):
return _bounded_query_rows(query, limit=1000, label=label)
_CAMPAIGN_LIST_DELTA_COLLECTIONS = (CAMPAIGNS_COLLECTION,) _CAMPAIGN_LIST_DELTA_COLLECTIONS = (CAMPAIGNS_COLLECTION,)
_CAMPAIGN_FULL_CURSOR_PREFIX = "full:campaigns:"
_CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS = ( _CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS = (
CAMPAIGNS_COLLECTION, CAMPAIGNS_COLLECTION,
CAMPAIGN_VERSIONS_COLLECTION, CAMPAIGN_VERSIONS_COLLECTION,
@@ -782,14 +820,89 @@ def _campaign_entry_matches_principal(session: Session, principal: ApiPrincipal,
return False return False
def _campaign_list_full_delta_response(session: Session, principal: ApiPrincipal) -> CampaignDeltaResponse: def _campaign_full_cursor(
campaigns = _campaign_query_for_principal(session, principal).order_by(Campaign.updated_at.desc()).all() *,
page: int,
snapshot_sequence: int,
) -> str:
return (
f"{_CAMPAIGN_FULL_CURSOR_PREFIX}"
f"{int(page)}:{int(snapshot_sequence)}"
)
def _decode_campaign_full_cursor(
value: str | None,
) -> tuple[int, int] | None:
if not value or not value.startswith(_CAMPAIGN_FULL_CURSOR_PREFIX):
return None
parts = value[len(_CAMPAIGN_FULL_CURSOR_PREFIX):].split(":", 1)
if len(parts) != 2:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid campaign full snapshot cursor",
)
try:
page, snapshot_sequence = (int(item) for item in parts)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid campaign full snapshot cursor",
) from exc
if page < 1 or snapshot_sequence < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid campaign full snapshot cursor",
)
return page, snapshot_sequence
def _campaign_list_full_delta_response(
session: Session,
principal: ApiPrincipal,
*,
cursor: tuple[int, int] | None = None,
limit: int = 500,
) -> CampaignDeltaResponse:
page = cursor[0] if cursor is not None else 1
snapshot_sequence = (
cursor[1]
if cursor is not None
else decode_sequence_watermark(
_campaign_delta_watermark(
session,
principal.tenant_id,
_CAMPAIGN_LIST_DELTA_COLLECTIONS,
),
)
)
query = _campaign_query_for_principal(session, principal)
total = query.order_by(None).count()
pages = max(1, (total + limit - 1) // limit)
campaigns = (
query.order_by(Campaign.updated_at.desc(), Campaign.id.asc())
.offset((page - 1) * limit)
.limit(limit)
.all()
)
has_more = page < pages
return CampaignDeltaResponse( return CampaignDeltaResponse(
campaigns=[CampaignResponse.model_validate(item) for item in campaigns], campaigns=[CampaignResponse.model_validate(item) for item in campaigns],
deleted=[], deleted=[],
watermark=_campaign_delta_watermark(session, principal.tenant_id, _CAMPAIGN_LIST_DELTA_COLLECTIONS), watermark=(
has_more=False, _campaign_full_cursor(
page=page + 1,
snapshot_sequence=snapshot_sequence,
)
if has_more
else encode_sequence_watermark(snapshot_sequence)
),
has_more=has_more,
full=True, full=True,
total=total,
page=page,
page_size=limit,
pages=pages,
) )
@@ -805,7 +918,11 @@ def _campaign_list_delta_response(session: Session, principal: ApiPrincipal, *,
module_id=CAMPAIGNS_MODULE_ID, module_id=CAMPAIGNS_MODULE_ID,
collections=_CAMPAIGN_LIST_DELTA_COLLECTIONS, collections=_CAMPAIGN_LIST_DELTA_COLLECTIONS,
): ):
return _campaign_list_full_delta_response(session, principal) return _campaign_list_full_delta_response(
session,
principal,
limit=limit,
)
entries_plus_one = sequence_entries_since( entries_plus_one = sequence_entries_since(
session, session,
@@ -854,6 +971,10 @@ def _campaign_list_delta_response(session: Session, principal: ApiPrincipal, *,
watermark=watermark, watermark=watermark,
has_more=has_more, has_more=has_more,
full=False, full=False,
total=len(visible_campaigns),
page=1,
page_size=limit,
pages=1,
) )
@@ -864,24 +985,35 @@ def list_campaigns_delta(
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
): ):
if since is None: full_cursor = _decode_campaign_full_cursor(since)
return _campaign_list_full_delta_response(session, principal) if since is None or full_cursor is not None:
return _campaign_list_full_delta_response(
session,
principal,
cursor=full_cursor,
limit=limit,
)
return _campaign_list_delta_response(session, principal, since=since, limit=limit) return _campaign_list_delta_response(session, principal, since=since, limit=limit)
@router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse) @router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse)
def list_recipient_import_mapping_profiles( def list_recipient_import_mapping_profiles(
limit: int = Query(default=200, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")), principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
): ):
profiles = ( profiles = _bounded_query_rows(
session.query(RecipientImportMappingProfile) session.query(RecipientImportMappingProfile)
.filter( .filter(
RecipientImportMappingProfile.tenant_id == principal.tenant_id, RecipientImportMappingProfile.tenant_id == principal.tenant_id,
RecipientImportMappingProfile.owner_user_id == principal.user.id, RecipientImportMappingProfile.owner_user_id == principal.user.id,
) )
.order_by(RecipientImportMappingProfile.updated_at.desc(), RecipientImportMappingProfile.name.asc()) .order_by(
.all() RecipientImportMappingProfile.updated_at.desc(),
RecipientImportMappingProfile.name.asc(),
),
limit=limit,
label="Recipient import mapping profile list",
) )
return RecipientImportMappingProfileListResponse( return RecipientImportMappingProfileListResponse(
profiles=[RecipientImportMappingProfileResponse.model_validate(profile) for profile in profiles] profiles=[RecipientImportMappingProfileResponse.model_validate(profile) for profile in profiles]
@@ -974,15 +1106,17 @@ def delete_recipient_import_mapping_profile(
@router.get("/aggregate-reports", response_model=AggregateReportCampaignList) @router.get("/aggregate-reports", response_model=AggregateReportCampaignList)
def list_aggregate_campaign_reports( def list_aggregate_campaign_reports(
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")), principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
): ):
"""List only the business metadata needed to select an aggregate report.""" """List only the business metadata needed to select an aggregate report."""
campaigns = ( campaigns = _bounded_query_rows(
_campaign_query_for_principal(session, principal) _campaign_query_for_principal(session, principal)
.order_by(Campaign.updated_at.desc()) .order_by(Campaign.updated_at.desc(), Campaign.id.asc()),
.all() limit=limit,
label="Aggregate report campaign list",
) )
return AggregateReportCampaignList( return AggregateReportCampaignList(
campaigns=[aggregate_report_campaign_item(campaign) for campaign in campaigns] campaigns=[aggregate_report_campaign_item(campaign) for campaign in campaigns]
@@ -1054,6 +1188,38 @@ def list_campaign_recipient_address_sources(
) )
@router.get(
"/{campaign_id}/postbox-catalog",
response_model=CampaignPostboxCatalogResponse,
)
def campaign_postbox_catalog(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("campaigns:recipient:write")
),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
integration = postbox_integration()
if not integration.available:
return CampaignPostboxCatalogResponse(available=False)
try:
payload = delivery_catalog_payload(
integration.delivery_catalog(
session,
tenant_id=principal.tenant_id,
)
)
except PostboxDeliveryUnavailable:
return CampaignPostboxCatalogResponse(available=False)
return CampaignPostboxCatalogResponse(
available=True,
postboxes=payload.get("postboxes", []),
templates=payload.get("templates", []),
organization_units=payload.get("organization_units", []),
)
@router.post("/{campaign_id}/recipient-address-sources/snapshot", response_model=CampaignRecipientAddressSourceSnapshotResponse) @router.post("/{campaign_id}/recipient-address-sources/snapshot", response_model=CampaignRecipientAddressSourceSnapshotResponse)
def snapshot_campaign_recipient_address_source( def snapshot_campaign_recipient_address_source(
campaign_id: str, campaign_id: str,
@@ -1108,11 +1274,12 @@ def _campaign_workspace_response(
versions: list[CampaignVersion] = [] versions: list[CampaignVersion] = []
if include_versions or include_current_version: if include_versions or include_current_version:
versions = ( versions = _bounded_query_rows(
session.query(CampaignVersion) session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id) .filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.desc()) .order_by(CampaignVersion.version_number.desc()),
.all() limit=1000,
label="Campaign version history",
) )
selected_version_id = version_id or campaign.current_version_id or (versions[0].id if versions else None) selected_version_id = version_id or campaign.current_version_id or (versions[0].id if versions else None)
@@ -1584,16 +1751,18 @@ def delete_draft_campaign(
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse]) @router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
def list_versions( def list_versions(
campaign_id: str, campaign_id: str,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
): ):
_get_campaign_for_principal(session, campaign_id, principal) _get_campaign_for_principal(session, campaign_id, principal)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
versions = ( versions = _bounded_query_rows(
session.query(CampaignVersion) session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id) .filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.desc()) .order_by(CampaignVersion.version_number.desc()),
.all() limit=limit,
label="Campaign version history",
) )
return [ return [
CampaignVersionResponse.model_validate( CampaignVersionResponse.model_validate(
@@ -2114,14 +2283,19 @@ def _job_summary_payload(
"validation_status": job.validation_status, "validation_status": job.validation_status,
"queue_status": job.queue_status, "queue_status": job.queue_status,
"send_status": job.send_status, "send_status": job.send_status,
"delivery_channel_policy": getattr(job, "delivery_channel_policy", "mail"),
"postbox_status": getattr(job, "postbox_status", "not_requested"),
"imap_status": job.imap_status, "imap_status": job.imap_status,
"eml_size_bytes": job.eml_size_bytes, "eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256, "eml_sha256": job.eml_sha256,
"attempt_count": job.attempt_count, "attempt_count": job.attempt_count,
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
"postbox_target_count": len(getattr(job, "resolved_postbox_targets", None) or []),
"last_error": public_delivery_result_message( "last_error": public_delivery_result_message(
last_error=job.last_error, last_error=job.last_error,
send_status=job.send_status, send_status=job.send_status,
imap_status=job.imap_status, imap_status=job.imap_status,
postbox_status=getattr(job, "postbox_status", "not_requested"),
), ),
"queued_at": job.queued_at, "queued_at": job.queued_at,
"outcome_unknown_at": job.outcome_unknown_at, "outcome_unknown_at": job.outcome_unknown_at,
@@ -2147,12 +2321,14 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
"issues": job.issues_snapshot or [], "issues": job.issues_snapshot or [],
"attachments": public_campaign_payload(job.resolved_attachments or []), "attachments": public_campaign_payload(job.resolved_attachments or []),
"resolved_recipients": job.resolved_recipients or {}, "resolved_recipients": job.resolved_recipients or {},
"resolved_postbox_targets": getattr(job, "resolved_postbox_targets", None) or [],
} }
def _job_attempts_payload( def _job_attempts_payload(
send_attempts: list[SendAttempt], send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt], imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
*, *,
include_diagnostics: bool = False, include_diagnostics: bool = False,
) -> dict[str, list[dict[str, object]]]: ) -> dict[str, list[dict[str, object]]]:
@@ -2187,13 +2363,43 @@ def _job_attempts_payload(
payload["claim_token"] = attempt.claim_token payload["claim_token"] = attempt.claim_token
payload["error_message"] = attempt.error_message payload["error_message"] = attempt.error_message
imap_payloads.append(payload) imap_payloads.append(payload)
return {"smtp": smtp_payloads, "imap": imap_payloads} postbox_payloads: list[dict[str, object]] = []
for attempt in postbox_attempts:
payload = {
"id": attempt.id,
"target_index": attempt.target_index,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"postbox_id": attempt.postbox_id,
"address": attempt.address,
"holder_count": attempt.holder_count,
"vacant": attempt.vacant,
"duplicate": attempt.duplicate,
"target": attempt.target_snapshot or {},
"started_at": attempt.started_at,
"finished_at": attempt.finished_at,
"error_code": attempt.error_code,
}
if include_diagnostics:
payload["idempotency_key"] = attempt.idempotency_key
payload["provider_delivery_id"] = attempt.provider_delivery_id
payload["provider_message_id"] = attempt.provider_message_id
payload["evidence"] = attempt.evidence or {}
payload["error_type"] = attempt.error_type
payload["error_message"] = attempt.error_message
postbox_payloads.append(payload)
return {
"smtp": smtp_payloads,
"imap": imap_payloads,
"postbox": postbox_payloads,
}
def _job_diagnostics_payload( def _job_diagnostics_payload(
job: CampaignJob, job: CampaignJob,
send_attempts: list[SendAttempt], send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt], imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
) -> CampaignJobDiagnosticsResponse: ) -> CampaignJobDiagnosticsResponse:
return CampaignJobDiagnosticsResponse( return CampaignJobDiagnosticsResponse(
job_id=job.id, job_id=job.id,
@@ -2215,6 +2421,7 @@ def _job_diagnostics_payload(
attempts=_job_attempts_payload( attempts=_job_attempts_payload(
send_attempts, send_attempts,
imap_attempts, imap_attempts,
postbox_attempts,
include_diagnostics=True, include_diagnostics=True,
), ),
) )
@@ -2303,7 +2510,14 @@ def _review_metadata_counts(review_rows: list[tuple[object, int, str, str]], rev
def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[str, int]]: def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[str, int]]:
result: dict[str, dict[str, int]] = {} result: dict[str, dict[str, int]] = {}
for field_name in ("build_status", "validation_status", "queue_status", "send_status", "imap_status"): for field_name in (
"build_status",
"validation_status",
"queue_status",
"send_status",
"postbox_status",
"imap_status",
):
column = getattr(CampaignJob, field_name) column = getattr(CampaignJob, field_name)
rows = session.query(column, func.count(CampaignJob.id)).filter(*filters).group_by(column).all() rows = session.query(column, func.count(CampaignJob.id)).filter(*filters).group_by(column).all()
result[field_name.removesuffix("_status")] = {str(value or "unknown"): int(count) for value, count in rows} result[field_name.removesuffix("_status")] = {str(value or "unknown"): int(count) for value, count in rows}
@@ -2317,6 +2531,7 @@ CAMPAIGN_JOB_GRID_SORT_COLUMNS = {
"validation": CampaignJob.validation_status, "validation": CampaignJob.validation_status,
"queue": CampaignJob.queue_status, "queue": CampaignJob.queue_status,
"send": CampaignJob.send_status, "send": CampaignJob.send_status,
"postbox": CampaignJob.postbox_status,
"imap": CampaignJob.imap_status, "imap": CampaignJob.imap_status,
"attempts": CampaignJob.attempt_count, "attempts": CampaignJob.attempt_count,
"updated": CampaignJob.updated_at, "updated": CampaignJob.updated_at,
@@ -2325,6 +2540,10 @@ CAMPAIGN_JOB_GRID_LIST_FILTERS = {
"validation": (CampaignJob.validation_status, {item.value for item in JobValidationStatus}), "validation": (CampaignJob.validation_status, {item.value for item in JobValidationStatus}),
"queue": (CampaignJob.queue_status, {item.value for item in JobQueueStatus}), "queue": (CampaignJob.queue_status, {item.value for item in JobQueueStatus}),
"send": (CampaignJob.send_status, {item.value for item in JobSendStatus}), "send": (CampaignJob.send_status, {item.value for item in JobSendStatus}),
"postbox": (
CampaignJob.postbox_status,
{item.value for item in JobPostboxStatus},
),
"imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}), "imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}),
} }
@@ -2493,8 +2712,7 @@ def _campaign_jobs_page_response(
cursor: str | None = None, cursor: str | None = None,
changed_job_ids: set[str] | None = None, changed_job_ids: set[str] | None = None,
) -> CampaignJobsResponse: ) -> CampaignJobsResponse:
total_unfiltered = int(session.query(func.count(CampaignJob.id)).filter(*base_filters).scalar() or 0) total_unfiltered, total = _campaign_jobs_page_counts(session, base_filters=base_filters, filtered=filtered)
total = int(session.query(func.count(CampaignJob.id)).filter(*filtered).scalar() or 0)
pages = (total + page_size - 1) // page_size if total else 0 pages = (total + page_size - 1) // page_size if total else 0
fingerprint = _campaign_jobs_cursor_fingerprint( fingerprint = _campaign_jobs_cursor_fingerprint(
campaign_id=campaign_id, campaign_id=campaign_id,
@@ -2509,47 +2727,26 @@ def _campaign_jobs_page_response(
sort_direction=sort_direction, sort_direction=sort_direction,
) )
ordering = _campaign_jobs_ordering(sort_by, sort_direction) ordering = _campaign_jobs_ordering(sort_by, sort_direction)
ordered_query = session.query(CampaignJob).filter(*filtered).order_by(*ordering) rows_plus_one, start_cursor = _campaign_jobs_page_rows(
start_cursor: str | None = None session,
effective_offset = 0 filtered=filtered,
if cursor: ordering=ordering,
if sort_by != "number" or sort_direction != "asc": page=page,
raise HTTPException( page_size=page_size,
status_code=status.HTTP_400_BAD_REQUEST, sort_by=sort_by,
detail="Campaign job cursors require number ascending order", sort_direction=sort_direction,
) cursor=cursor,
try: fingerprint=fingerprint,
cursor_values = decode_keyset_cursor(CAMPAIGN_JOBS_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
if cursor_values is None:
raise KeysetCursorError("Invalid pagination cursor")
page_query = session.query(CampaignJob).filter(*filtered).filter(_campaign_jobs_cursor_condition(cursor_values))
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
start_cursor = cursor
else:
effective_offset = (page - 1) * page_size
page_query = session.query(CampaignJob).filter(*filtered)
if effective_offset > 0 and sort_by == "number" and sort_direction == "asc":
previous_row = ordered_query.offset(effective_offset - 1).limit(1).first()
if previous_row is not None:
start_cursor = _campaign_jobs_cursor_for_row(previous_row, fingerprint=fingerprint)
rows_plus_one = (
page_query
.order_by(*ordering)
.offset(effective_offset)
.limit(page_size + 1)
.all()
) )
jobs = rows_plus_one[:page_size] jobs = rows_plus_one[:page_size]
next_cursor = ( next_cursor = _campaign_jobs_next_cursor(
_campaign_jobs_cursor_for_row(jobs[-1], fingerprint=fingerprint) jobs,
if changed_job_ids is None rows_plus_one=rows_plus_one,
and sort_by == "number" page_size=page_size,
and sort_direction == "asc" sort_by=sort_by,
and len(rows_plus_one) > page_size sort_direction=sort_direction,
and jobs changed_job_ids=changed_job_ids,
else None fingerprint=fingerprint,
) )
if changed_job_ids is not None: if changed_job_ids is not None:
jobs = [job for job in jobs if job.id in changed_job_ids] jobs = [job for job in jobs if job.id in changed_job_ids]
@@ -2568,6 +2765,110 @@ def _campaign_jobs_page_response(
) )
def _campaign_jobs_page_counts(
session: Session,
*,
base_filters: list[object],
filtered: list[object],
) -> tuple[int, int]:
total_unfiltered = int(session.query(func.count(CampaignJob.id)).filter(*base_filters).scalar() or 0)
total = int(session.query(func.count(CampaignJob.id)).filter(*filtered).scalar() or 0)
return total_unfiltered, total
def _campaign_jobs_page_rows(
session: Session,
*,
filtered: list[object],
ordering: list[object],
page: int,
page_size: int,
sort_by: str,
sort_direction: str,
cursor: str | None,
fingerprint: str,
) -> tuple[list[CampaignJob], str | None]:
if cursor:
page_query = _campaign_jobs_query_after_cursor(
session,
filtered=filtered,
cursor=cursor,
fingerprint=fingerprint,
sort_by=sort_by,
sort_direction=sort_direction,
)
return page_query.order_by(*ordering).limit(page_size + 1).all(), cursor
effective_offset = (page - 1) * page_size
page_query = session.query(CampaignJob).filter(*filtered)
start_cursor = _campaign_jobs_offset_cursor(
page_query,
ordering=ordering,
effective_offset=effective_offset,
sort_by=sort_by,
sort_direction=sort_direction,
fingerprint=fingerprint,
)
rows = page_query.order_by(*ordering).offset(effective_offset).limit(page_size + 1).all()
return rows, start_cursor
def _campaign_jobs_query_after_cursor(
session: Session,
*,
filtered: list[object],
cursor: str,
fingerprint: str,
sort_by: str,
sort_direction: str,
):
if sort_by != "number" or sort_direction != "asc":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Campaign job cursors require number ascending order",
)
try:
cursor_values = decode_keyset_cursor(CAMPAIGN_JOBS_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
if cursor_values is None:
raise KeysetCursorError("Invalid pagination cursor")
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return session.query(CampaignJob).filter(*filtered).filter(_campaign_jobs_cursor_condition(cursor_values))
def _campaign_jobs_offset_cursor(
query,
*,
ordering: list[object],
effective_offset: int,
sort_by: str,
sort_direction: str,
fingerprint: str,
) -> str | None:
if effective_offset <= 0 or sort_by != "number" or sort_direction != "asc":
return None
previous_row = query.order_by(*ordering).offset(effective_offset - 1).limit(1).first()
if previous_row is None:
return None
return _campaign_jobs_cursor_for_row(previous_row, fingerprint=fingerprint)
def _campaign_jobs_next_cursor(
jobs: list[CampaignJob],
*,
rows_plus_one: list[CampaignJob],
page_size: int,
sort_by: str,
sort_direction: str,
changed_job_ids: set[str] | None,
fingerprint: str,
) -> str | None:
cursor_supported = sort_by == "number" and sort_direction == "asc"
if changed_job_ids is not None or not cursor_supported or len(rows_plus_one) <= page_size or not jobs:
return None
return _campaign_jobs_cursor_for_row(jobs[-1], fingerprint=fingerprint)
def _campaign_jobs_cursor_fingerprint( def _campaign_jobs_cursor_fingerprint(
*, *,
campaign_id: str, campaign_id: str,
@@ -2639,13 +2940,14 @@ class CampaignJobsQuery:
validation_status: list[str] | None = Query(default=None), validation_status: list[str] | None = Query(default=None),
imap_status: list[str] | None = Query(default=None), imap_status: list[str] | None = Query(default=None),
query_text: str | None = Query(default=None, alias="q", max_length=200), query_text: str | None = Query(default=None, alias="q", max_length=200),
sort_by: Literal["number", "recipient", "subject", "validation", "queue", "send", "imap", "attempts", "updated"] = Query(default="number"), sort_by: Literal["number", "recipient", "subject", "validation", "queue", "send", "postbox", "imap", "attempts", "updated"] = Query(default="number"),
sort_direction: Literal["asc", "desc"] = Query(default="asc"), sort_direction: Literal["asc", "desc"] = Query(default="asc"),
filter_recipient: str | None = Query(default=None, max_length=500), filter_recipient: str | None = Query(default=None, max_length=500),
filter_subject: str | None = Query(default=None, max_length=1000), filter_subject: str | None = Query(default=None, max_length=1000),
filter_validation: str | None = Query(default=None, max_length=1000), filter_validation: str | None = Query(default=None, max_length=1000),
filter_queue: str | None = Query(default=None, max_length=1000), filter_queue: str | None = Query(default=None, max_length=1000),
filter_send: str | None = Query(default=None, max_length=1000), filter_send: str | None = Query(default=None, max_length=1000),
filter_postbox: str | None = Query(default=None, max_length=1000),
filter_imap: str | None = Query(default=None, max_length=1000), filter_imap: str | None = Query(default=None, max_length=1000),
filter_attempts: str | None = Query(default=None, max_length=100), filter_attempts: str | None = Query(default=None, max_length=100),
filter_evidence: str | None = Query(default=None, max_length=500), filter_evidence: str | None = Query(default=None, max_length=500),
@@ -2668,6 +2970,7 @@ class CampaignJobsQuery:
"validation": filter_validation, "validation": filter_validation,
"queue": filter_queue, "queue": filter_queue,
"send": filter_send, "send": filter_send,
"postbox": filter_postbox,
"imap": filter_imap, "imap": filter_imap,
"attempts": filter_attempts, "attempts": filter_attempts,
"evidence": filter_evidence, "evidence": filter_evidence,
@@ -2962,21 +3265,34 @@ def get_job_detail(
job = session.get(CampaignJob, job_id) job = session.get(CampaignJob, job_id)
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id: if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
send_attempts = ( send_attempts = _job_attempt_rows(
session.query(SendAttempt) session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id) .filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.asc()) .order_by(SendAttempt.attempt_number.asc()),
.all() label="SMTP attempts for this campaign job",
) )
imap_attempts = ( imap_attempts = _job_attempt_rows(
session.query(ImapAppendAttempt) session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id) .filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.asc()) .order_by(ImapAppendAttempt.attempt_number.asc()),
.all() label="IMAP attempts for this campaign job",
)
postbox_attempts = _job_attempt_rows(
session.query(PostboxDeliveryAttempt)
.filter(PostboxDeliveryAttempt.job_id == job.id)
.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.asc(),
),
label="Postbox attempts for this campaign job",
) )
return CampaignJobDetailResponse( return CampaignJobDetailResponse(
job=_job_detail_payload(job), job=_job_detail_payload(job),
attempts=_job_attempts_payload(send_attempts, imap_attempts), attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
postbox_attempts,
),
) )
@@ -2998,19 +3314,33 @@ def get_job_diagnostics(
job = session.get(CampaignJob, job_id) job = session.get(CampaignJob, job_id)
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id: if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
send_attempts = ( send_attempts = _job_attempt_rows(
session.query(SendAttempt) session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id) .filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.asc()) .order_by(SendAttempt.attempt_number.asc()),
.all() label="SMTP diagnostics for this campaign job",
) )
imap_attempts = ( imap_attempts = _job_attempt_rows(
session.query(ImapAppendAttempt) session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id) .filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.asc()) .order_by(ImapAppendAttempt.attempt_number.asc()),
.all() label="IMAP diagnostics for this campaign job",
)
postbox_attempts = _job_attempt_rows(
session.query(PostboxDeliveryAttempt)
.filter(PostboxDeliveryAttempt.job_id == job.id)
.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.asc(),
),
label="Postbox diagnostics for this campaign job",
)
return _job_diagnostics_payload(
job,
send_attempts,
imap_attempts,
postbox_attempts,
) )
return _job_diagnostics_payload(job, send_attempts, imap_attempts)
@router.get("/{campaign_id}/summary") @router.get("/{campaign_id}/summary")
@@ -3152,6 +3482,7 @@ def email_campaign_report(
@router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse) @router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse)
def list_campaign_share_targets( def list_campaign_share_targets(
campaign_id: str, campaign_id: str,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
): ):
@@ -3159,6 +3490,14 @@ def list_campaign_share_targets(
directory = _access_directory() directory = _access_directory()
users = [user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active"] users = [user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active"]
groups = [group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active"] groups = [group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active"]
if len(users) > limit or len(groups) > limit:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
f"Campaign share targets exceed the maximum response size of {limit} "
"users or groups. Use a searchable directory selector."
),
)
return CampaignShareTargetsResponse( return CampaignShareTargetsResponse(
users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users], users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users],
groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) for item in groups], groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) for item in groups],
@@ -3168,17 +3507,35 @@ def list_campaign_share_targets(
@router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse) @router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse)
def list_campaign_shares( def list_campaign_shares(
campaign_id: str, campaign_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
): ):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
shares = ( query = (
session.query(CampaignShare) session.query(CampaignShare)
.filter(CampaignShare.tenant_id == principal.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None)) .filter(CampaignShare.tenant_id == principal.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None))
.order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc()) )
total = query.order_by(None).count()
pages = max(1, (total + page_size - 1) // page_size)
shares = (
query.order_by(
CampaignShare.target_type.asc(),
CampaignShare.target_id.asc(),
CampaignShare.id.asc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all() .all()
) )
return CampaignShareListResponse(shares=[CampaignShareItem.model_validate(item) for item in shares]) return CampaignShareListResponse(
shares=[CampaignShareItem.model_validate(item) for item in shares],
total=total,
page=page,
page_size=page_size,
pages=pages,
)
@router.put("/{campaign_id}/owner", response_model=CampaignResponse) @router.put("/{campaign_id}/owner", response_model=CampaignResponse)
@@ -3302,7 +3659,8 @@ def campaign_delivery_options(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
version_id=version_id, version_id=version_id,
) ),
postbox_available=postbox_integration().available,
) )
except QueueingError as exc: except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@@ -3472,6 +3830,7 @@ def resolve_campaign_job_outcome(
job_id=job_id, job_id=job_id,
decision=payload.decision, decision=payload.decision,
note=payload.note, note=payload.note,
attempt_id=payload.attempt_id,
commit=False, commit=False,
) )
audit_from_principal( audit_from_principal(
@@ -3608,6 +3967,10 @@ def send_campaign_now_endpoint(
) )
return SendCampaignNowResponse(result=response_result) return SendCampaignNowResponse(result=response_result)
except SynchronousSendRejected as exc: except SynchronousSendRejected as exc:
# A synchronous request stages queue state before the all-message
# preflight can run. Rejecting that preflight must not leave work
# eligible for a background worker when no provider effect occurred.
session.rollback()
audit_from_principal( audit_from_principal(
session, session,
principal, principal,

View File

@@ -60,7 +60,9 @@
"integer", "integer",
"double", "double",
"date", "date",
"password" "password",
"organization_unit",
"organization_function"
], ],
"default": "string" "default": "string"
}, },
@@ -92,7 +94,27 @@
"mail_profile_id": { "mail_profile_id": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"description": "Stable reference to an authorized profile owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials." "description": "Stable reference to an authorized server envelope owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials."
},
"smtp_server_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit Mail-owned SMTP server selection."
},
"smtp_credential_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit core credential envelope bound to the selected SMTP server."
},
"imap_server_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit Mail-owned IMAP server selection."
},
"imap_credential_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit core credential envelope bound to the selected IMAP server."
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -509,6 +531,12 @@
"delivery": { "delivery": {
"type": "object", "type": "object",
"properties": { "properties": {
"channel_policy": {
"$ref": "#/$defs/delivery_channel_policy"
},
"postbox": {
"$ref": "#/$defs/postbox_delivery"
},
"rate_limit": { "rate_limit": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -626,6 +654,179 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"delivery_channel_policy": {
"type": "string",
"enum": [
"mail",
"postbox",
"mail_and_postbox",
"mail_then_postbox",
"postbox_then_mail"
],
"default": "mail"
},
"postbox_target": {
"type": "object",
"required": [
"id",
"mode"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"mode": {
"type": "string",
"enum": [
"direct",
"derived"
]
},
"label": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"postbox_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"address_key": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"template_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"organization_unit_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"organization_unit_field": {
"type": [
"string",
"null"
],
"maxLength": 255
},
"organization_unit_match": {
"type": "string",
"enum": [
"id",
"slug"
],
"default": "id"
},
"function_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"function_field": {
"type": [
"string",
"null"
],
"maxLength": 255
},
"function_match": {
"type": "string",
"enum": [
"id",
"slug"
],
"default": "id"
},
"context_key": {
"type": [
"string",
"null"
],
"maxLength": 255
},
"context_field": {
"type": [
"string",
"null"
],
"maxLength": 255
}
},
"additionalProperties": false
},
"postbox_delivery": {
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {
"$ref": "#/$defs/postbox_target"
},
"maxItems": 50,
"default": []
},
"classification": {
"type": "string",
"minLength": 1,
"maxLength": 50,
"default": "internal"
},
"unresolved_target": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "block"
},
"vacant_target": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "warn"
},
"duplicate_target": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "warn"
}
},
"additionalProperties": false,
"default": {}
},
"attachment_config": { "attachment_config": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -869,6 +1070,29 @@
"default": true, "default": true,
"description": "Deprecated compatibility alias for merge_disposition_notification_to. New campaign JSON should use merge_*." "description": "Deprecated compatibility alias for merge_disposition_notification_to. New campaign JSON should use merge_*."
}, },
"channel_policy": {
"oneOf": [
{
"$ref": "#/$defs/delivery_channel_policy"
},
{
"type": "null"
}
],
"default": null
},
"postbox_targets": {
"type": "array",
"items": {
"$ref": "#/$defs/postbox_target"
},
"maxItems": 50,
"default": []
},
"merge_postbox_targets": {
"type": "boolean",
"default": true
},
"attachments": { "attachments": {
"type": "array", "type": "array",
"items": { "items": {

View File

@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_campaign.backend.campaign.mail_profile_boundary import ( from govoplan_campaign.backend.campaign.mail_profile_boundary import (
@@ -194,6 +194,10 @@ class CampaignDeltaResponse(BaseModel):
watermark: str | None = None watermark: str | None = None
has_more: bool = False has_more: bool = False
full: bool = False full: bool = False
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse): class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse):
@@ -216,6 +220,10 @@ class CampaignShareItem(BaseModel):
class CampaignShareListResponse(BaseModel): class CampaignShareListResponse(BaseModel):
shares: list[CampaignShareItem] shares: list[CampaignShareItem]
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class CampaignShareTargetItem(BaseModel): class CampaignShareTargetItem(BaseModel):
@@ -342,6 +350,13 @@ class CampaignRecipientAddressSourcesResponse(BaseModel):
sources: list[CampaignRecipientAddressSource] = Field(default_factory=list) sources: list[CampaignRecipientAddressSource] = Field(default_factory=list)
class CampaignPostboxCatalogResponse(BaseModel):
available: bool = False
postboxes: list[dict[str, Any]] = Field(default_factory=list)
templates: list[dict[str, Any]] = Field(default_factory=list)
organization_units: list[dict[str, Any]] = Field(default_factory=list)
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel): class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -439,8 +454,11 @@ class CampaignResolveOutcomeRequest(BaseModel):
"not_sent", "not_sent",
"imap_appended", "imap_appended",
"imap_not_appended", "imap_not_appended",
"postbox_accepted",
"postbox_not_accepted",
] ]
note: str | None = Field(default=None, max_length=2000) note: str | None = Field(default=None, max_length=2000)
attempt_id: str | None = Field(default=None, max_length=36)
@model_validator(mode="after") @model_validator(mode="after")
def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest": def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest":
@@ -520,6 +538,7 @@ class CampaignDeliveryOptionsResponse(BaseModel):
campaign_id: str campaign_id: str
version_id: str version_id: str
worker_queue_available: bool worker_queue_available: bool
postbox_available: bool = False
synchronous_send: dict[str, Any] = Field(default_factory=dict) synchronous_send: dict[str, Any] = Field(default_factory=dict)
@@ -551,29 +570,22 @@ class CampaignActionResponse(BaseModel):
result: dict[str, Any] result: dict[str, Any]
class ReportEmailRequest(BaseModel): def _valid_report_email_domain(domain: str) -> bool:
model_config = ConfigDict(extra="forbid") if not domain or domain.startswith(".") or domain.endswith(".") or ".." in domain:
return False
return all(
label
and not label.startswith("-")
and not label.endswith("-")
and all(character.isalnum() or character == "-" for character in label)
for label in domain.split(".")
)
to: list[str] = Field(min_length=1, max_length=50)
version_id: str | None = None
include_jobs: bool = False
attach_jobs_csv: bool = False
attach_report_json: bool = False
dry_run: bool = False
@field_validator("to", mode="before") def _normalize_report_recipient(value: Any) -> str:
@classmethod if not isinstance(value, str):
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") raise ValueError("report recipients must be email-address strings")
recipient = item.strip() recipient = value.strip()
if len(recipient) > 320: if len(recipient) > 320:
raise ValueError("report recipient addresses must be at most 320 characters") raise ValueError("report recipient addresses must be at most 320 characters")
if any(ord(character) < 32 or ord(character) == 127 for character in recipient): if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
@@ -581,33 +593,45 @@ class ReportEmailRequest(BaseModel):
if recipient.count("@") != 1: if recipient.count("@") != 1:
raise ValueError("report recipients must be email addresses") raise ValueError("report recipients must be email addresses")
local, domain = recipient.split("@", 1) local, domain = recipient.split("@", 1)
if ( invalid_local = not local or local.startswith(".") or local.endswith(".") or ".." in local
not local invalid_address = (
or not domain any(character.isspace() for character in recipient)
or any(character.isspace() for character in recipient)
or any(character in ',;:<>[]()\\"' 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(".")
) )
): if invalid_local or invalid_address or not _valid_report_email_domain(domain):
raise ValueError("report recipients must be email addresses") raise ValueError("report recipients must be email addresses")
return recipient
ReportEmailAddress = Annotated[str, BeforeValidator(_normalize_report_recipient)]
def _deduplicate_report_recipients(value: list[str]) -> list[str]:
recipients: list[str] = []
seen: set[str] = set()
for recipient in value:
key = recipient.casefold() key = recipient.casefold()
if key in seen: if key not in seen:
continue
seen.add(key) seen.add(key)
recipients.append(recipient) recipients.append(recipient)
return recipients return recipients
class ReportEmailRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
to: list[ReportEmailAddress] = Field(min_length=1, max_length=50)
version_id: str | None = None
include_jobs: bool = False
attach_jobs_csv: bool = False
attach_report_json: bool = False
dry_run: bool = False
@field_validator("to")
@classmethod
def normalize_and_validate_recipients(cls, value: list[str]) -> list[str]:
return _deduplicate_report_recipients(value)
class ReportEmailResponse(BaseModel): class ReportEmailResponse(BaseModel):
result: dict[str, Any] result: dict[str, Any]

View File

@@ -9,16 +9,21 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
from govoplan_campaign.backend.campaign.models import DeliveryConfig from govoplan_campaign.backend.campaign.models import (
DeliveryChannelPolicy,
DeliveryConfig,
)
from govoplan_campaign.backend.campaign.mail_profile_boundary import ( from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError, CampaignMailProfileBoundaryError,
assert_campaign_uses_mail_profile_reference, assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_id, campaign_mail_profile_id,
campaign_mail_resource_ids,
) )
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
SNAPSHOT_VERSION = "5" SNAPSHOT_VERSION = "7"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", SNAPSHOT_VERSION}
class ExecutionSnapshotError(RuntimeError): class ExecutionSnapshotError(RuntimeError):
@@ -40,7 +45,11 @@ class ExecutionSnapshot(BaseModel):
snapshot_version: str = SNAPSHOT_VERSION snapshot_version: str = SNAPSHOT_VERSION
campaign_version_id: str campaign_version_id: str
campaign_json_sha256: str campaign_json_sha256: str
mail_profile_id: str mail_profile_id: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
imap_server_id: str | None = None
imap_credential_id: str | None = None
created_at: str created_at: str
build_token: str | None = None build_token: str | None = None
built_at: str | None = None built_at: str | None = None
@@ -50,6 +59,8 @@ class ExecutionSnapshot(BaseModel):
effective_policy_sha256: str | None = None effective_policy_sha256: str | None = None
smtp_transport_revision: str | None = None smtp_transport_revision: str | None = None
imap_transport_revision: str | None = None imap_transport_revision: str | None = None
uses_mail: bool = True
uses_postbox: bool = False
delivery: DeliveryConfig delivery: DeliveryConfig
@@ -67,7 +78,7 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]: def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json) _assert_version_mail_profile_boundary(raw_json, require_profile=True)
mail = mail_integration() mail = mail_integration()
profile_id = campaign_mail_profile_id(raw_json) profile_id = campaign_mail_profile_id(raw_json)
if profile_id is None: # Kept explicit for static typing; the assertion above requires it. if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
@@ -75,12 +86,17 @@ def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict
campaign = session.get(Campaign, version.campaign_id) campaign = session.get(Campaign, version.campaign_id)
if campaign is None: if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution") raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
references = campaign_mail_resource_ids(raw_json)
try: try:
return mail.campaign_profile_delivery_summary( return mail.campaign_profile_delivery_summary(
session, session,
tenant_id=campaign.tenant_id, tenant_id=campaign.tenant_id,
campaign_id=campaign.id, campaign_id=campaign.id,
profile_id=profile_id, profile_id=profile_id,
smtp_server_id=references["smtp_server_id"],
smtp_credential_id=references["smtp_credential_id"],
imap_server_id=references["imap_server_id"],
imap_credential_id=references["imap_credential_id"],
) )
except MailProfileError as exc: except MailProfileError as exc:
raise ExecutionSnapshotError(str(exc)) from exc raise ExecutionSnapshotError(str(exc)) from exc
@@ -96,27 +112,61 @@ def profile_transport_revisions(session: Session, version: CampaignVersion) -> d
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None: def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json) _assert_version_mail_profile_boundary(
raw_json,
require_profile=snapshot.uses_mail,
)
if not snapshot.uses_mail:
return
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id: if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"The campaign's Mail profile reference differs from the built execution snapshot. " "The campaign's Mail profile reference differs from the built execution snapshot. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
) )
references = campaign_mail_resource_ids(raw_json)
for key in (
"smtp_server_id",
"smtp_credential_id",
"imap_server_id",
"imap_credential_id",
):
configured = references[key]
if configured and configured != getattr(snapshot, key):
raise ExecutionSnapshotError(
"The campaign's Mail server or credential selection differs from the built "
"execution snapshot. Revalidate and rebuild before delivery."
)
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None: def _assert_version_mail_profile_boundary(
raw_json: dict[str, Any],
*,
require_profile: bool,
) -> None:
try: try:
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True) assert_campaign_uses_mail_profile_reference(
raw_json,
require_profile=require_profile,
)
except CampaignMailProfileBoundaryError as exc: except CampaignMailProfileBoundaryError as exc:
raise ExecutionSnapshotError(str(exc)) from exc raise ExecutionSnapshotError(str(exc)) from exc
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str: def _policy_fingerprint(
raw_json: dict[str, Any],
delivery: DeliveryConfig,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
delivery_payload = delivery.model_dump(mode="json")
if snapshot_version == "6":
delivery_payload.pop("channel_policy", None)
delivery_payload.pop("postbox", None)
return _sha256( return _sha256(
{ {
"validation_policy": raw_json.get("validation_policy"), "validation_policy": raw_json.get("validation_policy"),
"policy": raw_json.get("policy"), "policy": raw_json.get("policy"),
"delivery": delivery.model_dump(mode="json"), "delivery": delivery_payload,
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults") "attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
if isinstance(raw_json.get("attachments"), dict) if isinstance(raw_json.get("attachments"), dict)
else None, else None,
@@ -124,8 +174,12 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
) )
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]: def _job_execution_input_payload(
return { job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> dict[str, Any]:
payload = {
"job_id": job.id, "job_id": job.id,
"entry_index": job.entry_index, "entry_index": job.entry_index,
"entry_id": job.entry_id, "entry_id": job.entry_id,
@@ -140,17 +194,47 @@ def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []), "resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []), "issues_sha256": _sha256(job.issues_snapshot or []),
} }
if snapshot_version != "6":
payload.update(
{
"delivery_channel_policy": getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
),
"resolved_postbox_targets_sha256": _sha256(
getattr(job, "resolved_postbox_targets", None) or []
),
}
)
return payload
def job_execution_input_hash(job: CampaignJob) -> str: def job_execution_input_hash(
return _sha256(_job_execution_input_payload(job)) job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
return _sha256(
_job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
)
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str: def job_manifest_hash(
jobs: Iterable[CampaignJob],
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
"""Hash the immutable per-message execution records in stable order.""" """Hash the immutable per-message execution records in stable order."""
payload = [ payload = [
_job_execution_input_payload(job) _job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)) for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
] ]
return _sha256(payload) return _sha256(payload)
@@ -159,31 +243,67 @@ def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
def create_execution_snapshot( def create_execution_snapshot(
version: CampaignVersion, version: CampaignVersion,
*, *,
mail_profile_id: str, mail_profile_id: str | None,
smtp_transport_revision: str, smtp_transport_revision: str | None,
imap_transport_revision: str | None, imap_transport_revision: str | None,
delivery: DeliveryConfig, delivery: DeliveryConfig,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
jobs: Iterable[CampaignJob] = (), jobs: Iterable[CampaignJob] = (),
build_summary: dict[str, Any] | None = None, build_summary: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]: ) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs) job_list = list(jobs)
channel_policies = {
DeliveryChannelPolicy(
getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
)
)
for job in job_list
}
uses_mail = any(policy.uses_mail for policy in channel_policies)
uses_postbox = any(policy.uses_postbox for policy in channel_policies)
for job in job_list: for job in job_list:
job.execution_input_sha256 = job_execution_input_hash(job) job.execution_input_sha256 = job_execution_input_hash(
job,
snapshot_version=SNAPSHOT_VERSION,
)
summary = build_summary if isinstance(build_summary, dict) else {} summary = build_summary if isinstance(build_summary, dict) else {}
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value} queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
payload = ExecutionSnapshot( payload = ExecutionSnapshot(
campaign_version_id=version.id, campaign_version_id=version.id,
campaign_json_sha256=_sha256(raw_json), campaign_json_sha256=_sha256(raw_json),
mail_profile_id=mail_profile_id, mail_profile_id=mail_profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
imap_server_id=imap_server_id,
imap_credential_id=imap_credential_id,
build_token=str(summary.get("build_token") or "") or None, build_token=str(summary.get("build_token") or "") or None,
built_at=str(summary.get("built_at") or "") or None, built_at=str(summary.get("built_at") or "") or None,
job_count=len(job_list), job_count=len(job_list),
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses), 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, job_manifest_sha256=(
effective_policy_sha256=_policy_fingerprint(raw_json, delivery), job_manifest_hash(
job_list,
snapshot_version=SNAPSHOT_VERSION,
)
if job_list
else None
),
effective_policy_sha256=_policy_fingerprint(
raw_json,
delivery,
snapshot_version=SNAPSHOT_VERSION,
),
smtp_transport_revision=smtp_transport_revision, smtp_transport_revision=smtp_transport_revision,
imap_transport_revision=imap_transport_revision, imap_transport_revision=imap_transport_revision,
uses_mail=uses_mail,
uses_postbox=uses_postbox,
created_at=datetime.now(timezone.utc).isoformat(), created_at=datetime.now(timezone.utc).isoformat(),
delivery=delivery, delivery=delivery,
).model_dump(mode="json") ).model_dump(mode="json")
@@ -207,13 +327,17 @@ def _assert_snapshot_matches_persisted_inputs(
"Campaign inputs changed after this execution snapshot was built. " "Campaign inputs changed after this execution snapshot was built. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
) )
if not snapshot.smtp_transport_revision: if snapshot.uses_mail and not snapshot.smtp_transport_revision:
raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision") raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision")
if not snapshot.job_manifest_sha256: if not snapshot.job_manifest_sha256:
raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum") raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum")
if not snapshot.effective_policy_sha256: if not snapshot.effective_policy_sha256:
raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum") raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum")
if snapshot.effective_policy_sha256 != _policy_fingerprint(raw_json, snapshot.delivery): if snapshot.effective_policy_sha256 != _policy_fingerprint(
raw_json,
snapshot.delivery,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"Campaign delivery policy changed after the execution snapshot was created. " "Campaign delivery policy changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
@@ -224,7 +348,10 @@ def _assert_snapshot_matches_persisted_inputs(
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version") raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
if not getattr(effect_job, "execution_input_sha256", None): if not getattr(effect_job, "execution_input_sha256", None):
raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery") raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery")
if effect_job.execution_input_sha256 != job_execution_input_hash(effect_job): if effect_job.execution_input_sha256 != job_execution_input_hash(
effect_job,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. " "Built campaign job inputs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
@@ -246,8 +373,19 @@ def _assert_snapshot_matches_persisted_inputs(
queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses) queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses)
if ( if (
snapshot.queueable_job_count != queueable_count snapshot.queueable_job_count != queueable_count
or snapshot.job_manifest_sha256 != job_manifest_hash(jobs) or snapshot.job_manifest_sha256
or any(getattr(job, "execution_input_sha256", None) != job_execution_input_hash(job) for job in jobs) != job_manifest_hash(
jobs,
snapshot_version=snapshot.snapshot_version,
)
or any(
getattr(job, "execution_input_sha256", None)
!= job_execution_input_hash(
job,
snapshot_version=snapshot.snapshot_version,
)
for job in jobs
)
): ):
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. " "Built campaign job inputs changed after the execution snapshot was created. "
@@ -276,17 +414,20 @@ def ensure_execution_snapshot(
) )
except CampaignPathSecurityError as exc: except CampaignPathSecurityError as exc:
raise ExecutionSnapshotError(str(exc)) from exc raise ExecutionSnapshotError(str(exc)) from exc
_assert_version_mail_profile_boundary(raw_json) _assert_version_mail_profile_boundary(raw_json, require_profile=False)
if isinstance(version.execution_snapshot, dict): if isinstance(version.execution_snapshot, dict):
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION: stored_version = str(
version.execution_snapshot.get("snapshot_version") or ""
)
if stored_version not in SUPPORTED_SNAPSHOT_VERSIONS:
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. " "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 " "It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
"and rebuild a new campaign version." "and rebuild a new campaign version."
) )
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot) snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
expected = snapshot_hash(snapshot.model_dump(mode="json")) expected = snapshot_hash(version.execution_snapshot)
if not version.execution_snapshot_hash: if not version.execution_snapshot_hash:
raise ExecutionSnapshotError("Execution snapshot checksum is missing") raise ExecutionSnapshotError("Execution snapshot checksum is missing")
if version.execution_snapshot_hash != expected: if version.execution_snapshot_hash != expected:
@@ -303,11 +444,6 @@ def ensure_execution_snapshot(
from govoplan_campaign.backend.persistence.campaigns import load_version_config from govoplan_campaign.backend.persistence.campaigns import load_version_config
_, _, config = load_version_config(session, version.id) _, _, config = load_version_config(session, version.id)
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 = ( jobs = (
session.query(CampaignJob) session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id) .filter(CampaignJob.campaign_version_id == version.id)
@@ -316,14 +452,33 @@ def ensure_execution_snapshot(
) )
if not jobs: if not jobs:
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery") raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
revisions = profile_transport_revisions(session, version) uses_mail = any(
if not revisions["smtp"]: DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision") for job in jobs
)
profile_id = campaign_mail_profile_id(raw_json)
summary: dict[str, Any] = {}
if uses_mail:
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")
summary = profile_delivery_summary(session, version)
if not summary.get("smtp_transport_revision"):
raise ExecutionSnapshotError(
"The selected Mail profile has no SMTP transport revision"
)
payload, digest = create_execution_snapshot( payload, digest = create_execution_snapshot(
version, version,
mail_profile_id=profile_id, mail_profile_id=profile_id,
smtp_transport_revision=revisions["smtp"], smtp_server_id=summary.get("smtp_server_id"),
imap_transport_revision=revisions["imap"], smtp_credential_id=summary.get("smtp_credential_id"),
imap_server_id=summary.get("imap_server_id"),
imap_credential_id=summary.get("imap_credential_id"),
smtp_transport_revision=summary.get("smtp_transport_revision"),
imap_transport_revision=summary.get("imap_transport_revision"),
delivery=config.delivery, delivery=config.delivery,
jobs=jobs, jobs=jobs,
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {}, 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,506 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from email import policy
from email.parser import BytesParser
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.postbox import (
PostboxAttachmentRef,
PostboxDeliveryOutcomeUnknown,
PostboxDeliveryRejected,
PostboxDeliveryRequest,
PostboxParticipantRef,
PostboxTargetRef,
)
from govoplan_campaign.backend.db.models import (
CampaignJob,
JobPostboxStatus,
PostboxDeliveryAttempt,
)
from govoplan_campaign.backend.integrations import (
PostboxDeliveryUnavailable,
postbox_integration,
)
from govoplan_core.security.time import utc_now
ACCEPTED_POSTBOX_ATTEMPT_STATUSES = {
JobPostboxStatus.ACCEPTED.value,
JobPostboxStatus.ACCEPTED_VACANT.value,
}
@dataclass(slots=True)
class PostboxChannelOutcome:
accepted: int = 0
accepted_vacant: int = 0
rejected_temporary: int = 0
rejected_permanent: int = 0
outcome_unknown: int = 0
messages: list[str] = field(default_factory=list)
@property
def accepted_count(self) -> int:
return self.accepted + self.accepted_vacant
@property
def rejected_count(self) -> int:
return self.rejected_temporary + self.rejected_permanent
@property
def all_rejected_before_acceptance(self) -> bool:
return (
self.accepted_count == 0
and self.outcome_unknown == 0
and self.rejected_count > 0
)
@property
def status(self) -> str:
if self.outcome_unknown:
return JobPostboxStatus.OUTCOME_UNKNOWN.value
if self.accepted_count and self.rejected_count:
return JobPostboxStatus.PARTIALLY_ACCEPTED.value
if self.accepted_vacant and not self.accepted:
return JobPostboxStatus.ACCEPTED_VACANT.value
if self.accepted_count:
return JobPostboxStatus.ACCEPTED.value
if self.rejected_temporary:
return JobPostboxStatus.REJECTED_TEMPORARY.value
return JobPostboxStatus.REJECTED_PERMANENT.value
def _target_key(target: dict[str, Any]) -> str:
payload = json.dumps(
target,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _attempt_number(
session: Session,
*,
job_id: str,
target_key: str,
) -> int:
current = (
session.query(func.max(PostboxDeliveryAttempt.attempt_number))
.filter(
PostboxDeliveryAttempt.job_id == job_id,
PostboxDeliveryAttempt.target_key == target_key,
)
.scalar()
)
return int(current or 0) + 1
def _accepted_attempt(
session: Session,
*,
job_id: str,
target_key: str,
) -> PostboxDeliveryAttempt | None:
return (
session.query(PostboxDeliveryAttempt)
.filter(
PostboxDeliveryAttempt.job_id == job_id,
PostboxDeliveryAttempt.target_key == target_key,
PostboxDeliveryAttempt.status.in_(
list(ACCEPTED_POSTBOX_ATTEMPT_STATUSES)
),
)
.order_by(PostboxDeliveryAttempt.attempt_number.desc())
.first()
)
def _message_body(message_bytes: bytes) -> str | None:
message = BytesParser(policy=policy.default).parsebytes(message_bytes)
body = message.get_body(preferencelist=("plain", "html"))
if body is None:
payload = message.get_payload(decode=True)
if not isinstance(payload, bytes):
return None
return payload.decode(message.get_content_charset() or "utf-8", "replace")
try:
content = body.get_content()
except (LookupError, UnicodeError):
payload = body.get_payload(decode=True)
if not isinstance(payload, bytes):
return None
return payload.decode(body.get_content_charset() or "utf-8", "replace")
return str(content)
def _participants(job: CampaignJob) -> tuple[PostboxParticipantRef, ...]:
recipients = job.resolved_recipients or {}
values: list[PostboxParticipantRef] = []
for key in (
"from_all",
"to",
"cc",
"bcc",
"reply_to",
"bounce_to",
"disposition_notification_to",
):
for item in recipients.get(key) or []:
if not isinstance(item, dict):
continue
address = str(item.get("email") or "").strip() or None
label = str(item.get("name") or "").strip() or None
if address is None and label is None:
continue
values.append(
PostboxParticipantRef(
kind="sender" if key == "from_all" else key,
reference_type="mail_address",
label=label,
address=address,
)
)
return tuple(values)
def _attachments(job: CampaignJob) -> tuple[PostboxAttachmentRef, ...]:
values = [_eml_attachment(job)]
for rule_index, rule in enumerate(job.resolved_attachments or []):
if not isinstance(rule, dict):
continue
managed_matches = rule.get("managed_matches")
if isinstance(managed_matches, list) and managed_matches:
values.extend(_managed_attachments(managed_matches))
continue
matches = rule.get("matches")
if isinstance(matches, list):
values.extend(_campaign_attachments(job, rule_index=rule_index, matches=matches))
return tuple(values)
def _eml_attachment(job: CampaignJob) -> PostboxAttachmentRef:
return PostboxAttachmentRef(
reference_type="campaign_eml",
reference_id=job.id,
name=f"{job.entry_id or job.entry_index}.eml",
media_type="message/rfc822",
size_bytes=job.eml_size_bytes,
digest=job.eml_sha256,
metadata={"campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id},
)
MANAGED_ATTACHMENT_METADATA_KEYS = {
"asset_id",
"version_id",
"blob_id",
"display_path",
"relative_path",
"owner_type",
"owner_id",
"source_revision",
}
def _managed_attachment(match: dict[str, Any]) -> PostboxAttachmentRef | None:
reference_id = str(match.get("version_id") or match.get("asset_id") or match.get("blob_id") or "").strip()
if not reference_id:
return None
return PostboxAttachmentRef(
reference_type="file_version" if match.get("version_id") else "file_asset",
reference_id=reference_id,
name=str(match.get("filename") or "").strip() or None,
media_type=str(match.get("content_type")) if match.get("content_type") else None,
size_bytes=int(match["size_bytes"]) if match.get("size_bytes") is not None else None,
digest=str(match.get("checksum_sha256")) if match.get("checksum_sha256") else None,
metadata={key: value for key, value in match.items() if key in MANAGED_ATTACHMENT_METADATA_KEYS},
)
def _managed_attachments(matches: list[Any]) -> list[PostboxAttachmentRef]:
attachments = (_managed_attachment(match) for match in matches if isinstance(match, dict))
return [attachment for attachment in attachments if attachment is not None]
def _campaign_attachments(
job: CampaignJob,
*,
rule_index: int,
matches: list[Any],
) -> list[PostboxAttachmentRef]:
metadata = {"campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id, "job_id": job.id}
return [
PostboxAttachmentRef(
reference_type="campaign_attachment",
reference_id=f"{job.id}:{rule_index}:{match_index}",
name=str(match).rsplit("/", 1)[-1] or None,
metadata=metadata,
)
for match_index, match in enumerate(matches)
]
def _sender_label(job: CampaignJob) -> str | None:
recipients = job.resolved_recipients or {}
sender = recipients.get("from")
if not isinstance(sender, dict):
return None
name = str(sender.get("name") or "").strip()
address = str(sender.get("email") or "").strip()
if name and address:
return f"{name} <{address}>"
return address or name or None
def _request(
job: CampaignJob,
target: dict[str, Any],
*,
target_key: str,
body_text: str | None,
classification: str,
) -> PostboxDeliveryRequest:
return PostboxDeliveryRequest(
tenant_id=job.tenant_id,
target=PostboxTargetRef(postbox_id=str(target["postbox_id"])),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id=job.id,
idempotency_key=(
f"campaign:{job.campaign_version_id}:{job.id}:postbox:"
f"{target_key}"
),
subject=(job.subject or "").strip() or "(No subject)",
body_text=body_text,
sender_label=_sender_label(job),
classification=classification,
participants=_participants(job),
attachments=_attachments(job),
metadata={
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"campaign_job_id": job.id,
"entry_id": job.entry_id,
"entry_index": job.entry_index,
"delivery_channel_policy": job.delivery_channel_policy,
"target_snapshot": target,
},
)
def _record_rejection(
session: Session,
*,
job_id: str,
attempt_id: str,
exc: Exception,
temporary: bool,
) -> None:
session.rollback()
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
job = session.get(CampaignJob, job_id)
if attempt is None or job is None:
raise RuntimeError(
"Postbox rejection could not be written to Campaign evidence."
) from exc
attempt.status = (
JobPostboxStatus.REJECTED_TEMPORARY.value
if temporary
else JobPostboxStatus.REJECTED_PERMANENT.value
)
attempt.error_type = exc.__class__.__name__
attempt.error_code = str(getattr(exc, "code", "") or "") or None
attempt.error_message = str(exc)
attempt.finished_at = utc_now()
session.add(attempt)
session.add(job)
session.commit()
def _record_unknown(
session: Session,
*,
job_id: str,
attempt_id: str,
exc: Exception,
) -> None:
session.rollback()
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
job = session.get(CampaignJob, job_id)
if attempt is None or job is None:
raise RuntimeError(
"Unknown Postbox outcome could not be written to Campaign evidence."
) from exc
attempt.status = JobPostboxStatus.OUTCOME_UNKNOWN.value
attempt.error_type = exc.__class__.__name__
attempt.error_code = str(getattr(exc, "code", "") or "") or None
attempt.error_message = str(exc)
attempt.finished_at = utc_now()
job.postbox_status = JobPostboxStatus.OUTCOME_UNKNOWN.value
session.add(attempt)
session.add(job)
session.commit()
def deliver_campaign_job_to_postboxes(
session: Session,
*,
job: CampaignJob,
message_bytes: bytes,
classification: str,
) -> PostboxChannelOutcome:
outcome = PostboxChannelOutcome()
targets = [
target
for target in (job.resolved_postbox_targets or [])
if isinstance(target, dict) and target.get("postbox_id")
]
if not targets:
outcome.rejected_permanent = 1
outcome.messages.append("No frozen Postbox target is available.")
job.postbox_status = JobPostboxStatus.REJECTED_PERMANENT.value
session.add(job)
session.commit()
return outcome
body_text = _message_body(message_bytes)
for target_index, target in enumerate(targets):
key = _target_key(target)
accepted = _accepted_attempt(
session,
job_id=job.id,
target_key=key,
)
if accepted is not None:
if accepted.vacant:
outcome.accepted_vacant += 1
else:
outcome.accepted += 1
continue
attempt_number = _attempt_number(
session,
job_id=job.id,
target_key=key,
)
request = _request(
job,
target,
target_key=key,
body_text=body_text,
classification=classification,
)
attempt = PostboxDeliveryAttempt(
tenant_id=job.tenant_id,
job_id=job.id,
target_key=key,
target_index=target_index,
attempt_number=attempt_number,
idempotency_key=request.idempotency_key,
status=JobPostboxStatus.DELIVERING.value,
target_snapshot=target,
evidence={},
started_at=utc_now(),
)
job.postbox_attempt_count += 1
job.postbox_status = JobPostboxStatus.DELIVERING.value
session.add(attempt)
session.add(job)
session.commit()
attempt_id = attempt.id
try:
result = postbox_integration().deliver(session, request)
current_attempt = session.get(PostboxDeliveryAttempt, attempt_id)
current_job = session.get(CampaignJob, job.id)
if current_attempt is None or current_job is None:
raise RuntimeError(
"Campaign Postbox attempt disappeared before acceptance."
)
current_attempt.status = (
JobPostboxStatus.ACCEPTED_VACANT.value
if result.vacant
else JobPostboxStatus.ACCEPTED.value
)
current_attempt.provider_delivery_id = result.delivery_id
current_attempt.provider_message_id = result.message_id
current_attempt.postbox_id = result.postbox_id
current_attempt.address = result.address
current_attempt.holder_count = result.holder_count
current_attempt.vacant = result.vacant
current_attempt.duplicate = result.duplicate
current_attempt.evidence = dict(result.evidence)
current_attempt.finished_at = utc_now()
session.add(current_attempt)
session.add(current_job)
session.commit()
if result.vacant:
outcome.accepted_vacant += 1
else:
outcome.accepted += 1
except PostboxDeliveryOutcomeUnknown as exc:
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
outcome.messages.append(str(exc))
except PostboxDeliveryRejected as exc:
if exc.code == "idempotency_conflict":
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
else:
_record_rejection(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
temporary=exc.temporary,
)
if exc.temporary:
outcome.rejected_temporary += 1
else:
outcome.rejected_permanent += 1
outcome.messages.append(str(exc))
except PostboxDeliveryUnavailable as exc:
_record_rejection(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
temporary=True,
)
outcome.rejected_temporary += 1
outcome.messages.append(str(exc))
except Exception as exc:
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
outcome.messages.append(str(exc))
current_job = session.get(CampaignJob, job.id)
if current_job is not None:
current_job.postbox_status = outcome.status
session.add(current_job)
session.commit()
return outcome

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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
@@ -364,6 +365,18 @@ def test_aggregate_report_task_never_implies_recipient_detail_or_export_authorit
assert "export" in topic.metadata["verification"].lower() 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: def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_resend_claim() -> None:
from govoplan_campaign.backend.manifest import get_manifest from govoplan_campaign.backend.manifest import get_manifest

View File

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

View File

@@ -94,7 +94,7 @@ def _ensure(session: _Session, version) -> None:
ensure_execution_snapshot(session, version) # type: ignore[arg-type] ensure_execution_snapshot(session, version) # type: ignore[arg-type]
def test_v5_snapshot_requires_its_persisted_checksum() -> None: def test_current_snapshot_requires_its_persisted_checksum() -> None:
job = _job() job = _job()
version = _snapshotted_version(job) version = _snapshotted_version(job)
version.execution_snapshot_hash = None version.execution_snapshot_hash = None

View File

@@ -7,7 +7,9 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from sqlalchemy import create_engine, text from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import create_engine, inspect, text
from govoplan_campaign.backend import router from govoplan_campaign.backend import router
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
@@ -128,6 +130,10 @@ def test_post_provider_persistence_failure_freezes_imap_retry() -> None:
mail_profile_id="profile-1", mail_profile_id="profile-1",
smtp_transport_revision="smtp-revision", smtp_transport_revision="smtp-revision",
imap_transport_revision="imap-revision", imap_transport_revision="imap-revision",
smtp_server_id=None,
smtp_credential_id=None,
imap_server_id=None,
imap_credential_id=None,
delivery=SimpleNamespace( delivery=SimpleNamespace(
imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"), imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"),
), ),
@@ -218,6 +224,31 @@ def test_imap_claim_migration_preserves_and_renumbers_duplicate_attempts() -> No
] ]
def test_imap_attempt_claim_repair_adds_only_the_missing_column() -> None:
migration = importlib.import_module(
"govoplan_campaign.backend.migrations.versions.e9f0a1b2c3d4_v0114_repair_imap_attempt_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)"
)
)
context = MigrationContext.configure(connection)
with patch.object(migration, "op", Operations(context)):
migration.upgrade()
migration.upgrade()
columns = {
column["name"]
for column in inspect(connection).get_columns("imap_append_attempts")
}
assert columns == {"id", "job_id", "claim_token"}
@pytest.mark.parametrize("decision", ["smtp_accepted", "not_sent", "imap_appended", "imap_not_appended"]) @pytest.mark.parametrize("decision", ["smtp_accepted", "not_sent", "imap_appended", "imap_not_appended"])
def test_reconciliation_requires_an_evidence_note(decision: str) -> None: def test_reconciliation_requires_an_evidence_note(decision: str) -> None:
with pytest.raises(ValueError, match="evidence note"): with pytest.raises(ValueError, match="evidence note"):

View File

@@ -73,7 +73,7 @@ def test_mail_profile_documentation_is_classified_for_adaptive_views() -> None:
def test_campaign_mail_contract_rejects_every_legacy_server_field(legacy_key: str) -> None: def test_campaign_mail_contract_rejects_every_legacy_server_field(legacy_key: str) -> None:
raw = _campaign_json({"mail_profile_id": "profile-1", legacy_key: {}}) raw = _campaign_json({"mail_profile_id": "profile-1", legacy_key: {}})
with pytest.raises(CampaignMailProfileBoundaryError, match="select an authorized Mail profile"): with pytest.raises(CampaignMailProfileBoundaryError, match="select authorized Mail resources"):
assert_campaign_uses_mail_profile_reference(raw) assert_campaign_uses_mail_profile_reference(raw)
@@ -138,7 +138,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
delivery=DeliveryConfig(), delivery=DeliveryConfig(),
) )
assert payload["snapshot_version"] == "5" assert payload["snapshot_version"] == "7"
assert payload["mail_profile_id"] == "profile-1" assert payload["mail_profile_id"] == "profile-1"
assert "smtp" not in payload assert "smtp" not in payload
assert "imap" not in payload assert "imap" not in payload

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,400 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
from govoplan_campaign.backend.db.models import JobSendStatus
from govoplan_campaign.backend.sending.jobs import (
SendJobResult,
_MailChannelOutcome,
_final_multichannel_status,
_send_claimed_multichannel_job,
)
from govoplan_campaign.backend.sending.postbox_delivery import (
PostboxChannelOutcome,
)
def _context():
return SimpleNamespace(
message_bytes=b"message",
snapshot=SimpleNamespace(
delivery=SimpleNamespace(
postbox=SimpleNamespace(classification="internal")
)
),
)
def _job():
return SimpleNamespace(id="job-1")
class _Session:
def __init__(self, job) -> None:
self.job = job
def get(self, _model, _id):
return self.job
def add(self, _value) -> None:
return None
def commit(self) -> None:
return None
class PostboxFallbackOrchestrationTests(unittest.TestCase):
def test_mail_unknown_never_starts_postbox_fallback(self) -> None:
job = _job()
expected = SendJobResult(
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(outcome_unknown=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=PostboxChannelOutcome(),
),
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes"
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=expected,
),
):
result = _send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
self.assertIs(result, expected)
deliver_postbox.assert_not_called()
def test_mail_preacceptance_rejection_starts_postbox_fallback(self) -> None:
job = _job()
postbox_outcome = PostboxChannelOutcome(accepted=1)
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(rejected_permanent=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=PostboxChannelOutcome(),
),
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=postbox_outcome,
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.POSTBOX_ACCEPTED.value,
attempt_number=1,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_postbox.assert_called_once()
def test_accepted_postbox_fallback_prevents_later_mail_retry(self) -> None:
job = _job()
with (
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=PostboxChannelOutcome(
accepted=1,
rejected_temporary=1,
),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=PostboxChannelOutcome(accepted=2),
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.POSTBOX_ACCEPTED.value,
attempt_number=3,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_not_called()
deliver_postbox.assert_called_once()
def test_unknown_postbox_fallback_prevents_every_later_effect(self) -> None:
job = _job()
prior = PostboxChannelOutcome(outcome_unknown=1)
with (
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=prior,
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes"
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
),
) as finalize,
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_not_called()
deliver_postbox.assert_not_called()
self.assertIs(finalize.call_args.kwargs["postbox"], prior)
def test_postbox_acceptance_stops_mail_fallback(self) -> None:
job = _job()
with (
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=PostboxChannelOutcome(
accepted=1,
rejected_permanent=1,
),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.PARTIALLY_ACCEPTED.value,
attempt_number=2,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_not_called()
def test_all_postbox_rejections_start_mail_fallback(self) -> None:
job = _job()
with (
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=PostboxChannelOutcome(
rejected_temporary=1,
rejected_permanent=1,
),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(accepted=True),
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.SMTP_ACCEPTED.value,
attempt_number=1,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_called_once()
def test_explicit_dual_delivery_reports_partial_and_unknown(self) -> None:
self.assertEqual(
JobSendStatus.PARTIALLY_ACCEPTED.value,
_final_multichannel_status(
channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX,
mail=_MailChannelOutcome(accepted=True),
postbox=PostboxChannelOutcome(rejected_permanent=1),
),
)
self.assertEqual(
JobSendStatus.OUTCOME_UNKNOWN.value,
_final_multichannel_status(
channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX,
mail=_MailChannelOutcome(accepted=True),
postbox=PostboxChannelOutcome(outcome_unknown=1),
),
)
@pytest.mark.parametrize("policy", list(DeliveryChannelPolicy))
@pytest.mark.parametrize(
("mail", "postbox"),
[
(_MailChannelOutcome(outcome_unknown=True), PostboxChannelOutcome()),
(_MailChannelOutcome(accepted=True), PostboxChannelOutcome(outcome_unknown=1)),
(_MailChannelOutcome(outcome_unknown=True), PostboxChannelOutcome(accepted=1)),
],
)
def test_outcome_unknown_has_precedence_for_every_delivery_policy(
policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome,
postbox: PostboxChannelOutcome,
) -> None:
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == JobSendStatus.OUTCOME_UNKNOWN.value
@pytest.mark.parametrize("policy", list(DeliveryChannelPolicy))
@pytest.mark.parametrize(
("mail", "postbox", "expected"),
[
(
_MailChannelOutcome(rejected_permanent=True),
PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.FAILED_PERMANENT.value,
),
(
_MailChannelOutcome(rejected_temporary=True),
PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.FAILED_TEMPORARY.value,
),
(
_MailChannelOutcome(rejected_permanent=True),
PostboxChannelOutcome(rejected_temporary=1),
JobSendStatus.FAILED_TEMPORARY.value,
),
],
)
def test_rejection_precedence_is_exhaustive_for_every_delivery_policy(
policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome,
postbox: PostboxChannelOutcome,
expected: str,
) -> None:
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected
@pytest.mark.parametrize(
("policy", "mail", "postbox", "expected"),
[
(
DeliveryChannelPolicy.MAIL,
_MailChannelOutcome(accepted=True),
PostboxChannelOutcome(),
JobSendStatus.SMTP_ACCEPTED.value,
),
(
DeliveryChannelPolicy.POSTBOX,
None,
PostboxChannelOutcome(accepted=1),
JobSendStatus.POSTBOX_ACCEPTED.value,
),
(
DeliveryChannelPolicy.POSTBOX,
None,
PostboxChannelOutcome(accepted=1, rejected_permanent=1),
JobSendStatus.PARTIALLY_ACCEPTED.value,
),
(
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
_MailChannelOutcome(accepted=True),
PostboxChannelOutcome(accepted=1),
JobSendStatus.DELIVERED.value,
),
(
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
_MailChannelOutcome(accepted=True),
PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.PARTIALLY_ACCEPTED.value,
),
(
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
_MailChannelOutcome(rejected_permanent=True),
PostboxChannelOutcome(accepted=1),
JobSendStatus.PARTIALLY_ACCEPTED.value,
),
(
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
_MailChannelOutcome(rejected_permanent=True),
PostboxChannelOutcome(accepted=1),
JobSendStatus.POSTBOX_ACCEPTED.value,
),
(
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
_MailChannelOutcome(accepted=True),
PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.PARTIALLY_ACCEPTED.value,
),
],
)
def test_accepted_delivery_decision_table(
policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome,
expected: str,
) -> None:
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import unittest
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDirectoryEntryRef,
PostboxDeliveryRequest,
PostboxDeliveryResult,
PostboxTargetRef,
)
from govoplan_campaign.backend.integrations import (
PostboxCampaignIntegration,
PostboxDeliveryUnavailable,
)
class _PostboxDelivery:
def __init__(self) -> None:
self.requests = []
def deliver(self, session, request):
self.requests.append((session, request))
return PostboxDeliveryResult(
delivery_id="delivery-1",
postbox_id="postbox-1",
message_id="message-1",
address="intake@postbox",
status="accepted",
vacant=False,
holder_count=1,
)
def delivery_catalog(self, session, *, tenant_id):
return PostboxDeliveryCatalogRef()
def list_visible_postboxes(self, session, *, tenant_id, actor):
return ()
def resolve_postbox(
self,
session,
*,
tenant_id,
target,
materialize=False,
):
return PostboxDirectoryEntryRef(
id=target.postbox_id or "postbox-1",
tenant_id=tenant_id,
address="intake@postbox",
address_key="intake",
name="Intake",
status="active",
classification="internal",
)
class PostboxCampaignIntegrationTests(unittest.TestCase):
def test_optional_delivery_boundary_is_typed_and_explicit(self) -> None:
delegate = _PostboxDelivery()
integration = PostboxCampaignIntegration(delegate, delegate)
request = PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id="postbox-1"),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id="job-1",
idempotency_key="campaign-1:job-1:postbox-1",
subject="Decision",
)
result = integration.deliver(object(), request)
self.assertTrue(integration.available)
self.assertEqual("delivery-1", result.delivery_id)
self.assertEqual(request, delegate.requests[0][1])
def test_missing_postbox_is_reported_without_importing_module_code(
self,
) -> None:
integration = PostboxCampaignIntegration()
request = PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id="postbox-1"),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id="job-1",
idempotency_key="campaign-1:job-1:postbox-1",
subject="Decision",
)
self.assertFalse(integration.available)
with self.assertRaises(PostboxDeliveryUnavailable):
integration.deliver(object(), request)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,208 @@
from __future__ import annotations
from unittest.mock import patch
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDeliveryTemplateRef,
PostboxDirectoryEntryRef,
PostboxOrganizationFunctionTargetRef,
PostboxOrganizationUnitTargetRef,
)
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.postbox_targets import (
resolve_entry_postbox_targets,
)
from govoplan_campaign.backend.campaign.validation import (
validate_campaign_config,
)
from govoplan_campaign.backend.messages.models import MessageValidationStatus
def _config() -> CampaignConfig:
return CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {
"id": "campaign-1",
"name": "Postbox campaign",
"mode": "send",
},
"fields": [
{"name": "org_key", "type": "organization_unit"},
{"name": "function_key", "type": "organization_function"},
{"name": "case_key", "type": "string"},
],
"template": {
"subject": "Decision",
"text": "A decision is available.",
"body_mode": "text",
},
"entries": {
"inline": [
{
"id": "recipient-1",
"fields": {
"org_key": "finance",
"function_key": "caseworker",
"case_key": "case-42",
},
}
]
},
"delivery": {
"channel_policy": "postbox",
"postbox": {
"targets": [
{
"id": "direct",
"mode": "direct",
"address_key": "central-intake",
},
{
"id": "derived",
"mode": "derived",
"template_id": "template-1",
"organization_unit_field": "org_key",
"organization_unit_match": "slug",
"function_field": "function_key",
"function_match": "slug",
"context_field": "case_key",
},
]
},
},
}
)
def _catalog() -> PostboxDeliveryCatalogRef:
return PostboxDeliveryCatalogRef(
templates=(
PostboxDeliveryTemplateRef(
id="template-1",
slug="case-inbox",
name="Case inbox",
description=None,
published_revision_id="revision-1",
function_type_id=None,
scope_kind="tenant",
scope_id=None,
classification="internal",
allow_vacant_delivery=True,
),
),
organization_units=(
PostboxOrganizationUnitTargetRef(
id="unit-1",
slug="finance",
name="Finance",
functions=(
PostboxOrganizationFunctionTargetRef(
id="function-1",
slug="caseworker",
name="Caseworker",
),
),
),
),
)
class _PostboxIntegration:
def __init__(self) -> None:
self.targets: list[tuple[object, bool]] = []
def delivery_catalog(self, _session, *, tenant_id: str):
assert tenant_id == "tenant-1"
return _catalog()
def resolve_postbox(
self,
_session,
*,
tenant_id: str,
target,
materialize: bool,
):
self.targets.append((target, materialize))
if target.address_key == "central-intake":
return PostboxDirectoryEntryRef(
id="postbox-direct",
tenant_id=tenant_id,
address="central-intake@postbox",
address_key="central-intake",
name="Central intake",
status="active",
classification="internal",
holder_count=2,
vacant=False,
)
assert target.template_id == "template-1"
assert target.organization_unit_id == "unit-1"
assert target.function_id == "function-1"
assert target.context_key == "case-42"
return PostboxDirectoryEntryRef(
id="postbox-derived",
tenant_id=tenant_id,
address="finance.caseworker.case-42@postbox",
address_key="finance.caseworker.case-42",
name="Finance caseworker",
status="active",
classification="internal",
organization_unit_id="unit-1",
function_id="function-1",
context_key="case-42",
holder_count=1,
vacant=False,
)
def test_postbox_only_campaign_does_not_require_mail() -> None:
config = _config()
available = validate_campaign_config(config, postbox_available=True)
unavailable = validate_campaign_config(config, postbox_available=False)
assert {
issue.code
for issue in available.issues
if issue.severity.value == "error"
} == set()
assert "missing_mail_profile" not in {
issue.code for issue in unavailable.issues
}
assert "missing_sender" not in {
issue.code for issue in unavailable.issues
}
assert "postbox_unavailable" in {
issue.code for issue in unavailable.issues
}
def test_row_resolves_multiple_direct_and_field_derived_postboxes() -> None:
config = _config()
integration = _PostboxIntegration()
with patch(
"govoplan_campaign.backend.campaign.postbox_targets.postbox_integration",
return_value=integration,
):
targets, issues, status = resolve_entry_postbox_targets(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
config=config,
entry=config.entries.inline[0], # type: ignore[index]
validation_status=MessageValidationStatus.READY,
materialize=True,
)
assert issues == []
assert status == MessageValidationStatus.READY
assert [target["postbox_id"] for target in targets] == [
"postbox-direct",
"postbox-derived",
]
assert targets[1]["context_key"] == "case-42"
assert len(integration.targets) == 2
assert all(materialize for _target, materialize in integration.targets)

View File

@@ -42,6 +42,16 @@ def test_report_email_recipient_schema_rejects_unsafe_addresses(recipients: list
ReportEmailRequest.model_validate({"to": recipients}) ReportEmailRequest.model_validate({"to": recipients})
def test_report_email_recipient_error_identifies_the_invalid_list_item() -> None:
with pytest.raises(ValidationError) as captured:
ReportEmailRequest.model_validate({
"to": ["first@example.test", "invalid recipient", "third@example.test"],
})
assert captured.value.errors()[0]["loc"] == ("to", 1)
assert "email addresses" in captured.value.errors()[0]["msg"]
def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> None: def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"], attach_jobs_csv=True) payload = ReportEmailRequest(to=["recipient@example.test"], attach_jobs_csv=True)
principal = SimpleNamespace(tenant_id="tenant-1") principal = SimpleNamespace(tenant_id="tenant-1")

View File

@@ -10,6 +10,7 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus, JobSendStatus,
JobValidationStatus, JobValidationStatus,
) )
from govoplan_campaign.backend.capabilities import delivery_tasks_capability
from govoplan_campaign.backend.sending.jobs import ( from govoplan_campaign.backend.sending.jobs import (
SendJobResult, SendJobResult,
_queue_validation_statuses, _queue_validation_statuses,
@@ -48,6 +49,17 @@ def _job(entry_id: str, **overrides):
class CampaignQueueSelectionTests(unittest.TestCase): 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): def test_selects_queueable_jobs_without_reclassifying_retry_states(self):
skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value) skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value)
skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value) skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value)
@@ -129,6 +141,8 @@ class CampaignQueueSelectionTests(unittest.TestCase):
) )
snapshot = SimpleNamespace( snapshot = SimpleNamespace(
mail_profile_id="profile-1", mail_profile_id="profile-1",
smtp_server_id="smtp-server-1",
smtp_credential_id="smtp-credential-1",
smtp_transport_revision="frozen", smtp_transport_revision="frozen",
delivery=SimpleNamespace( delivery=SimpleNamespace(
rate_limit=SimpleNamespace(messages_per_minute=60), rate_limit=SimpleNamespace(messages_per_minute=60),

View File

@@ -195,7 +195,10 @@ def test_post_queue_growth_is_rejected_before_batch_or_provider_preflight() -> N
patch("govoplan_campaign.backend.sending.jobs._ensure_campaign_execution_snapshot"), 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.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._campaign_jobs_for_queue", return_value=initial_jobs),
patch("govoplan_campaign.backend.sending.jobs.queue_campaign_jobs", return_value=queued), patch(
"govoplan_campaign.backend.sending.jobs.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._campaign_jobs_for_version", return_value=post_queue_jobs),
patch("govoplan_campaign.backend.sending.jobs._preflight_synchronous_send_batch") as batch_preflight, patch("govoplan_campaign.backend.sending.jobs._preflight_synchronous_send_batch") as batch_preflight,
): ):
@@ -207,6 +210,7 @@ def test_post_queue_growth_is_rejected_before_batch_or_provider_preflight() -> N
) )
assert rejected.value.eligible_count == 3 assert rejected.value.eligible_count == 3
assert queue.call_args.kwargs["commit_queue"] is False
batch_preflight.assert_not_called() batch_preflight.assert_not_called()
@@ -251,6 +255,7 @@ def test_asynchronous_mode_matches_actual_worker_availability(
assert result.worker_queue_available is workers_available assert result.worker_queue_available is workers_available
assert result.enqueued_count == expected_enqueued assert result.enqueued_count == expected_enqueued
assert persist.call_args.kwargs["delivery_mode"] == expected_mode 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 assert enqueue.call_args.kwargs["enabled"] is workers_available
@@ -356,3 +361,45 @@ def test_batch_preflight_checks_every_message_before_provider_effects() -> None:
assert state_preflight.call_count == 2 assert state_preflight.call_count == 2
assert input_preflight.call_count == 2 assert input_preflight.call_count == 2
provider.send_campaign_email_bytes.assert_not_called() 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

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.10", "version": "0.1.12",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -21,7 +21,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"scripts": { "scripts": {
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js", "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",

View File

@@ -101,6 +101,10 @@ export type CampaignDeltaResponse = {
watermark?: string | null; watermark?: string | null;
has_more: boolean; has_more: boolean;
full: boolean; full: boolean;
total: number;
page: number;
page_size: number;
pages: number;
}; };
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & { export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
@@ -185,6 +189,58 @@ export type CampaignRecipientAddressSourcesResponse = {
sources: CampaignRecipientAddressSource[]; sources: CampaignRecipientAddressSource[];
}; };
export type CampaignPostboxDirectoryEntry = {
id: string;
address: string;
address_key: string;
name: string;
status: string;
classification: string;
organization_unit_id?: string | null;
organization_unit_name?: string | null;
function_id?: string | null;
function_name?: string | null;
context_key?: string | null;
holder_count: number;
vacant: boolean;
};
export type CampaignPostboxTemplate = {
id: string;
slug: string;
name: string;
description?: string | null;
published_revision_id: string;
function_type_id?: string | null;
scope_kind: string;
scope_id?: string | null;
classification: string;
allow_vacant_delivery: boolean;
};
export type CampaignPostboxFunction = {
id: string;
slug: string;
name: string;
function_type_id?: string | null;
};
export type CampaignPostboxOrganizationUnit = {
id: string;
slug: string;
name: string;
unit_type_id?: string | null;
parent_id?: string | null;
functions: CampaignPostboxFunction[];
};
export type CampaignPostboxCatalog = {
available: boolean;
postboxes: CampaignPostboxDirectoryEntry[];
templates: CampaignPostboxTemplate[];
organization_units: CampaignPostboxOrganizationUnit[];
};
export type CampaignRecipientSnapshotItem = { export type CampaignRecipientSnapshotItem = {
contact_id: string; contact_id: string;
display_name: string; display_name: string;
@@ -285,6 +341,7 @@ export type CampaignDeliveryOptions = {
campaign_id: string; campaign_id: string;
version_id: string; version_id: string;
worker_queue_available: boolean; worker_queue_available: boolean;
postbox_available: boolean;
synchronous_send: { synchronous_send: {
allowed?: boolean; allowed?: boolean;
reason?: string | null; reason?: string | null;
@@ -448,6 +505,7 @@ export type CampaignJobDetailResponse = {
attempts: { attempts: {
smtp?: Record<string, unknown>[]; smtp?: Record<string, unknown>[];
imap?: Record<string, unknown>[]; imap?: Record<string, unknown>[];
postbox?: Record<string, unknown>[];
}; };
}; };
@@ -488,6 +546,9 @@ export type AggregateCampaignReport = {
}; };
outcomes: { outcomes: {
smtp_accepted: AggregateReportCount; smtp_accepted: AggregateReportCount;
postbox_accepted: AggregateReportCount;
delivered: AggregateReportCount;
partially_accepted: AggregateReportCount;
failed: AggregateReportCount; failed: AggregateReportCount;
outcome_unknown: AggregateReportCount; outcome_unknown: AggregateReportCount;
queued_or_active: AggregateReportCount; queued_or_active: AggregateReportCount;
@@ -582,6 +643,13 @@ campaignId: string)
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`); return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
} }
export async function getCampaignPostboxCatalog(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignPostboxCatalog> {
return apiFetch<CampaignPostboxCatalog>(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`);
}
export async function snapshotCampaignRecipientAddressSource( export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -961,12 +1029,13 @@ export async function resolveCampaignJobOutcome(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
jobId: string, jobId: string,
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended", decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended" | "postbox_accepted" | "postbox_not_accepted",
note?: string) note?: string,
attemptId?: string)
: Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
method: "POST", method: "POST",
body: JSON.stringify({ decision, note: note || null }) body: JSON.stringify({ decision, note: note || null, attempt_id: attemptId || null })
}); });
} }
@@ -1057,8 +1126,15 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
} }
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> { export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`); const pageSize = 500;
return response.shares; const shares: CampaignShare[] = [];
for (let page = 1; ; page += 1) {
const response = await apiFetch<{shares: CampaignShare[];pages?: number;}>(settings, `/api/v1/campaigns/${campaignId}/shares?page=${page}&page_size=${pageSize}`);
shares.push(...response.shares);
if (page >= (response.pages ?? 1)) {
return shares;
}
}
} }
export async function updateCampaignOwner( export async function updateCampaignOwner(

View File

@@ -1,11 +1,12 @@
import type { import type {
ApiSettings, ApiSettings,
MailConnectionTestResponse, MailConnectionTestResponse,
MailCredentialEnvelope,
MailImapFolderListResponse, MailImapFolderListResponse,
MailServerProfile, MailServerProfile,
MockMailboxMessageResponse MockMailboxMessageResponse
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPost } from "./client"; import { apiFetch, apiGetList, apiPath, apiPost } from "./client";
const profileActionEndpoints = { const profileActionEndpoints = {
smtp: "test-smtp", smtp: "test-smtp",
@@ -16,11 +17,21 @@ const profileActionEndpoints = {
function runProfileAction<TResponse>( function runProfileAction<TResponse>(
settings: ApiSettings, settings: ApiSettings,
profileId: string, profileId: string,
action: keyof typeof profileActionEndpoints action: keyof typeof profileActionEndpoints,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<TResponse> { ): Promise<TResponse> {
return apiPost<TResponse>( return apiPost<TResponse>(
settings, settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}` apiPath(
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`,
{
server_id: serverId,
credential_id: credentialId,
campaign_id: campaignId
}
)
); );
} }
@@ -31,16 +42,58 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact
}); });
} }
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> { export function createCampaignMailCredential(
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp"); settings: ApiSettings,
profileId: string,
campaignId: string,
payload: {
name: string;
username: string;
password: string;
server_ids: string[];
}
): Promise<MailCredentialEnvelope> {
return apiFetch<MailCredentialEnvelope>(
settings,
apiPath(
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/campaign-credentials`,
{ campaign_id: campaignId }
),
{
method: "POST",
body: JSON.stringify(payload)
}
);
} }
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> { export async function testMailProfileSmtp(
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap"); settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
} }
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> { export async function testMailProfileImap(
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders"); settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
}
export async function listMailProfileImapFolders(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailImapFolderListResponse> {
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
} }
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> { export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {

View File

@@ -0,0 +1,125 @@
import { useCallback } from "react";
import { Send } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type CampaignListItem,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import { listCampaigns } from "../../api/campaigns";
const TERMINAL_STATUSES = new Set([
"sent",
"cancelled",
"archived",
"deleted"
]);
export default function CampaignActivityWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const showCompleted = configuration.showCompleted === true;
const load = useCallback(async () => {
const campaigns = await listCampaigns(settings);
return campaigns
.filter(
(campaign) =>
showCompleted || !TERMINAL_STATUSES.has(campaign.status)
)
.sort(compareCampaigns)
.slice(0, maxItems);
}, [maxItems, settings, showCompleted]);
const { data: campaigns, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading campaign activity">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No active campaigns."
items={(campaigns ?? []).map((campaign) => ({
id: campaign.id,
title: campaign.name,
detail: campaignProgress(campaign),
meta: updatedLabel(campaign),
leading: <Send size={17} aria-hidden="true" />,
trailing: (
<StatusBadge status={campaign.status} label={statusLabel(campaign.status)} />
),
to: `/campaigns/${encodeURIComponent(campaign.id)}`
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/campaigns">
Open campaigns
</Link>
</div>
</LoadingFrame>
);
}
function compareCampaigns(
left: CampaignListItem,
right: CampaignListItem
): number {
return timestamp(right) - timestamp(left);
}
function timestamp(campaign: CampaignListItem): number {
const value = campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at;
return value ? new Date(value).getTime() : 0;
}
function updatedLabel(campaign: CampaignListItem): string {
const value = campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at;
return value
? new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short"
}).format(new Date(value))
: "";
}
function campaignProgress(campaign: CampaignListItem): string {
const parts = [
campaign.sent ? `${campaign.sent} sent` : "",
campaign.failed ? `${campaign.failed} failed` : "",
campaign.blocked ? `${campaign.blocked} blocked` : "",
campaign.warnings ? `${campaign.warnings} warnings` : ""
].filter(Boolean);
return parts.join(" · ") || campaign.description || "No delivery totals yet";
}
function statusLabel(status: string): string {
return status.replaceAll("_", " ");
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}

View File

@@ -8,6 +8,7 @@ import { Button } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { PageScrollViewport } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns"; import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
@@ -31,7 +32,11 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
let response: CampaignDeltaResponse; let response: CampaignDeltaResponse;
do { do {
response = await listCampaignsDelta(settings, { since: nextWatermark }); response = await listCampaignsDelta(settings, { since: nextWatermark });
nextCampaigns = mergeCampaignDelta(nextCampaigns, response); nextCampaigns = mergeCampaignDelta(
nextCampaigns,
response,
Boolean(response.full && nextWatermark?.startsWith("full:campaigns:"))
);
nextWatermark = response.watermark ?? null; nextWatermark = response.watermark ?? null;
} while (response.has_more); } while (response.has_more);
setCampaigns(nextCampaigns); setCampaigns(nextCampaigns);
@@ -138,6 +143,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
return ( return (
<PageScrollViewport>
<div className="content-pad workspace-data-page campaigns-page"> <div className="content-pad workspace-data-page campaigns-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
@@ -179,7 +185,8 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
} }
</LoadingFrame> </LoadingFrame>
</Card> </Card>
</div>); </div>
</PageScrollViewport>);
} }
@@ -196,8 +203,12 @@ function formatLoadedAt(value: Date): string {
return formatDateTimeFromDate(value, { second: "2-digit" }); return formatDateTimeFromDate(value, { second: "2-digit" });
} }
function mergeCampaignDelta(current: CampaignListItem[], response: CampaignDeltaResponse): CampaignListItem[] { function mergeCampaignDelta(
if (response.full) return response.campaigns; current: CampaignListItem[],
response: CampaignDeltaResponse,
continuingFullSnapshot = false
): CampaignListItem[] {
if (response.full && !continuingFullSnapshot) return response.campaigns;
return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, { return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, {
deletedResourceType: "campaign", deletedResourceType: "campaign",
sort: sortCampaignsByUpdatedDesc sort: sortCampaignsByUpdatedDesc

View File

@@ -43,6 +43,9 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"claimed", "claimed",
"sending", "sending",
"smtp_accepted", "smtp_accepted",
"postbox_accepted",
"delivered",
"partially_accepted",
"sent", "sent",
"outcome_unknown", "outcome_unknown",
"failed_temporary", "failed_temporary",
@@ -50,6 +53,19 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"cancelled"]. "cancelled"].
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) })); map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"pending",
"delivering",
"accepted",
"accepted_vacant",
"partially_accepted",
"rejected_temporary",
"rejected_permanent",
"outcome_unknown",
"skipped"].
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [ const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested", "not_requested",
"pending", "pending",
@@ -253,7 +269,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}); });
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result); const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
const status = String(sendResult.status ?? "submitted"); const status = String(sendResult.status ?? "submitted");
if (status === "smtp_accepted" || status === "already_accepted") accepted += 1; if (["smtp_accepted", "postbox_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1;
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`); else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
} catch (err) { } catch (err) {
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`); failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
@@ -353,9 +369,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { 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, 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: "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: "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: "send", header: "Delivery", 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: "postbox", header: "Postbox", width: 155, sortable: true, filterable: true, columnType: "from-list", list: { options: POSTBOX_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(row.postbox_status ?? "unknown"))} />, value: (row) => String(row.postbox_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: "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: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_attempt_count ?? 0)) },
{ {
id: "evidence", id: "evidence",
header: "i18n:govoplan-campaign.evidence.7ea014de", header: "i18n:govoplan-campaign.evidence.7ea014de",
@@ -540,10 +557,13 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</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.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")} label={deliveryStatusLabel(String(detail.job.send_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>Postbox state</dt><dd><StatusBadge status={String(detail.job.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.postbox_status ?? "unknown"))} /></dd></div>
<div><dt>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</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.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> <div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
</dl> </dl>
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} /> <AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} />
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} /> <AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
</div> </div>
} }
@@ -565,13 +585,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
} }
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record<string, unknown>[];}) { function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";rows: Record<string, unknown>[];}) {
const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" : "i18n:govoplan-campaign.imap_append_attempts.b30e980a"; const title = kind === "smtp"
? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
: kind === "postbox"
? "Postbox delivery attempts"
: "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<section className="attempt-history-section"> <section className="attempt-history-section">
<h3>{title}</h3> <h3>{title}</h3>
<p className="muted small-note">i18n:govoplan-campaign.no.816c52fd {kind === "smtp" ? "i18n:govoplan-campaign.smtp.efff9cca" : "i18n:govoplan-campaign.imap.271f9ef2"} i18n:govoplan-campaign.attempt_has_been_recorded_for_this_job.e4050f01</p> <p className="muted small-note">No {kind} attempt has been recorded for this job.</p>
</section>); </section>);
} }
@@ -581,10 +605,12 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
{ 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")} /> }, { 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" ? 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: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
kind === "postbox" ?
{ id: "target", header: "Postbox", width: 220, sortable: true, filterable: true, value: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—"), render: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—") } :
{ 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: "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: "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: "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> } { 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 ?? row.error_code ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? row.error_code ?? "")}>{String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—")}</span> }
]; ];
return ( return (
@@ -601,9 +627,11 @@ function initialReportGridFilters(): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {}; const result: Record<string, string | string[]> = {};
const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS); const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS);
const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS); const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS);
const postbox = statusParameters(params, "postbox_status", POSTBOX_STATUS_OPTIONS);
const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS); const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS);
if (send.length > 0) result.send = send; if (send.length > 0) result.send = send;
if (imap.length > 0) result.imap = imap; if (imap.length > 0) result.imap = imap;
if (postbox.length > 0) result.postbox = postbox;
if (validation.length > 0) result.validation = validation; if (validation.length > 0) result.validation = validation;
return result; return result;
} }
@@ -635,14 +663,14 @@ function serializeInitialGridFilters(filters: Record<string, string | string[]>)
} }
function campaignJobSortColumn(value?: string): CampaignJobSortColumn { function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "imap" || value === "attempts" || value === "updated") { if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "postbox" || value === "imap" || value === "attempts" || value === "updated") {
return value; return value;
} }
return "number"; return "number";
} }
function retryableFailedStatus(status: string): boolean { function retryableFailedStatus(status: string): boolean {
return status === "failed_temporary" || status === "failed_permanent"; return status === "failed_temporary" || status === "failed_permanent" || status === "partially_accepted";
} }
function shortJobId(jobId: string): string { function shortJobId(jobId: string): string {

View File

@@ -1,5 +1,9 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, AuthInfo } from "../../types"; import type { ApiSettings, AuthInfo } from "../../types";
import {
getCampaignPostboxCatalog,
type CampaignPostboxCatalog
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
@@ -14,10 +18,15 @@ import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { hasScope } from "@govoplan/core-webui"; import { hasScope } from "@govoplan/core-webui";
import { RetentionPolicyEditor } from "@govoplan/core-webui"; import { RetentionPolicyEditor } from "@govoplan/core-webui";
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor"; import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
import { getDraftFields } from "./utils/fieldDefinitions";
import PostboxTargetsDialog, {
normalizePostboxTargets
} from "./components/PostboxTargetsDialog";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"]; const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
@@ -34,6 +43,14 @@ type GlobalSettingsPageProps = {
export default function GlobalSettingsPage({ settings, auth, campaignId, view = "settings" }: GlobalSettingsPageProps) { export default function GlobalSettingsPage({ settings, auth, campaignId, view = "settings" }: GlobalSettingsPageProps) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [editorState, setEditorState] = useState<EditorState>({}); const [editorState, setEditorState] = useState<EditorState>({});
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
const [postboxTargetsOpen, setPostboxTargetsOpen] = useState(false);
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
const isPolicyView = view === "policy"; const isPolicyView = view === "policy";
const version = data.currentVersion; const version = data.currentVersion;
@@ -59,12 +76,53 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
const delivery = asRecord(displayDraft.delivery); const delivery = asRecord(displayDraft.delivery);
const rateLimit = asRecord(delivery.rate_limit); const rateLimit = asRecord(delivery.rate_limit);
const retry = asRecord(delivery.retry); const retry = asRecord(delivery.retry);
const postboxDelivery = asRecord(delivery.postbox);
const fieldDefinitions = useMemo(
() => getDraftFields(displayDraft),
[displayDraft]
);
const statusTracking = asRecord(displayDraft.status_tracking); const statusTracking = asRecord(displayDraft.status_tracking);
const optIns = asRecord(editorState.opt_ins); const optIns = asRecord(editorState.opt_ins);
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read"); const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write"); const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
const pageTitle = isPolicyView ? "i18n:govoplan-campaign.campaign_policies.0b5de1f5" : "i18n:govoplan-campaign.campaign_settings.efffec26"; const pageTitle = isPolicyView ? "i18n:govoplan-campaign.campaign_policies.0b5de1f5" : "i18n:govoplan-campaign.campaign_settings.efffec26";
useEffect(() => {
if (!postboxModuleInstalled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
return;
}
let cancelled = false;
void getCampaignPostboxCatalog(settings, campaignId)
.then((catalog) => {
if (!cancelled) setPostboxCatalog(catalog);
})
.catch(() => {
if (!cancelled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
}
});
return () => {
cancelled = true;
};
}, [
campaignId,
postboxModuleInstalled,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
function patchEditor(path: string[], value: unknown) { function patchEditor(path: string[], value: unknown) {
if (locked) return; if (locked) return;
setEditorState((current) => updateNested(current, path, value)); setEditorState((current) => updateNested(current, path, value));
@@ -232,6 +290,43 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField> <FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} /> <ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div> </div>
{postboxModuleInstalled &&
<div className="campaign-postbox-delivery-settings">
<FormField label="Delivery channels">
<select
value={getText(delivery, "channel_policy", "mail")}
disabled={locked}
onChange={(event) => patch(["delivery", "channel_policy"], event.target.value)}
>
<option value="mail">Mail</option>
<option value="postbox">Postbox</option>
<option value="mail_and_postbox">Mail and Postbox</option>
<option value="mail_then_postbox">Mail, then Postbox on pre-acceptance rejection</option>
<option value="postbox_then_mail">Postbox, then Mail on pre-acceptance rejection</option>
</select>
</FormField>
<FormField label="Postbox classification">
<input
value={getText(postboxDelivery, "classification", "internal")}
disabled={locked}
onChange={(event) => patch(["delivery", "postbox", "classification"], event.target.value)}
/>
</FormField>
<FormField label="Default Postbox targets">
<Button
disabled={locked || !postboxCatalog.available}
onClick={() => setPostboxTargetsOpen(true)}
>
Configure ({normalizePostboxTargets(postboxDelivery.targets).length})
</Button>
</FormField>
{!postboxCatalog.available &&
<DismissibleAlert tone="warning" dismissible={false}>
Postbox delivery is installed but its delivery catalog is unavailable.
</DismissibleAlert>
}
</div>
}
</Card> </Card>
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible> <Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
@@ -246,6 +341,21 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
</> </>
} }
</LoadingFrame> </LoadingFrame>
{postboxTargetsOpen &&
<PostboxTargetsDialog
open
title="Default Postbox targets"
catalog={postboxCatalog}
fields={fieldDefinitions}
targets={normalizePostboxTargets(postboxDelivery.targets)}
locked={locked}
onSave={(targets) => {
patch(["delivery", "postbox", "targets"], targets);
setPostboxTargetsOpen(false);
}}
onClose={() => setPostboxTargetsOpen(false)}
/>
}
</div>); </div>);
} }

View File

@@ -2,21 +2,26 @@ import { useEffect, useState } from "react";
import { import {
Button, Button,
Card, Card,
Dialog,
DismissibleAlert, DismissibleAlert,
FormField, FormField,
LoadingFrame, LoadingFrame,
MailServerFolderLookupResultView, MailServerFolderLookupResultView,
MetricCard, MetricCard,
PageTitle, PageTitle,
PasswordField,
ToggleSwitch, ToggleSwitch,
usePlatformModuleInstalled, usePlatformModuleInstalled,
usePlatformUiCapability, usePlatformUiCapability,
type MailCredentialEnvelope,
type MailProfilesUiCapability, type MailProfilesUiCapability,
type MailServerConnectionTestResult, type MailServerConnectionTestResult,
type MailServerEndpoint,
type MailServerFolderLookupResult type MailServerFolderLookupResult
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
createCampaignMailCredential,
listMailProfileImapFolders, listMailProfileImapFolders,
listMailServerProfiles, listMailServerProfiles,
testMailProfileImap, testMailProfileImap,
@@ -39,6 +44,14 @@ type MailSettingsPageProps = {
view?: MailSettingsView; view?: MailSettingsView;
}; };
type CampaignCredentialDraft = {
name: string;
username: string;
password: string;
useSmtp: boolean;
useImap: boolean;
};
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) { export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
const mailModuleInstalled = usePlatformModuleInstalled("mail"); const mailModuleInstalled = usePlatformModuleInstalled("mail");
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles"); const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
@@ -53,6 +66,15 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null); const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null); const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null); const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
const [credentialDialogOpen, setCredentialDialogOpen] = useState(false);
const [credentialSaving, setCredentialSaving] = useState(false);
const [credentialDraft, setCredentialDraft] = useState<CampaignCredentialDraft>({
name: "",
username: "",
password: "",
useSmtp: true,
useImap: false
});
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
@@ -78,10 +100,28 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
const server = asRecord(displayDraft.server); const server = asRecord(displayDraft.server);
const selectedProfileId = getText(server, "mail_profile_id"); const selectedProfileId = getText(server, "mail_profile_id");
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null; const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
const smtpServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "smtp" && item.is_active);
const imapServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "imap" && item.is_active);
const selectedSmtpServer = smtpServers.find((item) => item.id === getText(server, "smtp_server_id"))
?? smtpServers.find((item) => item.is_default)
?? smtpServers[0]
?? null;
const selectedImapServer = imapServers.find((item) => item.id === getText(server, "imap_server_id"))
?? imapServers.find((item) => item.is_default)
?? imapServers[0]
?? null;
const selectedSmtpCredential = selectedSmtpServer?.credentials.find((item) => item.id === getText(server, "smtp_credential_id"))
?? selectedSmtpServer?.credentials.find((item) => item.is_default)
?? selectedSmtpServer?.credentials[0]
?? null;
const selectedImapCredential = selectedImapServer?.credentials.find((item) => item.id === getText(server, "imap_credential_id"))
?? selectedImapServer?.credentials.find((item) => item.is_default)
?? selectedImapServer?.credentials[0]
?? null;
const delivery = asRecord(displayDraft.delivery); const delivery = asRecord(displayDraft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent); const imapAppend = asRecord(delivery.imap_append_sent);
const imapAppendEnabled = getBool(imapAppend, "enabled"); const imapAppendEnabled = getBool(imapAppend, "enabled");
const selectedProfileHasImap = Boolean(selectedProfile?.imap); const selectedProfileHasImap = Boolean(selectedImapServer);
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile); const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId)); const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId));
@@ -117,22 +157,140 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
function selectMailProfile(profileId: string) { function selectMailProfile(profileId: string) {
if (!mailModuleInstalled || locked) return; if (!mailModuleInstalled || locked) return;
patch(["server"], profileId ? { mail_profile_id: profileId } : {}); const profile = mailProfiles.find((item) => item.id === profileId);
const smtpServer = profile?.servers?.find((item) => item.protocol === "smtp" && item.is_active && item.is_default)
?? profile?.servers?.find((item) => item.protocol === "smtp" && item.is_active);
const imapServer = profile?.servers?.find((item) => item.protocol === "imap" && item.is_active && item.is_default)
?? profile?.servers?.find((item) => item.protocol === "imap" && item.is_active);
const smtpCredential = smtpServer?.credentials.find((item) => item.is_active && item.is_default)
?? smtpServer?.credentials.find((item) => item.is_active);
const imapCredential = imapServer?.credentials.find((item) => item.is_active && item.is_default)
?? imapServer?.credentials.find((item) => item.is_active);
patch(["server"], profile ? mailReference(
profile.id,
smtpServer?.id,
smtpCredential?.id,
imapServer?.id,
imapCredential?.id
) : {});
setSmtpTestResult(null); setSmtpTestResult(null);
setImapTestResult(null); setImapTestResult(null);
setFolderResult(null); setFolderResult(null);
setProfileError(""); setProfileError("");
} }
function selectServer(protocol: "smtp" | "imap", serverId: string) {
if (!selectedProfile || locked) return;
const availableServers = protocol === "smtp" ? smtpServers : imapServers;
const selected = availableServers.find((item) => item.id === serverId) ?? null;
const credential = selected?.credentials.find((item) => item.is_active && item.is_default)
?? selected?.credentials.find((item) => item.is_active)
?? null;
patch(["server"], mailReference(
selectedProfile.id,
protocol === "smtp" ? selected?.id : selectedSmtpServer?.id,
protocol === "smtp" ? credential?.id : selectedSmtpCredential?.id,
protocol === "imap" ? selected?.id : selectedImapServer?.id,
protocol === "imap" ? credential?.id : selectedImapCredential?.id
));
if (protocol === "smtp") setSmtpTestResult(null);
else {
setImapTestResult(null);
setFolderResult(null);
}
}
function selectCredential(protocol: "smtp" | "imap", credentialId: string) {
if (!selectedProfile || locked) return;
patch(["server"], mailReference(
selectedProfile.id,
selectedSmtpServer?.id,
protocol === "smtp" ? credentialId : selectedSmtpCredential?.id,
selectedImapServer?.id,
protocol === "imap" ? credentialId : selectedImapCredential?.id
));
if (protocol === "smtp") setSmtpTestResult(null);
else {
setImapTestResult(null);
setFolderResult(null);
}
}
function openCredentialDialog() {
setCredentialDraft({
name: `${data.campaign?.name || "Campaign"} credential`,
username: "",
password: "",
useSmtp: Boolean(selectedSmtpServer),
useImap: Boolean(selectedImapServer)
});
setCredentialDialogOpen(true);
setLocalError("");
}
async function saveCampaignCredential() {
if (!selectedProfile || credentialSaving) return;
const serverIds = [
credentialDraft.useSmtp ? selectedSmtpServer?.id : null,
credentialDraft.useImap ? selectedImapServer?.id : null
].filter((value): value is string => Boolean(value));
if (
!credentialDraft.name.trim() ||
!credentialDraft.username.trim() ||
!credentialDraft.password ||
serverIds.length === 0
) return;
setCredentialSaving(true);
setLocalError("");
try {
const credential = await createCampaignMailCredential(
settings,
selectedProfile.id,
campaignId,
{
name: credentialDraft.name.trim(),
username: credentialDraft.username.trim(),
password: credentialDraft.password,
server_ids: serverIds
}
);
patch(["server"], mailReference(
selectedProfile.id,
selectedSmtpServer?.id,
credentialDraft.useSmtp ? credential.id : selectedSmtpCredential?.id,
selectedImapServer?.id,
credentialDraft.useImap ? credential.id : selectedImapCredential?.id
));
setCredentialDialogOpen(false);
await refreshMailProfiles();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setCredentialSaving(false);
}
}
async function runProfileTest(protocol: "smtp" | "imap") { async function runProfileTest(protocol: "smtp" | "imap") {
if (!selectedProfileId || locked) return; if (!selectedProfileId || locked) return;
setMailActionState(protocol); setMailActionState(protocol);
setLocalError(""); setLocalError("");
try { try {
if (protocol === "smtp") { if (protocol === "smtp") {
setSmtpTestResult(await testMailProfileSmtp(settings, selectedProfileId)); setSmtpTestResult(await testMailProfileSmtp(
settings,
selectedProfileId,
selectedSmtpServer?.id,
selectedSmtpCredential?.id,
campaignId
));
} else { } else {
setImapTestResult(await testMailProfileImap(settings, selectedProfileId)); setImapTestResult(await testMailProfileImap(
settings,
selectedProfileId,
selectedImapServer?.id,
selectedImapCredential?.id,
campaignId
));
} }
} catch (err) { } catch (err) {
const result = { ok: false, protocol, message: err instanceof Error ? err.message : String(err), details: {} }; const result = { ok: false, protocol, message: err instanceof Error ? err.message : String(err), details: {} };
@@ -148,7 +306,13 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
setMailActionState("folders"); setMailActionState("folders");
setLocalError(""); setLocalError("");
try { try {
setFolderResult(await listMailProfileImapFolders(settings, selectedProfileId)); setFolderResult(await listMailProfileImapFolders(
settings,
selectedProfileId,
selectedImapServer?.id,
selectedImapCredential?.id,
campaignId
));
} catch (err) { } catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} }); setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally { } finally {
@@ -204,8 +368,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
{!isPolicyView && mailModuleInstalled && <Card {!isPolicyView && mailModuleInstalled && <Card
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1" title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
actions={<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>}> actions={<div className="button-row compact-actions">
<p className="muted small-note">i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809</p> <Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
<Button variant="primary" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
</div>}>
<p className="muted small-note">The campaign stores stable references to the envelope, selected servers, and credentials.</p>
<div className="form-grid compact responsive-form-grid"> <div className="form-grid compact responsive-form-grid">
<FormField label="i18n:govoplan-campaign.profile.ff4fc027"> <FormField label="i18n:govoplan-campaign.profile.ff4fc027">
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}> <select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
@@ -213,16 +380,44 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)} {mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
</select> </select>
</FormField> </FormField>
<FormField label="SMTP server">
<select value={selectedSmtpServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("smtp", event.target.value)}>
<option value="">No SMTP server</option>
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
</select>
</FormField>
<FormField label="SMTP credential">
<select value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
<option value="">No credential</option>
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
)}
</select>
</FormField>
<FormField label="IMAP server">
<select value={selectedImapServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("imap", event.target.value)}>
<option value="">No IMAP server</option>
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
</select>
</FormField>
<FormField label="IMAP credential">
<select value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
<option value="">No credential</option>
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
)}
</select>
</FormField>
</div> </div>
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>} {selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
{selectedProfile && <div className="metric-grid inside"> {selectedProfile && <div className="metric-grid inside">
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} /> <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.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.smtp.efff9cca" value={selectedSmtpServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedSmtpServer ? "good" : "neutral"} />
<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"} /> <MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedImapServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedImapServer ? "good" : "neutral"} />
</div>} </div>}
<div className="button-row compact-actions"> <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("smtp")} disabled={!selectedSmtpServer || 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> <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> </div>
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>} {smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
@@ -230,6 +425,57 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>} {profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
</Card>} </Card>}
<Dialog
open={credentialDialogOpen}
title="Add campaign credential"
onClose={() => setCredentialDialogOpen(false)}
closeDisabled={credentialSaving}
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button onClick={() => setCredentialDialogOpen(false)} disabled={credentialSaving}>Cancel</Button>
<Button
variant="primary"
onClick={() => void saveCampaignCredential()}
disabled={
credentialSaving ||
!credentialDraft.name.trim() ||
!credentialDraft.username.trim() ||
!credentialDraft.password ||
!credentialDraft.useSmtp && !credentialDraft.useImap
}
>
{credentialSaving ? "Saving" : "Save credential"}
</Button>
</>
}
>
<div className="adaptive-config-form">
<section className="adaptive-config-section">
<header>
<h3>Campaign login</h3>
<p>This credential belongs to this campaign and can use only the selected mail servers.</p>
</header>
<div className="form-grid two">
<FormField label="Name">
<input value={credentialDraft.name} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} autoFocus />
</FormField>
<FormField label="Username">
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
</FormField>
<FormField label="Password">
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} autoComplete="new-password" />
</FormField>
</div>
<div className="form-grid two">
{selectedSmtpServer && <ToggleSwitch checked={credentialDraft.useSmtp} disabled={credentialSaving} onChange={(useSmtp) => setCredentialDraft({ ...credentialDraft, useSmtp })} label={`Use for SMTP: ${selectedSmtpServer.name}`} />}
{selectedImapServer && <ToggleSwitch checked={credentialDraft.useImap} disabled={credentialSaving} onChange={(useImap) => setCredentialDraft({ ...credentialDraft, useImap })} label={`Use for IMAP: ${selectedImapServer.name}`} />}
</div>
</section>
</div>
</Dialog>
{!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="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"> <div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
@@ -266,3 +512,28 @@ function profileScopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "group") return "i18n:govoplan-campaign.group.171a0606"; if (profile.scope_type === "group") return "i18n:govoplan-campaign.group.171a0606";
return "i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505"; return "i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505";
} }
function mailReference(
profileId: string,
smtpServerId?: string | null,
smtpCredentialId?: string | null,
imapServerId?: string | null,
imapCredentialId?: string | null
): Record<string, string> {
const value: Record<string, string> = { mail_profile_id: profileId };
if (smtpServerId) value.smtp_server_id = smtpServerId;
if (smtpServerId && smtpCredentialId) value.smtp_credential_id = smtpCredentialId;
if (imapServerId) value.imap_server_id = imapServerId;
if (imapServerId && imapCredentialId) value.imap_credential_id = imapCredentialId;
return value;
}
function serverEndpointLabel(config: MailServerEndpoint["config"]): string {
const host = typeof config.host === "string" && config.host ? config.host : "No host";
return config.port ? `${host}:${config.port}` : host;
}
function credentialLabel(credential: MailCredentialEnvelope): string {
const username = credential.public_data?.username;
return username ? `${credential.name} (${String(username)})` : credential.name;
}

View File

@@ -3,10 +3,12 @@ import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
createRecipientImportMappingProfile, createRecipientImportMappingProfile,
getCampaignPostboxCatalog,
listCampaignRecipientAddressSources, listCampaignRecipientAddressSources,
listRecipientImportMappingProfiles, listRecipientImportMappingProfiles,
snapshotCampaignRecipientAddressSource, snapshotCampaignRecipientAddressSource,
updateRecipientImportMappingProfile, updateRecipientImportMappingProfile,
type CampaignPostboxCatalog,
type CampaignRecipientAddressSource, type CampaignRecipientAddressSource,
type CampaignRecipientAddressSourceSnapshot, type CampaignRecipientAddressSourceSnapshot,
type RecipientImportMappingProfilePayload } from type RecipientImportMappingProfilePayload } from
@@ -30,6 +32,9 @@ import { getBool } from "./utils/draftEditor";
import { getDraftFields } from "./utils/fieldDefinitions"; import { getDraftFields } from "./utils/fieldDefinitions";
import FieldValueInput from "./components/FieldValueInput"; import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay"; import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import PostboxTargetsDialog, {
normalizePostboxTargets
} from "./components/PostboxTargetsDialog";
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders"; import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments"; import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { import {
@@ -105,6 +110,7 @@ const recipientAddressOverlayColumns: EntryAddressColumn[] = [
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesModuleInstalled = usePlatformModuleInstalled("files");
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false); const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
@@ -115,6 +121,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1); const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10); const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null); const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
const [headerAddressEditor, setHeaderAddressEditor] = useState<HeaderAddressEditorState>(null); const [headerAddressEditor, setHeaderAddressEditor] = useState<HeaderAddressEditorState>(null);
const version = data.currentVersion; const version = data.currentVersion;
@@ -192,12 +205,53 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
return () => {cancelled = true;}; return () => {cancelled = true;};
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); }, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!postboxModuleInstalled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
return;
}
let cancelled = false;
void getCampaignPostboxCatalog(settings, campaignId)
.then((catalog) => {
if (!cancelled) setPostboxCatalog(catalog);
})
.catch(() => {
if (!cancelled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
}
});
return () => {
cancelled = true;
};
}, [
campaignId,
postboxModuleInstalled,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => { useEffect(() => {
setRecipientAddressEditorIndex((current) => { setRecipientAddressEditorIndex((current) => {
if (current === null) return null; if (current === null) return null;
if (inlineEntries.length === 0) return null; if (inlineEntries.length === 0) return null;
return Math.max(0, Math.min(current, inlineEntries.length - 1)); return Math.max(0, Math.min(current, inlineEntries.length - 1));
}); });
setPostboxTargetEditorIndex((current) => {
if (current === null) return null;
if (inlineEntries.length === 0) return null;
return Math.max(0, Math.min(current, inlineEntries.length - 1));
});
}, [inlineEntries.length]); }, [inlineEntries.length]);
@@ -232,27 +286,36 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
replaceInlineEntries(nextEntries); replaceInlineEntries(nextEntries);
} }
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) { function saveEntryAddresses(
updateEntry(index, (entry) => entryWithAddressList(entry, key, addresses)); index: number,
} values: HeaderAddressValues,
merges: EntryAddressMergeValues
function updateEntryAddressGroups(index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) { ) {
updateEntry(index, (entry) => { updateEntry(index, (entry) => {
let nextEntry = entry; let nextEntry = entry;
for (const group of groups) { for (const column of recipientAddressOverlayColumns) {
const currentAddresses = getEntryAddresses(nextEntry, group.key); if (!(column.key in values)) continue;
nextEntry = entryWithAddressList(nextEntry, group.key, dedupeAddresses([...currentAddresses, ...group.addresses])); nextEntry = entryWithAddressList(nextEntry, column.key, values[column.key] ?? []);
if (!column.mergeKey || !(column.mergeKey in merges)) continue;
nextEntry = { ...nextEntry, [column.mergeKey]: Boolean(merges[column.mergeKey]) };
delete nextEntry[column.mergeKey.replace("merge_", "combine_")];
} }
return nextEntry; return nextEntry;
}); });
setRecipientAddressEditorIndex(null);
} }
function updateEntryMerge(index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) { function saveEntryPostboxTargets(
updateEntry(index, (entry) => { index: number,
const next = { ...entry, [mergeKey]: checked }; targets: ReturnType<typeof normalizePostboxTargets>,
delete next[mergeKey.replace("merge_", "combine_")]; merge: boolean
return next; ) {
}); updateEntry(index, (entry) => ({
...entry,
postbox_targets: targets,
merge_postbox_targets: merge
}));
setPostboxTargetEditorIndex(null);
} }
function updateEntryField(index: number, field: string, value: unknown) { function updateEntryField(index: number, field: string, value: unknown) {
@@ -305,8 +368,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
setAddressSourceImportOpen(false); setAddressSourceImportOpen(false);
} }
function updateHeaderAddressList(key: AddressFieldKey, addresses: MailboxAddress[]) { function saveHeaderAddresses(values: HeaderAddressValues) {
patch(["recipients", key], key === "from" ? addresses.slice(0, 1) : addresses); const nextRecipients = { ...recipientsSection };
for (const [key, addresses] of Object.entries(values) as Array<[AddressFieldKey, MailboxAddress[]]>) {
nextRecipients[key] = key === "from" ? addresses.slice(0, 1) : addresses;
}
patch(["recipients"], nextRecipients);
setHeaderAddressEditor(null);
} }
async function copyHeaderAddresses(columns: EntryAddressColumn[]) { async function copyHeaderAddresses(columns: EntryAddressColumn[]) {
@@ -448,12 +516,15 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
draft: displayDraft, draft: displayDraft,
locked, locked,
filesModuleInstalled, filesModuleInstalled,
postboxModuleInstalled,
postboxCatalog,
entries: inlineEntries, entries: inlineEntries,
fieldDefinitions, fieldDefinitions,
individualAttachmentBasePaths, individualAttachmentBasePaths,
zipConfig, zipConfig,
translateText, translateText,
openAddressEditor: setRecipientAddressEditorIndex, openAddressEditor: setRecipientAddressEditorIndex,
openPostboxTargetEditor: setPostboxTargetEditorIndex,
updateEntry, updateEntry,
updateEntryAttachments, updateEntryAttachments,
updateEntryField, updateEntryField,
@@ -508,11 +579,23 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
locked={locked} locked={locked}
recipientsSection={recipientsSection} recipientsSection={recipientsSection}
entryDefaults={entryDefaults} entryDefaults={entryDefaults}
updateEntryAddressList={updateEntryAddressList} onSave={(values, merges) => saveEntryAddresses(recipientAddressEditorIndex, values, merges)}
updateEntryAddressGroups={updateEntryAddressGroups}
updateEntryMerge={updateEntryMerge}
onClose={() => setRecipientAddressEditorIndex(null)} /> onClose={() => setRecipientAddressEditorIndex(null)} />
}
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
<PostboxTargetsDialog
open
title={`Recipient ${postboxTargetEditorIndex + 1} - Postbox targets`}
catalog={postboxCatalog}
fields={fieldDefinitions}
targets={normalizePostboxTargets(inlineEntries[postboxTargetEditorIndex].postbox_targets)}
merge={inlineEntries[postboxTargetEditorIndex].merge_postbox_targets !== false}
showMerge
locked={locked}
onSave={(targets, merge) => saveEntryPostboxTargets(postboxTargetEditorIndex, targets, merge)}
onClose={() => setPostboxTargetEditorIndex(null)} />
} }
{headerAddressEditor && {headerAddressEditor &&
<HeaderAddressEditorDialog <HeaderAddressEditorDialog
@@ -520,7 +603,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
columns={headerAddressEditor.columns} columns={headerAddressEditor.columns}
values={headerAddressValues(headerAddressEditor.columns, recipientsSection)} values={headerAddressValues(headerAddressEditor.columns, recipientsSection)}
locked={locked} locked={locked}
onAddressesChange={updateHeaderAddressList} onSave={saveHeaderAddresses}
onClose={() => setHeaderAddressEditor(null)} /> onClose={() => setHeaderAddressEditor(null)} />
} }
@@ -534,13 +617,12 @@ type RecipientAddressEditorDialogProps = {
locked: boolean; locked: boolean;
recipientsSection: Record<string, unknown>; recipientsSection: Record<string, unknown>;
entryDefaults: Record<string, unknown>; entryDefaults: Record<string, unknown>;
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void; onSave: (values: HeaderAddressValues, merges: EntryAddressMergeValues) => void;
updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void;
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
onClose: () => void; onClose: () => void;
}; };
type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>; type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>;
type EntryAddressMergeValues = Partial<Record<NonNullable<EntryAddressColumn["mergeKey"]>, boolean>>;
type AddressHeaderControlProps = { type AddressHeaderControlProps = {
columns: EntryAddressColumn[]; columns: EntryAddressColumn[];
@@ -596,26 +678,33 @@ type HeaderAddressEditorDialogProps = {
columns: EntryAddressColumn[]; columns: EntryAddressColumn[];
values: HeaderAddressValues; values: HeaderAddressValues;
locked: boolean; locked: boolean;
onAddressesChange: (key: AddressFieldKey, addresses: MailboxAddress[]) => void; onSave: (values: HeaderAddressValues) => void;
onClose: () => void; onClose: () => void;
}; };
function HeaderAddressEditorDialog({ title, columns, values, locked, onAddressesChange, onClose }: HeaderAddressEditorDialogProps) { function HeaderAddressEditorDialog({ title, columns, values, locked, onSave, onClose }: HeaderAddressEditorDialogProps) {
const { translateText } = usePlatformLanguage();
const columnKeys = new Set(columns.map((column) => column.key)); const columnKeys = new Set(columns.map((column) => column.key));
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() => cloneAddressValues(columns, values));
const [validationError, setValidationError] = useState("");
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean { function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key)); const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key));
if (groups.length === 0) return false; if (groups.length === 0) return false;
const nextValues: HeaderAddressValues = { ...values }; setDraftValues((current) => mergeAddressGroups(current, groups));
for (const group of groups) { setValidationError("");
nextValues[group.key] = dedupeAddresses([...(nextValues[group.key] ?? []), ...group.addresses]);
}
for (const group of groups) {
onAddressesChange(group.key, nextValues[group.key] ?? []);
}
return true; return true;
} }
function save() {
const prepared = prepareAddressValues(columns, draftValues, translateText);
if (prepared.error) {
setValidationError(prepared.error);
return;
}
onSave(prepared.values);
}
return ( return (
<Dialog <Dialog
open open
@@ -623,17 +712,24 @@ function HeaderAddressEditorDialog({ title, columns, values, locked, onAddresses
className="recipient-address-editor-modal" className="recipient-address-editor-modal"
bodyClassName="recipient-address-editor-body" bodyClassName="recipient-address-editor-body"
onClose={onClose} onClose={onClose}
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}> footer={<>
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
</>}>
<div className="recipient-address-editor"> <div className="recipient-address-editor">
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
{columns.map((column) => {columns.map((column) =>
<RecipientAddressCategoryEditor <RecipientAddressCategoryEditor
key={column.key} key={column.key}
column={column} column={column}
addresses={values[column.key] ?? []} addresses={draftValues[column.key] ?? []}
merge={false} merge={false}
locked={locked} locked={locked}
onAddressesChange={(addresses) => onAddressesChange(column.key, addresses)} onAddressesChange={(addresses) => {
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
setValidationError("");
}}
onPasteAddresses={applyPastedAddresses} /> onPasteAddresses={applyPastedAddresses} />
)} )}
@@ -647,21 +743,41 @@ function RecipientAddressEditorDialog({
locked, locked,
recipientsSection, recipientsSection,
entryDefaults, entryDefaults,
updateEntryAddressList, onSave,
updateEntryAddressGroups,
updateEntryMerge,
onClose onClose
}: RecipientAddressEditorDialogProps) { }: RecipientAddressEditorDialogProps) {
const { translateText } = usePlatformLanguage();
const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key)); const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key));
const availableKeys = new Set(availableColumns.map((column) => column.key)); const availableKeys = new Set(availableColumns.map((column) => column.key));
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() =>
Object.fromEntries(availableColumns.map((column) => [column.key, getEntryAddresses(entry, column.key).map(cloneMailboxAddress)]))
);
const [draftMerges, setDraftMerges] = useState<EntryAddressMergeValues>(() =>
Object.fromEntries(
availableColumns
.filter((column) => column.mergeKey)
.map((column) => [column.mergeKey!, getEntryMerge(entry, entryDefaults, column.mergeKey!)])
)
);
const [validationError, setValidationError] = useState("");
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean { function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key)); const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key));
if (groups.length === 0) return false; if (groups.length === 0) return false;
updateEntryAddressGroups(index, groups); setDraftValues((current) => mergeAddressGroups(current, groups));
setValidationError("");
return true; return true;
} }
function save() {
const prepared = prepareAddressValues(availableColumns, draftValues, translateText);
if (prepared.error) {
setValidationError(prepared.error);
return;
}
onSave(prepared.values, draftMerges);
}
return ( return (
<Dialog <Dialog
open open
@@ -669,9 +785,13 @@ function RecipientAddressEditorDialog({
className="recipient-address-editor-modal" className="recipient-address-editor-modal"
bodyClassName="recipient-address-editor-body" bodyClassName="recipient-address-editor-body"
onClose={onClose} onClose={onClose}
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}> footer={<>
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
</>}>
<div className="recipient-address-editor"> <div className="recipient-address-editor">
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
{availableColumns.length === 0 && {availableColumns.length === 0 &&
<p className="muted">No individual recipient address fields are enabled.</p> <p className="muted">No individual recipient address fields are enabled.</p>
} }
@@ -679,11 +799,16 @@ function RecipientAddressEditorDialog({
<RecipientAddressCategoryEditor <RecipientAddressCategoryEditor
key={column.key} key={column.key}
column={column} column={column}
addresses={getEntryAddresses(entry, column.key)} addresses={draftValues[column.key] ?? []}
merge={column.mergeKey ? getEntryMerge(entry, entryDefaults, column.mergeKey) : false} merge={column.mergeKey ? Boolean(draftMerges[column.mergeKey]) : false}
locked={locked} locked={locked}
onAddressesChange={(addresses) => updateEntryAddressList(index, column.key, addresses)} onAddressesChange={(addresses) => {
onMergeChange={column.mergeKey ? (merge) => updateEntryMerge(index, column.mergeKey!, merge) : undefined} setDraftValues((current) => ({ ...current, [column.key]: addresses }));
setValidationError("");
}}
onMergeChange={column.mergeKey ? (merge) => {
setDraftMerges((current) => ({ ...current, [column.mergeKey!]: merge }));
} : undefined}
onPasteAddresses={applyPastedAddresses} /> onPasteAddresses={applyPastedAddresses} />
)} )}
@@ -716,7 +841,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
function commitAddresses(nextAddresses: MailboxAddress[]) { function commitAddresses(nextAddresses: MailboxAddress[]) {
setDraftAddresses(nextAddresses); setDraftAddresses(nextAddresses);
onAddressesChange(nextAddresses.filter((address) => String(address.email ?? "").trim() || String(address.name ?? "").trim())); onAddressesChange(nextAddresses);
} }
function patchAddress(addressIndex: number, patch: Partial<MailboxAddress>) { function patchAddress(addressIndex: number, patch: Partial<MailboxAddress>) {
@@ -731,7 +856,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
{ name: "", email: "" }, { name: "", email: "" },
...draftAddresses.slice(insertIndex)]; ...draftAddresses.slice(insertIndex)];
setDraftAddresses(nextAddresses); commitAddresses(nextAddresses);
} }
function removeAddress(addressIndex: number) { function removeAddress(addressIndex: number) {
@@ -1971,12 +2096,15 @@ type RecipientProfileColumnContext = {
draft: Record<string, unknown>; draft: Record<string, unknown>;
locked: boolean; locked: boolean;
filesModuleInstalled: boolean; filesModuleInstalled: boolean;
postboxModuleInstalled: boolean;
postboxCatalog: CampaignPostboxCatalog;
entries: Record<string, unknown>[]; entries: Record<string, unknown>[];
fieldDefinitions: ReturnType<typeof getDraftFields>; fieldDefinitions: ReturnType<typeof getDraftFields>;
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>; individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
zipConfig: AttachmentZipCollection; zipConfig: AttachmentZipCollection;
translateText: (value: string) => string; translateText: (value: string) => string;
openAddressEditor: (index: number) => void; openAddressEditor: (index: number) => void;
openPostboxTargetEditor: (index: number) => void;
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void; updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void; updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
updateEntryField: (index: number, field: string, value: unknown) => void; updateEntryField: (index: number, field: string, value: unknown) => void;
@@ -1985,7 +2113,7 @@ type RecipientProfileColumnContext = {
removeEntry: (index: number) => void; removeEntry: (index: number) => void;
}; };
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] { function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [ return [
{ {
id: "number", id: "number",
@@ -2034,6 +2162,45 @@ function recipientProfileColumns({ settings, campaignId, draft, locked, filesMod
value: recipientAddressFilterValue value: recipientAddressFilterValue
}, },
{ id: "active", header: "i18n:govoplan-campaign.active.a733b809", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "inactive", label: "i18n:govoplan-campaign.inactive.09af574c" }] }, render: (entry, index) => <ToggleSwitch label="i18n:govoplan-campaign.active.a733b809" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" }, { id: "active", header: "i18n:govoplan-campaign.active.a733b809", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "inactive", label: "i18n:govoplan-campaign.inactive.09af574c" }] }, render: (entry, index) => <ToggleSwitch label="i18n:govoplan-campaign.active.a733b809" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
...(postboxModuleInstalled ? [{
id: "delivery",
header: "Delivery",
width: "minmax(260px, 0.9fr)",
resizable: true,
filterable: true,
render: (entry, index) => {
const targets = normalizePostboxTargets(entry.postbox_targets);
return (
<div className="campaign-recipient-delivery-cell">
<select
value={typeof entry.channel_policy === "string" ? entry.channel_policy : ""}
disabled={locked}
aria-label={`Recipient ${index + 1} delivery policy`}
onChange={(event) => updateEntry(index, (current) => {
const next = { ...current };
if (event.target.value) next.channel_policy = event.target.value;
else delete next.channel_policy;
return next;
})}
>
<option value="">Campaign default</option>
<option value="mail">Mail</option>
<option value="postbox">Postbox</option>
<option value="mail_and_postbox">Mail and Postbox</option>
<option value="mail_then_postbox">Mail, then Postbox fallback</option>
<option value="postbox_then_mail">Postbox, then Mail fallback</option>
</select>
<Button
disabled={locked || !postboxCatalog.available}
onClick={() => openPostboxTargetEditor(index)}
>
Postboxes ({targets.length})
</Button>
</div>
);
},
value: (entry) => `${String(entry.channel_policy ?? "default")} ${normalizePostboxTargets(entry.postbox_targets).map((target) => target.label ?? target.postbox_id ?? target.template_id ?? "").join(" ")}`
} as DataGridColumn<Record<string, unknown>>] : []),
...(individualAttachmentBasePaths.length > 0 ? [{ ...(individualAttachmentBasePaths.length > 0 ? [{
id: "attachments", id: "attachments",
header: "i18n:govoplan-campaign.attachments.6771ade6", header: "i18n:govoplan-campaign.attachments.6771ade6",
@@ -2147,9 +2314,59 @@ function getAddressColumn(key: AddressFieldKey): EntryAddressColumn {
return column; return column;
} }
function cloneMailboxAddress(address: MailboxAddress): MailboxAddress {
return { name: address.name ?? "", email: address.email ?? "" };
}
function cloneAddressValues(columns: EntryAddressColumn[], values: HeaderAddressValues): HeaderAddressValues {
return Object.fromEntries(
columns.map((column) => [column.key, (values[column.key] ?? []).map(cloneMailboxAddress)])
) as HeaderAddressValues;
}
function mergeAddressGroups(
values: HeaderAddressValues,
groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>
): HeaderAddressValues {
const nextValues = cloneAddressValues(recipientAddressOverlayColumns, values);
for (const group of groups) {
nextValues[group.key] = dedupeAddresses([
...(nextValues[group.key] ?? []),
...group.addresses.map(cloneMailboxAddress)
]);
}
return nextValues;
}
function prepareAddressValues(
columns: EntryAddressColumn[],
values: HeaderAddressValues,
translateText: (value: string) => string
): {values: HeaderAddressValues;error: string;} {
const prepared: HeaderAddressValues = {};
for (const column of columns) {
const addresses: MailboxAddress[] = [];
for (const address of values[column.key] ?? []) {
const name = String(address.name ?? "").trim();
const email = String(address.email ?? "").trim();
if (!name && !email) continue;
if (!email) {
return {
values: prepared,
error: `${translateText(column.label)}: enter an email address or remove the incomplete row.`
};
}
addresses.push({ name, email });
}
prepared[column.key] = column.allowMultiple ? dedupeAddresses(addresses) : addresses.slice(0, 1);
}
return { values: prepared, error: "" };
}
function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] { function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] {
if (column.allowMultiple) return addresses; const rows = addresses.map(cloneMailboxAddress);
return addresses.length > 0 ? addresses.slice(0, 1) : [{ name: "", email: "" }]; if (column.allowMultiple) return rows;
return rows.length > 0 ? rows.slice(0, 1) : [{ name: "", email: "" }];
} }
function formatMailboxAddress(address: MailboxAddress): string { function formatMailboxAddress(address: MailboxAddress): string {

View File

@@ -0,0 +1,567 @@
import { useMemo, useState } from "react";
import { Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import type {
CampaignPostboxCatalog,
CampaignPostboxOrganizationUnit
} from "../../../api/campaigns";
import type { CampaignFieldDefinition } from "../utils/fieldDefinitions";
import { asRecord } from "../utils/campaignView";
export type CampaignPostboxTarget = {
id: string;
mode: "direct" | "derived";
label?: string;
postbox_id?: string;
address_key?: string;
template_id?: string;
organization_unit_id?: string;
organization_unit_field?: string;
organization_unit_match?: "id" | "slug";
function_id?: string;
function_field?: string;
function_match?: "id" | "slug";
context_key?: string;
context_field?: string;
};
type Props = {
open: boolean;
title: string;
catalog: CampaignPostboxCatalog;
fields: CampaignFieldDefinition[];
targets: CampaignPostboxTarget[];
locked?: boolean;
merge?: boolean;
showMerge?: boolean;
onSave: (targets: CampaignPostboxTarget[], merge: boolean) => void;
onClose: () => void;
};
export default function PostboxTargetsDialog({
open,
title,
catalog,
fields,
targets,
locked = false,
merge = true,
showMerge = false,
onSave,
onClose
}: Props) {
const [draftTargets, setDraftTargets] = useState<CampaignPostboxTarget[]>(
() => targets.map(normalizeTarget)
);
const [draftMerge, setDraftMerge] = useState(merge);
const [error, setError] = useState("");
function updateTarget(index: number, patch: Partial<CampaignPostboxTarget>) {
setDraftTargets((current) =>
current.map((target, currentIndex) =>
currentIndex === index ? normalizeTarget({ ...target, ...patch }) : target
)
);
setError("");
}
function addTarget() {
const firstPostbox = catalog.postboxes[0];
setDraftTargets((current) => [
...current,
{
id: newTargetId(),
mode: "direct",
postbox_id: firstPostbox?.id ?? ""
}
]);
}
function save() {
const prepared = draftTargets.map(normalizeTarget);
const invalidIndex = prepared.findIndex((target) => !targetIsComplete(target));
if (invalidIndex >= 0) {
setError(`Target ${invalidIndex + 1} is incomplete.`);
return;
}
onSave(prepared, draftMerge);
}
return (
<Dialog
open={open}
title={title}
className="campaign-postbox-target-modal"
bodyClassName="campaign-postbox-target-body"
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={locked} onClick={save}>Save</Button>
</>
}
>
<div className="campaign-postbox-target-toolbar">
{showMerge &&
<ToggleSwitch
label="Postbox target defaults"
inactiveLabel="Replace defaults"
activeLabel="Add to defaults"
checked={draftMerge}
disabled={locked}
onChange={setDraftMerge}
/>
}
<Button
type="button"
variant="primary"
disabled={locked || catalog.postboxes.length + catalog.templates.length === 0}
onClick={addTarget}
>
<Plus aria-hidden="true" />
Add target
</Button>
</div>
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
{draftTargets.length === 0 &&
<div className="data-grid-empty">No Postbox targets configured.</div>
}
<div className="campaign-postbox-target-list">
{draftTargets.map((target, index) =>
<PostboxTargetRow
key={target.id}
target={target}
index={index}
count={draftTargets.length}
catalog={catalog}
fields={fields}
locked={locked}
onChange={(patch) => updateTarget(index, patch)}
onMove={(offset) => setDraftTargets((current) => moveTarget(current, index, index + offset))}
onRemove={() => setDraftTargets((current) => current.filter((_, currentIndex) => currentIndex !== index))}
/>
)}
</div>
</Dialog>
);
}
function PostboxTargetRow({
target,
index,
count,
catalog,
fields,
locked,
onChange,
onMove,
onRemove
}: {
target: CampaignPostboxTarget;
index: number;
count: number;
catalog: CampaignPostboxCatalog;
fields: CampaignFieldDefinition[];
locked: boolean;
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
onMove: (offset: number) => void;
onRemove: () => void;
}) {
const selectedUnit = catalog.organization_units.find(
(unit) => unit.id === target.organization_unit_id
);
return (
<section className="campaign-postbox-target-row">
<div className="campaign-postbox-target-row-heading">
<strong>Target {index + 1}</strong>
<TableActionGroup actions={[
{ id: "up", label: "Move target up", icon: <ArrowUp aria-hidden="true" />, disabled: locked || index === 0, onClick: () => onMove(-1) },
{ id: "down", label: "Move target down", icon: <ArrowDown aria-hidden="true" />, disabled: locked || index === count - 1, onClick: () => onMove(1) },
{ id: "remove", label: "Remove target", icon: <Trash2 aria-hidden="true" />, disabled: locked, variant: "danger", onClick: onRemove }
]} />
</div>
<div className="form-grid compact responsive-form-grid campaign-postbox-target-grid">
<FormField label="Target type">
<select
value={target.mode}
disabled={locked}
onChange={(event) => onChange(
event.target.value === "derived"
? derivedTargetDefaults(target.id, catalog, fields)
: directTargetDefaults(target.id, catalog)
)}
>
<option value="direct">Direct Postbox</option>
<option value="derived">Derived from fields</option>
</select>
</FormField>
<FormField label="Label">
<input
value={target.label ?? ""}
disabled={locked}
onChange={(event) => onChange({ label: event.target.value })}
/>
</FormField>
{target.mode === "direct" ?
<DirectTargetFields
target={target}
catalog={catalog}
locked={locked}
onChange={onChange}
/> :
<DerivedTargetFields
target={target}
catalog={catalog}
fields={fields}
selectedUnit={selectedUnit}
locked={locked}
onChange={onChange}
/>
}
</div>
</section>
);
}
function DirectTargetFields({
target,
catalog,
locked,
onChange
}: {
target: CampaignPostboxTarget;
catalog: CampaignPostboxCatalog;
locked: boolean;
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
}) {
return (
<FormField label="Postbox">
<select
value={target.postbox_id ?? ""}
disabled={locked}
onChange={(event) => onChange({ postbox_id: event.target.value })}
>
<option value="">Select a Postbox</option>
{catalog.postboxes.map((postbox) =>
<option key={postbox.id} value={postbox.id}>
{postbox.name} ({postbox.address}){postbox.vacant ? " - vacant" : ""}
</option>
)}
</select>
</FormField>
);
}
function DerivedTargetFields({
target,
catalog,
fields,
selectedUnit,
locked,
onChange
}: {
target: CampaignPostboxTarget;
catalog: CampaignPostboxCatalog;
fields: CampaignFieldDefinition[];
selectedUnit?: CampaignPostboxOrganizationUnit;
locked: boolean;
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
}) {
const organizationFields = useMemo(
() => fields.filter((field) => field.type === "organization_unit" || field.type === "string"),
[fields]
);
const functionFields = useMemo(
() => fields.filter((field) => field.type === "organization_function" || field.type === "string"),
[fields]
);
return (
<>
<FormField label="Postbox template">
<select
value={target.template_id ?? ""}
disabled={locked}
onChange={(event) => onChange({ template_id: event.target.value })}
>
<option value="">Select a template</option>
{catalog.templates.map((template) =>
<option key={template.id} value={template.id}>{template.name}</option>
)}
</select>
</FormField>
<DerivedReferenceField
label="Organization unit"
fixedValue={target.organization_unit_id}
fieldValue={target.organization_unit_field}
match={target.organization_unit_match}
fixedOptions={catalog.organization_units}
fields={organizationFields}
locked={locked}
onFixed={(value) => onChange({
organization_unit_id: value,
organization_unit_field: undefined,
function_id: undefined
})}
onField={(value) => onChange({
organization_unit_id: undefined,
organization_unit_field: value
})}
onMatch={(value) => onChange({ organization_unit_match: value })}
/>
<DerivedReferenceField
label="Function"
fixedValue={target.function_id}
fieldValue={target.function_field}
match={target.function_match}
fixedOptions={selectedUnit?.functions ?? []}
fields={functionFields}
locked={locked}
fixedDisabled={!target.organization_unit_id}
onFixed={(value) => onChange({
function_id: value,
function_field: undefined
})}
onField={(value) => onChange({
function_id: undefined,
function_field: value
})}
onMatch={(value) => onChange({ function_match: value })}
/>
<FormField label="Context source">
<select
value={target.context_field ? "field" : target.context_key ? "fixed" : "none"}
disabled={locked}
onChange={(event) => {
const mode = event.target.value;
onChange({
context_key: mode === "fixed" ? target.context_key ?? "" : undefined,
context_field: mode === "field" ? target.context_field ?? fields[0]?.name ?? "" : undefined
});
}}
>
<option value="none">No context</option>
<option value="fixed">Fixed value</option>
<option value="field">Campaign field</option>
</select>
</FormField>
{target.context_field !== undefined ?
<FormField label="Context field">
<select
value={target.context_field}
disabled={locked}
onChange={(event) => onChange({ context_field: event.target.value })}
>
<option value="">Select a field</option>
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
</FormField> :
target.context_key !== undefined &&
<FormField label="Context value">
<input
value={target.context_key}
disabled={locked}
onChange={(event) => onChange({ context_key: event.target.value })}
/>
</FormField>
}
</>
);
}
function DerivedReferenceField({
label,
fixedValue,
fieldValue,
match,
fixedOptions,
fields,
locked,
fixedDisabled = false,
onFixed,
onField,
onMatch
}: {
label: string;
fixedValue?: string;
fieldValue?: string;
match?: "id" | "slug";
fixedOptions: Array<{ id: string; name: string; slug: string }>;
fields: CampaignFieldDefinition[];
locked: boolean;
fixedDisabled?: boolean;
onFixed: (value: string) => void;
onField: (value: string) => void;
onMatch: (value: "id" | "slug") => void;
}) {
const usesField = fieldValue !== undefined;
return (
<>
<FormField label={`${label} source`}>
<select
value={usesField ? "field" : "fixed"}
disabled={locked}
onChange={(event) =>
event.target.value === "field"
? onField(fieldValue ?? fields[0]?.name ?? "")
: onFixed(fixedValue ?? fixedOptions[0]?.id ?? "")
}
>
<option value="fixed">Fixed value</option>
<option value="field">Campaign field</option>
</select>
</FormField>
{usesField ?
<>
<FormField label={`${label} field`}>
<select value={fieldValue ?? ""} disabled={locked} onChange={(event) => onField(event.target.value)}>
<option value="">Select a field</option>
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
</FormField>
<FormField label="Field contains">
<select value={match ?? "id"} disabled={locked} onChange={(event) => onMatch(event.target.value as "id" | "slug")}>
<option value="id">Stable ID</option>
<option value="slug">Key / slug</option>
</select>
</FormField>
</> :
<FormField label={label}>
<select value={fixedValue ?? ""} disabled={locked || fixedDisabled} onChange={(event) => onFixed(event.target.value)}>
<option value="">Select {label.toLowerCase()}</option>
{fixedOptions.map((option) => <option key={option.id} value={option.id}>{option.name} ({option.slug})</option>)}
</select>
</FormField>
}
</>
);
}
export function normalizePostboxTargets(value: unknown): CampaignPostboxTarget[] {
if (!Array.isArray(value)) return [];
return value
.map(asRecord)
.map((target) => normalizeTarget({
id: String(target.id ?? ""),
mode: target.mode === "derived" ? "derived" : "direct",
label: optionalText(target.label),
postbox_id: optionalText(target.postbox_id),
address_key: optionalText(target.address_key),
template_id: optionalText(target.template_id),
organization_unit_id: optionalText(target.organization_unit_id),
organization_unit_field: optionalText(target.organization_unit_field),
organization_unit_match: target.organization_unit_match === "slug" ? "slug" : "id",
function_id: optionalText(target.function_id),
function_field: optionalText(target.function_field),
function_match: target.function_match === "slug" ? "slug" : "id",
context_key: optionalText(target.context_key),
context_field: optionalText(target.context_field)
}))
.filter((target) => Boolean(target.id));
}
function normalizeTarget(target: CampaignPostboxTarget): CampaignPostboxTarget {
const common = {
id: target.id || newTargetId(),
mode: target.mode,
...(target.label?.trim() ? { label: target.label.trim() } : {})
};
if (target.mode === "direct") {
return {
...common,
mode: "direct",
...(target.postbox_id ? { postbox_id: target.postbox_id } : {}),
...(!target.postbox_id && target.address_key ? { address_key: target.address_key } : {})
};
}
return {
...common,
mode: "derived",
template_id: target.template_id ?? "",
...(target.organization_unit_field !== undefined
? {
organization_unit_field: target.organization_unit_field,
organization_unit_match: target.organization_unit_match ?? "id"
}
: { organization_unit_id: target.organization_unit_id ?? "" }),
...(target.function_field !== undefined
? {
function_field: target.function_field,
function_match: target.function_match ?? "id"
}
: { function_id: target.function_id ?? "" }),
...(target.context_field !== undefined
? { context_field: target.context_field }
: target.context_key !== undefined
? { context_key: target.context_key }
: {})
};
}
function targetIsComplete(target: CampaignPostboxTarget): boolean {
if (!target.id) return false;
if (target.mode === "direct") {
return Boolean(target.postbox_id || target.address_key);
}
return Boolean(
target.template_id
&& (target.organization_unit_id || target.organization_unit_field)
&& (target.function_id || target.function_field)
);
}
function directTargetDefaults(id: string, catalog: CampaignPostboxCatalog): CampaignPostboxTarget {
return {
id,
mode: "direct",
postbox_id: catalog.postboxes[0]?.id ?? ""
};
}
function derivedTargetDefaults(
id: string,
catalog: CampaignPostboxCatalog,
fields: CampaignFieldDefinition[]
): CampaignPostboxTarget {
const unit = catalog.organization_units[0];
return {
id,
mode: "derived",
template_id: catalog.templates[0]?.id ?? "",
...(unit
? {
organization_unit_id: unit.id,
function_id: unit.functions[0]?.id ?? ""
}
: {
organization_unit_field: fields[0]?.name ?? "",
organization_unit_match: "id",
function_field: fields[0]?.name ?? "",
function_match: "id"
})
};
}
function moveTarget(
targets: CampaignPostboxTarget[],
source: number,
destination: number
): CampaignPostboxTarget[] {
if (destination < 0 || destination >= targets.length) return targets;
const next = [...targets];
const [target] = next.splice(source, 1);
next.splice(destination, 0, target);
return next;
}
function optionalText(value: unknown): string | undefined {
const text = String(value ?? "").trim();
return text || undefined;
}
function newTargetId(): string {
const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `postbox-${suffix}`;
}

View File

@@ -1,7 +1,15 @@
import { asArray, asRecord } from "./campaignView"; import { asArray, asRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor"; import { getBool, getText } from "./draftEditor";
export const fieldTypeOptions = ["string", "integer", "double", "date", "password"] as const; export const fieldTypeOptions = [
"string",
"integer",
"double",
"date",
"password",
"organization_unit",
"organization_function"
] as const;
export type CampaignFieldType = typeof fieldTypeOptions[number]; export type CampaignFieldType = typeof fieldTypeOptions[number];
export type CampaignFieldDefinition = { export type CampaignFieldDefinition = {

View File

@@ -5,6 +5,7 @@ export type CampaignJobSortColumn =
| "validation" | "validation"
| "queue" | "queue"
| "send" | "send"
| "postbox"
| "imap" | "imap"
| "attempts" | "attempts"
| "updated"; | "updated";
@@ -31,6 +32,7 @@ const FILTER_PARAMETERS: Record<string, string> = {
validation: "filter_validation", validation: "filter_validation",
queue: "filter_queue", queue: "filter_queue",
send: "filter_send", send: "filter_send",
postbox: "filter_postbox",
imap: "filter_imap", imap: "filter_imap",
attempts: "filter_attempts", attempts: "filter_attempts",
evidence: "filter_evidence" evidence: "filter_evidence"

View File

@@ -1,13 +1,26 @@
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> { export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
const server = isRecord(value.server) ? value.server : {}; const server = isRecord(value.server) ? value.server : {};
const rawProfileId = server.mail_profile_id; const profileId = textId(server.mail_profile_id);
const profileId = typeof rawProfileId === "string" ? rawProfileId.trim() : ""; const smtpServerId = textId(server.smtp_server_id);
const smtpCredentialId = textId(server.smtp_credential_id);
const imapServerId = textId(server.imap_server_id);
const imapCredentialId = textId(server.imap_credential_id);
const reference: Record<string, string> = {};
if (profileId) reference.mail_profile_id = profileId;
if (profileId && smtpServerId) reference.smtp_server_id = smtpServerId;
if (profileId && smtpServerId && smtpCredentialId) reference.smtp_credential_id = smtpCredentialId;
if (profileId && imapServerId) reference.imap_server_id = imapServerId;
if (profileId && imapServerId && imapCredentialId) reference.imap_credential_id = imapCredentialId;
return { return {
...value, ...value,
server: profileId ? { mail_profile_id: profileId } : {} server: reference
}; };
} }
function textId(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); return Boolean(value) && typeof value === "object" && !Array.isArray(value);
} }

View File

@@ -29,6 +29,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { PageScrollViewport } from "@govoplan/core-webui";
import { import {
StatusBadge, StatusBadge,
TableActionGroup, TableActionGroup,
@@ -755,6 +756,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
], [navigate, permissions.canOpenReport, selectedRow, selectedVersionId]); ], [navigate, permissions.canOpenReport, selectedRow, selectedVersionId]);
return ( return (
<PageScrollViewport>
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
@@ -844,7 +846,8 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
if (cancelTarget) void runAction(cancelTarget, "cancel"); if (cancelTarget) void runAction(cancelTarget, "cancel");
}} }}
onCancel={() => setCancelTarget(null)} /> onCancel={() => setCancelTarget(null)} />
</div>); </div>
</PageScrollViewport>);
} }

View File

@@ -8,6 +8,7 @@ import {
DismissibleAlert, DismissibleAlert,
LoadingFrame, LoadingFrame,
MetricCard, MetricCard,
PageScrollViewport,
PageTitle, PageTitle,
StatusBadge, StatusBadge,
TableActionGroup, TableActionGroup,
@@ -165,6 +166,7 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
const outcomes = report?.outcomes; const outcomes = report?.outcomes;
return ( return (
<PageScrollViewport className="aggregate-reports-page">
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
@@ -212,6 +214,9 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
would require a separately suppressed aggregate response from the server. */} would require a separately suppressed aggregate response from the server. */}
<div className="dashboard-grid"> <div className="dashboard-grid">
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" /> <MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" />
<MetricCard label="Postbox accepted" value={countValue(outcomes.postbox_accepted)} tone="good" />
<MetricCard label="Both channels accepted" value={countValue(outcomes.delivered)} tone="good" />
<MetricCard label="Partially accepted" value={countValue(outcomes.partially_accepted)} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={countValue(outcomes.failed)} tone="danger" /> <MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={countValue(outcomes.failed)} tone="danger" />
<MetricCard label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={countValue(outcomes.outcome_unknown)} tone="warning" /> <MetricCard label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={countValue(outcomes.outcome_unknown)} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.queued_or_active.afdcd7da" value={countValue(outcomes.queued_or_active)} tone="info" /> <MetricCard label="i18n:govoplan-campaign.queued_or_active.afdcd7da" value={countValue(outcomes.queued_or_active)} tone="info" />
@@ -246,6 +251,7 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
)} )}
</LoadingFrame> </LoadingFrame>
</div> </div>
</PageScrollViewport>
); );
} }

View File

@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { PageScrollViewport } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui"; import { FieldLabel } from "@govoplan/core-webui";
import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui"; import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui";
@@ -164,6 +165,7 @@ export default function TemplatesPage() {
} }
return ( return (
<PageScrollViewport>
<div className="content-pad workspace-data-page module-entry-page"> <div className="content-pad workspace-data-page module-entry-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
@@ -194,7 +196,8 @@ export default function TemplatesPage() {
className="compact-table-wrap module-table-wrap module-entry-table" /> className="compact-table-wrap module-table-wrap module-entry-table" />
</Card> </Card>
</div>); </div>
</PageScrollViewport>);
} }

View File

@@ -1,7 +1,14 @@
import { createElement, lazy, useCallback } from "react"; import { createElement, lazy, useCallback } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui"; import {
ResourceAccessBoundary,
type ApiSettings,
type AuthInfo,
type DashboardWidgetsUiCapability,
type PlatformWebModule
} from "@govoplan/core-webui";
import { getCampaign } from "./api/campaigns"; import { getCampaign } from "./api/campaigns";
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/campaign-workspace.css"; import "./styles/campaign-workspace.css";
@@ -17,6 +24,50 @@ const translations = {
en: generatedTranslations.en, en: generatedTranslations.en,
de: generatedTranslations.de de: generatedTranslations.de
}; };
const campaignDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "campaigns.activity",
surfaceId: "campaigns.widget.activity",
title: "Campaign activity",
description: "Recently changed campaigns and delivery state.",
moduleId: "campaigns",
category: "Communication",
order: 50,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: campaignRead,
refreshIntervalMs: 30_000,
defaultConfiguration: {
maxItems: 5,
showCompleted: false
},
configurationFields: [
{
id: "maxItems",
label: "Maximum campaigns",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
},
{
id: "showCompleted",
label: "Include completed campaigns",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(CampaignActivityWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const campaignModule: PlatformWebModule = { export const campaignModule: PlatformWebModule = {
id: "campaigns", id: "campaigns",
@@ -25,6 +76,15 @@ export const campaignModule: PlatformWebModule = {
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["files", "mail"], optionalDependencies: ["files", "mail"],
translations, translations,
viewSurfaces: [
{
id: "campaigns.widget.activity",
moduleId: "campaigns",
kind: "section",
label: "Campaign activity widget",
order: 50
}
],
navItems: [ navItems: [
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 }, { to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 },
{ {
@@ -43,7 +103,10 @@ export const campaignModule: PlatformWebModule = {
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }, { path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/operator", anyOf: operatorScopes, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) }, { path: "/operator", anyOf: operatorScopes, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: ({ settings }) => createElement(AggregateReportsPage, { settings }) }, { path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: ({ settings }) => createElement(AggregateReportsPage, { settings }) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }] { path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
uiCapabilities: {
"dashboard.widgets": campaignDashboardWidgets
}
}; };

View File

@@ -947,6 +947,83 @@
overflow: auto; overflow: auto;
} }
.campaign-postbox-delivery-settings {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
align-items: end;
margin-top: 16px;
}
.campaign-postbox-target-modal {
width: min(1040px, calc(100vw - 32px));
}
.campaign-postbox-target-body {
max-height: min(76vh, 820px);
overflow: auto;
}
.campaign-postbox-target-toolbar,
.campaign-postbox-target-row-heading,
.campaign-recipient-delivery-cell {
display: flex;
align-items: center;
gap: 10px;
}
.campaign-postbox-target-toolbar {
justify-content: space-between;
margin-bottom: 12px;
}
.campaign-postbox-target-list {
display: grid;
gap: 10px;
}
.campaign-postbox-target-row {
display: grid;
gap: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
padding: 10px;
}
.campaign-postbox-target-row-heading {
justify-content: space-between;
}
.campaign-postbox-target-grid {
grid-template-columns: repeat(3, minmax(180px, 1fr));
}
.campaign-recipient-delivery-cell {
min-width: 0;
}
.campaign-recipient-delivery-cell select {
min-width: 0;
flex: 1 1 170px;
}
.campaign-recipient-delivery-cell .button {
flex: 0 0 auto;
}
@media (max-width: 900px) {
.campaign-postbox-delivery-settings,
.campaign-postbox-target-grid {
grid-template-columns: 1fr;
}
.campaign-recipient-delivery-cell {
align-items: stretch;
flex-direction: column;
}
}
.recipient-address-editor { .recipient-address-editor {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -1003,12 +1080,13 @@
.recipient-address-empty-row { .recipient-address-empty-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(150px, .7fr) minmax(220px, 1fr) auto;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
} }
.recipient-address-empty { .recipient-address-empty {
grid-column: 1 / 3;
margin: 0; margin: 0;
} }
@@ -1082,6 +1160,10 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.recipient-address-empty {
grid-column: auto;
}
.recipient-address-category-header { .recipient-address-category-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
@@ -1218,8 +1300,8 @@
/* Shared message preview overlay --------------------------------------- */ /* Shared message preview overlay --------------------------------------- */
.message-preview-modal { .message-preview-modal {
width: min(920px, 100%); width: min(1104px, calc(100vw - 48px));
height: min(780px, calc(100dvh - 48px)); height: min(936px, calc(100dvh - 48px));
max-height: calc(100dvh - 48px); max-height: calc(100dvh - 48px);
} }
@@ -1237,15 +1319,16 @@
.message-preview-modal .modal-body { .message-preview-modal .modal-body {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
display: grid; overflow: hidden;
align-content: start;
gap: 1rem;
overflow: auto;
scrollbar-gutter: stable;
} }
.message-preview-content { .message-preview-content {
display: contents; display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
min-height: 0;
overflow: hidden;
} }
.message-preview-modal .modal-footer { .message-preview-modal .modal-footer {
@@ -1257,10 +1340,24 @@
margin-right: auto; margin-right: auto;
} }
.message-preview-modal .message-display-panel {
flex: 1 1 auto;
grid-template-rows: auto minmax(0, 1fr) auto;
min-height: 0;
overflow: hidden;
}
.message-preview-modal .message-display-body-section {
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.message-preview-modal .message-display-body, .message-preview-modal .message-display-body,
.message-preview-modal .message-display-html-frame { .message-preview-modal .message-display-html-frame {
height: clamp(280px, 45vh, 520px); height: 100%;
max-height: clamp(280px, 45vh, 520px); min-height: 0;
max-height: none;
} }
.message-preview-meta { .message-preview-meta {
@@ -1272,6 +1369,17 @@
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
} }
.message-preview-modal .message-display-attachments {
flex: 0 0 auto;
min-height: 0;
overflow: hidden;
}
.message-preview-modal .message-display-attachments-scroll {
max-height: 116px;
overflow: auto;
}
.attachment-chip-grid { .attachment-chip-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1303,6 +1411,9 @@
} }
.message-preview-raw { .message-preview-raw {
flex: 0 0 auto;
max-height: 132px;
overflow: auto;
border-top: 1px solid var(--line-subtle); border-top: 1px solid var(--line-subtle);
padding-top: 0.75rem; padding-top: 0.75rem;
} }

View File

@@ -21,7 +21,7 @@ assert(
"message previews use the central stacked Dialog with an accessible close label" "message previews use the central stacked Dialog with an accessible close label"
); );
assert( assert(
styles.includes("height: min(780px, calc(100dvh - 48px));"), styles.includes("height: min(936px, calc(100dvh - 48px));"),
"desktop previews use a stable responsive height" "desktop previews use a stable responsive height"
); );
assert( assert(

View File

@@ -106,7 +106,7 @@ assert(overlaySource.includes("{actions && <div"), "built-message footer actions
assert(overlaySource.includes("<Dialog") && overlaySource.includes("closeLabel={closeLabel}"), "the preview uses the central Dialog and its accessible close label"); assert(overlaySource.includes("<Dialog") && overlaySource.includes("closeLabel={closeLabel}"), "the preview uses the central Dialog and its accessible close label");
const styles = readFileSync("src/styles/campaign-workspace.css", "utf8"); const styles = readFileSync("src/styles/campaign-workspace.css", "utf8");
assert(styles.includes("height: min(780px, calc(100dvh - 48px));"), "desktop previews use a stable responsive height"); assert(styles.includes("height: min(936px, calc(100dvh - 48px));"), "desktop previews use a stable responsive height");
assert(styles.includes("height: calc(100dvh - 16px);"), "small viewports use the available dynamic viewport height"); assert(styles.includes("height: calc(100dvh - 16px);"), "small viewports use the available dynamic viewport height");
assert(styles.includes(".message-preview-modal .modal-footer"), "preview footer has a stable layout rule"); assert(styles.includes(".message-preview-modal .modal-footer"), "preview footer has a stable layout rule");
assert(styles.includes(".attachment-linking-file-list.is-compact"), "compact attachment links use a bounded scroll surface"); assert(styles.includes(".attachment-linking-file-list.is-compact"), "compact attachment links use a bounded scroll surface");