60 Commits

Author SHA1 Message Date
a8c0750dd7 docs(campaign): clarify legacy mail secret treatment 2026-07-21 20:49:20 +02:00
bfbb86564c fix(campaign): align adaptive handbook with shipped UI 2026-07-21 19:15:52 +02:00
c05bb8e474 docs: surface permission-gated Campaign tasks 2026-07-21 18:53:07 +02:00
99ef25b08f docs: add adaptive Campaign composition guide 2026-07-21 18:46:22 +02:00
03100b77db refactor: simplify Campaign editor validation 2026-07-21 18:16:09 +02:00
0ac903c82d security: make Campaign mutation audits atomic 2026-07-21 17:56:59 +02:00
60776803c5 security: authorize every Campaign effect version 2026-07-21 17:56:47 +02:00
9a709b1264 security: separate Campaign reader and operator data 2026-07-21 17:55:54 +02:00
af833ca38c security: bound Campaign editor metadata 2026-07-21 17:55:30 +02:00
50c509d161 security: seal Campaign delivery inputs 2026-07-21 17:54:34 +02:00
9f4eab07f6 docs: expand adaptive Campaign guidance 2026-07-21 17:29:08 +02:00
25a69b3fa9 docs: add multi-perspective Campaign handbook 2026-07-21 17:29:01 +02:00
24538c2a99 chore: release Campaign 0.1.9 2026-07-21 17:15:02 +02:00
7d8579194d docs: document the Campaign-to-Mail delivery boundary 2026-07-21 17:14:55 +02:00
09c63de813 security: harden Campaign delivery effects and reconciliation 2026-07-21 17:14:33 +02:00
057e660b17 feat: add Campaign delivery ownership and IMAP claim primitives 2026-07-21 17:13:49 +02:00
701c0fe184 refactor(webui): select Mail-owned profiles for campaigns 2026-07-21 17:13:22 +02:00
2c5519908a refactor(webui): consume Core navigation and retention components 2026-07-21 13:52:59 +02:00
dc56687af6 Remove shared CSS ownership from Campaign 2026-07-21 13:47:28 +02:00
2c70c553ac Use central metric cards for Campaign summaries 2026-07-21 13:24:18 +02:00
8627d0e135 Import Campaign data grids directly from Core 2026-07-21 13:24:04 +02:00
d2adcca7ae Remove Campaign-owned shared CSS 2026-07-21 13:19:20 +02:00
002ca4b371 refactor(webui): use core access explanation 2026-07-21 13:18:40 +02:00
641bead0d8 Deduplicate campaign template rendering 2026-07-21 13:14:32 +02:00
3c305753d6 Refactor campaign job queue selection 2026-07-21 12:54:11 +02:00
ef513816f8 Refactor mock campaign send reporting 2026-07-21 12:54:08 +02:00
b7653b58f4 refactor(webui): use central campaign components 2026-07-21 12:04:30 +02:00
ce92499333 fix(api): hide campaign operational internals 2026-07-21 12:03:56 +02:00
04bc6c430e Fix Campaign navigation action markup 2026-07-21 03:17:11 +02:00
04c214b149 Use the central Dialog for Campaign previews 2026-07-21 03:17:11 +02:00
35b3cc151d Clean Campaign audit and test resources 2026-07-21 03:17:11 +02:00
c54fec4cc3 Remove obsolete Campaign schema copies 2026-07-21 03:17:11 +02:00
1d291377c0 Block local file paths at Campaign server boundaries 2026-07-21 03:17:11 +02:00
78f52a36d4 refactor(campaign-ui): align workspace layouts 2026-07-20 20:09:11 +02:00
0c4f198802 refactor(campaign-ui): separate IMAP append settings 2026-07-20 20:08:58 +02:00
c71fa3dc64 feat(campaign-ui): configure attachment delivery behavior 2026-07-20 20:08:53 +02:00
0a6064ec62 feat(campaign-ui): review and link managed attachments 2026-07-20 20:08:41 +02:00
fce6dd1138 feat(campaign-ui): add explicit job retry controls 2026-07-20 20:08:29 +02:00
4b9eb79065 refactor(campaign-ui): consolidate recipient editing 2026-07-20 20:08:10 +02:00
7ea5bdb217 fix(campaign-ui): make discard restore server state 2026-07-20 20:08:00 +02:00
f755cdf48d fix(campaign): remove stale mapper coupling 2026-07-20 20:07:39 +02:00
8965b27517 feat(campaign): expose attachment linking and single send 2026-07-20 20:07:25 +02:00
12036b1f36 feat(campaign): support reviewed single-message delivery 2026-07-20 20:07:18 +02:00
ad34365f6c feat(campaign): enforce attachment delivery policies 2026-07-20 20:07:12 +02:00
724ca779d6 fix(campaign): stabilize message preview overlays 2026-07-20 17:11:23 +02:00
2e593b7fa4 intermittent commit 2026-07-14 13:22:10 +02:00
3f0b14a726 Harden campaign import handling 2026-07-11 18:49:35 +02:00
30b43913e8 Release v0.1.8 2026-07-11 16:49:01 +02:00
83b2082c74 Release v0.1.7 2026-07-11 02:34:56 +02:00
5775347746 Pin campaign Excel import dependency 2026-07-11 01:09:43 +02:00
083c089003 Use Node XLSX reader in campaign import tests 2026-07-11 00:59:44 +02:00
54c9881a25 Add campaign resource access explanations 2026-07-11 00:39:40 +02:00
da4c8731fc Sync GovOPlaN module state 2026-07-10 21:57:21 +02:00
9a6b6c7e8e Use capability-based module boundaries 2026-07-10 17:33:52 +02:00
52e6ed49c5 chore: sync GovOPlaN module split state 2026-07-10 12:51:18 +02:00
57f6066bf7 Release v0.1.6 2026-07-07 15:55:37 +02:00
533c4c8fe3 Release v0.1.5 2026-07-07 15:49:06 +02:00
fb6fb67d45 Release v0.1.4 2026-07-02 14:59:52 +02:00
ab2343aa88 Release v0.1.3 2026-06-26 01:39:19 +02:00
23318c709a Release v0.1.2 2026-06-25 19:58:20 +02:00
177 changed files with 27590 additions and 9018 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/campaign
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/campaign
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/campaign
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/campaign
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/campaign
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

22
.gitignore vendored
View File

@@ -327,3 +327,25 @@ cython_debug/
*.vsix *.vsix
**/runtime/ **/runtime/
# GovOPlaN WebUI test output
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
webui/.review-preview-test-build/
# GovOPlaN shared ignore rules from govoplan-core
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
.review-preview-test-build/
webui/.component-test-build/
webui/.module-test-build/
*.db
# GovOPlaN local runtime state
runtime/
webui/.module-test-build/
webui/.component-test-build/

38
AGENTS.md Normal file
View File

@@ -0,0 +1,38 @@
# GovOPlaN Campaign Codex Guide
## Scope
This repository owns the `campaigns` module: campaign authoring, validation, message building, attachment resolution, queue/review/send control, reports, campaign module manifest, and `@govoplan/campaign-webui`.
Core owns auth, tenants, RBAC, database/session primitives, CSRF/API helpers, shared components, and shell layout. Files and mail integrations are optional and must be accessed through core module metadata or capabilities.
## Local Commands
Install and run through core:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -r requirements-dev.txt
```
Focused WebUI tests:
```bash
cd /mnt/DATA/git/govoplan-campaign/webui
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:policy-ui
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:template-preview
```
For combined checks, run:
```bash
cd /mnt/DATA/git/govoplan
tools/checks/check-focused.sh
```
## Working Rules
- Do not add required files/mail imports for campaign startup.
- Use core-provided capabilities or module metadata for optional file chooser, managed file usage, mail profile, delivery, and mailbox behavior.
- Shared WebUI components belong in core; campaign WebUI should consume them through `@govoplan/core-webui`.
- Avoid DataGrid changes unless explicitly requested.

View File

@@ -1,5 +1,9 @@
# govoplan-campaign # govoplan-campaign
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Campaign is the campaign authoring, validation, review, sending-control, and reporting module. It bundles backend campaign APIs with the campaign WebUI package. GovOPlaN Campaign is the campaign authoring, validation, review, sending-control, and reporting module. It bundles backend campaign APIs with the campaign WebUI package.
## Ownership ## Ownership
@@ -11,19 +15,46 @@ This repository owns:
- campaign/version/job/issue/send-attempt/append-attempt models and migrations - campaign/version/job/issue/send-attempt/append-attempt models and migrations
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths - campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
- WebUI package `@govoplan/campaign-webui` - WebUI package `@govoplan/campaign-webui`
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, `/address-book`, and `/templates` - route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, and `/templates`
Core owns auth, tenants, RBAC evaluation, database/session primitives, CSRF/API helpers, shell layout, and route rendering. Files and mail own their respective storage and transport capabilities. Core owns the auth facade, RBAC/capability contracts, database/session
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
optional platform module for tenant administration and tenant resolver behavior.
Files and mail own their respective storage and transport capabilities.
## Dependencies ## Dependencies
The module has runtime dependencies on: The module has one required runtime dependency:
- `govoplan-core` for platform services - `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration
- `govoplan-files` for managed attachment integration
- `govoplan-mail` for SMTP/IMAP profile and delivery integration
Optional UI behavior can still check installed module availability through core module metadata, but the current backend package declares files and mail as required. Files and mail are optional module integrations declared in the campaign manifest:
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
- `govoplan-mail` owns reusable profiles, encrypted SMTP/IMAP credentials, delivery policy checks, connection tests, and transport execution. Campaign JSON stores only `server.mail_profile_id`; inline transport settings and credentials are rejected. Without Mail, campaigns can still be authored, but profile validation and real delivery are unavailable.
Public campaign, version, job, and report responses expose business data and
delivery evidence, but never process-local paths, storage-backend keys, or
worker claim tokens. Operational troubleshooting uses the dedicated job
diagnostics endpoint and requires the tenant-level
`campaigns:diagnostic:read` permission. The campaign sender role receives this
permission; tenant-wide administrator scopes continue to grant it through the
standard policy evaluator.
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
Campaign also provides narrow kernel capabilities so other modules and core
services can cooperate without importing campaign internals:
- `campaigns.access` for campaign share/existence checks
- `campaigns.mailPolicyContext` for campaign-scoped mail policy and owner
context
- `campaigns.policyContext` for retention/policy provenance
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
- `campaigns.retention` for campaign-owned retention cleanup
Keep these capability payloads narrow: stable ids, policy payloads, and task
results only.
## Development ## Development
@@ -58,6 +89,18 @@ Frontend package:
Platform RBAC and governance rules are documented in `govoplan-core/docs/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
## Operations
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
- [Recipient and address boundary](docs/RECIPIENT_ADDRESS_BOUNDARY.md) defines the split between campaign-local recipients and future reusable address management.
- [Example campaigns and release checklist](docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md) defines the maintained example scenarios and release gates.
- [Campaign examples](examples/README.md) is the credential-free scenario catalogue that release fixtures must follow.
- [SMTP/IMAP test bed](dev/mail-testbed/README.md) provides the GreenMail Docker Compose setup and transport smoke for dedicated non-production delivery tests.
## Release packaging ## Release packaging
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. The campaign WebUI package depends on the tagged files and mail WebUI packages for release builds. The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. Files and mail WebUI packages remain optional product-composition dependencies supplied by the core host build, not required campaign package dependencies.

View File

@@ -0,0 +1,11 @@
GOVOPLAN_MAIL_TEST_LOCAL_PART=campaign-test
GOVOPLAN_MAIL_TEST_DOMAIN=govoplan.test
GOVOPLAN_MAIL_TEST_USER=campaign-test@govoplan.test
GOVOPLAN_MAIL_TEST_PASSWORD=campaign-test-password
GOVOPLAN_MAIL_TEST_RECIPIENT=campaign-test@govoplan.test
GOVOPLAN_MAIL_TEST_FROM=campaign-test@govoplan.test
GOVOPLAN_MAIL_TEST_SMTP_PORT=3025
GOVOPLAN_MAIL_TEST_IMAP_PORT=3143
GOVOPLAN_MAIL_TEST_SENT_FOLDER=Sent
GOVOPLAN_MAIL_TEST_ZIP_PASSWORD=zip-test-password
GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS=45

View File

@@ -0,0 +1,69 @@
# Campaign SMTP/IMAP Test Bed
This test bed provides dedicated non-production SMTP/IMAP infrastructure for
campaign delivery checks. It uses GreenMail and must never be configured with
production recipients or credentials.
## Start
```bash
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
cp .env.example .env
docker compose --env-file .env up -d
```
Default endpoints:
- SMTP: `127.0.0.1:3025`, plain transport
- IMAP: `127.0.0.1:3143`, plain transport
- GreenMail API/UI endpoint: `127.0.0.1:38080`
## Smoke Test
Run the transport smoke from the core virtual environment after installing the
campaign and mail modules:
```bash
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
set -a
. ./.env
set +a
/mnt/DATA/git/govoplan-core/.venv/bin/python run_transport_smoke.py
```
The smoke sends and appends three messages:
- no attachment
- one normal attachment
- one password-protected AES ZIP attachment
It verifies SMTP authentication, IMAP authentication, folder listing, delivery
to `INBOX`, and append-to-Sent behavior.
If the smoke is started immediately after `docker compose up -d`, GreenMail may
bind the SMTP/IMAP ports before the services are fully ready. The smoke retries
login and folder setup for `GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS`.
## Use With A Campaign
Use the same settings in a campaign mail profile:
- SMTP host `127.0.0.1`, port `3025`, security `plain`
- IMAP host `127.0.0.1`, port `3143`, security `plain`
- username and password from `.env`
- append folder from `GOVOPLAN_MAIL_TEST_SENT_FOLDER`
When changing the mailbox, keep `GOVOPLAN_MAIL_TEST_USER` equal to
`${GOVOPLAN_MAIL_TEST_LOCAL_PART}@${GOVOPLAN_MAIL_TEST_DOMAIN}` because the
compose file creates the GreenMail account from local part, password, and
domain while login uses the full email address.
Then run the controlled sending checklist from
`docs/CAMPAIGN_DELIVERY_RUNBOOK.md` for no attachment, one attachment, and
password-protected ZIP campaign variants.
## Stop
```bash
docker compose --env-file .env down -v
```

View File

@@ -0,0 +1,17 @@
services:
greenmail:
image: greenmail/standalone:2.1.9
container_name: govoplan-campaign-mail-testbed
restart: unless-stopped
environment:
GREENMAIL_OPTS: >-
-Dgreenmail.setup.test.all
-Dgreenmail.hostname=0.0.0.0
-Dgreenmail.auth.disabled=false
-Dgreenmail.users=${GOVOPLAN_MAIL_TEST_LOCAL_PART:-campaign-test}:${GOVOPLAN_MAIL_TEST_PASSWORD:-campaign-test-password}@${GOVOPLAN_MAIL_TEST_DOMAIN:-govoplan.test}
-Dgreenmail.users.login=email
-Dgreenmail.verbose
ports:
- "${GOVOPLAN_MAIL_TEST_SMTP_PORT:-3025}:3025"
- "${GOVOPLAN_MAIL_TEST_IMAP_PORT:-3143}:3143"
- "127.0.0.1:38080:8080"

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
from __future__ import annotations
import imaplib
import mimetypes
import os
import tempfile
import time
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from pathlib import Path
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
from govoplan_mail.backend.sending.imap import append_message_to_sent, list_imap_folders, list_imap_messages, test_imap_login
from govoplan_mail.backend.sending.smtp import send_email_message, test_smtp_login
from govoplan_campaign.backend.services.zip_service import create_zip_archive
def _env(name: str, default: str) -> str:
return os.environ.get(name, default).strip() or default
SMTP_HOST = _env("GOVOPLAN_MAIL_TEST_SMTP_HOST", "127.0.0.1")
IMAP_HOST = _env("GOVOPLAN_MAIL_TEST_IMAP_HOST", "127.0.0.1")
SMTP_PORT = int(_env("GOVOPLAN_MAIL_TEST_SMTP_PORT", "3025"))
IMAP_PORT = int(_env("GOVOPLAN_MAIL_TEST_IMAP_PORT", "3143"))
USERNAME = _env("GOVOPLAN_MAIL_TEST_USER", "campaign-test@govoplan.test")
PASSWORD = _env("GOVOPLAN_MAIL_TEST_PASSWORD", "campaign-test-password")
SENDER = _env("GOVOPLAN_MAIL_TEST_FROM", USERNAME)
RECIPIENT = _env("GOVOPLAN_MAIL_TEST_RECIPIENT", USERNAME)
SENT_FOLDER = _env("GOVOPLAN_MAIL_TEST_SENT_FOLDER", "Sent")
ZIP_PASSWORD = _env("GOVOPLAN_MAIL_TEST_ZIP_PASSWORD", "zip-test-password")
SERVICE_READY_TIMEOUT_SECONDS = int(_env("GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS", "45"))
def _smtp_config() -> SmtpConfig:
return SmtpConfig(
host=SMTP_HOST,
port=SMTP_PORT,
security=TransportSecurity.PLAIN,
username=USERNAME,
password=PASSWORD,
timeout_seconds=10,
)
def _imap_config() -> ImapConfig:
return ImapConfig(
host=IMAP_HOST,
port=IMAP_PORT,
security=TransportSecurity.PLAIN,
username=USERNAME,
password=PASSWORD,
sent_folder=SENT_FOLDER,
timeout_seconds=10,
)
def _message(subject: str, body: str, attachments: list[Path] | None = None) -> EmailMessage:
msg = EmailMessage()
msg["From"] = SENDER
msg["To"] = RECIPIENT
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=False)
msg["Message-ID"] = make_msgid(domain="govoplan.test")
msg.set_content(body)
for attachment in attachments or []:
content_type = mimetypes.guess_type(attachment.name)[0] or "application/octet-stream"
maintype, subtype = content_type.split("/", 1)
msg.add_attachment(attachment.read_bytes(), maintype=maintype, subtype=subtype, filename=attachment.name)
return msg
def _ensure_folder(config: ImapConfig, folder: str) -> None:
client = imaplib.IMAP4(host=config.host or "", port=config.port or 143, timeout=config.timeout_seconds)
try:
if config.username and config.password:
client.login(config.username, config.password)
typ, data = client.create(folder)
if typ not in {"OK", "NO"}:
raise RuntimeError(f"Could not create IMAP folder {folder!r}: {data!r}")
finally:
try:
client.logout()
except Exception:
pass
def _wait_for_delivery(config: ImapConfig, *, before_count: int, expected_increment: int) -> None:
deadline = time.monotonic() + 15
last_count = before_count
while time.monotonic() < deadline:
result = list_imap_messages(imap_config=config, folder="INBOX", limit=10)
last_count = result.total_count
if last_count >= before_count + expected_increment:
return
time.sleep(0.5)
raise RuntimeError(
f"Expected at least {expected_increment} new INBOX message(s), "
f"but count moved from {before_count} to {last_count}."
)
def _wait_for_service(label: str, check):
deadline = time.monotonic() + SERVICE_READY_TIMEOUT_SECONDS
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
return check()
except Exception as exc: # GreenMail can bind before SMTP/IMAP is ready.
last_error = exc
time.sleep(1)
raise RuntimeError(f"{label} did not become ready within {SERVICE_READY_TIMEOUT_SECONDS}s: {last_error}") from last_error
def main() -> int:
smtp = _smtp_config()
imap = _imap_config()
smtp_login = _wait_for_service("SMTP", lambda: test_smtp_login(smtp_config=smtp))
imap_login = _wait_for_service("IMAP", lambda: test_imap_login(imap_config=imap))
print(f"SMTP login OK: {smtp_login.host}:{smtp_login.port} authenticated={smtp_login.authenticated}")
print(f"IMAP login OK: {imap_login.host}:{imap_login.port} authenticated={imap_login.authenticated}")
_wait_for_service("IMAP folder creation", lambda: _ensure_folder(imap, SENT_FOLDER))
folders = _wait_for_service("IMAP folder listing", lambda: list_imap_folders(imap_config=imap))
print("IMAP folders: " + ", ".join(folder.name for folder in folders.folders))
before_inbox = list_imap_messages(imap_config=imap, folder="INBOX", limit=10).total_count
with tempfile.TemporaryDirectory(prefix="govoplan-campaign-mail-test-") as tmp:
root = Path(tmp)
attachment = root / "single-attachment.txt"
attachment.write_text("GovOPlaN single attachment smoke test\n", encoding="utf-8")
zip_source = root / "zip-source.txt"
zip_source.write_text("GovOPlaN password-protected ZIP smoke test\n", encoding="utf-8")
zip_path = create_zip_archive(root / "password-protected.zip", [zip_source], ZIP_PASSWORD)
messages = [
_message("[GovOPlaN test] no attachment", "No attachment SMTP/IMAP smoke."),
_message("[GovOPlaN test] one attachment", "One attachment SMTP/IMAP smoke.", [attachment]),
_message("[GovOPlaN test] password ZIP", "Password-protected ZIP SMTP/IMAP smoke.", [zip_path]),
]
for message in messages:
result = send_email_message(
message,
smtp_config=smtp,
envelope_from=SENDER,
envelope_recipients=[RECIPIENT],
)
print(f"SMTP accepted {result.accepted_count} recipient(s): {message['Subject']}")
append = append_message_to_sent(bytes(message), imap_config=imap, folder=SENT_FOLDER)
print(f"IMAP appended {append.bytes_appended} byte(s) to {append.folder}: {message['Subject']}")
_wait_for_delivery(imap, before_count=before_inbox, expected_increment=3)
sent_count = list_imap_messages(imap_config=imap, folder=SENT_FOLDER, limit=10).total_count
if sent_count < 3:
raise RuntimeError(f"Expected at least 3 messages in {SENT_FOLDER!r}, found {sent_count}.")
print("Transport smoke OK: no attachment, one attachment, and password-protected ZIP messages verified.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,107 @@
# Campaign Delivery Runbook
This runbook covers controlled campaign delivery after a campaign version has
been validated, built, reviewed, and locked.
## Operating Modes
- Local direct send: `CELERY_ENABLED=false`. Queueing stores jobs in the DB, and
small development runs can be processed with "Send queued now".
- Worker send: `CELERY_ENABLED=true` with Redis/Celery workers running. Queueing
publishes delivery tasks, and the Review & Send page polls summary counters.
- Mock send: use only for development review. It does not prove real SMTP/IMAP
credentials or server policy.
## Before First Live Use
- Use dedicated non-production SMTP/IMAP credentials.
- Store and test those credentials in a Mail-module profile. Campaign must
contain only the selected `server.mail_profile_id` reference.
- Start the repository test bed in `dev/mail-testbed/` when a local
production-like SMTP/IMAP server is sufficient.
- Use a dedicated mailbox/folder for append-to-Sent tests.
- Confirm policy allows the SMTP host, envelope sender, recipients, and optional
IMAP append target.
- Run one campaign each for no attachment, one attachment, and password-protected
ZIP before using production recipients.
- Keep the report page open during tests; it is the operational source of truth
for attempts, outcomes, and reconciliation.
## Deliverability Preflight
Before the first live send for a sender domain or mail-server profile:
- Confirm the selected SMTP identity matches the visible From/envelope sender
policy.
- Confirm SPF, DKIM, and DMARC are handled by the sending infrastructure or
documented as out of scope for the selected test environment.
- Confirm rate limits are explicitly set for the expected provider and recipient
volume.
- Confirm bounce/reply/notification addresses are monitored by an operational
mailbox or intentionally disabled.
- Confirm large attachments and password-protected ZIPs are acceptable for the
recipient systems.
- Confirm owner transfer or policy changes force profile reselection and
revalidation before live delivery.
## Queue And Send
1. Validate the version with file checks enabled.
2. Build the version and inspect all blocking review items.
3. Queue only after the selected version is the intended immutable execution
version.
4. In local mode, use "Send queued now" for small test runs.
5. In worker mode, verify queue counters move from queued/claimed/sending to a
terminal SMTP state.
## Outcome Handling
- `smtp_accepted`: Do not retry. If IMAP append is enabled and pending, run or
enqueue the append action.
- `failed_temporary`: Retry explicitly after checking the error and retry count.
- `failed_permanent`: Retry only if the operator has corrected the root cause and
intentionally includes permanent failures.
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
provider control panels, then reconcile as accepted or not sent.
- `claimed` or `sending` that does not progress: treat as a worker interruption.
Re-run worker handling or reconcile if SMTP may already have accepted the
message.
- IMAP `appending`: A worker owns the durable append claim. Do not start a
second append; if the worker cannot finish, reconcile only after checking the
mailbox.
- IMAP `outcome_unknown`: Never append automatically. An operator with
`campaigns:campaign:reconcile` must record an evidence note and resolve it as
`imap_appended` or `imap_not_appended`. Only the latter becomes explicitly
retryable.
## Reconciliation
- Choose "Accepted" only with external evidence that SMTP accepted the message.
The job becomes protected from retry and may proceed to IMAP append.
- Choose "Not sent" only when SMTP did not accept the message. The job becomes a
temporary failure and can be selected by explicit retry.
- Add a note that identifies the evidence used, for example SMTP log line,
provider message ID, or operator ticket.
## Fault Injection Checklist
Use mock infrastructure first, then repeat against the non-production real test
bed where possible:
- SMTP temporary failure.
- SMTP permanent failure.
- Recipient refusal after partial SMTP acceptance.
- Connection drop or worker interruption during SMTP.
- IMAP append failure after SMTP acceptance.
- Worker restart with queued, claimed, and sending jobs.
## Reporting Checks
- Partial delivery must show accepted, failed, and unknown counts separately.
- Accepted and unknown jobs must not appear in retry selections.
- Reconciled accepted jobs must remain protected from resend.
- Reconciled not-sent jobs must appear only as explicit retry candidates.
- The final CSV export should include message id, resolved envelope headers,
attachment evidence, EML reference/checksum, latest SMTP response/error, and
latest IMAP folder/error before the campaign is considered operationally
closed.

458
docs/CAMPAIGN_HANDBOOK.md Normal file
View File

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

View File

@@ -0,0 +1,78 @@
# Example Campaigns And Release Checklist
This document defines the campaign examples and release gates that should be
kept working before a GovOPlaN Campaign release is tagged.
## Example Campaign Set
Maintain fixtures or guided examples for these scenarios. The canonical
scenario catalogue lives in `examples/README.md`; committed fixture files should
be added under `examples/` only when they validate against the current campaign
schema and are safe to run in non-production environments.
- simple announcement with one active recipient and no attachments
- multi-recipient message with To, CC, BCC, Reply-To, bounce, and disposition
notification fields
- campaign with global attachments and recipient-specific attachment rules
- campaign with password-protected ZIP attachments
- campaign using a reusable mail profile from the mail module
- legacy inline SMTP/IMAP campaign rejected with an actionable profile-migration
error and no returned transport data
- campaign with validation warnings that may be sent only after explicit review
- campaign with blocked recipients or attachment errors that must not be sent
- mock delivery campaign that captures SMTP and IMAP append messages in the mail
development mailbox
- real non-production delivery campaign against the GreenMail test bed
## Fixture Rules
- Examples must not contain production recipient data or production credentials.
- Attachment examples should use deterministic small files and checksums.
- Mail secrets belong only to encrypted Mail profiles and must never appear in
a campaign fixture, placeholder, or campaign-local secret reference.
- Examples that require optional modules must declare the required modules and
capabilities in their README or fixture metadata.
- Examples must stay valid when files or mail modules are physically absent,
with optional behavior disabled instead of import failures.
## Release Gates
Before tagging a campaign release:
- Review `examples/README.md` and update the scenario catalogue when a release
adds or removes delivery behavior.
- Run core module permutation tests with campaign installed both with and
without files/mail.
- Validate and build each maintained example campaign.
- Run the mock delivery example when the mail development mailbox capability is
enabled.
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path.
- Confirm reusable mail profile selection is revalidated after campaign owner
transfer.
- Confirm every inline SMTP/IMAP field is rejected on import/write, omitted
from responses, and blocked from legacy execution until explicitly migrated.
- Confirm execution snapshots store only the Mail profile reference and
opaque Mail-owned transport revisions, not resolved transport material.
- Confirm delivery reports include SMTP outcome, IMAP append outcome, latest
error, generated EML reference, and attachment evidence.
- Confirm retries cannot resend messages already accepted by SMTP unless an
explicit reconciliation path allows it.
## Ownership Transfer Check
Reusable user/group mail profiles are owner-context-sensitive. When campaign
ownership changes, the editable current version must require profile reselection
and validation before live delivery. A locked delivery-final version should not
be silently rewritten.
## Delivery Checklist
Use the Review & Send preflight panel and the delivery runbook together:
1. Validate and resolve all blocking policy/data issues.
2. Build exact messages.
3. Review warnings, generated recipients, body content, and attachment evidence.
4. Run mock delivery if available for the release channel.
5. Test the selected Mail profile against non-production infrastructure.
6. Send only after queue, rate limit, and append-to-Sent behavior are understood.
7. Reconcile failed, unknown, or pending jobs from the report/audit surfaces.

View File

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

View File

@@ -0,0 +1,79 @@
# Recipient And Address Management Boundary
Campaigns own campaign-local recipient entries because sending and reporting
need a frozen recipient snapshot. Long-lived address management is a separate
domain owned by `govoplan-addresses`.
## `govoplan-campaign` Owns
- campaign-local recipient entries
- campaign-local recipient import mapping and validation
- message addressing for a concrete campaign version
- send/build/report evidence for the exact recipients used
- campaign-local exclusions, warnings, and review status
- recipient-specific attachment and template evidence
Campaign data is immutable once a version is built for sending. Later address
book changes must not rewrite historical campaign evidence.
## `govoplan-addresses` Owns
- Adrema-style address management
- reusable person, organization, household, and postal-address records
- reusable email address lists and segments
- postal-letter recipient views
- consent, legal-basis, and communication-preference metadata
- deduplication and merge workflows
- import/export of reusable address directories
- address quality checks and change history
The addresses module provides the UI/module boundary for this domain and should
grow stable DTOs and capabilities that campaigns, mail, forms, reporting,
portal, and postbox modules can consume without direct imports.
## Current Integration Contract
The campaign module asks the platform registry for address capabilities and
keeps working when they are absent. It must not import `govoplan-addresses`
ORM models, services, or WebUI components.
When `addresses.lookup` is available, campaign recipient address fields use it
for autocomplete. The campaign page may warm a small suggestion cache with an
empty query and then query by typed text as the user edits sender, reply-to,
global recipient, or per-recipient address fields.
When `addresses.recipient_source` is available, campaign offers a separate
address-book/list import flow. This is for importing reusable recipient sources
into the campaign recipient table; normal one-off address entry still happens
inside the address fields while typing.
The capability should return snapshots, not live ORM objects:
- selected source id and display label
- normalized recipient rows
- provenance fields for source, segment, legal basis, and import time
- update markers so campaigns can show whether a draft is based on stale source
data
Campaign stores the resolved snapshot in the campaign version. It may keep a
reference to the address source for traceability, but the built campaign remains
auditable even if the address source changes later.
If the source revision changes after import, campaign shows a stale-source
warning and lets the user reopen the import dialog with that source preselected.
The user still chooses append or replace; campaign should not silently rewrite
recipient rows.
## Non-Goals For Campaign
Campaign should not become the global address book. It should not own:
- deduplication across campaigns
- consent lifecycle
- master-data merge policy
- address-directory permissions beyond campaign use
- postal address normalization
- reusable segmentation rules
Those belong in `govoplan-addresses` or a dedicated records/identity module when
the domain needs stronger governance.

View File

@@ -0,0 +1,87 @@
# Recipient Import Guide
Recipient import lets campaign authors turn spreadsheet-like source data into
campaign-local recipient entries. It is intentionally campaign-local:
reusable address books and Adrema-style address management belong in the
`govoplan-addresses` module.
## Supported Inputs
The current importer is designed for tabular data with a header row. CSV and
spreadsheet-derived tables should be normalized before import so the campaign UI
sees:
- column headers
- row values
- source filename and sheet name when available
- a stable ordered and unordered header fingerprint
Avoid importing production secrets or credentials as recipient fields. Recipient
custom fields may be used in templates and reports, so they should be treated as
campaign data.
## Mapping Fields
Common headers are detected automatically:
- `email`, `e_mail`, `mail`, `to`, `to_email`, `recipient`,
`recipient_email`
- `name`, `full_name`, `recipient_name`, `to_name`
- `id`, `entry_id`, `recipient_id`
Authors can map columns to address fields:
- `from`
- `to`
- `cc`
- `bcc`
- `reply_to`
Rows without a valid `to` address should remain visible with validation issues
instead of disappearing silently. Authors should be able to fix the source file,
adjust the mapping, or exclude the row before building the campaign.
## Mapping Profiles
Mapping profiles save a known column layout so repeated imports can reuse the
same mapping. The importer stores ordered and unordered header fingerprints so a
profile can distinguish exact column order from equivalent column sets.
Administrators should curate shared profiles only for stable recurring sources.
Campaign-local profiles are acceptable for one-off work and experiments.
## User Workflow
1. Open the campaign recipient/data import screen.
2. Upload or paste a tabular source.
3. Review detected headers and preview rows.
4. Pick or adjust a mapping profile.
5. Confirm validation issues, exclusions, and generated recipient ids.
6. Import into the campaign draft.
7. Build messages and review recipient-specific evidence before sending.
## Admin Workflow
Administrators should:
- define naming conventions for recurring mapping profiles
- verify that imported fields have a lawful processing basis for the campaign
- keep reusable address-directory ownership out of campaigns because
`govoplan-addresses` owns that domain
- use campaign reports and audit evidence to trace which source produced which
recipient entries
- remove obsolete shared profiles when an upstream source layout changes
## Evidence Expectations
The campaign should preserve enough evidence to explain a send:
- source filename and sheet name where available
- header fingerprints
- mapping profile id/name when used
- row number or source id
- validation status and exclusion reason
- final recipient addresses used for the built message
The evidence should be available in reports without requiring the original
source file to be reprocessed.

45
examples/README.md Normal file
View File

@@ -0,0 +1,45 @@
# GovOPlaN Campaign Examples
These examples are the maintained scenario catalogue for campaign release
checks. They are intentionally small and credential-free. Add concrete fixture
files next to this README only when they can be validated by the current
campaign schema and do not require production data.
## Scenarios
| Scenario | Required Modules | Release Check |
| --- | --- | --- |
| `simple-announcement` | core, access, campaigns | Validate and build one active recipient without attachments. |
| `addressing-matrix` | core, access, campaigns | Exercise To, CC, BCC, Reply-To, bounce, and disposition-notification fields. |
| `global-attachment` | core, access, campaigns; optional files | Build one deterministic attachment and verify evidence. |
| `recipient-attachment-rules` | core, access, campaigns; optional files | Match recipient-specific attachment rules and verify per-recipient evidence. |
| `zip-protected` | core, access, campaigns | Build password-protected AES ZIP output and verify password-source metadata. |
| `mail-profile-send` | core, access, campaigns, mail | Select a reusable mail profile and send through the GreenMail test bed. |
| `legacy-inline-mail-rejected` | core, access, campaigns, mail | Confirm legacy campaign-local SMTP/IMAP data fails closed and requires explicit profile migration. |
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. |
| `blocked-send` | core, access, campaigns | Confirm blocked recipients or missing attachments cannot be queued. |
| `mock-delivery` | core, access, campaigns, mail with dev capability | Capture messages in the development mailbox. |
| `greenmail-delivery` | core, access, campaigns, mail | Send no-attachment, normal attachment, and ZIP attachment variants through `dev/mail-testbed`. |
## Fixture Rules
- Do not commit real recipients, mail credentials, or production attachment
names.
- Keep attachments deterministic and small.
- Store transport secrets only in encrypted Mail profiles or local `.env`
values consumed directly by the test bed, never in campaign JSON.
- Declare optional module requirements in fixture metadata.
- Fixtures must not import files or mail modules directly; optional behavior is
discovered through core module metadata and capabilities.
## Validation Flow
Before a release tag:
1. Run module permutation startup checks from core.
2. Validate every committed example fixture against the current campaign schema.
3. Build exact messages for each fixture.
4. Run the mock-delivery example when the dev mailbox capability is enabled.
5. Run `dev/mail-testbed/run_transport_smoke.py`.
6. Execute the delivery checklist in
`docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md`.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.1", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,12 +19,11 @@
"LICENSE" "LICENSE"
], ],
"dependencies": { "dependencies": {
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1", "read-excel-file": "9.2.0"
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^0.555.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.1.1"

View File

@@ -4,20 +4,18 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-campaign" name = "govoplan-campaign"
version = "0.1.1" version = "0.1.9"
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"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.1", "govoplan-core>=0.1.8",
"govoplan-files>=0.1.1",
"govoplan-mail>=0.1.1",
"jsonschema>=4,<5", "jsonschema>=4,<5",
"pydantic>=2,<3", "pydantic>=2,<3",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
"pyzipper>=0.3,<1", "pyzipper>=0.4,<1",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import fnmatch import fnmatch
import re import time
from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@@ -11,6 +12,12 @@ from pydantic import BaseModel, ConfigDict, Field
from govoplan_campaign.backend.campaign.entries import load_campaign_entries from govoplan_campaign.backend.campaign.entries import load_campaign_entries
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.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig, ZipArchiveConfig, ZipRuleMode from govoplan_campaign.backend.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig, ZipArchiveConfig, ZipRuleMode
from govoplan_campaign.backend.path_security import (
CampaignPathSecurityError,
assert_logical_relative_path,
is_managed_source,
)
from govoplan_campaign.backend.template_rendering import render_template
class AttachmentScope(StrEnum): class AttachmentScope(StrEnum):
@@ -69,6 +76,10 @@ class ResolvedAttachment(BaseModel):
zip_mode: ZipRuleMode = ZipRuleMode.INHERIT zip_mode: ZipRuleMode = ZipRuleMode.INHERIT
zip_archive_id: str | None = None zip_archive_id: str | None = None
zip_filename: str | None = None zip_filename: str | None = None
message_filename_template: str | None = None
zip_entry_name_template: str | None = None
message_filenames: list[str] = Field(default_factory=list)
zip_entry_names: list[str] = Field(default_factory=list)
status: AttachmentMatchStatus status: AttachmentMatchStatus
behavior: Behavior | None = None behavior: Behavior | None = None
matches: list[str] = Field(default_factory=list) matches: list[str] = Field(default_factory=list)
@@ -99,6 +110,7 @@ class AttachmentResolutionReport(BaseModel):
attachments_base_path: str attachments_base_path: str
entries_count: int entries_count: int
entries: list[EntryAttachmentResolution] = Field(default_factory=list) entries: list[EntryAttachmentResolution] = Field(default_factory=list)
profile: dict[str, Any] = Field(default_factory=dict)
@property @property
def ready_count(self) -> int: def ready_count(self) -> int:
@@ -133,43 +145,8 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
return (campaign_path.parent / path).resolve() return (campaign_path.parent / path).resolve()
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def _render_template(template: str, values: dict[str, Any]) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0)
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str: def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
rendered = _render_template(config.base_dir, values).strip() rendered = render_template(config.base_dir, values, keep_missing=True).strip()
return rendered or "." return rendered or "."
@@ -253,7 +230,7 @@ def _attachment_zip_archive(
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None: def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
if archive is None: if archive is None:
return None return None
rendered = _render_template(archive.name or "attachments.zip", values).strip() or "attachments.zip" rendered = render_template(archive.name or "attachments.zip", values, keep_missing=True).strip() or "attachments.zip"
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip" return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
@@ -304,7 +281,55 @@ def _resolve_attachment_directory(
return (legacy_root / rendered_base_dir).resolve(), None return (legacy_root / rendered_base_dir).resolve(), None
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]: @dataclass(slots=True)
class AttachmentMatchIndex:
_files_by_directory: dict[tuple[str, bool], list[Path]] = field(default_factory=dict)
filesystem_scans: int = 0
indexed_files: int = 0
fallback_globs: int = 0
def _directory_key(self, directory: Path, *, recursive: bool) -> tuple[str, bool]:
return str(directory.resolve()), recursive
def _indexed_files(self, directory: Path, *, recursive: bool) -> list[Path]:
key = self._directory_key(directory, recursive=recursive)
if key not in self._files_by_directory:
iterator = directory.rglob("*") if recursive else directory.iterdir()
files = sorted(path for path in iterator if path.is_file())
self._files_by_directory[key] = files
self.filesystem_scans += 1
self.indexed_files += len(files)
return self._files_by_directory[key]
def iter_files(self, directory: Path, *, recursive: bool = True) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
return list(self._indexed_files(directory, recursive=recursive))
def match_files(self, directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
if not include_subdirs and ("/" in file_filter or "\\" in file_filter):
# Preserve pathlib.glob semantics for legacy filters that include a
# relative path segment instead of only a filename pattern.
self.fallback_globs += 1
return sorted(path for path in directory.glob(file_filter) if path.is_file())
return sorted(path for path in self._indexed_files(directory, recursive=include_subdirs) if fnmatch.fnmatch(path.name, file_filter))
def stats(self, *, duration_ms: float, rules_resolved: int) -> dict[str, Any]:
return {
"duration_ms": round(duration_ms, 2),
"rules_resolved": rules_resolved,
"indexed_directories": len(self._files_by_directory),
"filesystem_scans": self.filesystem_scans,
"indexed_files": self.indexed_files,
"fallback_globs": self.fallback_globs,
}
def _match_files(directory: Path, file_filter: str, include_subdirs: bool, match_index: AttachmentMatchIndex | None = None) -> list[Path]:
if match_index is not None:
return match_index.match_files(directory, file_filter, include_subdirs)
if not directory.exists() or not directory.is_dir(): if not directory.exists() or not directory.is_dir():
return [] return []
if include_subdirs: if include_subdirs:
@@ -314,6 +339,23 @@ def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> li
return sorted(path for path in directory.glob(file_filter) if path.is_file()) return sorted(path for path in directory.glob(file_filter) if path.is_file())
def _confine_managed_matches(directory: Path, matches: list[Path]) -> tuple[list[Path], bool]:
root = directory.resolve()
confined: list[Path] = []
rejected = False
for match in matches:
try:
resolved = match.resolve()
except (OSError, RuntimeError):
rejected = True
continue
if not resolved.is_relative_to(root):
rejected = True
continue
confined.append(match)
return confined, rejected
def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue: def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue:
code = "missing_required_attachment" if config.required else "missing_optional_attachment" code = "missing_required_attachment" if config.required else "missing_optional_attachment"
severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
@@ -335,6 +377,27 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
) )
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
return config.attachments.send_without_attachments_behavior or (
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
)
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
messages = {
Behavior.BLOCK: "No attachment file was resolved for this message, and campaign policy blocks sending without attachments.",
Behavior.ASK: "No attachment file was resolved for this message. Confirm the message should still be sent.",
Behavior.DROP: "No attachment file was resolved for this message. Campaign policy excludes messages without attachments.",
Behavior.WARN: "No attachment file was resolved for this message. Campaign policy allows sending with a warning.",
}
return AttachmentIssue(
severity=ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING,
code="missing_attachment_coverage",
message=messages.get(behavior, "No attachment file was resolved for this message."),
behavior=behavior,
)
def _resolve_one_config( def _resolve_one_config(
*, *,
campaign_file: str | Path, campaign_file: str | Path,
@@ -343,22 +406,60 @@ def _resolve_one_config(
scope: AttachmentScope, scope: AttachmentScope,
index: int, index: int,
config: AttachmentConfig, config: AttachmentConfig,
match_index: AttachmentMatchIndex | None = None,
) -> ResolvedAttachment: ) -> ResolvedAttachment:
rendered_base_dir = _rendered_base_dir(config, values) rendered_base_dir = _rendered_base_dir(config, values)
rendered_file_filter = _render_template(config.file_filter, values) rendered_file_filter = render_template(config.file_filter, values, keep_missing=True)
directory, selected_base_path = _resolve_attachment_directory( directory, selected_base_path = _resolve_attachment_directory(
campaign_file=campaign_file, campaign_file=campaign_file,
campaign_config=campaign_config, campaign_config=campaign_config,
attachment_config=config, attachment_config=config,
rendered_base_dir=rendered_base_dir, rendered_base_dir=rendered_base_dir,
) )
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
allow_multiple = _rule_allows_multiple(config, rendered_file_filter) allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
issues: list[AttachmentIssue] = [] issues: list[AttachmentIssue] = []
behavior: Behavior | None = None behavior: Behavior | None = None
managed_source = selected_base_path is not None and is_managed_source(selected_base_path.source)
unsafe_managed_path = False
if managed_source:
try:
assert_logical_relative_path(
rendered_file_filter,
field="rendered managed attachment file_filter",
)
except CampaignPathSecurityError as exc:
matches = []
unsafe_managed_path = True
issues.append(
AttachmentIssue(
severity=ResolutionSeverity.ERROR,
code="unsafe_managed_attachment_path",
message=str(exc),
behavior=Behavior.BLOCK,
)
)
else:
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
matches, rejected = _confine_managed_matches(directory, matches)
if rejected:
matches = []
unsafe_managed_path = True
issues.append(
AttachmentIssue(
severity=ResolutionSeverity.ERROR,
code="managed_attachment_path_escape",
message="A managed attachment match resolved outside its materialized source directory.",
behavior=Behavior.BLOCK,
)
)
else:
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
if not matches: if unsafe_managed_path:
status = AttachmentMatchStatus.MISSING
behavior = Behavior.BLOCK
elif not matches:
status = AttachmentMatchStatus.MISSING status = AttachmentMatchStatus.MISSING
behavior = _missing_behavior(campaign_config, config) behavior = _missing_behavior(campaign_config, config)
issues.append(_issue_for_missing(config, behavior)) issues.append(_issue_for_missing(config, behavior))
@@ -389,6 +490,8 @@ def _resolve_one_config(
zip_mode=config.zip.mode, zip_mode=config.zip.mode,
zip_archive_id=archive.id if archive else None, zip_archive_id=archive.id if archive else None,
zip_filename=_render_zip_filename(archive, values), zip_filename=_render_zip_filename(archive, values),
message_filename_template=config.message_filename_template,
zip_entry_name_template=config.zip_entry_name_template,
status=status, status=status,
behavior=behavior, behavior=behavior,
matches=[str(path) for path in matches], matches=[str(path) for path in matches],
@@ -417,6 +520,7 @@ def resolve_entry_attachments(
campaign_file: str | Path, campaign_file: str | Path,
entry: EntryConfig, entry: EntryConfig,
entry_index: int, entry_index: int,
match_index: AttachmentMatchIndex | None = None,
) -> EntryAttachmentResolution: ) -> EntryAttachmentResolution:
values = build_template_values(config, entry) values = build_template_values(config, entry)
resolved: list[ResolvedAttachment] = [] resolved: list[ResolvedAttachment] = []
@@ -431,10 +535,20 @@ def resolve_entry_attachments(
scope=scope, scope=scope,
index=index, index=index,
config=attachment_config, config=attachment_config,
match_index=match_index,
) )
) )
issues = [issue for item in resolved for issue in item.issues] issues = [issue for item in resolved for issue in item.issues]
missing_coverage_behavior = _send_without_attachments_behavior(config)
if (
entry.active
and resolved
and missing_coverage_behavior != Behavior.CONTINUE
and sum(len(item.matches) for item in resolved) == 0
and not any(issue.behavior == Behavior.BLOCK for issue in issues)
):
issues.append(_issue_for_missing_attachment_coverage(missing_coverage_behavior))
return EntryAttachmentResolution( return EntryAttachmentResolution(
entry_index=entry_index, entry_index=entry_index,
entry_id=entry.id, entry_id=entry.id,
@@ -448,10 +562,13 @@ def resolve_entry_attachments(
def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport: def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport:
entries = load_campaign_entries(config, campaign_file=campaign_file) entries = load_campaign_entries(config, campaign_file=campaign_file)
base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else config.attachments.base_path) base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else config.attachments.base_path)
started = time.perf_counter()
match_index = AttachmentMatchIndex()
resolved_entries = [ resolved_entries = [
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index) resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index, match_index=match_index)
for index, entry in enumerate(entries, start=1) for index, entry in enumerate(entries, start=1)
] ]
rules_resolved = sum(len(entry.attachments) for entry in resolved_entries)
return AttachmentResolutionReport( return AttachmentResolutionReport(
campaign_id=config.campaign.id, campaign_id=config.campaign.id,
campaign_name=config.campaign.name, campaign_name=config.campaign.name,
@@ -459,4 +576,5 @@ def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str |
attachments_base_path=str(base_path), attachments_base_path=str(base_path),
entries_count=len(entries), entries_count=len(entries),
entries=resolved_entries, entries=resolved_entries,
profile=match_index.stats(duration_ms=(time.perf_counter() - started) * 1000, rules_resolved=rules_resolved),
) )

View File

@@ -7,6 +7,7 @@ from typing import Any
from jsonschema import Draft202012Validator, FormatChecker from jsonschema import Draft202012Validator, FormatChecker
from .mail_profile_boundary import assert_campaign_uses_mail_profile_reference
from .models import CampaignConfig from .models import CampaignConfig
@@ -47,11 +48,20 @@ def _default_schema_path() -> Path:
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json" return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json"
def _default_schema_ui_path() -> Path:
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.ui.json"
def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]: def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]:
path = Path(schema_path) if schema_path else _default_schema_path() path = Path(schema_path) if schema_path else _default_schema_path()
return load_campaign_json(path) return load_campaign_json(path)
def load_campaign_schema_ui(schema_path: str | Path | None = None) -> dict[str, Any]:
path = Path(schema_path) if schema_path else _default_schema_ui_path()
return load_campaign_json(path)
def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None: def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None:
schema = load_campaign_schema(schema_path) schema = load_campaign_schema(schema_path)
validator = Draft202012Validator(schema, format_checker=FormatChecker()) validator = Draft202012Validator(schema, format_checker=FormatChecker())
@@ -74,6 +84,7 @@ def load_campaign_config(
schema_path: str | Path | None = None, schema_path: str | Path | None = None,
) -> CampaignConfig: ) -> CampaignConfig:
data = load_campaign_json(path) data = load_campaign_json(path)
assert_campaign_uses_mail_profile_reference(data)
if validate_schema: if validate_schema:
validate_against_schema(data, schema_path=schema_path) validate_against_schema(data, schema_path=schema_path)
return CampaignConfig.model_validate(data) return CampaignConfig.model_validate(data)

View File

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

View File

@@ -6,8 +6,6 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
class StrictModel(BaseModel): class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True) model_config = ConfigDict(extra="forbid", populate_by_name=True)
@@ -24,7 +22,7 @@ class FieldType(StrEnum):
INTEGER = "integer" INTEGER = "integer"
DOUBLE = "double" DOUBLE = "double"
DATE = "date" DATE = "date"
PASSWORD = "password" PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary.
class RecipientType(StrEnum): class RecipientType(StrEnum):
@@ -108,12 +106,14 @@ class FieldDefinition(StrictModel):
can_override: bool = True can_override: bool = True
class MailProfileCapabilities(StrictModel):
smtp_available: bool = False
imap_available: bool = False
class ServerConfig(StrictModel): class ServerConfig(StrictModel):
mail_profile_id: str | None = None mail_profile_id: str | None = None
inherit_smtp_credentials: bool = True profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities)
inherit_imap_credentials: bool = True
smtp: SmtpConfig | None = None
imap: ImapConfig | None = None
class RecipientConfig(StrictModel): class RecipientConfig(StrictModel):
@@ -180,10 +180,17 @@ class TemplateSourceConfig(StrictModel):
return self return self
class TemplateBodyMode(StrEnum):
TEXT = "text"
HTML = "html"
BOTH = "both"
class TemplateConfig(StrictModel): class TemplateConfig(StrictModel):
subject: str | None = None subject: str | None = None
text: str | None = None text: str | None = None
html: str | None = None html: str | None = None
body_mode: TemplateBodyMode = TemplateBodyMode.BOTH
source: TemplateSourceConfig | None = None source: TemplateSourceConfig | None = None
@model_validator(mode="after") @model_validator(mode="after")
@@ -343,6 +350,8 @@ class AttachmentConfig(StrictModel):
include_subdirs: bool = False include_subdirs: bool = False
required: bool = True required: bool = True
allow_multiple: bool = False allow_multiple: bool = False
message_filename_template: str | None = None
zip_entry_name_template: str | None = None
@field_validator("type_", mode="before") @field_validator("type_", mode="before")
@classmethod @classmethod
@@ -363,11 +372,20 @@ class AttachmentsConfig(StrictModel):
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list) base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
allow_individual: bool = False allow_individual: bool = False
send_without_attachments: bool = True send_without_attachments: bool = True
send_without_attachments_behavior: Behavior | None = None
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig) zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global") global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
missing_behavior: Behavior = Behavior.ASK missing_behavior: Behavior = Behavior.ASK
ambiguous_behavior: Behavior = Behavior.ASK ambiguous_behavior: Behavior = Behavior.ASK
@model_validator(mode="after")
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
if self.send_without_attachments_behavior is None:
self.send_without_attachments_behavior = Behavior.CONTINUE if self.send_without_attachments else Behavior.BLOCK
else:
self.send_without_attachments = self.send_without_attachments_behavior != Behavior.BLOCK
return self
@property @property
def individual_base_path_values(self) -> set[str]: def individual_base_path_values(self) -> set[str]:
return {base_path.path for base_path in self.base_paths if base_path.allow_individual} return {base_path.path for base_path in self.base_paths if base_path.allow_individual}
@@ -434,6 +452,39 @@ class EntryConfig(StrictModel):
last_sent: str | None = None last_sent: str | None = None
class ImportColumnProvenance(StrictModel):
column_index: int = Field(ge=0)
header: str
kind: str
field_name: str | None = None
new_field_name: str | None = None
class ImportProvenance(StrictModel):
id: str
imported_at: str
mode: Literal["append", "replace"]
source_type: Literal["csv", "xlsx", "text", "addresses"]
source_id: str | None = None
source_label: str | None = None
source_revision: str | None = None
source_provenance: dict[str, Any] = Field(default_factory=dict)
filename: str | None = None
sheet_name: str | None = None
encoding: str | None = None
delimiter: str | None = None
header_rows: int = Field(default=0, ge=0)
quoted: bool | None = None
value_separators: str | None = None
rows_total: int = Field(default=0, ge=0)
valid_rows: int = Field(default=0, ge=0)
invalid_rows: int = Field(default=0, ge=0)
imported_rows: int = Field(default=0, ge=0)
field_names_created: list[str] = Field(default_factory=list)
attachment_patterns: int = Field(default=0, ge=0)
mapping: list[ImportColumnProvenance] = Field(default_factory=list)
class SourceConfig(StrictModel): class SourceConfig(StrictModel):
type: SourceType type: SourceType
path: str path: str
@@ -447,6 +498,7 @@ class EntriesConfig(StrictModel):
source: SourceConfig | None = None source: SourceConfig | None = None
mapping: dict[str, str] | None = None mapping: dict[str, str] | None = None
defaults: EntryConfig | None = None defaults: EntryConfig | None = None
imports: list[ImportProvenance] = Field(default_factory=list)
@model_validator(mode="after") @model_validator(mode="after")
def inline_or_external(self) -> "EntriesConfig": def inline_or_external(self) -> "EntriesConfig":

View File

@@ -1,14 +1,17 @@
from __future__ import annotations from __future__ import annotations
import csv import csv
from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
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, ZipPasswordMode, ZipPasswordScope, ZipRuleMode from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
from ..attachments.resolver import resolve_campaign_attachments
class Severity(StrEnum): class Severity(StrEnum):
@@ -51,6 +54,13 @@ class SemanticReport(BaseModel):
return self.error_count == 0 return self.error_count == 0
@dataclass(frozen=True, slots=True)
class _EntriesValidation:
mode: str
count: int | None
issues: list[SemanticIssue]
def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue: def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue:
return SemanticIssue(severity=severity, code=code, message=message, path=path) return SemanticIssue(severity=severity, code=code, message=message, path=path)
@@ -200,57 +210,95 @@ def _attachment_path_issues(config: CampaignConfig) -> list[SemanticIssue]:
def _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]: def _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]:
collection = config.attachments.zip collection = config.attachments.zip
issues: list[SemanticIssue] = []
if not collection.enabled: if not collection.enabled:
return issues return []
if not collection.archives: if not collection.archives:
return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")] return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")]
issues, archive_ids, standard_count = _zip_archive_catalog_issues(config)
if standard_count != 1:
issues.append(_issue(Severity.ERROR, "zip_standard_archive_invalid", "Exactly one ZIP archive must be selected as the campaign standard", "/attachments/zip/archives"))
issues.extend(_zip_rule_selection_issues(config, archive_ids))
return issues
def _zip_archive_catalog_issues(config: CampaignConfig) -> tuple[list[SemanticIssue], set[str], int]:
issues: list[SemanticIssue] = []
archive_ids: set[str] = set() archive_ids: set[str] = set()
archive_names: set[str] = set() archive_names: set[str] = set()
standard_count = 0 standard_count = 0
field_definitions = {field.name: field for field in config.fields} field_definitions = {field.name: field for field in config.fields}
for index, archive in enumerate(collection.archives): for index, archive in enumerate(config.attachments.zip.archives):
path = f"/attachments/zip/archives/{index}" path = f"/attachments/zip/archives/{index}"
issues.extend(_zip_archive_identity_issues(archive, path, archive_ids, archive_names))
if archive.standard:
standard_count += 1
issues.extend(_zip_password_issues(config, archive, path, field_definitions))
return issues, archive_ids, standard_count
def _zip_archive_identity_issues(
archive: ZipArchiveConfig,
path: str,
archive_ids: set[str],
archive_names: set[str],
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
if not archive.id.strip(): if not archive.id.strip():
issues.append(_issue(Severity.ERROR, "zip_archive_id_missing", "ZIP archive has no identifier", f"{path}/id")) issues.append(_issue(Severity.ERROR, "zip_archive_id_missing", "ZIP archive has no identifier", f"{path}/id"))
elif archive.id in archive_ids: elif archive.id in archive_ids:
issues.append(_issue(Severity.ERROR, "zip_archive_id_duplicate", f"ZIP archive id {archive.id!r} is used more than once", f"{path}/id")) issues.append(_issue(Severity.ERROR, "zip_archive_id_duplicate", f"ZIP archive id {archive.id!r} is used more than once", f"{path}/id"))
archive_ids.add(archive.id) archive_ids.add(archive.id)
normalized_archive_name = _normalized_zip_archive_name(archive.name) normalized_archive_name = _normalized_zip_archive_name(archive.name)
if not normalized_archive_name: if not normalized_archive_name:
issues.append(_issue(Severity.ERROR, "zip_archive_name_missing", "ZIP archive has no filename", f"{path}/name")) issues.append(_issue(Severity.ERROR, "zip_archive_name_missing", "ZIP archive has no filename", f"{path}/name"))
elif normalized_archive_name in archive_names: elif normalized_archive_name in archive_names:
issues.append(_issue(Severity.ERROR, "zip_archive_name_duplicate", f"ZIP archive filename {archive.name!r} is used more than once; archive filenames must be unique", f"{path}/name")) issues.append(_issue(Severity.ERROR, "zip_archive_name_duplicate", f"ZIP archive filename {archive.name!r} is used more than once; archive filenames must be unique", f"{path}/name"))
archive_names.add(normalized_archive_name) archive_names.add(normalized_archive_name)
if archive.standard: return issues
standard_count += 1
def _zip_password_issues(
config: CampaignConfig,
archive: ZipArchiveConfig,
path: str,
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
if not archive.password_enabled: if not archive.password_enabled:
continue return []
if archive.password_mode == ZipPasswordMode.DIRECT: if archive.password_mode == ZipPasswordMode.DIRECT:
if not (archive.password or ""): if not (archive.password or ""):
issues.append(_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password")) return [_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password")]
continue return []
if archive.password_mode == ZipPasswordMode.TEMPLATE: if archive.password_mode == ZipPasswordMode.TEMPLATE:
if not (archive.password_template or ""): if not (archive.password_template or ""):
issues.append(_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template")) return [_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template")]
continue return []
return _zip_password_field_issues(config, archive, path, field_definitions)
def _zip_password_field_issues(
config: CampaignConfig,
archive: ZipArchiveConfig,
path: str,
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
field_name = (archive.password_field or "").strip() field_name = (archive.password_field or "").strip()
field = field_definitions.get(field_name) field = field_definitions.get(field_name)
if not field_name: if not field_name:
issues.append(_issue(Severity.ERROR, "zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no field is selected", f"{path}/password_field")) return [_issue(Severity.ERROR, "zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no field is selected", f"{path}/password_field")]
elif field is None: if field is None:
issues.append(_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field")) return [_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field")]
elif field.type != FieldType.PASSWORD: if getattr(field, "type", None) != FieldType.PASSWORD:
issues.append(_issue(Severity.WARNING, "zip_password_field_not_password_type", f"ZIP password field {field_name!r} is not configured with field type 'password'", f"{path}/password_field")) return [_issue(Severity.WARNING, "zip_password_field_not_password_type", f"ZIP password field {field_name!r} is not configured with field type 'password'", f"{path}/password_field")]
elif archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""): if archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""):
issues.append(_issue(Severity.ERROR, "zip_global_password_value_missing", f"Global ZIP password field {field_name!r} has no campaign-wide value", f"/global_values/{field_name}")) return [_issue(Severity.ERROR, "zip_global_password_value_missing", f"Global ZIP password field {field_name!r} has no campaign-wide value", f"/global_values/{field_name}")]
return []
if standard_count != 1:
issues.append(_issue(Severity.ERROR, "zip_standard_archive_invalid", "Exactly one ZIP archive must be selected as the campaign standard", "/attachments/zip/archives"))
def _zip_rule_selection_issues(config: CampaignConfig, archive_ids: set[str]) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
for path, rule, _is_individual in _iter_attachment_rules(config): for path, rule, _is_individual in _iter_attachment_rules(config):
selection = (rule.zip.archive_id or ZipRuleMode.INHERIT.value).strip() selection = (rule.zip.archive_id or ZipRuleMode.INHERIT.value).strip()
if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value, ZipRuleMode.EXCLUDE.value} and selection not in archive_ids: if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value, ZipRuleMode.EXCLUDE.value} and selection not in archive_ids:
@@ -276,6 +324,288 @@ def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_pr
] ]
def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> list[SemanticIssue]:
return [
_issue(
Severity.WARNING,
"unknown_global_value",
f"global_values contains {key!r}, but it is not declared in fields",
f"/global_values/{key}",
)
for key in config.global_values
if declared_names and key not in declared_names
]
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
profile_id = (config.server.mail_profile_id or "").strip()
if (config.campaign.mode == "send" or config.delivery.imap_append_sent.enabled) and not profile_id:
issues.append(
_issue(
Severity.ERROR,
"missing_mail_profile",
"Select an authorized Mail-module profile; campaigns cannot store SMTP/IMAP settings or credentials.",
"/server/mail_profile_id",
)
)
capabilities = config.server.profile_capabilities
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available:
issues.append(
_issue(
Severity.ERROR,
"mail_profile_without_smtp",
"campaign mode is 'send', but the selected Mail profile has no SMTP configuration",
"/server/mail_profile_id",
)
)
if config.delivery.imap_append_sent.enabled and profile_id and not capabilities.imap_available:
issues.append(
_issue(
Severity.ERROR,
"mail_profile_without_imap",
"IMAP append is enabled, but the selected Mail profile has no IMAP configuration",
"/server/mail_profile_id",
)
)
return issues
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
"""Require Campaign-owned sender data before a send-mode build."""
if config.campaign.mode != "send":
return []
if config.entries.is_inline:
return [
_issue(
Severity.ERROR,
"missing_sender",
"No effective From address is configured; Campaign must resolve the sender before building.",
f"/entries/inline/{index}/from",
)
for index, entry in enumerate(config.entries.inline or [])
if entry.active and not effective_address_lists(config, entry)["from"]
]
if config.recipients.from_:
return []
defaults = config.entries.defaults
mapping_can_supply_sender = bool(
config.recipients.allow_individual_from
and (
(defaults is not None and defaults.from_)
or "from.email" in (config.entries.mapping or {})
)
)
if mapping_can_supply_sender:
return []
return [
_issue(
Severity.ERROR,
"missing_sender",
"Configure recipients.from, or allow and map an individual From address, before building a send-mode campaign.",
"/recipients/from",
)
]
def _entries_validation(
config: CampaignConfig,
*,
campaign_path: Path,
check_files: bool,
field_names: set[str],
field_definitions: dict[str, object],
) -> _EntriesValidation:
if config.entries.is_inline:
inline_entries = config.entries.inline or []
issues = _inline_entries_issues(config, inline_entries)
return _EntriesValidation(mode="inline", count=len(inline_entries), issues=issues)
issues = _external_entries_issues(
config,
campaign_path=campaign_path,
check_files=check_files,
field_names=field_names,
field_definitions=field_definitions,
)
source_type = config.entries.source.type.value if config.entries.source else "unknown"
return _EntriesValidation(mode=f"external:{source_type}", count=None, issues=issues)
def _inline_entries_issues(config: CampaignConfig, inline_entries: list[EntryConfig]) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
if not inline_entries:
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
for index, entry in enumerate(inline_entries):
if entry.active:
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
return issues
def _external_entries_issues(
config: CampaignConfig,
*,
campaign_path: Path,
check_files: bool,
field_names: set[str],
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
mapping = config.entries.mapping or {}
if not mapping:
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
issues.extend(_mapping_issues(mapping, field_names, field_definitions))
if config.entries.defaults:
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
if check_files and config.entries.source:
issues.extend(_entries_source_file_issues(config, campaign_path, mapping))
return issues
def _mapping_issues(
mapping: dict[str, str],
field_names: set[str],
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
for target in mapping:
if not _mapping_target_known(target, field_names):
issues.append(
_issue(
Severity.WARNING,
"unknown_mapping_target",
f"mapping target {target!r} is not recognized by the current campaign model",
f"/entries/mapping/{target}",
)
)
field_name = _mapping_target_field_name(target)
field = field_definitions.get(field_name or "")
if field_name and field is not None and not getattr(field, "can_override", True):
issues.append(
_issue(
Severity.WARNING,
"mapping_target_not_overridable",
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
f"/entries/mapping/{target}",
)
)
return issues
def _entries_source_file_issues(config: CampaignConfig, campaign_path: Path, mapping: dict[str, str]) -> list[SemanticIssue]:
source = config.entries.source
if source is None:
return []
source_path = _resolve(campaign_path, source.path)
if not source_path.exists():
return [
_issue(
Severity.ERROR,
"entries_source_not_found",
f"entries source file does not exist: {source_path}",
"/entries/source/path",
)
]
if source.type != SourceType.CSV or not source.has_header:
return []
try:
header = _csv_header(source_path, source.delimiter, source.encoding)
except OSError as exc:
return [_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path")]
header_set = set(header or [])
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
if not missing_columns:
return []
return [
_issue(
Severity.ERROR,
"mapping_columns_missing",
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
"/entries/mapping",
)
]
def _file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
issues.extend(_attachment_file_check_issues(config, campaign_path))
issues.extend(_attachment_resolution_check_issues(config, campaign_path))
issues.extend(_template_source_file_issues(config, campaign_path))
return issues
def _attachment_file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
if config.attachments.base_paths:
return [
_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachment base path {base_path_config.name!r} does not exist: {_resolve(campaign_path, base_path_config.path)}",
f"/attachments/base_paths/{index}/path",
)
for index, base_path_config in enumerate(config.attachments.base_paths)
if not _resolve(campaign_path, base_path_config.path).exists()
]
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
if attachments_base_path.exists():
return []
return [
_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachments.base_path does not exist: {attachments_base_path}",
"/attachments/base_path",
)
]
def _attachment_resolution_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
try:
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
except Exception as exc:
return [
_issue(
Severity.ERROR,
"attachment_resolution_failed",
f"attachment rules could not be resolved: {exc}",
"/attachments",
)
]
issues: list[SemanticIssue] = []
for entry in report.entries:
entry_path = f"/entries/{entry.entry_id or entry.entry_index}"
for issue in entry.issues:
if any(issue is attachment_issue for attachment in entry.attachments for attachment_issue in attachment.issues):
continue
issues.append(_issue(Severity(issue.severity.value), issue.code, issue.message, entry_path))
for attachment in entry.attachments:
attachment_path = (
f"/attachments/global/{attachment.index}"
if attachment.scope.value == "global"
else f"{entry_path}/attachments/{attachment.index}"
)
for issue in attachment.issues:
issues.append(_issue(Severity(issue.severity.value), issue.code, issue.message, attachment_path))
return issues
def _template_source_file_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
for schema_path, raw_path in _iter_template_source_paths(config):
path = _resolve(campaign_path, raw_path)
if not path.exists():
issues.append(
_issue(
Severity.ERROR,
"template_source_not_found",
f"template source file does not exist: {path}",
schema_path,
)
)
return issues
def validate_campaign_config( def validate_campaign_config(
config: CampaignConfig, config: CampaignConfig,
*, *,
@@ -289,147 +619,30 @@ def validate_campaign_config(
field_definitions = {field.name: field for field in config.fields} field_definitions = {field.name: field for field in config.fields}
declared_names = set(field_definitions) declared_names = set(field_definitions)
for key in config.global_values: issues.extend(_global_value_issues(config, declared_names))
if declared_names and key not in declared_names:
issues.append(_issue(
Severity.WARNING,
"unknown_global_value",
f"global_values contains {key!r}, but it is not declared in fields",
f"/global_values/{key}",
))
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(_sender_issues(config))
if config.server.imap and config.server.imap.enabled: entries = _entries_validation(
missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")] config,
if missing: campaign_path=campaign_path,
issues.append(_issue( check_files=check_files,
Severity.ERROR, field_names=field_names,
"incomplete_imap_config", field_definitions=field_definitions,
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing), )
"/server/imap", issues.extend(entries.issues)
))
if config.delivery.imap_append_sent.enabled and not (config.server.imap and config.server.imap.enabled):
issues.append(_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but server.imap.enabled is not true",
"/delivery/imap_append_sent/enabled",
))
if config.campaign.mode == "send" and not config.server.smtp:
issues.append(_issue(
Severity.ERROR,
"missing_smtp_config",
"campaign mode is 'send', but no server.smtp configuration is present",
"/server/smtp",
))
if config.server.smtp:
missing = [name for name in ["host", "port"] if getattr(config.server.smtp, name) in (None, "")]
if missing:
issues.append(_issue(
Severity.WARNING,
"incomplete_smtp_config",
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
"/server/smtp",
))
if config.entries.is_inline:
inline_entries = config.entries.inline or []
entries_count = len(inline_entries)
entries_mode = "inline"
if entries_count == 0:
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
for index, entry in enumerate(inline_entries):
if entry.active:
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
else:
entries_count = None
entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}"
mapping = config.entries.mapping or {}
if not mapping:
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
for target in mapping:
if not _mapping_target_known(target, field_names):
issues.append(_issue(
Severity.WARNING,
"unknown_mapping_target",
f"mapping target {target!r} is not recognized by the current campaign model",
f"/entries/mapping/{target}",
))
field_name = _mapping_target_field_name(target)
if field_name and field_name in field_definitions and not field_definitions[field_name].can_override:
issues.append(_issue(
Severity.WARNING,
"mapping_target_not_overridable",
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
f"/entries/mapping/{target}",
))
if config.entries.defaults:
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
if check_files and config.entries.source:
source_path = _resolve(campaign_path, config.entries.source.path)
if not source_path.exists():
issues.append(_issue(
Severity.ERROR,
"entries_source_not_found",
f"entries source file does not exist: {source_path}",
"/entries/source/path",
))
elif config.entries.source.type == SourceType.CSV and config.entries.source.has_header:
try:
header = _csv_header(source_path, config.entries.source.delimiter, config.entries.source.encoding)
header_set = set(header or [])
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
if missing_columns:
issues.append(_issue(
Severity.ERROR,
"mapping_columns_missing",
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
"/entries/mapping",
))
except OSError as exc:
issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path"))
if check_files: if check_files:
if config.attachments.base_paths: issues.extend(_file_check_issues(config, campaign_path))
for index, base_path_config in enumerate(config.attachments.base_paths):
attachments_base_path = _resolve(campaign_path, base_path_config.path)
if not attachments_base_path.exists():
issues.append(_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachment base path {base_path_config.name!r} does not exist: {attachments_base_path}",
f"/attachments/base_paths/{index}/path",
))
else:
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
if not attachments_base_path.exists():
issues.append(_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachments.base_path does not exist: {attachments_base_path}",
"/attachments/base_path",
))
for schema_path, raw_path in _iter_template_source_paths(config):
path = _resolve(campaign_path, raw_path)
if not path.exists():
issues.append(_issue(
Severity.ERROR,
"template_source_not_found",
f"template source file does not exist: {path}",
schema_path,
))
report = SemanticReport( report = SemanticReport(
campaign_id=config.campaign.id, campaign_id=config.campaign.id,
campaign_name=config.campaign.name, campaign_name=config.campaign.name,
issues=issues, issues=issues,
entries_mode=entries_mode, entries_mode=entries.mode,
entries_count=entries_count, entries_count=entries.count,
attachments_base_path=_attachment_base_path_report_value(config), attachments_base_path=_attachment_base_path_report_value(config),
rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}", rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}",
imap_append_enabled=config.delivery.imap_append_sent.enabled, imap_append_enabled=config.delivery.imap_append_sent.enabled,

View File

@@ -0,0 +1,293 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from sqlalchemy import and_, or_
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
from govoplan_core.core.campaigns import (
CampaignAccessProvider,
CampaignDeliveryTaskProvider,
CampaignMailPolicyContext,
CampaignMailPolicyContextProvider,
CampaignPolicyContext,
CampaignPolicyContextProvider,
CampaignRetentionProvider,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"}
class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider):
def get_campaign_mail_policy_context(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
) -> CampaignMailPolicyContext | None:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
return None
return CampaignMailPolicyContext(
id=campaign.id,
tenant_id=campaign.tenant_id,
owner_user_id=campaign.owner_user_id,
owner_group_id=campaign.owner_group_id,
mail_profile_policy=dict(campaign.mail_profile_policy or {}),
)
def set_campaign_mail_profile_policy(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
policy: Mapping[str, object],
) -> Mapping[str, object]:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
raise ValueError("Campaign policy not found")
payload = dict(policy)
campaign.mail_profile_policy = payload
session.add(campaign) # type: ignore[attr-defined]
return payload
def mail_policy_context_capability(context: object) -> CampaignMailPolicyContextService:
return CampaignMailPolicyContextService()
class CampaignAccessService(CampaignAccessProvider):
def campaign_exists(self, session: object, *, tenant_id: str, campaign_id: str) -> bool:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
return bool(campaign and campaign.tenant_id == tenant_id)
def can_read_campaign(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
user_id: str,
group_ids: Iterable[str] = (),
tenant_admin: bool = False,
) -> bool:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
return False
if tenant_admin or campaign.owner_user_id == user_id:
return True
group_id_set = {str(group_id) for group_id in group_ids}
if campaign.owner_group_id and campaign.owner_group_id in group_id_set:
return True
share_clauses = [
and_(
CampaignShare.target_type == "user",
CampaignShare.target_id == user_id,
)
]
if group_id_set:
share_clauses.append(
and_(
CampaignShare.target_type == "group",
CampaignShare.target_id.in_(group_id_set),
)
)
share = (
session.query(CampaignShare) # type: ignore[attr-defined]
.filter(
CampaignShare.tenant_id == tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
or_(*share_clauses),
)
.first()
)
return share is not None
def explain_resource_provenance(
self,
session: object,
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
action: str,
) -> tuple[AccessDecisionProvenance, ...]:
normalized_type = resource_type.lower().strip()
if normalized_type not in {"campaign", "campaign_object", "campaigns:campaign"}:
return ()
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
if campaign is None or (principal.tenant_id and campaign.tenant_id != principal.tenant_id):
return (
AccessDecisionProvenance(
kind="resource",
id=resource_id,
tenant_id=principal.tenant_id,
source="campaigns.not_found",
details={"resource_type": "campaign", "found": False},
),
)
items = [
AccessDecisionProvenance(
kind="resource",
id=campaign.id,
label=campaign.name,
tenant_id=campaign.tenant_id,
source="campaigns.campaign",
details={
"resource_type": "campaign",
"external_id": campaign.external_id,
"status": campaign.status,
},
)
]
items.extend(_owner_provenance(campaign, principal))
items.extend(_tenant_admin_provenance(principal))
permission_values = {"read", "write"} if action in READ_ACTIONS else {"write"}
shares = (
session.query(CampaignShare) # type: ignore[attr-defined]
.filter(
CampaignShare.tenant_id == campaign.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
CampaignShare.permission.in_(sorted(permission_values)),
or_(
(CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.membership_id),
(CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(sorted(principal.group_ids))),
),
)
.order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc())
.all()
)
for share in shares:
items.append(
AccessDecisionProvenance(
kind="share",
id=share.id,
label=share.permission,
tenant_id=share.tenant_id,
source="campaigns.share",
details={
"target_type": share.target_type,
"target_id": share.target_id,
"permission": share.permission,
},
)
)
return tuple(items)
def access_capability(context: object) -> CampaignAccessService:
return CampaignAccessService()
def _owner_provenance(campaign: Campaign, principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]:
if campaign.owner_user_id and campaign.owner_user_id == principal.membership_id:
return (
AccessDecisionProvenance(
kind="owner",
id=campaign.owner_user_id,
tenant_id=campaign.tenant_id,
source="campaigns.owner",
details={"owner_type": "user"},
),
)
if campaign.owner_group_id and campaign.owner_group_id in principal.group_ids:
return (
AccessDecisionProvenance(
kind="owner",
id=campaign.owner_group_id,
tenant_id=campaign.tenant_id,
source="campaigns.owner",
details={"owner_type": "group"},
),
)
return ()
def _tenant_admin_provenance(principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]:
if not scopes_grant_compatible(principal.scopes, "tenant:*"):
return ()
return (
AccessDecisionProvenance(
kind="policy",
id="tenant:*",
label="tenant:*",
tenant_id=principal.tenant_id,
source="campaigns.tenant_admin",
details={"grant": "tenant_admin"},
),
)
class CampaignPolicyContextService(CampaignPolicyContextProvider):
def get_campaign_policy_context(
self,
session: object,
*,
tenant_id: str | None = None,
campaign_id: str,
) -> CampaignPolicyContext | None:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or (tenant_id is not None and campaign.tenant_id != tenant_id):
return None
return CampaignPolicyContext(
id=campaign.id,
tenant_id=campaign.tenant_id,
owner_user_id=campaign.owner_user_id,
owner_group_id=campaign.owner_group_id,
settings=dict(campaign.settings or {}),
)
def set_campaign_settings(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object]:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
raise ValueError("Campaign privacy policy not found")
payload = dict(settings)
campaign.settings = payload
session.add(campaign) # type: ignore[attr-defined]
return payload
def policy_context_capability(context: object) -> CampaignPolicyContextService:
return CampaignPolicyContextService()
class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider):
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
from govoplan_campaign.backend.sending.jobs import send_campaign_job
return send_campaign_job(session, job_id=job_id, enqueue_imap_task=enqueue_imap_task).as_dict() # type: ignore[arg-type]
def append_sent_for_job(self, session: object, *, job_id: str) -> Mapping[str, object]:
from govoplan_campaign.backend.sending.jobs import append_sent_for_job
return append_sent_for_job(session, job_id=job_id).as_dict() # type: ignore[arg-type]
def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService:
return CampaignDeliveryTaskService()
class CampaignRetentionService(CampaignRetentionProvider):
def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id):
from govoplan_campaign.backend.retention import apply_campaign_retention
return apply_campaign_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id) # type: ignore[arg-type]
def retention_capability(context: object) -> CampaignRetentionService:
return CampaignRetentionService()

View File

@@ -0,0 +1,330 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import event
from sqlalchemy.orm import Session as OrmSession
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.sqlalchemy_change_tracking import (
ensure_object_id,
has_attr_changes,
object_state,
operation_for_object,
previous_value,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignShare,
CampaignVersion,
ImapAppendAttempt,
SendAttempt,
new_uuid,
)
CAMPAIGNS_MODULE_ID = "campaigns"
CAMPAIGNS_COLLECTION = "campaigns.campaigns"
CAMPAIGN_VERSIONS_COLLECTION = "campaigns.versions"
CAMPAIGN_JOBS_COLLECTION = "campaigns.jobs"
CAMPAIGN_ISSUES_COLLECTION = "campaigns.issues"
CAMPAIGN_ATTEMPTS_COLLECTION = "campaigns.attempts"
_REGISTERED = False
def register_campaign_change_tracking() -> None:
global _REGISTERED
if _REGISTERED:
return
event.listen(OrmSession, "before_flush", _record_campaign_changes)
_REGISTERED = True
def _record_campaign_changes(session: OrmSession, _flush_context: object, _instances: object) -> None:
for obj in tuple(session.new) + tuple(session.dirty) + tuple(session.deleted):
if isinstance(obj, Campaign):
_record_campaign_change(session, obj)
elif isinstance(obj, CampaignShare):
_record_share_visibility_change(session, obj)
elif isinstance(obj, CampaignVersion):
_record_version_change(session, obj)
elif isinstance(obj, CampaignJob):
_record_job_change(session, obj)
elif isinstance(obj, CampaignIssue):
_record_issue_change(session, obj)
elif isinstance(obj, (SendAttempt, ImapAppendAttempt)):
_record_attempt_change(session, obj)
def _record_campaign_change(session: OrmSession, campaign: Campaign) -> None:
operation = _operation_for_campaign(
campaign,
changed_attrs=(
"external_id",
"name",
"description",
"status",
"current_version_id",
"owner_user_id",
"owner_group_id",
"settings",
"mail_profile_policy",
),
)
if operation is None:
return
campaign_id = _ensure_id(campaign)
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGNS_COLLECTION,
resource_type="campaign",
resource_id=campaign_id,
operation=operation,
tenant_id=campaign.tenant_id,
actor_type="user" if campaign.created_by_user_id else None,
actor_id=campaign.created_by_user_id,
payload=_campaign_payload(campaign),
)
def _record_share_visibility_change(session: OrmSession, share: CampaignShare) -> None:
state = object_state(share)
if not share.campaign_id:
return
if not state.deleted and not state.pending and not has_attr_changes(
state,
("campaign_id", "target_type", "target_id", "permission", "revoked_at"),
):
return
_ensure_id(share)
operation = "deleted" if state.deleted or share.revoked_at is not None else "updated"
campaign = session.get(Campaign, share.campaign_id)
payload = _campaign_payload(campaign) if campaign is not None else {"campaign_id": share.campaign_id}
payload.update({
"share_id": share.id,
"share_target_type": share.target_type,
"share_target_id": share.target_id,
"share_permission": share.permission,
"share_revoked_at": _isoformat(share.revoked_at),
})
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGNS_COLLECTION,
resource_type="campaign",
resource_id=share.campaign_id,
operation=operation,
tenant_id=share.tenant_id,
actor_type="user" if share.created_by_user_id else None,
actor_id=share.created_by_user_id,
payload=payload,
)
def _record_version_change(session: OrmSession, version: CampaignVersion) -> None:
operation = _operation_for_object(
version,
changed_attrs=(
"raw_json",
"schema_version",
"source_filename",
"source_base_path",
"workflow_state",
"current_flow",
"current_step",
"is_complete",
"editor_state",
"autosaved_at",
"published_at",
"locked_at",
"locked_by_user_id",
"user_lock_state",
"user_locked_at",
"user_locked_by_user_id",
"validation_summary",
"build_summary",
"execution_snapshot_hash",
"execution_snapshot_at",
),
)
if operation is None:
return
version_id = _ensure_id(version)
campaign = session.get(Campaign, version.campaign_id) if version.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_VERSIONS_COLLECTION,
resource_type="campaign_version",
resource_id=version_id,
operation=operation,
tenant_id=campaign.tenant_id if campaign is not None else None,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": version.campaign_id}),
"version_id": version_id,
"version_number": version.version_number,
"workflow_state": version.workflow_state,
"current_flow": version.current_flow,
"current_step": version.current_step,
},
)
def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
operation = _operation_for_object(
job,
changed_attrs=(
"build_status",
"validation_status",
"queue_status",
"send_status",
"imap_status",
"attempt_count",
"last_error",
"queued_at",
"claimed_at",
"smtp_started_at",
"outcome_unknown_at",
"sent_at",
"resolved_recipients",
"resolved_attachments",
"issues_snapshot",
),
)
if operation is None:
return
job_id = _ensure_id(job)
campaign = session.get(Campaign, job.campaign_id) if job.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_JOBS_COLLECTION,
resource_type="campaign_job",
resource_id=job_id,
operation=operation,
tenant_id=job.tenant_id,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
"job_id": job_id,
"version_id": job.campaign_version_id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"build_status": job.build_status,
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"imap_status": job.imap_status,
},
)
def _record_issue_change(session: OrmSession, issue: CampaignIssue) -> None:
operation = _operation_for_object(issue, changed_attrs=("severity", "code", "message", "source", "behavior"))
if operation is None:
return
issue_id = _ensure_id(issue)
campaign = session.get(Campaign, issue.campaign_id) if issue.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_ISSUES_COLLECTION,
resource_type="campaign_issue",
resource_id=issue_id,
operation=operation,
tenant_id=issue.tenant_id,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": issue.campaign_id}),
"issue_id": issue_id,
"version_id": issue.campaign_version_id,
"job_id": issue.job_id,
"severity": issue.severity,
"code": issue.code,
},
)
def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppendAttempt) -> None:
operation = _operation_for_object(
attempt,
changed_attrs=("status", "claim_token", "smtp_status_code", "smtp_response", "error_type", "error_message", "folder"),
)
if operation is None:
return
attempt_id = _ensure_id(attempt)
job = session.get(CampaignJob, attempt.job_id) if attempt.job_id else None
if job is None:
return
campaign = session.get(Campaign, job.campaign_id) if job.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_ATTEMPTS_COLLECTION,
resource_type="campaign_attempt",
resource_id=attempt_id,
operation=operation,
tenant_id=job.tenant_id,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
"attempt_id": attempt_id,
"attempt_kind": "imap" if isinstance(attempt, ImapAppendAttempt) else "smtp",
"job_id": job.id,
"version_id": job.campaign_version_id,
},
)
def _operation_for_campaign(obj: Campaign, *, changed_attrs: tuple[str, ...]) -> str | None:
state = object_state(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not has_attr_changes(state, changed_attrs):
return None
status_history = state.attrs.status.history
if status_history.has_changes() and any(value == "deleted" for value in status_history.added):
return "deleted"
return "updated"
def _operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> str | None:
return operation_for_object(obj, changed_attrs=changed_attrs)
def _ensure_id(obj: object) -> str:
return ensure_object_id(obj, new_uuid)
def _campaign_payload(campaign: Campaign | None) -> dict[str, Any]:
if campaign is None:
return {}
return {
"campaign_id": campaign.id,
"owner_user_id": campaign.owner_user_id,
"owner_group_id": campaign.owner_group_id,
"previous_owner_user_id": previous_value(campaign, "owner_user_id"),
"previous_owner_group_id": previous_value(campaign, "owner_group_id"),
"status": campaign.status,
"previous_status": previous_value(campaign, "status"),
"current_version_id": campaign.current_version_id,
"previous_current_version_id": previous_value(campaign, "current_version_id"),
}
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value else None
__all__ = [
"CAMPAIGNS_COLLECTION",
"CAMPAIGNS_MODULE_ID",
"CAMPAIGN_ATTEMPTS_COLLECTION",
"CAMPAIGN_ISSUES_COLLECTION",
"CAMPAIGN_JOBS_COLLECTION",
"CAMPAIGN_VERSIONS_COLLECTION",
"register_campaign_change_tracking",
]

View File

@@ -10,11 +10,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin from govoplan_core.db.base import Base, TimestampMixin
try:
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership
except (ImportError, ModuleNotFoundError):
pass
def new_uuid() -> str: def new_uuid() -> str:
return str(uuid.uuid4()) return str(uuid.uuid4())
@@ -97,7 +92,9 @@ class JobSendStatus(StrEnum):
class JobImapStatus(StrEnum): class JobImapStatus(StrEnum):
NOT_REQUESTED = "not_requested" NOT_REQUESTED = "not_requested"
PENDING = "pending" PENDING = "pending"
APPENDING = "appending"
APPENDED = "appended" APPENDED = "appended"
OUTCOME_UNKNOWN = "outcome_unknown"
FAILED = "failed" FAILED = "failed"
SKIPPED = "skipped" SKIPPED = "skipped"
@@ -113,10 +110,10 @@ class Campaign(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),) __table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
@@ -125,7 +122,6 @@ class Campaign(Base, TimestampMixin):
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
tenant: Mapped[Tenant] = relationship()
versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan") versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan") jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
@@ -135,15 +131,39 @@ class CampaignShare(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),) __table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False) permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class RecipientImportMappingProfile(Base, TimestampMixin):
__tablename__ = "campaign_recipient_import_mapping_profiles"
__table_args__ = (
Index("ix_recipient_import_profiles_owner", "tenant_id", "owner_user_id"),
Index("ix_recipient_import_profiles_ordered_fp", "tenant_id", "owner_user_id", "ordered_header_fingerprint"),
Index("ix_recipient_import_profiles_unordered_fp", "tenant_id", "owner_user_id", "unordered_header_fingerprint"),
)
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)
owner_user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
column_count: Mapped[int] = mapped_column(Integer, nullable=False)
headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
normalized_headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
ordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
unordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
delimiter: Mapped[str] = mapped_column(String(8), nullable=False)
header_rows: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
quoted: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False)
mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
class CampaignVersion(Base, TimestampMixin): class CampaignVersion(Base, TimestampMixin):
__tablename__ = "campaign_versions" __tablename__ = "campaign_versions"
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),) __table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)
@@ -177,7 +197,7 @@ class CampaignVersion(Base, TimestampMixin):
autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
# Explicit user-requested lock. This is deliberately separate from # Explicit user-requested lock. This is deliberately separate from
# locked_at, which represents the reversible validation lock used by the # locked_at, which represents the reversible validation lock used by the
@@ -185,7 +205,7 @@ class CampaignVersion(Base, TimestampMixin):
# RBAC permission for unlocking; permanent locks never unlock in place. # RBAC permission for unlocking; permanent locks never unlock in place.
user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
@@ -195,13 +215,19 @@ class CampaignVersion(Base, TimestampMixin):
campaign: Mapped[Campaign] = relationship(back_populates="versions") campaign: Mapped[Campaign] = relationship(back_populates="versions")
@property
def mail_profile_migration_required(self) -> bool:
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_boundary_violations
return bool(campaign_mail_profile_boundary_violations(self.raw_json))
class CampaignJob(Base, TimestampMixin): class CampaignJob(Base, TimestampMixin):
__tablename__ = "campaign_jobs" __tablename__ = "campaign_jobs"
__table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),) __table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True) campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
entry_index: Mapped[int] = mapped_column(Integer, nullable=False) entry_index: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -214,6 +240,7 @@ class CampaignJob(Base, TimestampMixin):
eml_local_path: Mapped[str | None] = mapped_column(String(1000)) eml_local_path: Mapped[str | None] = mapped_column(String(1000))
eml_size_bytes: Mapped[int | None] = mapped_column(Integer) eml_size_bytes: Mapped[int | None] = mapped_column(Integer)
eml_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) eml_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
execution_input_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True) build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True)
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True) validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
@@ -228,6 +255,8 @@ class CampaignJob(Base, TimestampMixin):
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
smtp_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) smtp_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
outcome_unknown_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) outcome_unknown_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
imap_claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
imap_claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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)
@@ -241,7 +270,7 @@ class CampaignIssue(Base, TimestampMixin):
__tablename__ = "campaign_issues" __tablename__ = "campaign_issues"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True) campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True)
job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True) job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True)
@@ -257,7 +286,7 @@ class AttachmentBlob(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),) __table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
mime_type: Mapped[str | None] = mapped_column(String(255)) mime_type: Mapped[str | None] = mapped_column(String(255))
@@ -269,8 +298,8 @@ class AttachmentInstance(Base, TimestampMixin):
__tablename__ = "attachment_instances" __tablename__ = "attachment_instances"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True) campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True)
blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True) blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True)
logical_name: Mapped[str | None] = mapped_column(String(500)) logical_name: Mapped[str | None] = mapped_column(String(500))
@@ -297,10 +326,14 @@ class SendAttempt(Base, TimestampMixin):
class ImapAppendAttempt(Base, TimestampMixin): class ImapAppendAttempt(Base, TimestampMixin):
__tablename__ = "imap_append_attempts" __tablename__ = "imap_append_attempts"
__table_args__ = (
UniqueConstraint("job_id", "attempt_number", name="uq_imap_append_attempts_job_attempt"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True) job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True)
folder: Mapped[str | None] = mapped_column(String(500)) folder: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(50), nullable=False) status: Mapped[str] = mapped_column(String(50), nullable=False)
error_message: Mapped[str | None] = mapped_column(Text) error_message: Mapped[str | None] = mapped_column(Text)
@@ -319,7 +352,6 @@ __all__ = [
"CampaignVersion", "CampaignVersion",
"CampaignVersionFlow", "CampaignVersionFlow",
"CampaignVersionWorkflowState", "CampaignVersionWorkflowState",
"Group",
"ImapAppendAttempt", "ImapAppendAttempt",
"IssueSeverity", "IssueSeverity",
"JobBuildStatus", "JobBuildStatus",
@@ -328,7 +360,4 @@ __all__ = [
"JobSendStatus", "JobSendStatus",
"JobValidationStatus", "JobValidationStatus",
"SendAttempt", "SendAttempt",
"Tenant",
"User",
"UserGroupMembership",
] ]

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from email import policy from email import policy
from email.message import EmailMessage from email.message import EmailMessage
from typing import Any from typing import Any
@@ -12,26 +13,49 @@ from govoplan_campaign.backend.campaign.validation import validate_campaign_conf
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
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 MessageAddress, MessageDraft, MessageValidationStatus from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
from govoplan_files.backend.storage.campaign_attachments import ( from govoplan_campaign.backend.integrations import files_integration, mail_integration
annotate_built_messages_with_managed_files, from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
prepared_campaign_snapshot,
public_attachment_summary_payload,
)
from govoplan_mail.backend.dev.mock_mailbox import (
clear_records,
consume_fail_next_imap,
consume_fail_next_smtp,
get_failures,
list_records,
record_imap_append,
record_smtp_delivery,
)
class MockCampaignSendError(RuntimeError): class MockCampaignSendError(RuntimeError):
pass pass
@dataclass(slots=True)
class _MockSendOutcome:
row: dict[str, Any]
sent_count: int = 0
failed_count: int = 0
skipped_count: int = 0
imap_appended_count: int = 0
imap_failed_count: int = 0
@dataclass(slots=True)
class _MockSendBatch:
results: list[dict[str, Any]]
sent_count: int = 0
failed_count: int = 0
skipped_count: int = 0
imap_appended_count: int = 0
imap_failed_count: int = 0
@property
def attempted_count(self) -> int:
return self.sent_count + self.failed_count
def _mock_mailbox() -> Any | None:
return mail_integration().mock_mailbox()
def _require_mock_mailbox() -> Any:
mailbox = _mock_mailbox()
if mailbox is None:
raise MockCampaignSendError("Mail module is not available")
return mailbox
def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None: def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None:
if address is None: if address is None:
return None return None
@@ -47,7 +71,7 @@ def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]:
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]: def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
return [public_attachment_summary_payload(attachment) for attachment in message.attachments] return [files_integration().public_attachment_summary_payload(attachment) for attachment in message.attachments]
def _message_payload(message: MessageDraft) -> dict[str, Any]: def _message_payload(message: MessageDraft) -> dict[str, Any]:
@@ -92,7 +116,8 @@ def _raw_message_bytes(message: EmailMessage) -> bytes:
def _smtp_rejection_matches(recipients: list[str]) -> list[str]: def _smtp_rejection_matches(recipients: list[str]) -> list[str]:
needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower() mailbox = _mock_mailbox()
needle = ((mailbox.get_failures() if mailbox else {}).get("smtp_reject_recipients_containing") or "").strip().lower()
if not needle: if not needle:
return [] return []
return [recipient for recipient in recipients if needle in recipient.lower()] return [recipient for recipient in recipients if needle in recipient.lower()]
@@ -117,6 +142,394 @@ def _can_mock_send(
return False, f"Validation status is {message.validation_status.value}" return False, f"Validation status is {message.validation_status.value}"
def _mock_send_batch(
*,
config: Any,
built_messages: list[Any],
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> _MockSendBatch:
batch = _MockSendBatch(results=[])
for built in built_messages:
outcome = _mock_send_message(
config=config,
built=built,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
batch.results.append(outcome.row)
batch.sent_count += outcome.sent_count
batch.failed_count += outcome.failed_count
batch.skipped_count += outcome.skipped_count
batch.imap_appended_count += outcome.imap_appended_count
batch.imap_failed_count += outcome.imap_failed_count
return batch
def _mock_send_message(
*,
config: Any,
built: Any,
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> _MockSendOutcome:
draft = built.draft
row = _mock_send_row(draft)
can_send, skip_reason = _can_mock_send(
draft,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
)
if not can_send or built.mime is None:
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
return _MockSendOutcome(row=row, skipped_count=1)
recipients = _recipient_emails(draft)
envelope_from = _envelope_from(draft)
if not recipients:
row.update({"status": "skipped", "message": "No envelope recipients"})
return _MockSendOutcome(row=row, skipped_count=1)
if not send:
row.update({
"status": "ready",
"message": f"Would send to {len(recipients)} recipient(s)",
"envelope_from": envelope_from,
"envelope_recipients": recipients,
})
return _MockSendOutcome(row=row)
return _send_mock_smtp_message(
config=config,
built=built,
mailbox=mailbox,
row=row,
recipients=recipients,
envelope_from=envelope_from,
append_sent=append_sent,
)
def _mock_send_row(draft: MessageDraft) -> dict[str, Any]:
return {
"entry_index": draft.entry_index,
"entry_id": draft.entry_id,
"subject": draft.subject,
"validation_status": draft.validation_status.value,
"build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status),
"to": _message_addresses_payload(draft.to),
"attachments": _attachment_payloads(draft),
"issues": _issue_payloads(draft),
}
def _send_mock_smtp_message(
*,
config: Any,
built: Any,
mailbox: Any | None,
row: dict[str, Any],
recipients: list[str],
envelope_from: str,
append_sent: bool,
) -> _MockSendOutcome:
try:
if mailbox is not None and mailbox.consume_fail_next_smtp():
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
rejected = _smtp_rejection_matches(recipients)
if rejected and len(rejected) == len(recipients):
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
accepted = [recipient for recipient in recipients if recipient not in rejected]
smtp_record = mailbox.record_smtp_delivery(
built.mime,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host="mock.smtp.local",
)
row.update({
"status": "sent",
"message": f"Mock SMTP captured as {smtp_record.id}",
"smtp_message_id": smtp_record.id,
"envelope_from": envelope_from,
"envelope_recipients": accepted,
"refused_recipients": rejected,
})
imap_appended, imap_failed = _append_mock_sent_message(config, built, mailbox, row, append_sent=append_sent)
return _MockSendOutcome(row=row, sent_count=1, imap_appended_count=imap_appended, imap_failed_count=imap_failed)
except Exception as exc:
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
return _MockSendOutcome(row=row, failed_count=1)
def _append_mock_sent_message(
config: Any,
built: Any,
mailbox: Any | None,
row: dict[str, Any],
*,
append_sent: bool,
) -> tuple[int, int]:
if not append_sent:
return 0, 0
try:
if mailbox is not None and mailbox.consume_fail_next_imap():
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
folder = _mock_sent_folder(config)
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
return 1, 0
except Exception as exc:
row.update({"imap_status": "failed", "imap_error": str(exc)})
return 0, 1
def _mock_sent_folder(config: Any) -> str:
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
return config.delivery.imap_append_sent.folder
if config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
return config.server.imap.sent_folder
return "Sent"
def _mock_campaign_version(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None,
) -> tuple[Campaign, CampaignVersion]:
campaign = (
session.query(Campaign)
.filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id)
.one_or_none()
)
if not campaign:
raise MockCampaignSendError("Campaign not found or not accessible")
wanted_version_id = version_id or campaign.current_version_id
if not wanted_version_id:
raise MockCampaignSendError("Campaign has no current version")
version = session.get(CampaignVersion, wanted_version_id)
if not version or version.campaign_id != campaign.id:
raise MockCampaignSendError(
"Campaign version not found or not part of campaign"
)
return campaign, version
def _mock_mailbox_for_run(*, send: bool, clear_mailbox: bool) -> Any | None:
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
if clear_mailbox and mailbox is not None:
mailbox.clear_records()
return mailbox
def _build_mock_campaign_run(
session: Session,
*,
tenant_id: str,
campaign: Campaign,
version: CampaignVersion,
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
check_files: bool,
) -> tuple[Any, Any, _MockSendBatch]:
files = files_integration()
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
assert_server_safe_campaign_paths(
raw_json,
managed_files_available=files.available,
)
with files.prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=raw_json,
include_bytes=True,
prefix="govoplan-mock-send-",
) as prepared:
prepared_raw = load_campaign_json(prepared.path)
config = load_campaign_config_from_json(
session,
tenant_id=tenant_id,
raw_json=prepared_raw,
campaign_id=campaign.id,
)
validation_report = validate_campaign_config(
config,
campaign_file=prepared.path,
check_files=check_files,
)
build_result = build_campaign_messages(
config,
campaign_file=prepared.path,
write_eml=False,
)
files.annotate_built_messages_with_managed_files(
build_result.built_messages,
prepared.managed_files_by_local_path,
)
send_batch = _mock_send_batch(
config=config,
built_messages=build_result.built_messages,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
return validation_report, build_result, send_batch
def _mock_validation_payload(validation_report: Any) -> dict[str, Any]:
payload = validation_report.model_dump(mode="json")
payload.update(
{
"ok": validation_report.ok,
"error_count": validation_report.error_count,
"warning_count": validation_report.warning_count,
}
)
return payload
def _mock_build_payload(build_result: Any) -> dict[str, Any]:
report = build_result.report
payload = report.model_dump(mode="json")
payload.update(
{
"built_count": report.built_count,
"queueable_count": report.queueable_count,
"needs_review_count": report.needs_review_count,
"blocked_count": report.blocked_count,
"warning_count": report.warning_count,
"ready_count": report.ready_count,
"messages": [_message_payload(message) for message in report.messages],
}
)
return payload
def _mock_send_steps(
*,
validation_report: Any,
validation_payload: dict[str, Any],
build_report: Any,
send_batch: _MockSendBatch,
send: bool,
append_sent: bool,
) -> list[dict[str, Any]]:
return [
{
"key": "validate",
"label": "Validate campaign JSON",
"status": "ok" if validation_report.ok else "needs_review",
"summary": validation_payload,
},
{
"key": "build",
"label": "Build messages",
"status": "ok" if build_report.queueable_count else "needs_review",
"summary": {
"built": build_report.built_count,
"queueable": build_report.queueable_count,
"needs_review": build_report.needs_review_count,
"blocked": build_report.blocked_count,
},
},
{
"key": "send",
"label": "Mock SMTP delivery",
"status": (
"skipped"
if not send
else "ok"
if send_batch.failed_count == 0
else "needs_review"
),
"summary": {
"attempted": send_batch.attempted_count,
"sent": send_batch.sent_count,
"failed": send_batch.failed_count,
"skipped": send_batch.skipped_count,
},
},
{
"key": "imap",
"label": "Mock IMAP Sent append",
"status": (
"skipped"
if not send or not append_sent
else "ok"
if send_batch.imap_failed_count == 0
else "needs_review"
),
"summary": {
"appended": send_batch.imap_appended_count,
"failed": send_batch.imap_failed_count,
},
},
]
def _mock_campaign_send_response(
*,
campaign: Campaign,
version: CampaignVersion,
mailbox: Any | None,
validation_report: Any,
validation_payload: dict[str, Any],
build_result: Any,
build_payload: dict[str, Any],
send_batch: _MockSendBatch,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> dict[str, Any]:
return {
"campaign_id": campaign.id,
"version_id": version.id,
"version_number": version.version_number,
"send_requested": send,
"include_warnings": include_warnings,
"include_needs_review": include_needs_review,
"append_sent": append_sent,
"steps": _mock_send_steps(
validation_report=validation_report,
validation_payload=validation_payload,
build_report=build_result.report,
send_batch=send_batch,
send=send,
append_sent=append_sent,
),
"validation": validation_payload,
"build": build_payload,
"send": {
"attempted_count": send_batch.attempted_count,
"sent_count": send_batch.sent_count,
"failed_count": send_batch.failed_count,
"skipped_count": send_batch.skipped_count,
"imap_appended_count": send_batch.imap_appended_count,
"imap_failed_count": send_batch.imap_failed_count,
"results": send_batch.results,
},
"mailbox": {
"messages": mailbox.list_records(limit=200) if mailbox is not None else []
},
}
def run_mock_campaign_send( def run_mock_campaign_send(
session: Session, session: Session,
*, *,
@@ -138,153 +551,38 @@ def run_mock_campaign_send(
mailbox only when send=True. mailbox only when send=True.
""" """
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none() campaign, version = _mock_campaign_version(
if not campaign:
raise MockCampaignSendError("Campaign not found or not accessible")
wanted_version_id = version_id or campaign.current_version_id
if not wanted_version_id:
raise MockCampaignSendError("Campaign has no current version")
version = session.get(CampaignVersion, wanted_version_id)
if not version or version.campaign_id != campaign.id:
raise MockCampaignSendError("Campaign version not found or not part of campaign")
if clear_mailbox:
clear_records()
with prepared_campaign_snapshot(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign.id, campaign_id=campaign_id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, version_id=version_id,
include_bytes=True, )
prefix="multimailer-mock-send-", mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox)
) as prepared: validation_report, build_result, send_batch = _build_mock_campaign_run(
prepared_raw = load_campaign_json(prepared.path) session,
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id) tenant_id=tenant_id,
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files) campaign=campaign,
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False) version=version,
annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path) mailbox=mailbox,
send=send,
send_results: list[dict[str, Any]] = [] include_warnings=include_warnings,
sent_count = 0 include_needs_review=include_needs_review,
failed_count = 0 append_sent=append_sent,
skipped_count = 0 check_files=check_files,
imap_appended_count = 0 )
imap_failed_count = 0 validation_payload = _mock_validation_payload(validation_report)
build_payload = _mock_build_payload(build_result)
for built in build_result.built_messages: return _mock_campaign_send_response(
draft = built.draft campaign=campaign,
can_send, skip_reason = _can_mock_send(draft, include_warnings=include_warnings, include_needs_review=include_needs_review) version=version,
row: dict[str, Any] = { mailbox=mailbox,
"entry_index": draft.entry_index, validation_report=validation_report,
"entry_id": draft.entry_id, validation_payload=validation_payload,
"subject": draft.subject, build_result=build_result,
"validation_status": draft.validation_status.value, build_payload=build_payload,
"build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status), send_batch=send_batch,
"to": _message_addresses_payload(draft.to), send=send,
"attachments": _attachment_payloads(draft), include_warnings=include_warnings,
"issues": _issue_payloads(draft), include_needs_review=include_needs_review,
} append_sent=append_sent,
)
if not can_send or built.mime is None:
skipped_count += 1
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
send_results.append(row)
continue
recipients = _recipient_emails(draft)
envelope_from = _envelope_from(draft)
if not recipients:
skipped_count += 1
row.update({"status": "skipped", "message": "No envelope recipients"})
send_results.append(row)
continue
if not send:
row.update({"status": "ready", "message": f"Would send to {len(recipients)} recipient(s)", "envelope_from": envelope_from, "envelope_recipients": recipients})
send_results.append(row)
continue
try:
if consume_fail_next_smtp():
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
rejected = _smtp_rejection_matches(recipients)
if rejected and len(rejected) == len(recipients):
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
accepted = [recipient for recipient in recipients if recipient not in rejected]
smtp_record = record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
sent_count += 1
row.update({
"status": "sent",
"message": f"Mock SMTP captured as {smtp_record.id}",
"smtp_message_id": smtp_record.id,
"envelope_from": envelope_from,
"envelope_recipients": accepted,
"refused_recipients": rejected,
})
if append_sent:
try:
if consume_fail_next_imap():
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
folder = "Sent"
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
folder = config.delivery.imap_append_sent.folder
elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
folder = config.server.imap.sent_folder
imap_record = record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
imap_appended_count += 1
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
except Exception as exc:
imap_failed_count += 1
row.update({"imap_status": "failed", "imap_error": str(exc)})
except Exception as exc:
failed_count += 1
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
send_results.append(row)
validation_json = validation_report.model_dump(mode="json")
validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count})
build_report = build_result.report
build_json = build_report.model_dump(mode="json")
build_json.update({
"built_count": build_report.built_count,
"queueable_count": build_report.queueable_count,
"needs_review_count": build_report.needs_review_count,
"blocked_count": build_report.blocked_count,
"warning_count": build_report.warning_count,
"ready_count": build_report.ready_count,
"messages": [_message_payload(message) for message in build_report.messages],
})
attempted_count = sum(1 for row in send_results if row.get("status") in {"sent", "failed"})
return {
"campaign_id": campaign.id,
"version_id": version.id,
"version_number": version.version_number,
"send_requested": send,
"include_warnings": include_warnings,
"include_needs_review": include_needs_review,
"append_sent": append_sent,
"steps": [
{"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json},
{"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}},
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if failed_count == 0 else "needs_review"), "summary": {"attempted": attempted_count, "sent": sent_count, "failed": failed_count, "skipped": skipped_count}},
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if imap_failed_count == 0 else "needs_review"), "summary": {"appended": imap_appended_count, "failed": imap_failed_count}},
],
"validation": validation_json,
"build": build_json,
"send": {
"attempted_count": attempted_count,
"sent_count": sent_count,
"failed_count": failed_count,
"skipped_count": skipped_count,
"imap_appended_count": imap_appended_count,
"imap_failed_count": imap_failed_count,
"results": send_results,
},
"mailbox": {"messages": list_records(limit=200)},
}

View File

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

View File

@@ -1,210 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from .fields import Field, FieldConfiguration, FieldContents
from .recipients import Recipient, RecipientList
from .template import MailTemplate
class TransportSecurity(StrEnum):
PLAIN = "plain"
TLS = "tls"
STARTTLS = "starttls"
@property
def standard_port(self) -> int:
return 465 if self == TransportSecurity.TLS else 587
@dataclass
class MailServerSettings:
server: str = ""
port: int | None = None
username: str = ""
password: str = ""
transport_security: TransportSecurity = TransportSecurity.PLAIN
def use_plain(self) -> "MailServerSettings":
self.transport_security = TransportSecurity.PLAIN
self.port = self.port or self.transport_security.standard_port
return self
def use_tls(self) -> "MailServerSettings":
self.transport_security = TransportSecurity.TLS
self.port = self.port or self.transport_security.standard_port
return self
def use_starttls(self) -> "MailServerSettings":
self.transport_security = TransportSecurity.STARTTLS
self.port = self.port or self.transport_security.standard_port
return self
def resolved_port(self) -> int:
return self.port or self.transport_security.standard_port
@dataclass(frozen=True, slots=True)
class MailAttachmentConfig:
base_dir: Path
file_filter: str = "*"
include_subdirs: bool = False
@dataclass
class MailEntry:
field_config: FieldConfiguration
is_active: bool = True
from_recipient: Recipient | None = None
to: RecipientList = field(default_factory=RecipientList)
cc: RecipientList = field(default_factory=RecipientList)
bcc: RecipientList = field(default_factory=RecipientList)
combine_to: bool = True
combine_cc: bool = True
combine_bcc: bool = True
attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
combine_attachments: bool = True
field_contents: FieldContents = field(init=False)
def __post_init__(self) -> None:
self.field_contents = FieldContents(self.field_config)
def add_to(self, recipient: Recipient) -> "MailEntry":
self.to.add_recipient(recipient)
return self
def add_cc(self, recipient: Recipient) -> "MailEntry":
self.cc.add_recipient(recipient)
return self
def add_bcc(self, recipient: Recipient) -> "MailEntry":
self.bcc.add_recipient(recipient)
return self
def no_combine_to(self) -> "MailEntry":
self.combine_to = False
return self
def combine_to_recipients(self) -> "MailEntry":
self.combine_to = True
return self
def no_combine_attachments(self) -> "MailEntry":
self.combine_attachments = False
return self
def combine_attachments_with_global(self) -> "MailEntry":
self.combine_attachments = True
return self
def add_mail_attachment_config(self, config: MailAttachmentConfig) -> "MailEntry":
self.attachment_configs.append(config)
return self
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailEntry":
if not self.field_contents.set_field_content_for_name(name, value):
raise KeyError(f"unknown field: {name}")
return self
def get_field_content_from_name(self, name: str) -> Field:
return self.field_contents.get_field_content_from_name(name)
@dataclass
class MailCampaign:
mail_server_settings: MailServerSettings | None = None
global_from: Recipient | None = None
global_to: RecipientList = field(default_factory=RecipientList)
global_cc: RecipientList = field(default_factory=RecipientList)
global_bcc: RecipientList = field(default_factory=RecipientList)
individual_from: bool = False
individual_to: bool = False
individual_cc: bool = False
individual_bcc: bool = False
base_attachment_path: Path = Path(".")
global_attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
individual_attachments: bool = False
send_without_attachments: bool = True
field_config: FieldConfiguration = field(default_factory=FieldConfiguration)
field_contents: FieldContents = field(init=False)
subject_template: MailTemplate = field(default_factory=MailTemplate)
mail_template: MailTemplate = field(default_factory=MailTemplate)
mail_entries: list[MailEntry] = field(default_factory=list)
def __post_init__(self) -> None:
self.field_contents = FieldContents(self.field_config)
@classmethod
def with_server_settings(cls, settings: MailServerSettings) -> "MailCampaign":
return cls(mail_server_settings=settings)
def add_field(self, name: str, field_type) -> "MailCampaign":
from .fields import FieldDescription
self.field_config.add_field_at_end(FieldDescription(name, field_type))
self.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
for entry in self.mail_entries:
entry.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
return self
def set_from(self, recipient: Recipient) -> "MailCampaign":
self.global_from = recipient
return self
def add_to(self, recipient: Recipient) -> "MailCampaign":
self.global_to.add_recipient(recipient)
return self
def allow_individual_to(self) -> "MailCampaign":
self.individual_to = True
return self
def disallow_individual_to(self) -> "MailCampaign":
self.individual_to = False
return self
def allow_individual_attachments(self) -> "MailCampaign":
self.individual_attachments = True
return self
def disallow_individual_attachments(self) -> "MailCampaign":
self.individual_attachments = False
return self
def dont_send_without_attachments(self) -> "MailCampaign":
self.send_without_attachments = False
return self
def send_without_attachments_allowed(self) -> "MailCampaign":
self.send_without_attachments = True
return self
def add_new_mail_entry(self) -> MailEntry:
entry = MailEntry(self.field_config)
self.mail_entries.append(entry)
return entry
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailCampaign":
if not self.field_contents.set_field_content_for_name(name, value):
raise KeyError(f"unknown field: {name}")
return self
def get_field_content_from_name(self, name: str) -> Field:
return self.field_contents.get_field_content_from_name(name)
def all_recipients_for(self, entry: MailEntry) -> list[Recipient]:
recipients: list[Recipient] = []
if not self.individual_to or entry.combine_to:
recipients.extend(self.global_to.recipients)
if not self.individual_cc or entry.combine_cc:
recipients.extend(self.global_cc.recipients)
if not self.individual_bcc or entry.combine_bcc:
recipients.extend(self.global_bcc.recipients)
if self.individual_to:
recipients.extend(entry.to.recipients)
if self.individual_cc:
recipients.extend(entry.cc.recipients)
if self.individual_bcc:
recipients.extend(entry.bcc.recipients)
return recipients

View File

@@ -1,126 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from enum import StrEnum
from typing import Any
class FieldType(StrEnum):
STRING = "string"
INTEGER = "integer"
DOUBLE = "double"
DATE = "date"
PASSWORD = "password"
@dataclass(slots=True)
class FieldDescription:
name: str
type: FieldType = FieldType.STRING
can_override: bool = True
@dataclass(slots=True)
class Field:
content: Any
@classmethod
def with_content(cls, content: Any) -> "Field":
if content is None:
raise ValueError("content must not be None")
return cls(content=content)
@property
def type(self) -> FieldType:
if isinstance(self.content, bool):
return FieldType.STRING
if isinstance(self.content, int):
return FieldType.INTEGER
if isinstance(self.content, float):
return FieldType.DOUBLE
if isinstance(self.content, (date, datetime)):
return FieldType.DATE
if isinstance(self.content, (bytes, bytearray)):
return FieldType.PASSWORD
return FieldType.STRING
def as_string(self) -> str:
if isinstance(self.content, (bytes, bytearray)):
return self.content.decode("utf-8")
if isinstance(self.content, (date, datetime)):
return self.content.isoformat()
return str(self.content)
@dataclass
class FieldConfiguration:
fields: list[FieldDescription] = field(default_factory=list)
def add_field_at_end(self, field_description: FieldDescription) -> "FieldConfiguration":
return self.add_field_at_position(len(self.fields), field_description)
def add_field_at_start(self, field_description: FieldDescription) -> "FieldConfiguration":
return self.add_field_at_position(0, field_description)
def add_field_at_position(self, position: int, field_description: FieldDescription) -> "FieldConfiguration":
if self.has_field(field_description.name):
raise ValueError(f"field already exists: {field_description.name}")
position = max(0, min(position, len(self.fields)))
self.fields.insert(position, field_description)
return self
def has_field(self, name: str) -> bool:
return any(f.name == name for f in self.fields)
def get_field_description(self, name: str) -> FieldDescription | None:
return next((f for f in self.fields if f.name == name), None)
def get_field_names(self) -> list[str]:
return [f.name for f in self.fields]
@dataclass
class FieldContents:
field_config: FieldConfiguration
field_map: dict[str, Field] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_description in self.field_config.fields:
self.ensure_field(field_description)
def ensure_field(self, field_description: FieldDescription) -> None:
if field_description.name in self.field_map:
return
match field_description.type:
case FieldType.INTEGER:
value = 0
case FieldType.DOUBLE:
value = 0.0
case FieldType.DATE:
value = date.today()
case FieldType.PASSWORD:
value = b""
case _:
value = ""
self.field_map[field_description.name] = Field.with_content(value)
def get_field_content_from_name(self, name: str) -> Field:
try:
return self.field_map[name]
except KeyError as exc:
raise KeyError(f"unknown field: {name}") from exc
def set_field_content_for_name(self, name: str, value: Field | Any) -> bool:
if name not in self.field_map:
return False
if not isinstance(value, Field):
value = Field.with_content(value)
expected = self.field_map[name].type
if expected != value.type and expected != FieldType.PASSWORD:
raise TypeError(f"field {name!r} expects {expected}, got {value.type}")
self.field_map[name] = value
return True
def as_value_map(self, prefix: str) -> dict[str, str]:
return {f"{prefix}::{name}": field.as_string() for name, field in self.field_map.items()}

View File

@@ -1,29 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from email.message import EmailMessage
from typing import Iterator
@dataclass
class MailQueue:
messages: list[EmailMessage] = field(default_factory=list)
def add_mail(self, message: EmailMessage) -> None:
self.messages.append(message)
def remove_mail(self, message: EmailMessage) -> bool:
if message in self.messages:
self.messages.remove(message)
return True
return False
@property
def mail_count(self) -> int:
return len(self.messages)
def is_empty(self) -> bool:
return not self.messages
def __iter__(self) -> Iterator[EmailMessage]:
return iter(self.messages)

View File

@@ -1,43 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from email.utils import formataddr
from enum import StrEnum
class RecipientType(StrEnum):
TO = "to"
CC = "cc"
BCC = "bcc"
@dataclass(frozen=True, slots=True)
class Recipient:
address: str
name: str | None = None
type: RecipientType = RecipientType.TO
def formatted(self) -> str:
return formataddr((self.name or self.address, self.address))
@dataclass
class RecipientList:
recipients: list[Recipient] = field(default_factory=list)
def add_recipient(self, recipient: Recipient) -> "RecipientList":
if recipient not in self.recipients:
self.recipients.append(recipient)
return self
def add_multiple_recipients(self, recipients: list[Recipient] | tuple[Recipient, ...]) -> "RecipientList":
for recipient in recipients:
self.add_recipient(recipient)
return self
def clear_all_recipients(self) -> "RecipientList":
self.recipients.clear()
return self
def by_type(self, recipient_type: RecipientType) -> list[Recipient]:
return [r for r in self.recipients if r.type == recipient_type]

View File

@@ -1,28 +0,0 @@
from __future__ import annotations
import re
from dataclasses import dataclass
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
@dataclass
class MailTemplate:
template_string: str = ""
def set_template_string(self, template: str) -> "MailTemplate":
self.template_string = template
return self
def get_used_fields(self) -> set[str]:
return set(_FIELD_PATTERN.findall(self.template_string))
def apply_values(self, values: dict[str, str], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str:
key = match.group(1)
if key in values:
return values[key]
return match.group(0) if keep_missing else ""
rendered = _FIELD_PATTERN.sub(replace, self.template_string)
return rendered.replace(r"\${", "${").replace(r"\}", "}")

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import json
import shutil
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from govoplan_campaign.backend.runtime import capability
FILES_CAPABILITY = "files.campaign_attachments"
MAIL_CAPABILITY = "mail.campaign_delivery"
class OptionalModuleUnavailable(RuntimeError):
pass
class SmtpConfigurationError(RuntimeError):
pass
class SmtpSendError(RuntimeError):
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False) -> None:
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
class ImapConfigurationError(RuntimeError):
pass
class ImapAppendError(RuntimeError):
def __init__(
self,
message: str,
*,
temporary: bool | None = None,
outcome_unknown: bool = False,
) -> None:
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
class MailProfileError(OptionalModuleUnavailable):
pass
class _PreparedCampaignSnapshot:
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
self._directory = directory
self.path = path
self.raw_json = raw_json
self.managed_files_by_local_path: dict[str, Any] = {}
self.shared_assets: list[Any] = []
self.candidate_assets: list[Any] = []
def cleanup(self) -> None:
shutil.rmtree(self._directory, ignore_errors=True)
class FilesCampaignIntegration:
def __init__(self, delegate: Any | None = None) -> None:
self._delegate = delegate
@property
def available(self) -> bool:
return self._delegate is not None
@contextmanager
def prepared_campaign_snapshot(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
if self._delegate is not None:
with self._delegate.prepared_campaign_snapshot(*args, **kwargs) as prepared:
yield prepared
return
raw_json = kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {}
prefix = str(kwargs.get("prefix") or "govoplan-campaign-")
directory = Path(tempfile.mkdtemp(prefix=prefix))
snapshot = _PreparedCampaignSnapshot(directory, directory / "campaign.json", raw_json)
snapshot.path.write_text(json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
try:
yield snapshot
finally:
snapshot.cleanup()
def managed_match_payloads(self, matches: Any, managed_files_by_local_path: dict[str, Any]) -> list[dict[str, Any]]:
if self._delegate is None:
return []
return self._delegate.managed_match_payloads(matches, managed_files_by_local_path)
def public_attachment_summary_payload(self, attachment: Any) -> dict[str, Any]:
if self._delegate is not None:
return self._delegate.public_attachment_summary_payload(attachment)
if hasattr(attachment, "model_dump"):
return attachment.model_dump(mode="json")
if isinstance(attachment, dict):
return dict(attachment)
return {"path": str(attachment)}
def annotate_built_messages_with_managed_files(self, built_messages: Any, managed_files_by_local_path: dict[str, Any]) -> None:
if self._delegate is not None:
self._delegate.annotate_built_messages_with_managed_files(built_messages, managed_files_by_local_path)
def record_campaign_attachment_uses_for_jobs(self, session: Any, jobs: Any, *, stage: str) -> None:
if self._delegate is not None:
self._delegate.record_campaign_attachment_uses_for_jobs(session, jobs, stage=stage)
def current_version_and_blob(self, session: Any, asset: Any) -> tuple[Any, Any]:
if self._delegate is None:
raise OptionalModuleUnavailable("Files module is not available")
return self._delegate.current_version_and_blob(session, asset)
def share_assets_with_campaign(self, session: Any, **kwargs: Any) -> list[dict[str, Any]]:
if self._delegate is None:
raise OptionalModuleUnavailable("Files module is not available")
return self._delegate.share_assets_with_campaign(session, **kwargs)
def mark_job_attachment_uses_sent(self, session: Any, job: Any) -> None:
if self._delegate is not None:
self._delegate.mark_job_attachment_uses_sent(session, job)
class MailCampaignIntegration:
def __init__(self, delegate: Any | None = None) -> None:
self._delegate = delegate
if delegate is not None:
self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError)
MailProfileError = MailProfileError
SmtpConfigurationError = SmtpConfigurationError
SmtpSendError = SmtpSendError
ImapConfigurationError = ImapConfigurationError
ImapAppendError = ImapAppendError
@property
def available(self) -> bool:
return self._delegate is not None
def _require(self) -> Any:
if self._delegate is None:
raise MailProfileError("Mail module is not available")
return self._delegate
def assert_campaign_mail_policy_allows_json(self, session: Any, **kwargs: Any) -> None:
if self._delegate is None:
raw_json = kwargs.get("raw_json")
profile_id = self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {})
if profile_id:
raise MailProfileError("Campaign mail-server profiles require the mail module")
return None
try:
return self._delegate.assert_campaign_mail_policy_allows_json(session, **kwargs)
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def mail_profile_id_from_campaign_json(self, raw_json: dict[str, Any]) -> str | None:
if self._delegate is not None:
return self._delegate.mail_profile_id_from_campaign_json(raw_json)
server = raw_json.get("server") if isinstance(raw_json, dict) else None
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
return str(profile_id).strip() if profile_id else None
def campaign_profile_delivery_summary(self, session: Any, **kwargs: Any) -> dict[str, Any]:
delegate = self._require()
try:
return delegate.campaign_profile_delivery_summary(session, **kwargs)
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def wait_for_rate_limit(self, **kwargs: Any) -> None:
if self._delegate is None:
return None
return self._delegate.wait_for_rate_limit(**kwargs)
def send_campaign_email_bytes(self, *args: Any, **kwargs: Any) -> Any:
delegate = self._require()
try:
return delegate.send_campaign_email_bytes(*args, **kwargs)
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
raise SmtpConfigurationError(str(exc)) from exc
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
delegate = self._require()
try:
return delegate.append_campaign_message_to_sent(*args, **kwargs)
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
raise ImapAppendError(
str(exc),
temporary=getattr(exc, "temporary", None),
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
) from exc
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
raise ImapConfigurationError(str(exc)) from exc
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def mock_mailbox(self) -> Any | None:
if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"):
return None
return self._delegate.mock_mailbox()
def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
def mail_integration() -> MailCampaignIntegration:
return MailCampaignIntegration(capability(MAIL_CAPABILITY))

View File

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

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import mimetypes import mimetypes
import re import re
import tempfile import tempfile
import time
from dataclasses import dataclass from dataclasses import dataclass
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import make_msgid, formatdate from email.utils import make_msgid, formatdate
@@ -10,6 +11,7 @@ from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
from govoplan_campaign.backend.attachments.resolver import ( from govoplan_campaign.backend.attachments.resolver import (
AttachmentMatchIndex,
AttachmentMatchStatus, AttachmentMatchStatus,
EntryAttachmentResolution, EntryAttachmentResolution,
MessageAttachmentStatus, MessageAttachmentStatus,
@@ -27,12 +29,17 @@ from govoplan_campaign.backend.campaign.models import (
MissingAddressBehavior, MissingAddressBehavior,
RecipientConfig, RecipientConfig,
SendStatus, SendStatus,
TemplateBodyMode,
ZipArchiveConfig, ZipArchiveConfig,
ZipPasswordMode, ZipPasswordMode,
ZipPasswordScope, ZipPasswordScope,
) )
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
from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders as _find_unresolved_placeholders,
render_template as _render_template,
)
from .models import ( from .models import (
CampaignBuildReport, CampaignBuildReport,
@@ -44,28 +51,6 @@ from .models import (
MessageValidationStatus, MessageValidationStatus,
) )
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
@dataclass(slots=True) @dataclass(slots=True)
class BuiltMessage: class BuiltMessage:
draft: MessageDraft draft: MessageDraft
@@ -78,6 +63,33 @@ class CampaignBuildResult:
built_messages: list[BuiltMessage] built_messages: list[BuiltMessage]
@dataclass(slots=True)
class _EntryMessageContext:
resolution: EntryAttachmentResolution
senders: list[RecipientConfig]
sender: RecipientConfig | None
recipients: dict[str, list[RecipientConfig]]
issues: list[MessageIssue]
validation_status: MessageValidationStatus
@dataclass(slots=True)
class _RenderedMessageTemplate:
subject: str
text_body: str | None
html_body: str | None
body_mode: str
values: dict[str, Any]
@dataclass(slots=True)
class _MimeBuildResult:
message: EmailMessage | None
build_status: BuildStatus
validation_status: MessageValidationStatus
attachment_count: int
def _resolve(campaign_file: str | Path, raw_path: str) -> Path: def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve() campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser() path = Path(raw_path).expanduser()
@@ -93,29 +105,6 @@ def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str =
return path.read_text(encoding=encoding) return path.read_text(encoding=encoding)
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0) if keep_missing else ""
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _find_unresolved_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return {
_normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None: def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
if recipient is None: if recipient is None:
return None return None
@@ -144,6 +133,13 @@ def _load_template_parts(config: CampaignConfig, campaign_file: str | Path) -> t
return template.subject or "", template.text, template.html return template.subject or "", template.text, template.html
def _template_body_mode(config: CampaignConfig) -> str:
mode = config.template.body_mode
if isinstance(mode, TemplateBodyMode):
return mode.value
return str(mode or TemplateBodyMode.BOTH.value)
def _issue_from_behavior(*, code: str, message: str, behavior: str, source: str) -> MessageIssue: def _issue_from_behavior(*, code: str, message: str, behavior: str, source: str) -> MessageIssue:
severity = "error" if behavior == "block" else "warning" severity = "error" if behavior == "block" else "warning"
return MessageIssue(severity=severity, code=code, message=message, behavior=behavior, source=source) return MessageIssue(severity=severity, code=code, message=message, behavior=behavior, source=source)
@@ -181,6 +177,10 @@ def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[Message
zip_mode=attachment.zip_mode.value, zip_mode=attachment.zip_mode.value,
zip_archive_id=attachment.zip_archive_id, zip_archive_id=attachment.zip_archive_id,
zip_filename=attachment.zip_filename, zip_filename=attachment.zip_filename,
message_filename_template=attachment.message_filename_template,
zip_entry_name_template=attachment.zip_entry_name_template,
message_filenames=attachment.message_filenames,
zip_entry_names=attachment.zip_entry_names,
base_path_name=attachment.base_path_name, base_path_name=attachment.base_path_name,
base_path=attachment.base_path, base_path=attachment.base_path,
file_filter=attachment.file_filter, file_filter=attachment.file_filter,
@@ -204,6 +204,26 @@ def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolu
] ]
def _append_no_attachment_coverage_issue(issues: list[MessageIssue]) -> None:
if any(issue.code == "missing_attachment_coverage" for issue in issues):
return
issues.append(
MessageIssue(
severity="error",
code="missing_attachment_coverage",
message="No attachment file was resolved for this message, and sending without attachments is not allowed.",
behavior=Behavior.BLOCK.value,
source="attachments",
)
)
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
return config.attachments.send_without_attachments_behavior or (
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
)
def _safe_filename(value: str | None, fallback: str) -> str: def _safe_filename(value: str | None, fallback: str) -> str:
raw = value or fallback raw = value or fallback
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._") safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
@@ -267,18 +287,6 @@ def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_i
return filename if filename.lower().endswith(".zip") else f"{filename}.zip" return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
def _deduplicated_paths(paths: list[Path]) -> list[Path]:
unique: list[Path] = []
seen: set[str] = set()
for path in paths:
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
unique.append(path)
return unique
def _unique_attachment_filename(filename: str, used: set[str]) -> str: def _unique_attachment_filename(filename: str, used: set[str]) -> str:
candidate = filename candidate = filename
path = Path(filename) path = Path(filename)
@@ -290,6 +298,59 @@ def _unique_attachment_filename(filename: str, used: set[str]) -> str:
return candidate return candidate
def _deduplicated_archive_members(members: list[tuple[Path, str]]) -> list[tuple[Path, str]]:
unique: list[tuple[Path, str]] = []
seen: set[str] = set()
for path, archive_name in members:
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
unique.append((path, archive_name))
return unique
def _attachment_filename_values(
*,
values: dict[str, Any],
attachment: ResolvedAttachment,
path: Path,
position: int,
) -> dict[str, Any]:
filename_values = dict(values)
filename_values.update({
"file.name": path.name,
"file.stem": path.stem,
"file.suffix": path.suffix,
"file.ext": path.suffix[1:] if path.suffix.startswith(".") else path.suffix,
"file.index": position,
"attachment.id": attachment.attachment_id or "",
"attachment.label": attachment.label or "",
})
return filename_values
def _render_attachment_filename(
*,
template: str | None,
attachment: ResolvedAttachment,
path: Path,
values: dict[str, Any],
position: int,
) -> str:
if not template:
return path.name
rendered = _render_template(
template,
_attachment_filename_values(values=values, attachment=attachment, path=path, position=position),
keep_missing=False,
)
filename = _safe_filename(rendered, path.name)
if path.suffix and not Path(filename).suffix:
filename = f"{filename}{path.suffix}"
return filename
def _attach_files( def _attach_files(
*, *,
message: EmailMessage, message: EmailMessage,
@@ -301,26 +362,51 @@ def _attach_files(
work_dir: Path, work_dir: Path,
) -> int: ) -> int:
attached_count = 0 attached_count = 0
archive_members: dict[str, list[Path]] = {} archive_members: dict[str, list[tuple[Path, str]]] = {}
archive_attachments: dict[str, list[ResolvedAttachment]] = {} archive_attachments: dict[str, list[ResolvedAttachment]] = {}
used_message_filenames: set[str] = set() used_message_filenames: set[str] = set()
used_zip_member_filenames: dict[str, set[str]] = {}
for attachment in resolution.attachments:
attachment.message_filenames = []
attachment.zip_entry_names = []
attachment.zip_filename = None
for attachment in resolution.attachments: for attachment in resolution.attachments:
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches: if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
continue continue
match_paths = [Path(match) for match in attachment.matches] match_paths = [Path(match) for match in attachment.matches]
if attachment.zip_enabled and attachment.zip_archive_id: if attachment.zip_enabled and attachment.zip_archive_id:
archive_members.setdefault(attachment.zip_archive_id, []).extend(match_paths) used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, set())
for position, path in enumerate(match_paths, start=1):
requested = _render_attachment_filename(
template=attachment.zip_entry_name_template,
attachment=attachment,
path=path,
values=values,
position=position,
)
archive_name = _unique_attachment_filename(requested, used_archive_names)
archive_members.setdefault(attachment.zip_archive_id, []).append((path, archive_name))
attachment.zip_entry_names.append(archive_name)
archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment) archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment)
continue continue
for path in match_paths: for position, path in enumerate(match_paths, start=1):
filename = _unique_attachment_filename(path.name, used_message_filenames) requested = _render_attachment_filename(
template=attachment.message_filename_template,
attachment=attachment,
path=path,
values=values,
position=position,
)
filename = _unique_attachment_filename(requested, used_message_filenames)
attachment.message_filenames.append(filename)
data, maintype, subtype = _attachment_bytes(path) data, maintype, subtype = _attachment_bytes(path)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
attached_count += 1 attached_count += 1
for archive in config.attachments.zip.archives: for archive in config.attachments.zip.archives:
members = _deduplicated_paths(archive_members.get(archive.id, [])) members = _deduplicated_archive_members(archive_members.get(archive.id, []))
if not members: if not members:
continue continue
filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames) filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames)
@@ -329,6 +415,7 @@ def _attach_files(
work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename, work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename,
members, members,
password, password,
archive.method.value,
) )
data, maintype, subtype = _attachment_bytes(archive_path) data, maintype, subtype = _attachment_bytes(archive_path)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
@@ -352,78 +439,219 @@ def _write_eml(message: EmailMessage, output_dir: Path, entry: EntryConfig, entr
return str(path), path.stat().st_size return str(path), path.stat().st_size
def build_entry_message( def _entry_message_context(
*, *,
config: CampaignConfig, config: CampaignConfig,
campaign_file: str | Path, campaign_file: str | Path,
entry: EntryConfig, entry: EntryConfig,
entry_index: int, entry_index: int,
output_dir: Path | None = None, attachment_match_index: AttachmentMatchIndex | None,
write_eml: bool = False, ) -> _EntryMessageContext:
work_dir: Path | None = None, resolution = resolve_entry_attachments(
) -> BuiltMessage: config=config,
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index) campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
match_index=attachment_match_index,
)
effective_addresses = effective_address_lists(config, entry) effective_addresses = effective_address_lists(config, entry)
senders = effective_addresses["from"] senders = effective_addresses["from"]
sender = senders[0] if senders else None sender = senders[0] if senders else None
recipients = {key: value for key, value in effective_addresses.items() if key != "from"} recipients = {key: value for key, value in effective_addresses.items() if key != "from"}
issues = _message_issues_from_attachment_resolution(resolution) issues = _message_issues_from_attachment_resolution(resolution)
validation_status = _validation_status_from_attachment_status(resolution.status) validation_status = _validation_status_from_attachment_status(resolution.status)
validation_status = _apply_ignored_field_override_issues(config, entry, issues, validation_status)
return _EntryMessageContext(
resolution=resolution,
senders=senders,
sender=sender,
recipients=recipients,
issues=issues,
validation_status=validation_status,
)
def _apply_ignored_field_override_issues(
config: CampaignConfig,
entry: EntryConfig,
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
ignored_field_overrides = ignored_entry_field_overrides(config, entry) ignored_field_overrides = ignored_entry_field_overrides(config, entry)
if ignored_field_overrides: if not ignored_field_overrides:
return validation_status
issues.append( issues.append(
MessageIssue( MessageIssue(
severity="warning", severity="warning",
code="field_override_not_allowed", code="field_override_not_allowed",
message="Recipient field value(s) ignored because the campaign field does not allow overrides: " + ", ".join(ignored_field_overrides), message=(
"Recipient field value(s) ignored because the campaign field does not allow overrides: "
+ ", ".join(ignored_field_overrides)
),
behavior="warn", behavior="warn",
source="fields", source="fields",
) )
) )
if validation_status == MessageValidationStatus.READY: if validation_status == MessageValidationStatus.READY:
validation_status = MessageValidationStatus.WARNING return MessageValidationStatus.WARNING
return validation_status
if not entry.active:
draft = MessageDraft( def _message_draft(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
context: _EntryMessageContext,
build_status: BuildStatus,
validation_status: MessageValidationStatus,
send_status: SendStatus = SendStatus.DRAFT,
imap_status: ImapStatus | None = None,
subject: str | None = None,
attachment_count: int = 0,
issues: list[MessageIssue] | None = None,
eml_path: str | None = None,
eml_size: int | None = None,
) -> MessageDraft:
if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
return MessageDraft(
entry_index=entry_index, entry_index=entry_index,
entry_id=entry.id, entry_id=entry.id,
active=False, active=entry.active,
build_status=build_status,
validation_status=validation_status,
send_status=send_status,
imap_status=imap_status,
subject=subject,
from_=_message_address(context.sender),
from_all=_message_addresses(context.senders),
to=_message_addresses(context.recipients["to"]),
cc=_message_addresses(context.recipients["cc"]),
bcc=_message_addresses(context.recipients["bcc"]),
reply_to=_message_addresses(context.recipients["reply_to"]),
bounce_to=_message_addresses(context.recipients["bounce_to"]),
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
attachment_count=attachment_count,
attachments=_attachment_summaries(context.resolution),
issues=issues if issues is not None else context.issues,
eml_path=eml_path,
eml_size_bytes=eml_size,
)
def _inactive_entry_message(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
context: _EntryMessageContext,
) -> BuiltMessage:
draft = _message_draft(
config=config,
entry=entry,
entry_index=entry_index,
context=context,
build_status=BuildStatus.BUILD_FAILED, build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.INACTIVE, validation_status=MessageValidationStatus.INACTIVE,
send_status=SendStatus.DRAFT,
imap_status=ImapStatus.SKIPPED, imap_status=ImapStatus.SKIPPED,
from_=_message_address(sender), issues=[
from_all=_message_addresses(senders), MessageIssue(
to=_message_addresses(recipients["to"]), severity="info",
cc=_message_addresses(recipients["cc"]), code="inactive_entry",
bcc=_message_addresses(recipients["bcc"]), message="Entry is inactive",
reply_to=_message_addresses(recipients["reply_to"]), behavior=config.validation_policy.inactive_entry.value,
bounce_to=_message_addresses(recipients["bounce_to"]), source="entry",
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]), )
attachments=_attachment_summaries(resolution), ],
issues=[MessageIssue(severity="info", code="inactive_entry", message="Entry is inactive", behavior=config.validation_policy.inactive_entry.value, source="entry")],
) )
return BuiltMessage(draft=draft, mime=None) return BuiltMessage(draft=draft, mime=None)
if not recipients["to"]:
behavior = config.validation_policy.missing_email.value
issues.append(_issue_from_behavior(code="missing_email", message="No effective To recipient is configured", behavior=behavior, source="recipients"))
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
def _validate_required_recipients(
config: CampaignConfig,
recipients: dict[str, list[RecipientConfig]],
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
if recipients["to"]:
return validation_status
behavior = config.validation_policy.missing_email.value
issues.append(
_issue_from_behavior(
code="missing_email",
message="No effective To recipient is configured",
behavior=behavior,
source="recipients",
)
)
if behavior == MissingAddressBehavior.BLOCK.value:
return MessageValidationStatus.BLOCKED
return MessageValidationStatus.EXCLUDED
def _validate_required_sender(
senders: list[RecipientConfig],
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
if senders:
return validation_status
issues.append(
MessageIssue(
severity="error",
code="missing_sender",
message="No effective From address is configured; Campaign must resolve the sender before building.",
behavior="block",
source="recipients",
)
)
return MessageValidationStatus.BLOCKED
def _render_message_template(
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
) -> _RenderedMessageTemplate:
subject_template, text_template, html_template = _load_template_parts(config, campaign_file) subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
body_mode = _template_body_mode(config)
values = build_template_values(config, entry) values = build_template_values(config, entry)
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders) subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders)
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders) if text_template is not None else None text_body = None
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders) if html_template is not None else None html_body = None
if text_template is not None:
unresolved = sorted( text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders)
_find_unresolved_placeholders(subject) if html_template is not None:
| _find_unresolved_placeholders(text_body) html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders)
| _find_unresolved_placeholders(html_body) return _RenderedMessageTemplate(
subject=subject,
text_body=text_body,
html_body=html_body,
body_mode=body_mode,
values=values,
) )
if unresolved:
def _unresolved_template_fields(rendered: _RenderedMessageTemplate) -> list[str]:
unresolved_fields = _find_unresolved_placeholders(rendered.subject)
if rendered.body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(rendered.text_body)
if rendered.body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(rendered.html_body)
return sorted(unresolved_fields)
def _validate_rendered_template(
config: CampaignConfig,
rendered: _RenderedMessageTemplate,
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
unresolved = _unresolved_template_fields(rendered)
if not unresolved:
return validation_status
behavior = config.validation_policy.template_error.value behavior = config.validation_policy.template_error.value
issues.append( issues.append(
_issue_from_behavior( _issue_from_behavior(
@@ -433,10 +661,18 @@ def build_entry_message(
source="template", source="template",
) )
) )
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED if behavior == MissingAddressBehavior.BLOCK.value:
return MessageValidationStatus.BLOCKED
return MessageValidationStatus.EXCLUDED
message = EmailMessage()
try: def _populate_message_headers(
message: EmailMessage,
*,
senders: list[RecipientConfig],
recipients: dict[str, list[RecipientConfig]],
subject: str,
) -> None:
message["Date"] = formatdate(localtime=True) message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid() message["Message-ID"] = make_msgid()
if senders: if senders:
@@ -454,70 +690,165 @@ def build_entry_message(
if recipients["reply_to"]: if recipients["reply_to"]:
message["Reply-To"] = _format_recipient_header(recipients["reply_to"]) message["Reply-To"] = _format_recipient_header(recipients["reply_to"])
if recipients["disposition_notification_to"]: if recipients["disposition_notification_to"]:
message["Disposition-Notification-To"] = _format_recipient_header(recipients["disposition_notification_to"]) message["Disposition-Notification-To"] = _format_recipient_header(
recipients["disposition_notification_to"]
)
# bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender. # bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender.
message["Subject"] = subject message["Subject"] = subject
if html_body is not None:
message.set_content(text_body or "")
message.add_alternative(html_body, subtype="html")
else:
message.set_content(text_body or "")
def _populate_message_body(message: EmailMessage, rendered: _RenderedMessageTemplate) -> None:
effective_html_body = rendered.html_body if rendered.html_body and rendered.html_body.strip() else None
if rendered.body_mode == TemplateBodyMode.HTML.value:
message.set_content(effective_html_body or "", subtype="html")
elif rendered.body_mode == TemplateBodyMode.TEXT.value:
message.set_content(rendered.text_body or "")
elif effective_html_body is not None:
message.set_content(rendered.text_body or "")
message.add_alternative(effective_html_body, subtype="html")
else:
message.set_content(rendered.text_body or "")
def _build_mime_message(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
output_dir: Path | None,
work_dir: Path | None,
context: _EntryMessageContext,
rendered: _RenderedMessageTemplate,
) -> _MimeBuildResult:
message = EmailMessage()
try:
_populate_message_headers(
message,
senders=context.senders,
recipients=context.recipients,
subject=rendered.subject,
)
_populate_message_body(message, rendered)
if work_dir is None: if work_dir is None:
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="multimailer-build-")) work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
attachment_count = _attach_files( attachment_count = _attach_files(
message=message, message=message,
config=config, config=config,
entry=entry, entry=entry,
entry_index=entry_index, entry_index=entry_index,
resolution=resolution, resolution=context.resolution,
values=values, values=rendered.values,
work_dir=work_dir, work_dir=work_dir,
) )
build_status = BuildStatus.BUILT if attachment_count == 0 and context.resolution.attachments and _send_without_attachments_behavior(config) == Behavior.BLOCK:
_append_no_attachment_coverage_issue(context.issues)
return _MimeBuildResult(
message=None,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.BLOCKED,
attachment_count=0,
)
return _MimeBuildResult(
message=message,
build_status=BuildStatus.BUILT,
validation_status=context.validation_status,
attachment_count=attachment_count,
)
except ZipBuildError as exc: except ZipBuildError as exc:
issues.append(MessageIssue(severity="error", code=exc.code, message=str(exc), behavior="block", source="attachments")) context.issues.append(
validation_status = MessageValidationStatus.BLOCKED MessageIssue(
build_status = BuildStatus.BUILD_FAILED severity="error",
attachment_count = 0 code=exc.code,
message = None # type: ignore[assignment] message=str(exc),
behavior="block",
source="attachments",
)
)
except Exception as exc: except Exception as exc:
issues.append(MessageIssue(severity="error", code="build_failed", message=str(exc), behavior="block", source="builder")) context.issues.append(
validation_status = MessageValidationStatus.BLOCKED MessageIssue(
build_status = BuildStatus.BUILD_FAILED severity="error",
attachment_count = 0 code="build_failed",
message = None # type: ignore[assignment] message=str(exc),
behavior="block",
source="builder",
)
)
return _MimeBuildResult(
message=None,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.BLOCKED,
attachment_count=0,
)
def build_entry_message(
*,
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
entry_index: int,
output_dir: Path | None = None,
write_eml: bool = False,
work_dir: Path | None = None,
attachment_match_index: AttachmentMatchIndex | None = None,
) -> BuiltMessage:
context = _entry_message_context(
config=config,
campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
attachment_match_index=attachment_match_index,
)
if not entry.active:
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
context.validation_status = _validate_required_sender(
context.senders,
context.issues,
context.validation_status,
)
context.validation_status = _validate_required_recipients(
config,
context.recipients,
context.issues,
context.validation_status,
)
rendered = _render_message_template(config, campaign_file, entry)
context.validation_status = _validate_rendered_template(
config,
rendered,
context.issues,
context.validation_status,
)
mime_result = _build_mime_message(
config=config,
entry=entry,
entry_index=entry_index,
output_dir=output_dir,
work_dir=work_dir,
context=context,
rendered=rendered,
)
eml_path: str | None = None eml_path: str | None = None
eml_size: int | None = None eml_size: int | None = None
if write_eml and output_dir is not None and message is not None: if write_eml and output_dir is not None and mime_result.message is not None:
eml_path, eml_size = _write_eml(message, output_dir, entry, entry_index) eml_path, eml_size = _write_eml(mime_result.message, output_dir, entry, entry_index)
draft = MessageDraft( draft = _message_draft(
config=config,
entry=entry,
entry_index=entry_index, entry_index=entry_index,
entry_id=entry.id, context=context,
active=entry.active, build_status=mime_result.build_status,
build_status=build_status, validation_status=mime_result.validation_status,
validation_status=validation_status, subject=rendered.subject,
send_status=SendStatus.DRAFT, attachment_count=mime_result.attachment_count,
imap_status=_imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED,
subject=subject,
from_=_message_address(sender),
from_all=_message_addresses(senders),
to=_message_addresses(recipients["to"]),
cc=_message_addresses(recipients["cc"]),
bcc=_message_addresses(recipients["bcc"]),
reply_to=_message_addresses(recipients["reply_to"]),
bounce_to=_message_addresses(recipients["bounce_to"]),
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
attachment_count=attachment_count,
attachments=_attachment_summaries(resolution),
issues=issues,
eml_path=eml_path, eml_path=eml_path,
eml_size_bytes=eml_size, eml_size=eml_size,
) )
return BuiltMessage(draft=draft, mime=message) return BuiltMessage(draft=draft, mime=mime_result.message)
@@ -526,6 +857,7 @@ def _unsent_attachment_issues(
config: CampaignConfig, config: CampaignConfig,
campaign_file: str | Path, campaign_file: str | Path,
built_messages: list[BuiltMessage], built_messages: list[BuiltMessage],
attachment_match_index: AttachmentMatchIndex | None = None,
) -> list[MessageIssue]: ) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value behavior = config.validation_policy.unsent_attachment_files.value
if behavior == Behavior.CONTINUE.value: if behavior == Behavior.CONTINUE.value:
@@ -545,6 +877,9 @@ def _unsent_attachment_issues(
directory = _resolve(campaign_file, base_path.path) directory = _resolve(campaign_file, base_path.path)
if not directory.exists() or not directory.is_dir(): if not directory.exists() or not directory.is_dir():
continue continue
if attachment_match_index is not None:
all_files = sorted(path.resolve() for path in attachment_match_index.iter_files(directory, recursive=True))
else:
all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file()) all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file())
unsent = [path for path in all_files if path not in matched_files] unsent = [path for path in all_files if path not in matched_files]
if not unsent: if not unsent:
@@ -587,7 +922,9 @@ def build_campaign_messages(
entries = load_campaign_entries(config, campaign_file=campaign_path) entries = load_campaign_entries(config, campaign_file=campaign_path)
output_path = Path(output_dir).resolve() if output_dir is not None else None output_path = Path(output_dir).resolve() if output_dir is not None else None
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp: started = time.perf_counter()
attachment_match_index = AttachmentMatchIndex()
with tempfile.TemporaryDirectory(prefix="govoplan-build-") as tmp:
work_dir = output_path or Path(tmp) work_dir = output_path or Path(tmp)
built_messages = [ built_messages = [
build_entry_message( build_entry_message(
@@ -598,15 +935,22 @@ def build_campaign_messages(
output_dir=output_path, output_dir=output_path,
write_eml=write_eml, write_eml=write_eml,
work_dir=work_dir, work_dir=work_dir,
attachment_match_index=attachment_match_index,
) )
for index, entry in enumerate(entries, start=1) for index, entry in enumerate(entries, start=1)
if entry.active if entry.active
] ]
_apply_campaign_level_issues( _apply_campaign_level_issues(
built_messages, built_messages,
_unsent_attachment_issues(config=config, campaign_file=campaign_path, built_messages=built_messages), _unsent_attachment_issues(
config=config,
campaign_file=campaign_path,
built_messages=built_messages,
attachment_match_index=attachment_match_index,
),
) )
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
report = CampaignBuildReport( report = CampaignBuildReport(
campaign_id=config.campaign.id, campaign_id=config.campaign.id,
campaign_name=config.campaign.name, campaign_name=config.campaign.name,
@@ -614,5 +958,9 @@ def build_campaign_messages(
entries_count=len(entries), entries_count=len(entries),
inactive_entries_count=sum(1 for entry in entries if not entry.active), inactive_entries_count=sum(1 for entry in entries if not entry.active),
messages=[built.draft for built in built_messages], messages=[built.draft for built in built_messages],
attachment_resolution_profile=attachment_match_index.stats(
duration_ms=(time.perf_counter() - started) * 1000,
rules_resolved=rules_resolved,
),
) )
return CampaignBuildResult(report=report, built_messages=built_messages) return CampaignBuildResult(report=report, built_messages=built_messages)

View File

@@ -55,6 +55,10 @@ class MessageAttachmentSummary(BaseModel):
zip_mode: str = "inherit" zip_mode: str = "inherit"
zip_archive_id: str | None = None zip_archive_id: str | None = None
zip_filename: str | None = None zip_filename: str | None = None
message_filename_template: str | None = None
zip_entry_name_template: str | None = None
message_filenames: list[str] = Field(default_factory=list)
zip_entry_names: list[str] = Field(default_factory=list)
base_path_name: str | None = None base_path_name: str | None = None
base_path: str | None = None base_path: str | None = None
file_filter: str file_filter: str
@@ -109,6 +113,7 @@ class CampaignBuildReport(BaseModel):
entries_count: int entries_count: int
inactive_entries_count: int = 0 inactive_entries_count: int = 0
messages: list[MessageDraft] = Field(default_factory=list) messages: list[MessageDraft] = Field(default_factory=list)
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
@property @property
def built_count(self) -> int: def built_count(self) -> int:

View File

@@ -0,0 +1,71 @@
"""recipient import mapping profiles
Revision ID: 2c3d4e5f7081
Revises: 2e3f4a5b6c7d
Create Date: 2026-06-26 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "2c3d4e5f7081"
down_revision = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
def _scope_fk_target(inspector) -> str:
tables = set(inspector.get_table_names())
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "campaign_recipient_import_mapping_profiles" in inspector.get_table_names():
return
scope_fk_target = _scope_fk_target(inspector)
op.create_table(
"campaign_recipient_import_mapping_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("owner_user_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("column_count", sa.Integer(), nullable=False),
sa.Column("headers", sa.JSON(), nullable=False),
sa.Column("normalized_headers", sa.JSON(), nullable=False),
sa.Column("ordered_header_fingerprint", sa.String(length=64), nullable=False),
sa.Column("unordered_header_fingerprint", sa.String(length=64), nullable=False),
sa.Column("delimiter", sa.String(length=8), nullable=False),
sa.Column("header_rows", sa.Integer(), nullable=False),
sa.Column("quoted", sa.Boolean(), nullable=False),
sa.Column("value_separators", sa.String(length=50), nullable=False),
sa.Column("mappings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_scopes"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_campaign_recipient_import_mapping_profiles")),
)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), "campaign_recipient_import_mapping_profiles", ["tenant_id"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), "campaign_recipient_import_mapping_profiles", ["owner_user_id"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["ordered_header_fingerprint"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["unordered_header_fingerprint"], unique=False)
op.create_index("ix_recipient_import_profiles_owner", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id"], unique=False)
op.create_index("ix_recipient_import_profiles_ordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "ordered_header_fingerprint"], unique=False)
op.create_index("ix_recipient_import_profiles_unordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "unordered_header_fingerprint"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "campaign_recipient_import_mapping_profiles" not in inspector.get_table_names():
return
op.drop_index("ix_recipient_import_profiles_unordered_fp", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index("ix_recipient_import_profiles_ordered_fp", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index("ix_recipient_import_profiles_owner", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_table("campaign_recipient_import_mapping_profiles")

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
"""v0.1.7 campaign baseline
Revision ID: 2c3d4e5f7081
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '2c3d4e5f7081'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('campaign_recipient_import_mapping_profiles',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('owner_user_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('column_count', sa.Integer(), nullable=False),
sa.Column('headers', sa.JSON(), nullable=False),
sa.Column('normalized_headers', sa.JSON(), nullable=False),
sa.Column('ordered_header_fingerprint', sa.String(length=64), nullable=False),
sa.Column('unordered_header_fingerprint', sa.String(length=64), nullable=False),
sa.Column('delimiter', sa.String(length=8), nullable=False),
sa.Column('header_rows', sa.Integer(), nullable=False),
sa.Column('quoted', sa.Boolean(), nullable=False),
sa.Column('value_separators', sa.String(length=50), nullable=False),
sa.Column('mappings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_campaign_recipient_import_mapping_profiles_owner_user_id_access_users'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_campaign_recipient_import_mapping_profiles_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_recipient_import_mapping_profiles'))
)
op.create_index(op.f('ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint'), 'campaign_recipient_import_mapping_profiles', ['ordered_header_fingerprint'], unique=False)
op.create_index(op.f('ix_campaign_recipient_import_mapping_profiles_owner_user_id'), 'campaign_recipient_import_mapping_profiles', ['owner_user_id'], unique=False)
op.create_index(op.f('ix_campaign_recipient_import_mapping_profiles_tenant_id'), 'campaign_recipient_import_mapping_profiles', ['tenant_id'], unique=False)
op.create_index(op.f('ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint'), 'campaign_recipient_import_mapping_profiles', ['unordered_header_fingerprint'], unique=False)
op.create_index('ix_recipient_import_profiles_ordered_fp', 'campaign_recipient_import_mapping_profiles', ['tenant_id', 'owner_user_id', 'ordered_header_fingerprint'], unique=False)
op.create_index('ix_recipient_import_profiles_owner', 'campaign_recipient_import_mapping_profiles', ['tenant_id', 'owner_user_id'], unique=False)
op.create_index('ix_recipient_import_profiles_unordered_fp', 'campaign_recipient_import_mapping_profiles', ['tenant_id', 'owner_user_id', 'unordered_header_fingerprint'], unique=False)
def downgrade() -> None:
op.drop_table('campaign_recipient_import_mapping_profiles')

View File

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

View File

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

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
from pathlib import PureWindowsPath
from typing import Any, Iterable
class CampaignPathSecurityError(ValueError):
"""Raised when server-provided campaign JSON could read local files."""
def _text(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def assert_logical_relative_path(value: object, *, field: str) -> None:
raw = _text(value)
if not raw:
return
if "\x00" in raw:
raise CampaignPathSecurityError(f"{field} contains a NUL byte")
normalized = raw.replace("\\", "/")
windows_path = PureWindowsPath(raw)
if (
normalized.startswith("/")
or normalized.startswith("~")
or windows_path.is_absolute()
or bool(windows_path.drive)
):
raise CampaignPathSecurityError(f"{field} must be a relative managed-file path")
if any(part == ".." for part in normalized.split("/")):
raise CampaignPathSecurityError(f"{field} must not contain parent-directory traversal")
def is_managed_source(value: object) -> bool:
raw = _text(value)
if not raw.startswith("managed:"):
return False
parts = raw.split(":", 2)
return len(parts) == 3 and parts[1] in {"user", "group"} and bool(parts[2].strip())
def _attachment_rules(raw_json: dict[str, Any]) -> Iterable[tuple[str, dict[str, Any]]]:
attachments = raw_json.get("attachments")
if isinstance(attachments, dict):
global_rules = attachments.get("global")
if isinstance(global_rules, list):
for index, rule in enumerate(global_rules):
if isinstance(rule, dict):
yield f"attachments.global[{index}]", rule
entries = raw_json.get("entries")
inline = entries.get("inline") if isinstance(entries, dict) else None
if isinstance(inline, list):
for entry_index, entry in enumerate(inline):
rules = entry.get("attachments") if isinstance(entry, dict) else None
if not isinstance(rules, list):
continue
for rule_index, rule in enumerate(rules):
if isinstance(rule, dict):
yield f"entries.inline[{entry_index}].attachments[{rule_index}]", rule
defaults = entries.get("defaults") if isinstance(entries, dict) else None
default_rules = defaults.get("attachments") if isinstance(defaults, dict) else None
if isinstance(default_rules, list):
for rule_index, rule in enumerate(default_rules):
if isinstance(rule, dict):
yield f"entries.defaults.attachments[{rule_index}]", rule
def _assert_inline_server_sources(
raw_json: dict[str, Any],
*,
source_filename: str | None,
source_base_path: str | None,
) -> None:
if _text(source_filename):
raise CampaignPathSecurityError("source_filename is not accepted for server-managed campaigns")
if _text(source_base_path):
raise CampaignPathSecurityError("source_base_path is not accepted for server-managed campaigns")
template = raw_json.get("template")
template_source = template.get("source") if isinstance(template, dict) else None
path_fields = ("subject_path", "text_path", "html_path")
if isinstance(template_source, dict) and any(_text(template_source.get(key)) for key in path_fields):
raise CampaignPathSecurityError(
"template source paths are not accepted for server-managed campaigns; store template content inline"
)
entries = raw_json.get("entries")
entries_source = entries.get("source") if isinstance(entries, dict) else None
if isinstance(entries_source, dict) and _text(entries_source.get("path")):
raise CampaignPathSecurityError(
"entries.source.path is not accepted for server-managed campaigns; import recipients into the campaign snapshot"
)
def _managed_attachment_base_paths(attachments: dict[str, Any]) -> dict[str, dict[str, Any]]:
assert_logical_relative_path(attachments.get("base_path"), field="attachments.base_path")
raw_base_paths = attachments.get("base_paths")
base_paths = raw_base_paths if isinstance(raw_base_paths, list) else []
managed: dict[str, dict[str, Any]] = {}
for index, item in enumerate(base_paths):
if not isinstance(item, dict):
continue
assert_logical_relative_path(
item.get("path"),
field=f"attachments.base_paths[{index}].path",
)
source_is_managed = is_managed_source(item.get("source"))
base_path_id = _text(item.get("id"))
if base_path_id and source_is_managed:
managed[base_path_id] = item
if item.get("unsent_warning") is True and not source_is_managed:
raise CampaignPathSecurityError(
f"attachments.base_paths[{index}] must use a managed Files source before unsent-file scanning is enabled"
)
return managed
def _assert_managed_attachment_rules(
raw_json: dict[str, Any],
*,
managed_base_paths: dict[str, dict[str, Any]],
managed_files_available: bool,
) -> None:
rules = list(_attachment_rules(raw_json))
if not rules:
return
if not managed_files_available:
raise CampaignPathSecurityError("campaign attachments require the Files module in server mode")
for field, rule in rules:
base_path_id = _text(rule.get("base_path_id"))
if not base_path_id or base_path_id not in managed_base_paths:
raise CampaignPathSecurityError(
f"{field}.base_path_id must select an attachments.base_paths entry backed by managed Files"
)
assert_logical_relative_path(rule.get("base_dir"), field=f"{field}.base_dir")
assert_logical_relative_path(rule.get("file_filter"), field=f"{field}.file_filter")
def assert_server_safe_campaign_paths(
raw_json: dict[str, Any],
*,
source_filename: str | None = None,
source_base_path: str | None = None,
managed_files_available: bool,
) -> None:
"""Reject local-file references at the HTTP/server trust boundary.
File-oriented campaign loading remains available to trusted operator code
through the campaign loader and message builder. Persisted/API campaigns
must use inline templates and recipients plus managed Files attachment
sources, which are materialized into an isolated snapshot before use.
"""
_assert_inline_server_sources(
raw_json,
source_filename=source_filename,
source_base_path=source_base_path,
)
attachments = raw_json.get("attachments")
if not isinstance(attachments, dict):
return
_assert_managed_attachment_rules(
raw_json,
managed_base_paths=_managed_attachment_base_paths(attachments),
managed_files_available=managed_files_available,
)

View File

@@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
import copy
import hashlib import hashlib
import json import json
import os
from datetime import UTC, datetime
from email import policy from email import policy
from email.parser import BytesParser from email.parser import BytesParser
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import copy
from datetime import UTC, datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import func from sqlalchemy import func
@@ -25,19 +26,18 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus, JobSendStatus,
JobValidationStatus, JobValidationStatus,
) )
from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json, validate_against_schema from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_id,
)
from govoplan_campaign.backend.campaign.validation import validate_campaign_config from govoplan_campaign.backend.campaign.validation import validate_campaign_config
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 from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions
from govoplan_campaign.backend.campaign.models import CampaignConfig from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_mail.backend.mail_profiles import materialize_campaign_mail_profile_config from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
prepared_campaign_snapshot,
public_attachment_summary_payload,
)
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime" RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots" CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
@@ -49,8 +49,9 @@ class CampaignPersistenceError(RuntimeError):
def _ensure_dirs() -> None: def _ensure_dirs() -> None:
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) for directory in (CAMPAIGN_SNAPSHOT_DIR, BUILD_OUTPUT_DIR):
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, mode=0o700, exist_ok=True)
directory.chmod(0o700)
@@ -64,22 +65,44 @@ def load_campaign_config_from_json(
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_id: str | None = None, owner_group_id: str | None = None,
) -> CampaignConfig: ) -> CampaignConfig:
materialized = materialize_campaign_mail_profile_config( # Validate the persisted Campaign-to-Mail contract before asking Mail for a
# non-secret capability summary. Campaign never receives resolved transport
# settings, account identities, or credentials.
assert_campaign_uses_mail_profile_reference(raw_json)
validate_against_schema(raw_json)
materialized = copy.deepcopy(raw_json)
profile_id = campaign_mail_profile_id(raw_json)
if profile_id:
summary = mail_integration().campaign_profile_delivery_summary(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
raw_json=raw_json,
campaign_id=campaign_id, campaign_id=campaign_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,
) )
validate_against_schema(materialized) materialized.setdefault("server", {})["profile_capabilities"] = {
"smtp_available": bool(summary.get("smtp_available")),
"imap_available": bool(summary.get("imap_available")),
}
return CampaignConfig.model_validate(materialized) return CampaignConfig.model_validate(materialized)
def _write_campaign_snapshot(version: CampaignVersion) -> Path: def _write_campaign_snapshot(version: CampaignVersion) -> Path:
_ensure_dirs() _ensure_dirs()
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json" path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8") flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o600)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
descriptor = -1
json.dump(version.raw_json, stream, ensure_ascii=False, indent=2)
finally:
if descriptor >= 0:
os.close(descriptor)
return path return path
@@ -98,13 +121,14 @@ def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | No
def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]: def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]:
"""Return a DB/runtime-safe campaign JSON snapshot. """Resolve paths for an explicitly trusted, file-oriented import.
The CLI naturally resolves relative paths against the campaign.json file. The CLI naturally resolves relative paths against the campaign.json file.
Once the campaign is stored in the database, the JSON snapshot lives in Once the campaign is stored in the database, the JSON snapshot lives in
app/mailer/runtime/campaign_snapshots. To keep existing file-based app/mailer/runtime/campaign_snapshots. To keep existing file-based
campaigns working, relative file paths are normalized to absolute paths at campaigns working, relative file paths are normalized to absolute paths at
import time when a source_base_path is known. import time when a source_base_path is known. HTTP/API callers are rejected
by ``assert_server_safe_campaign_paths`` before they can reach this helper.
""" """
base = Path(source_base_path).expanduser().resolve() if source_base_path else None base = Path(source_base_path).expanduser().resolve() if source_base_path else None
data = copy.deepcopy(raw_json) data = copy.deepcopy(raw_json)
@@ -133,7 +157,14 @@ def create_campaign_version_from_json(
raw_json: dict[str, Any], raw_json: dict[str, Any],
source_filename: str | None = None, source_filename: str | None = None,
source_base_path: str | None = None, source_base_path: str | None = None,
commit: bool = True,
) -> tuple[Campaign, CampaignVersion]: ) -> tuple[Campaign, CampaignVersion]:
assert_server_safe_campaign_paths(
raw_json,
source_filename=source_filename,
source_base_path=source_base_path,
managed_files_available=files_integration().available,
)
if source_base_path is None and source_filename: if source_base_path is None and source_filename:
source_path = Path(source_filename).expanduser() source_path = Path(source_filename).expanduser()
source_base_path = str(source_path.parent if source_path.suffix else source_path) source_base_path = str(source_path.parent if source_path.suffix else source_path)
@@ -181,8 +212,11 @@ def create_campaign_version_from_json(
session.flush() session.flush()
campaign.current_version_id = version.id campaign.current_version_id = version.id
session.add(campaign) session.add(campaign)
if commit:
_write_campaign_snapshot(version) _write_campaign_snapshot(version)
session.commit() session.commit()
else:
session.flush()
return campaign, version return campaign, version
@@ -241,8 +275,12 @@ def load_version_config(session: Session, version_id: str):
campaign = session.get(Campaign, version.campaign_id) campaign = session.get(Campaign, version.campaign_id)
if not campaign: if not campaign:
raise CampaignPersistenceError(f"Campaign not found for version: {version_id}") raise CampaignPersistenceError(f"Campaign not found for version: {version_id}")
path = _write_campaign_snapshot(version)
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_server_safe_campaign_paths(
raw_json,
managed_files_available=files_integration().available,
)
path = _write_campaign_snapshot(version)
return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id) return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id)
@@ -274,13 +312,14 @@ def validate_campaign_version(
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.") raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
if check_files: if check_files:
with prepared_campaign_snapshot( files = files_integration()
with files.prepared_campaign_snapshot(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign.id, campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=False, include_bytes=False,
prefix="multimailer-managed-validate-", prefix="govoplan-managed-validate-",
) 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)
@@ -379,7 +418,7 @@ 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_attachments=[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,
) )
@@ -405,22 +444,23 @@ def build_campaign_version(
_ensure_version_validated_and_locked(version) _ensure_version_validated_and_locked(version)
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
with prepared_campaign_snapshot( files = files_integration()
with files.prepared_campaign_snapshot(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign.id, campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True, include_bytes=True,
prefix="multimailer-managed-build-", prefix="govoplan-managed-build-",
) 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)
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)
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) 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): for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
if isinstance(message_payload, dict): if isinstance(message_payload, dict):
message_payload["attachments"] = [public_attachment_summary_payload(item) for item in message.attachments] message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
report_json["built_at"] = datetime.now(UTC).isoformat() report_json["built_at"] = datetime.now(UTC).isoformat()
report_json["build_token"] = uuid4().hex report_json["build_token"] = uuid4().hex
report_json.update({ report_json.update({
@@ -459,17 +499,24 @@ def build_campaign_version(
# records in bulk. This avoids one flush plus several metadata queries per # records in bulk. This avoids one flush plus several metadata queries per
# recipient for large campaigns. # recipient for large campaigns.
session.flush() session.flush()
record_campaign_attachment_uses_for_jobs( files.record_campaign_attachment_uses_for_jobs(
session, session,
[job for job, _message in job_build_pairs], [job for job, _message in job_build_pairs],
stage="built", stage="built",
) )
if not managed_config.server.smtp: if not managed_config.server.profile_capabilities.smtp_available:
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created") 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( execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
version, version,
smtp=managed_config.server.smtp, mail_profile_id=profile_id,
imap=managed_config.server.imap, smtp_transport_revision=revisions["smtp"],
imap_transport_revision=revisions["imap"],
delivery=managed_config.delivery, delivery=managed_config.delivery,
jobs=[job for job, _message in job_build_pairs], jobs=[job for job, _message in job_build_pairs],
build_summary=report_json, build_summary=report_json,

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -18,19 +19,61 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus, JobSendStatus,
) )
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
from govoplan_mail.backend.mail_profiles import assert_campaign_mail_policy_allows_json from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_editor_state_for_edit,
campaign_mail_profile_boundary_violations,
campaign_mail_profile_id,
assert_campaign_uses_mail_profile_reference,
public_campaign_mail_server,
validate_campaign_editor_state,
)
from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.persistence.campaigns import ( from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError, CampaignPersistenceError,
_next_version_number, _next_version_number,
_write_campaign_snapshot, _write_campaign_snapshot,
normalize_campaign_paths, normalize_campaign_paths,
) )
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
class LockedCampaignVersionError(CampaignPersistenceError): class LockedCampaignVersionError(CampaignPersistenceError):
"""Raised when a caller tries to edit an immutable campaign version.""" """Raised when a caller tries to edit an immutable campaign version."""
@dataclass(slots=True)
class _PartialValidationCollector:
section: str | None
issues: list[dict[str, Any]] = field(default_factory=list)
def issue(self, severity: str, section: str, field: str, code: str, message: str) -> None:
if self.section is None or self.section == section:
self.issues.append({
"severity": severity,
"section": section,
"field": field,
"code": code,
"message": message,
})
def result(self) -> dict[str, Any]:
return {
"ok": not any(item["severity"] == "error" for item in self.issues),
"section": self.section,
"error_count": sum(1 for item in self.issues if item["severity"] == "error"),
"warning_count": sum(1 for item in self.issues if item["severity"] == "warning"),
"info_count": sum(1 for item in self.issues if item["severity"] == "info"),
"issues": self.issues,
}
def _require_campaign(session: Session, campaign_id: str) -> Campaign:
campaign = session.get(Campaign, campaign_id)
if campaign is None:
raise CampaignPersistenceError(f"Campaign not found: {campaign_id}")
return campaign
USER_LOCK_TEMPORARY = "temporary" USER_LOCK_TEMPORARY = "temporary"
USER_LOCK_PERMANENT = "permanent" USER_LOCK_PERMANENT = "permanent"
USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT} USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT}
@@ -71,26 +114,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
}, },
"fields": [], "fields": [],
"global_values": {}, "global_values": {},
"server": { "server": {},
"inherit_smtp_credentials": True,
"inherit_imap_credentials": True,
"smtp": {
"host": "",
"port": 587,
"username": "",
"password": "",
"security": "starttls",
},
"imap": {
"enabled": False,
"host": "",
"port": 993,
"username": "",
"password": "",
"security": "tls",
"sent_folder": "auto",
},
},
"recipients": { "recipients": {
"from": [], "from": [],
"allow_individual_from": False, "allow_individual_from": False,
@@ -125,6 +149,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
], ],
"allow_individual": True, "allow_individual": True,
"send_without_attachments": False, "send_without_attachments": False,
"send_without_attachments_behavior": "block",
"global": [], "global": [],
"zip": {"enabled": False, "archives": []}, "zip": {"enabled": False, "archives": []},
"missing_behavior": "ask", "missing_behavior": "ask",
@@ -183,6 +208,7 @@ def create_minimal_campaign(
description: str | None = None, description: str | None = None,
current_flow: str = CampaignVersionFlow.CREATE.value, current_flow: str = CampaignVersionFlow.CREATE.value,
current_step: str = "basics", current_step: str = "basics",
commit: bool = True,
) -> tuple[Campaign, CampaignVersion]: ) -> tuple[Campaign, CampaignVersion]:
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none() existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
if existing: if existing:
@@ -216,8 +242,11 @@ def create_minimal_campaign(
session.flush() session.flush()
campaign.current_version_id = version.id campaign.current_version_id = version.id
session.add(campaign) session.add(campaign)
if commit:
_write_campaign_snapshot(version) _write_campaign_snapshot(version)
session.commit() session.commit()
else:
session.flush()
return campaign, version return campaign, version
@@ -312,6 +341,8 @@ def fork_campaign_version_for_edit(
source_filename: str | None = None, source_filename: str | None = None,
source_base_path: str | None = None, source_base_path: str | None = None,
autosave: bool = True, autosave: bool = True,
migrate_legacy_mail_settings: bool = False,
commit: bool = True,
) -> CampaignVersion: ) -> CampaignVersion:
"""Create the next sole working version from immutable campaign history. """Create the next sole working version from immutable campaign history.
@@ -321,8 +352,7 @@ 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 = session.get(Campaign, campaign_id) campaign = _require_campaign(session, campaign_id)
assert campaign is not None
if campaign_has_active_working_version(session, campaign): if campaign_has_active_working_version(session, campaign):
current = session.get(CampaignVersion, campaign.current_version_id) current = session.get(CampaignVersion, campaign.current_version_id)
@@ -337,9 +367,25 @@ def fork_campaign_version_for_edit(
"Create the next working copy from the campaign's current immutable version." "Create the next working copy from the campaign's current immutable version."
) )
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json) source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
source_requires_mail_migration = bool(campaign_mail_profile_boundary_violations(source_json))
if source_requires_mail_migration and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Create the editable copy from "
"the Mail settings migration action so the audit record is preserved and the copy uses a Mail profile."
)
base_json = raw_json if raw_json is not None else copy.deepcopy(source_json)
if source_requires_mail_migration and raw_json is None:
base_json["server"] = public_campaign_mail_server(source_json)
assert_server_safe_campaign_paths(
base_json,
source_filename=source_filename,
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) runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id) assert_campaign_uses_mail_profile_reference(runtime_json)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
new_version = CampaignVersion( new_version = CampaignVersion(
campaign_id=campaign.id, campaign_id=campaign.id,
@@ -352,7 +398,11 @@ def fork_campaign_version_for_edit(
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value), current_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, current_step=current_step if current_step is not None else source.current_step,
is_complete=False, is_complete=False,
editor_state=editor_state if editor_state is not None else copy.deepcopy(source.editor_state or {}), editor_state=(
validate_campaign_editor_state(editor_state)
if editor_state is not None
else campaign_editor_state_for_edit(source.editor_state)
),
autosaved_at=datetime.now(UTC) if autosave else None, autosaved_at=datetime.now(UTC) if autosave else None,
) )
session.add(new_version) session.add(new_version)
@@ -362,8 +412,11 @@ def fork_campaign_version_for_edit(
campaign.current_version_id = new_version.id campaign.current_version_id = new_version.id
campaign.status = CampaignStatus.DRAFT.value campaign.status = CampaignStatus.DRAFT.value
session.add(campaign) session.add(campaign)
if commit:
_write_campaign_snapshot(new_version) _write_campaign_snapshot(new_version)
session.commit() session.commit()
else:
session.flush()
return new_version return new_version
@@ -421,6 +474,7 @@ def unlock_validated_campaign_version(
tenant_id: str, tenant_id: str,
campaign_id: str, campaign_id: str,
version_id: str, version_id: str,
commit: bool = True,
) -> CampaignVersion: ) -> CampaignVersion:
"""Unlock a validation snapshot so it can be edited again. """Unlock a validation snapshot so it can be edited again.
@@ -430,8 +484,7 @@ def unlock_validated_campaign_version(
""" """
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 = session.get(Campaign, campaign_id) campaign = _require_campaign(session, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="unlock") ensure_current_working_version(campaign, version, action="unlock")
if is_temporary_user_locked_version(version): if is_temporary_user_locked_version(version):
@@ -471,7 +524,10 @@ def unlock_validated_campaign_version(
campaign.status = CampaignStatus.DRAFT.value campaign.status = CampaignStatus.DRAFT.value
session.add(version) session.add(version)
session.add(campaign) session.add(campaign)
if commit:
session.commit() session.commit()
else:
session.flush()
return version return version
def update_campaign_version( def update_campaign_version(
@@ -489,10 +545,18 @@ def update_campaign_version(
source_filename: str | None = None, source_filename: str | None = None,
source_base_path: str | None = None, source_base_path: str | None = None,
autosave: bool = False, autosave: bool = False,
migrate_legacy_mail_settings: bool = False,
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_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 = session.get(Campaign, campaign_id) campaign = _require_campaign(session, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="edit") ensure_current_working_version(campaign, version, action="edit")
if is_version_locked(version): if is_version_locked(version):
@@ -502,7 +566,19 @@ 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 = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id) if campaign_mail_profile_boundary_violations(version.raw_json) and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
"will not be changed automatically."
)
assert_campaign_uses_mail_profile_reference(runtime_json)
if campaign_mail_profile_boundary_violations(version.raw_json) and campaign_mail_profile_id(runtime_json) is None:
raise CampaignPersistenceError(
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
"Select a Mail profile before saving."
)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
version.raw_json = runtime_json version.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)
@@ -516,7 +592,7 @@ def update_campaign_version(
if is_complete is not None: if is_complete is not None:
version.is_complete = is_complete version.is_complete = is_complete
if editor_state is not None: if editor_state is not None:
version.editor_state = editor_state version.editor_state = validate_campaign_editor_state(editor_state)
if source_filename is not None: if source_filename is not None:
version.source_filename = source_filename version.source_filename = source_filename
if source_base_path is not None: if source_base_path is not None:
@@ -539,8 +615,11 @@ def update_campaign_version(
session.add(version) session.add(version)
session.add(campaign) session.add(campaign)
session.flush() session.flush()
if commit:
_write_campaign_snapshot(version) _write_campaign_snapshot(version)
session.commit() session.commit()
else:
session.flush()
return version return version
@@ -553,6 +632,7 @@ def update_campaign_review_state(
inspection_complete: bool, inspection_complete: bool,
reviewed_message_keys: list[str], reviewed_message_keys: list[str],
user_id: str | None, user_id: str | None,
commit: bool = True,
) -> CampaignVersion: ) -> CampaignVersion:
"""Persist review acknowledgement without mutating the locked campaign data. """Persist review acknowledgement without mutating the locked campaign data.
@@ -567,25 +647,48 @@ def update_campaign_review_state(
campaign_id=campaign_id, campaign_id=campaign_id,
version_id=version_id, version_id=version_id,
) )
campaign = session.get(Campaign, campaign_id) campaign = _require_campaign(session, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="record review state for") ensure_current_working_version(campaign, version, action="record review state for")
if is_version_final_locked(version): if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.") raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
build_token = _campaign_review_build_token(version)
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
if inspection_complete:
normalized_reviewed = _complete_campaign_review_keys(session, version, normalized_reviewed)
_write_campaign_review_state(
version,
build_token=build_token,
inspection_complete=inspection_complete,
reviewed_message_keys=normalized_reviewed,
user_id=user_id,
)
session.add(version)
if commit:
session.commit()
else:
session.flush()
return version
def _campaign_review_build_token(version: CampaignVersion) -> str:
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {} build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
if not build_summary: if not build_summary:
raise CampaignPersistenceError("Build messages before recording review state.") raise CampaignPersistenceError("Build messages before recording review state.")
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip() build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
if not build_token: if build_token:
# Backwards-compatible upgrade for build summaries created before return build_token
# review-state tokens were introduced.
build_token = uuid4().hex build_token = uuid4().hex
build_summary = copy.deepcopy(build_summary) updated_summary = copy.deepcopy(build_summary)
build_summary["build_token"] = build_token updated_summary["build_token"] = build_token
version.build_summary = build_summary version.build_summary = updated_summary
return build_token
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
if inspection_complete: def _complete_campaign_review_keys(
session: Session,
version: CampaignVersion,
reviewed_message_keys: list[str],
) -> list[str]:
jobs = ( jobs = (
session.query(CampaignJob) session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id) .filter(CampaignJob.campaign_version_id == version.id)
@@ -595,36 +698,47 @@ def update_campaign_review_state(
blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"] blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"]
if blocking: if blocking:
raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.") raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.")
reviewed = set(normalized_reviewed) missing = sorted(_required_review_keys(jobs) - set(reviewed_message_keys))
explicit = {
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status == "needs_review"
}
missing = sorted(explicit - reviewed)
if missing: if missing:
raise CampaignPersistenceError( raise CampaignPersistenceError(
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing) "Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
) )
bulk_acceptable = [ return list(dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]))
def _required_review_keys(jobs: list[CampaignJob]) -> set[str]:
return {
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status == "needs_review"
}
def _bulk_acceptable_review_keys(jobs: list[CampaignJob]) -> list[str]:
return [
str(job.entry_id or job.entry_index) str(job.entry_id or job.entry_index)
for job in jobs for job in jobs
if job.validation_status in {"warning", "excluded"} if job.validation_status in {"warning", "excluded"}
] ]
normalized_reviewed = list(dict.fromkeys([*normalized_reviewed, *bulk_acceptable]))
def _write_campaign_review_state(
version: CampaignVersion,
*,
build_token: str,
inspection_complete: bool,
reviewed_message_keys: list[str],
user_id: str | None,
) -> None:
editor_state = copy.deepcopy(version.editor_state or {}) editor_state = copy.deepcopy(version.editor_state or {})
editor_state["review_send"] = { editor_state["review_send"] = {
"build_token": build_token, "build_token": build_token,
"inspection_complete": bool(inspection_complete), "inspection_complete": bool(inspection_complete),
"reviewed_message_keys": normalized_reviewed, "reviewed_message_keys": reviewed_message_keys,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(UTC).isoformat(),
"updated_by_user_id": user_id, "updated_by_user_id": user_id,
} }
version.editor_state = editor_state version.editor_state = editor_state
session.add(version)
session.commit()
return version
def lock_campaign_version_temporarily( def lock_campaign_version_temporarily(
@@ -634,6 +748,7 @@ def lock_campaign_version_temporarily(
campaign_id: str, campaign_id: str,
version_id: str, version_id: str,
user_id: str | None, user_id: str | None,
commit: bool = True,
) -> CampaignVersion: ) -> CampaignVersion:
"""Apply a reversible user-requested lock without changing workflow state.""" """Apply a reversible user-requested lock without changing workflow state."""
@@ -643,8 +758,7 @@ def lock_campaign_version_temporarily(
campaign_id=campaign_id, campaign_id=campaign_id,
version_id=version_id, version_id=version_id,
) )
campaign = session.get(Campaign, campaign_id) campaign = _require_campaign(session, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="lock") ensure_current_working_version(campaign, version, action="lock")
if is_version_final_locked(version): if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.") raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
@@ -659,7 +773,10 @@ def lock_campaign_version_temporarily(
version.user_locked_at = datetime.now(UTC) version.user_locked_at = datetime.now(UTC)
version.user_locked_by_user_id = user_id version.user_locked_by_user_id = user_id
session.add(version) session.add(version)
if commit:
session.commit() session.commit()
else:
session.flush()
return version return version
@@ -669,6 +786,7 @@ def unlock_user_locked_campaign_version(
tenant_id: str, tenant_id: str,
campaign_id: str, campaign_id: str,
version_id: str, version_id: str,
commit: bool = True,
) -> CampaignVersion: ) -> CampaignVersion:
"""Remove a reversible user lock without invalidating campaign data.""" """Remove a reversible user lock without invalidating campaign data."""
@@ -678,8 +796,7 @@ def unlock_user_locked_campaign_version(
campaign_id=campaign_id, campaign_id=campaign_id,
version_id=version_id, version_id=version_id,
) )
campaign = session.get(Campaign, campaign_id) campaign = _require_campaign(session, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="unlock") ensure_current_working_version(campaign, version, action="unlock")
state = campaign_version_user_lock_state(version) state = campaign_version_user_lock_state(version)
if state == USER_LOCK_PERMANENT: if state == USER_LOCK_PERMANENT:
@@ -693,7 +810,10 @@ def unlock_user_locked_campaign_version(
version.user_locked_at = None version.user_locked_at = None
version.user_locked_by_user_id = None version.user_locked_by_user_id = None
session.add(version) session.add(version)
if commit:
session.commit() session.commit()
else:
session.flush()
return version return version
@@ -704,6 +824,7 @@ def permanently_lock_campaign_version(
campaign_id: str, campaign_id: str,
version_id: str, version_id: str,
user_id: str | None, user_id: str | None,
commit: bool = True,
) -> CampaignVersion: ) -> CampaignVersion:
"""Apply an irreversible user lock. """Apply an irreversible user lock.
@@ -717,8 +838,7 @@ def permanently_lock_campaign_version(
campaign_id=campaign_id, campaign_id=campaign_id,
version_id=version_id, version_id=version_id,
) )
campaign = session.get(Campaign, campaign_id) campaign = _require_campaign(session, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="lock permanently") ensure_current_working_version(campaign, version, action="lock permanently")
if is_version_final_locked(version): if is_version_final_locked(version):
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.") raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
@@ -732,7 +852,10 @@ def permanently_lock_campaign_version(
# Retain published_at as a compatibility marker for existing integrations. # Retain published_at as a compatibility marker for existing integrations.
version.published_at = version.published_at or now version.published_at = version.published_at or now
session.add(version) session.add(version)
if commit:
session.commit() session.commit()
else:
session.flush()
return version return version
@@ -743,6 +866,7 @@ def publish_campaign_version(
campaign_id: str, campaign_id: str,
version_id: str, version_id: str,
user_id: str | None = None, user_id: str | None = None,
commit: bool = True,
) -> CampaignVersion: ) -> CampaignVersion:
"""Backwards-compatible alias for the permanent user lock.""" """Backwards-compatible alias for the permanent user lock."""
@@ -752,6 +876,7 @@ def publish_campaign_version(
campaign_id=campaign_id, campaign_id=campaign_id,
version_id=version_id, version_id=version_id,
user_id=user_id, user_id=user_id,
commit=commit,
) )
@@ -762,66 +887,122 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
lets the WebUI autosave and validate one wizard step at a time. lets the WebUI autosave and validate one wizard step at a time.
""" """
issues: list[dict[str, Any]] = [] collector = _PartialValidationCollector(section=section)
_validate_partial_basics(collector, _dict_value(raw_json, "campaign"))
recipients = _dict_value(raw_json, "recipients")
_validate_partial_sender(collector, recipients)
_validate_partial_mail_profile(collector, raw_json)
_validate_partial_recipients(collector, _dict_value(raw_json, "entries"))
_validate_partial_template(collector, _dict_value(raw_json, "template"))
_validate_partial_attachments(collector, _dict_value(raw_json, "attachments"))
_validate_partial_delivery(collector, _dict_value(raw_json, "delivery"))
return collector.result()
def issue(severity: str, sec: str, field: str, code: str, message: str) -> None:
if section is None or section == sec:
issues.append({
"severity": severity,
"section": sec,
"field": field,
"code": code,
"message": message,
})
campaign = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} def _dict_value(value: dict[str, Any], key: str) -> dict[str, Any]:
candidate = value.get(key)
return candidate if isinstance(candidate, dict) else {}
def _validate_partial_basics(collector: _PartialValidationCollector, campaign: dict[str, Any]) -> None:
if not campaign.get("id"): if not campaign.get("id"):
issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.") collector.issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
if not campaign.get("name"): if not campaign.get("name"):
issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.") collector.issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
recipients = raw_json.get("recipients") if isinstance(raw_json.get("recipients"), dict) else {}
def _validate_partial_sender(collector: _PartialValidationCollector, recipients: dict[str, Any]) -> None:
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {} sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
if not sender.get("email"): if not sender.get("email"):
issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.") collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
entries = raw_json.get("entries") if isinstance(raw_json.get("entries"), dict) else {}
def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_json: dict[str, Any]) -> None:
violations = campaign_mail_profile_boundary_violations(raw_json)
if violations:
collector.issue(
"error",
"sender",
"server",
"campaign_local_mail_transport_forbidden",
"Campaign-local SMTP/IMAP settings are not supported. Select or migrate to an authorized Mail-module profile.",
)
elif campaign_mail_profile_id(raw_json) is None:
collector.issue(
"warning",
"sender",
"server.mail_profile_id",
"missing_mail_profile",
"Select an authorized Mail-module profile before validating or delivering this campaign.",
)
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
has_inline = bool(entries.get("inline")) has_inline = bool(entries.get("inline"))
has_source = isinstance(entries.get("source"), dict) has_source = isinstance(entries.get("source"), dict)
if not has_inline and not has_source: if not has_inline and not has_source:
issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.") collector.issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
if has_source: if has_source and not _entries_source_has_email_mapping(entries):
collector.issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool:
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {} mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
if not any(key in mapping for key in ("to.0.email", "to.email", "email")): return any(key in mapping for key in ("to.0.email", "to.email", "email"))
issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {}
if not template.get("subject") and not (isinstance(template.get("source"), dict) and template["source"].get("subject_path")):
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
if not template.get("text") and not template.get("html") and not isinstance(template.get("source"), dict):
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {} def _validate_partial_template(collector: _PartialValidationCollector, template: dict[str, Any]) -> None:
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
if not template.get("subject") and not source_template.get("subject_path"):
collector.issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
body_state = _partial_template_body_state(template, source_template)
_validate_partial_template_body(collector, body_state)
def _partial_template_body_state(template: dict[str, Any], source_template: dict[str, Any]) -> dict[str, Any]:
return {
"mode": template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None,
"has_text": bool(template.get("text")) or bool(source_template.get("text_path")),
"has_html": bool(template.get("html")) or bool(source_template.get("html_path")),
"has_source": bool(source_template),
}
def _validate_partial_template_body(collector: _PartialValidationCollector, state: dict[str, Any]) -> None:
mode = state["mode"]
has_text = bool(state["has_text"])
has_html = bool(state["has_html"])
if mode == "text" and not has_text:
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
elif mode == "html" and not has_html:
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
elif mode == "both":
_validate_partial_dual_body_template(collector, has_text=has_text, has_html=has_html)
elif not has_text and not has_html and not state["has_source"]:
collector.issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
def _validate_partial_dual_body_template(collector: _PartialValidationCollector, *, has_text: bool, has_html: bool) -> None:
if not has_text:
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
if not has_html:
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
def _validate_partial_attachments(collector: _PartialValidationCollector, attachments: dict[str, Any]) -> None:
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else [] base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths) has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths)
if not has_named_base_path and not attachments.get("base_path"): if not has_named_base_path and not attachments.get("base_path"):
issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.") collector.issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {}
def _validate_partial_delivery(collector: _PartialValidationCollector, delivery: dict[str, Any]) -> None:
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {} rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
messages_per_minute = rate_limit.get("messages_per_minute") messages_per_minute = rate_limit.get("messages_per_minute")
if messages_per_minute is not None: if messages_per_minute is None:
return
try: try:
if int(messages_per_minute) < 1: if int(messages_per_minute) < 1:
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.") collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
except (TypeError, ValueError): except (TypeError, ValueError):
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.") collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")
return {
"ok": not any(item["severity"] == "error" for item in issues),
"section": section,
"error_count": sum(1 for item in issues if item["severity"] == "error"),
"warning_count": sum(1 for item in issues if item["severity"] == "warning"),
"info_count": sum(1 for item in issues if item["severity"] == "info"),
"issues": issues,
}

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import csv import csv
import io import io
import logging
import math import math
from collections import Counter from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -20,12 +21,20 @@ from govoplan_campaign.backend.db.models import (
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
public_delivery_result_message,
public_source_filename,
)
class CampaignReportError(RuntimeError): class CampaignReportError(RuntimeError):
pass pass
logger = logging.getLogger(__name__)
def _utcnow_iso() -> str: def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
@@ -55,23 +64,38 @@ def _selected_version(
return version return version
def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None: def _version_info(
version: CampaignVersion | None,
*,
include_diagnostics: bool = False,
) -> dict[str, Any] | None:
if not version: if not version:
return None return None
return { return {
"id": version.id, "id": version.id,
"version_number": version.version_number, "version_number": version.version_number,
"schema_version": version.schema_version, "schema_version": version.schema_version,
"source_filename": version.source_filename, "source_filename": public_source_filename(version.source_filename),
"created_at": version.created_at.isoformat() if version.created_at else None, "created_at": version.created_at.isoformat() if version.created_at else None,
"validation_summary": version.validation_summary, "validation_summary": public_campaign_payload(
"build_summary": version.build_summary, version.validation_summary,
include_diagnostics=include_diagnostics,
),
"build_summary": public_campaign_payload(
version.build_summary,
include_diagnostics=include_diagnostics,
),
"execution_snapshot_hash": version.execution_snapshot_hash, "execution_snapshot_hash": version.execution_snapshot_hash,
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None, "execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
} }
def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, Any]: def _load_delivery_info(
version: CampaignVersion | None,
jobs: list[CampaignJob],
*,
include_diagnostics: bool = False,
) -> dict[str, Any]:
"""Read deterministic delivery settings from the immutable execution snapshot.""" """Read deterministic delivery settings from the immutable execution snapshot."""
default = { default = {
@@ -81,25 +105,31 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
"execution_snapshot_hash": None, "execution_snapshot_hash": None,
"execution_snapshot_at": None, "execution_snapshot_at": None,
"snapshot_version": None, "snapshot_version": None,
"build_token": None,
"built_at": None, "built_at": None,
"job_manifest_sha256": None,
"job_count": 0, "job_count": 0,
"queueable_job_count": 0, "queueable_job_count": 0,
"effective_policy_sha256": None,
"smtp_config_fingerprint": None,
"imap_config_fingerprint": None,
"estimated_remaining_send_seconds": None, "estimated_remaining_send_seconds": None,
"estimated_remaining_send_human": None, "estimated_remaining_send_human": None,
}
if include_diagnostics:
default.update(
{
"build_token": None, # nosec B105 - absent report value, not a credential.
"job_manifest_sha256": None,
"effective_policy_sha256": None,
"smtp_transport_revision": None,
"imap_transport_revision": None,
"background_workers_enabled": bool(core_settings.celery_enabled), "background_workers_enabled": bool(core_settings.celery_enabled),
} }
)
if not version or not isinstance(version.execution_snapshot, dict): if not version or not isinstance(version.execution_snapshot, dict):
default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery." default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery."
return default return default
try: try:
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot) snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
except Exception as exc: # pragma: no cover - reporting should remain available except Exception: # pragma: no cover - reporting should remain available
default["load_error"] = str(exc) logger.warning("Campaign execution snapshot could not be loaded for a report")
default["load_error"] = "Execution snapshot is invalid; rebuild the selected version before delivery."
return default return default
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
@@ -108,7 +138,7 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
if messages_per_minute and pending: if messages_per_minute and pending:
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60)) estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60))
return { result = {
"rate_limit": { "rate_limit": {
"messages_per_minute": messages_per_minute, "messages_per_minute": messages_per_minute,
"concurrency": snapshot.delivery.rate_limit.concurrency, "concurrency": snapshot.delivery.rate_limit.concurrency,
@@ -124,18 +154,24 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
"execution_snapshot_hash": version.execution_snapshot_hash, "execution_snapshot_hash": version.execution_snapshot_hash,
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None, "execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
"snapshot_version": snapshot.snapshot_version, "snapshot_version": snapshot.snapshot_version,
"build_token": snapshot.build_token,
"built_at": snapshot.built_at, "built_at": snapshot.built_at,
"job_manifest_sha256": snapshot.job_manifest_sha256,
"job_count": snapshot.job_count, "job_count": snapshot.job_count,
"queueable_job_count": snapshot.queueable_job_count, "queueable_job_count": snapshot.queueable_job_count,
"effective_policy_sha256": snapshot.effective_policy_sha256,
"smtp_config_fingerprint": snapshot.smtp_config_fingerprint,
"imap_config_fingerprint": snapshot.imap_config_fingerprint,
"estimated_remaining_send_seconds": estimated_seconds, "estimated_remaining_send_seconds": estimated_seconds,
"estimated_remaining_send_human": _human_duration(estimated_seconds), "estimated_remaining_send_human": _human_duration(estimated_seconds),
}
if include_diagnostics:
result.update(
{
"build_token": snapshot.build_token,
"job_manifest_sha256": snapshot.job_manifest_sha256,
"effective_policy_sha256": snapshot.effective_policy_sha256,
"smtp_transport_revision": snapshot.smtp_transport_revision,
"imap_transport_revision": snapshot.imap_transport_revision,
"background_workers_enabled": bool(core_settings.celery_enabled), "background_workers_enabled": bool(core_settings.celery_enabled),
} }
)
return result
def _human_duration(seconds: int | None) -> str | None: def _human_duration(seconds: int | None) -> str | None:
@@ -222,15 +258,19 @@ def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[s
"send_status": job.send_status, "send_status": job.send_status,
"imap_status": job.imap_status, "imap_status": job.imap_status,
"attempt_count": job.attempt_count, "attempt_count": job.attempt_count,
"last_error": job.last_error, "last_error": public_delivery_result_message(
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
),
"updated_at": job.updated_at.isoformat() if job.updated_at else None, "updated_at": job.updated_at.isoformat() if job.updated_at else None,
} }
for job in failed[:limit] for job in failed[:limit]
] ]
def _job_row(job: CampaignJob) -> dict[str, Any]: def _job_row(job: CampaignJob, *, include_diagnostics: bool = False) -> dict[str, Any]:
return { row = {
"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,
@@ -243,17 +283,115 @@ def _job_row(job: CampaignJob) -> dict[str, Any]:
"imap_status": job.imap_status, "imap_status": job.imap_status,
"attempt_count": job.attempt_count, "attempt_count": job.attempt_count,
"queued_at": job.queued_at.isoformat() if job.queued_at else None, "queued_at": job.queued_at.isoformat() if job.queued_at else None,
"claimed_at": job.claimed_at.isoformat() if job.claimed_at else None,
"smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None,
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None, "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,
"last_error": job.last_error, "last_error": public_delivery_result_message(
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
),
"eml_size_bytes": job.eml_size_bytes, "eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256, "eml_sha256": job.eml_sha256,
"issues_count": len(job.issues_snapshot or []), "issues_count": len(job.issues_snapshot or []),
"attachment_config_count": len(job.resolved_attachments or []), "attachment_config_count": len(job.resolved_attachments or []),
"matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)), "matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)),
} }
if include_diagnostics:
row.update(
{
"claimed_at": job.claimed_at.isoformat() if job.claimed_at else None,
"smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None,
}
)
return row
def _address_summary(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, dict):
email = str(value.get("email") or "").strip()
name = str(value.get("name") or "").strip()
if name and email:
return f"{name} <{email}>"
return email or name
if isinstance(value, list):
return "; ".join(item for item in (_address_summary(item) for item in value) if item)
return str(value)
def _latest_by_job_id(attempts: list[Any]) -> dict[str, Any]:
latest: dict[str, Any] = {}
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
def _attachment_names(attachments: list[dict[str, Any]] | None) -> str:
names: list[str] = []
for attachment in attachments or []:
if not isinstance(attachment, dict):
continue
for key in ("message_filenames", "zip_entry_names"):
values = attachment.get(key)
if isinstance(values, list):
names.extend(str(value) for value in values if value)
zip_filename = attachment.get("zip_filename")
if zip_filename:
names.append(str(zip_filename))
matches = attachment.get("matches")
if isinstance(matches, list):
for match in matches:
if isinstance(match, dict):
filename = match.get("filename") or match.get("name") or match.get("display_name")
if filename:
names.append(str(filename))
elif match:
names.append(str(match).rsplit("/", 1)[-1])
seen: set[str] = set()
deduplicated = []
for name in names:
if name and name not in seen:
seen.add(name)
deduplicated.append(name)
return "; ".join(deduplicated)
def _job_evidence_row(
job: CampaignJob,
*,
latest_smtp: SendAttempt | None = None,
latest_imap: ImapAppendAttempt | None = None,
include_diagnostics: bool = False,
) -> dict[str, Any]:
row = _job_row(job, include_diagnostics=include_diagnostics)
recipients = job.resolved_recipients or {}
row.update({
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"message_id_header": job.message_id_header,
"from": _address_summary(recipients.get("from")),
"to": _address_summary(recipients.get("to")),
"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),
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None,
"latest_smtp_started_at": latest_smtp.started_at.isoformat() if latest_smtp and latest_smtp.started_at else None,
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None,
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap else None,
"latest_imap_status": latest_imap.status if latest_imap else None,
"latest_imap_folder": latest_imap.folder if latest_imap else None,
"latest_imap_created_at": latest_imap.created_at.isoformat() if latest_imap and latest_imap.created_at else None,
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None,
})
return row
def generate_campaign_report( def generate_campaign_report(
@@ -263,7 +401,8 @@ def generate_campaign_report(
campaign_id: str, campaign_id: str,
version_id: str | None = None, version_id: str | None = None,
include_jobs: bool = False, include_jobs: bool = False,
include_recent_failures: bool = True, include_recent_failures: bool = False,
include_diagnostics: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Generate a dashboard/report payload for one campaign. """Generate a dashboard/report payload for one campaign.
@@ -274,38 +413,136 @@ 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)
report = _campaign_report_payload(
session,
campaign=campaign,
version=version,
jobs=jobs,
tenant_id=tenant_id,
include_recent_failures=include_recent_failures,
include_diagnostics=include_diagnostics,
)
if include_jobs:
report["jobs"] = [
_job_row(job, include_diagnostics=include_diagnostics)
for job in jobs
]
return report
def _report_jobs(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> list[CampaignJob]:
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,
) )
if version: if version:
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)
jobs = ( return jobs_query.order_by(CampaignJob.entry_index.asc()).all()
jobs_query
.order_by(CampaignJob.entry_index.asc())
.all() def _campaign_report_payload(
) session: Session,
*,
campaign: Campaign,
version: CampaignVersion | None,
jobs: list[CampaignJob],
tenant_id: str,
include_recent_failures: bool,
include_diagnostics: bool,
) -> dict[str, Any]:
job_ids = [job.id for job in jobs] job_ids = [job.id for job in jobs]
send_attempts = session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count() if job_ids else 0 report = {
imap_attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count() if job_ids else 0 "generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign),
"current_version": _version_info(version, include_diagnostics=include_diagnostics),
"selected_version_id": version.id if version else None,
"cards": _campaign_report_cards(version, jobs),
"status_counts": _campaign_report_status_counts(version, jobs),
"issues": {
**_issue_summary_from_jobs(jobs),
"persisted_campaign_issue_count": _persisted_campaign_issue_count(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
},
"attachments": _attachment_summary(jobs),
"attempts": _campaign_report_attempt_counts(session, job_ids),
"delivery": _load_delivery_info(
version,
jobs,
include_diagnostics=include_diagnostics,
),
}
if include_recent_failures:
report["recent_failures"] = _recent_failures(jobs)
return report
def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
return {
"id": campaign.id,
"external_id": campaign.external_id,
"name": campaign.name,
"description": campaign.description,
"status": campaign.status,
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
}
def _campaign_report_attempt_counts(session: Session, job_ids: list[str]) -> dict[str, int]:
return {
"send_attempts": int(session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
"imap_append_attempts": int(session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
}
def _persisted_campaign_issue_count(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> int:
issue_query = session.query(CampaignIssue).filter( issue_query = session.query(CampaignIssue).filter(
CampaignIssue.tenant_id == tenant_id, CampaignIssue.tenant_id == tenant_id,
CampaignIssue.campaign_id == campaign.id, CampaignIssue.campaign_id == campaign_id,
) )
if version: if version:
issue_query = issue_query.filter(CampaignIssue.campaign_version_id == version.id) issue_query = issue_query.filter(CampaignIssue.campaign_version_id == version.id)
else: else:
issue_query = issue_query.filter(False) issue_query = issue_query.filter(False)
persisted_issues = issue_query.count() return int(issue_query.count())
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]) validation_counts = _counter([job.validation_status for job in jobs])
queue_counts = _counter([job.queue_status for job in jobs]) inactive_entries = _inactive_entry_count(version)
if inactive_entries:
validation_counts["inactive"] = inactive_entries
return {
"build": _counter([job.build_status for job in jobs]),
"validation": validation_counts,
"queue": _counter([job.queue_status for job in jobs]),
"send": _counter([job.send_status for job in jobs]),
"imap": _counter([job.imap_status for job in jobs]),
}
def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]:
send_counts = _counter([job.send_status for job in jobs]) send_counts = _counter([job.send_status for job in jobs])
imap_counts = _counter([job.imap_status for job in jobs]) imap_counts = _counter([job.imap_status for job in jobs])
build_counts = _counter([job.build_status for job in jobs])
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built") queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
needs_attention = sum( needs_attention = sum(
1 1
@@ -320,25 +557,8 @@ def generate_campaign_report(
not_attempted = send_counts.get("not_queued", 0) not_attempted = send_counts.get("not_queued", 0)
queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 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)
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {} inactive_entries = _inactive_entry_count(version)
inactive_entries = int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0) return {
if inactive_entries:
validation_counts["inactive"] = inactive_entries
report: dict[str, Any] = {
"generated_at": _utcnow_iso(),
"campaign": {
"id": campaign.id,
"external_id": campaign.external_id,
"name": campaign.name,
"description": campaign.description,
"status": campaign.status,
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
},
"current_version": _version_info(version),
"selected_version_id": version.id if version else None,
"cards": {
"jobs_total": len(jobs), "jobs_total": len(jobs),
"inactive": inactive_entries, "inactive": inactive_entries,
"queueable": queueable, "queueable": queueable,
@@ -353,30 +573,12 @@ def generate_campaign_report(
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)), "partially_completed": bool(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),
},
"status_counts": {
"build": build_counts,
"validation": validation_counts,
"queue": queue_counts,
"send": send_counts,
"imap": imap_counts,
},
"issues": {
**_issue_summary_from_jobs(jobs),
"persisted_campaign_issue_count": persisted_issues,
},
"attachments": _attachment_summary(jobs),
"attempts": {
"send_attempts": int(send_attempts),
"imap_append_attempts": int(imap_attempts),
},
"delivery": _load_delivery_info(version, jobs),
} }
if include_recent_failures:
report["recent_failures"] = _recent_failures(jobs)
if include_jobs: def _inactive_entry_count(version: CampaignVersion | None) -> int:
report["jobs"] = [_job_row(job) for job in jobs] build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
return report return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
def generate_jobs_csv( def generate_jobs_csv(
@@ -385,6 +587,7 @@ def generate_jobs_csv(
tenant_id: str, tenant_id: str,
campaign_id: str, campaign_id: str,
version_id: str | None = None, version_id: str | None = None,
include_diagnostics: bool = False,
) -> str: ) -> str:
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)
@@ -401,13 +604,48 @@ def generate_jobs_csv(
.order_by(CampaignJob.entry_index.asc()) .order_by(CampaignJob.entry_index.asc())
.all() .all()
) )
rows = [_job_row(job) for job in jobs] job_ids = [job.id for job in jobs]
smtp_attempts = (
session.query(SendAttempt)
.filter(SendAttempt.job_id.in_(job_ids))
.order_by(SendAttempt.job_id.asc(), SendAttempt.attempt_number.asc())
.all()
if job_ids
else []
)
imap_attempts = (
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id.in_(job_ids))
.order_by(ImapAppendAttempt.job_id.asc(), ImapAppendAttempt.attempt_number.asc())
.all()
if job_ids
else []
)
latest_smtp = _latest_by_job_id(smtp_attempts)
latest_imap = _latest_by_job_id(imap_attempts)
rows = [
_job_evidence_row(
job,
latest_smtp=latest_smtp.get(job.id),
latest_imap=latest_imap.get(job.id),
include_diagnostics=include_diagnostics,
)
for job in jobs
]
fieldnames = [ fieldnames = [
"job_id", "job_id",
"campaign_id",
"campaign_version_id",
"entry_index", "entry_index",
"entry_id", "entry_id",
"recipient_email", "recipient_email",
"from",
"to",
"cc",
"bcc",
"reply_to",
"subject", "subject",
"message_id_header",
"build_status", "build_status",
"validation_status", "validation_status",
"queue_status", "queue_status",
@@ -415,8 +653,6 @@ def generate_jobs_csv(
"imap_status", "imap_status",
"attempt_count", "attempt_count",
"queued_at", "queued_at",
"claimed_at",
"smtp_started_at",
"outcome_unknown_at", "outcome_unknown_at",
"sent_at", "sent_at",
"last_error", "last_error",
@@ -425,7 +661,21 @@ def generate_jobs_csv(
"issues_count", "issues_count",
"attachment_config_count", "attachment_config_count",
"matched_file_count", "matched_file_count",
"attachment_names",
"latest_smtp_attempt_number",
"latest_smtp_status",
"latest_smtp_status_code",
"latest_smtp_started_at",
"latest_smtp_finished_at",
"latest_imap_attempt_number",
"latest_imap_status",
"latest_imap_folder",
"latest_imap_created_at",
"latest_imap_updated_at",
] ]
if include_diagnostics:
sent_at_index = fieldnames.index("outcome_unknown_at")
fieldnames[sent_at_index:sent_at_index] = ["claimed_at", "smtp_started_at"]
buffer = io.StringIO() buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=fieldnames) writer = csv.DictWriter(buffer, fieldnames=fieldnames)
writer.writeheader() writer.writeheader()

View File

@@ -9,11 +9,12 @@ from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_campaign.backend.campaign.loader import load_campaign_config from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot from govoplan_campaign.backend.persistence.campaigns import load_version_config
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendResult, send_email_message from govoplan_campaign.backend.integrations import SmtpConfigurationError
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
class CampaignReportEmailError(RuntimeError): class CampaignReportEmailError(RuntimeError):
@@ -30,8 +31,6 @@ class CampaignReportEmailResult:
sent: bool sent: bool
attached_jobs_csv: bool attached_jobs_csv: bool
attached_report_json: bool attached_report_json: bool
smtp_host: str | None = None
smtp_port: int | None = None
accepted_count: int | None = None accepted_count: int | None = None
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
@@ -44,8 +43,6 @@ class CampaignReportEmailResult:
"sent": self.sent, "sent": self.sent,
"attached_jobs_csv": self.attached_jobs_csv, "attached_jobs_csv": self.attached_jobs_csv,
"attached_report_json": self.attached_report_json, "attached_report_json": self.attached_report_json,
"smtp_host": self.smtp_host,
"smtp_port": self.smtp_port,
"accepted_count": self.accepted_count, "accepted_count": self.accepted_count,
} }
@@ -62,17 +59,15 @@ def _selected_version(
return version return version
def _load_config(version: CampaignVersion) -> CampaignConfig: def _load_config(session: Session, version: CampaignVersion) -> CampaignConfig:
snapshot_path = _write_campaign_snapshot(version) _campaign, _version, config = load_version_config(session, version.id)
return load_campaign_config(snapshot_path) return config
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]: def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
if config.recipients.from_: if config.recipients.from_:
return config.recipients.from_[0].email, config.recipients.from_[0].name return config.recipients.from_[0].email, config.recipients.from_[0].name
if config.server.smtp and config.server.smtp.username and "@" in config.server.smtp.username: raise SmtpConfigurationError("Report email requires a Campaign-owned recipients.from address")
return config.server.smtp.username, None
raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address")
def _text_summary(report: dict[str, Any]) -> str: def _text_summary(report: dict[str, Any]) -> str:
@@ -104,7 +99,7 @@ def _text_summary(report: dict[str, Any]) -> str:
] ]
if delivery.get("estimated_remaining_send_human"): if delivery.get("estimated_remaining_send_human"):
lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"]) lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"])
lines.extend(["", "This report was generated by MultiMailer."]) lines.extend(["", "This report was generated by GovOPlaN."])
return "\n".join(lines) return "\n".join(lines)
@@ -118,20 +113,20 @@ def build_report_message(
report_json: dict[str, Any] | None = None, report_json: dict[str, Any] | None = None,
) -> EmailMessage: ) -> EmailMessage:
from_email, from_name = _effective_from(config) from_email, from_name = _effective_from(config)
subject = f"MultiMailer report: {campaign.name}" subject = f"GovOPlaN report: {campaign.name}"
msg = EmailMessage() msg = EmailMessage()
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = formataddr((from_name or from_email, from_email)) msg["From"] = formataddr((from_name or from_email, from_email))
msg["To"] = ", ".join(to) msg["To"] = ", ".join(to)
msg["X-MultiMailer-Report"] = "campaign" msg["X-GovOPlaN-Report"] = "campaign"
msg.set_content(_text_summary(report)) msg.set_content(_text_summary(report))
if jobs_csv is not None: if jobs_csv is not None:
filename = f"multimailer-{campaign.external_id}-jobs.csv" filename = f"govoplan-{campaign.external_id}-jobs.csv"
msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename) msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename)
if report_json is not None: if report_json is not None:
filename = f"multimailer-{campaign.external_id}-report.json" filename = f"govoplan-{campaign.external_id}-report.json"
msg.add_attachment( msg.add_attachment(
json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"), json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"),
maintype="application", maintype="application",
@@ -149,7 +144,7 @@ def send_campaign_report_email(
version_id: str | None = None, version_id: str | None = None,
to: list[str], to: list[str],
include_jobs: bool = False, include_jobs: bool = False,
attach_jobs_csv: bool = True, attach_jobs_csv: bool = False,
attach_report_json: bool = False, attach_report_json: bool = False,
dry_run: bool = False, dry_run: bool = False,
) -> CampaignReportEmailResult: ) -> CampaignReportEmailResult:
@@ -160,10 +155,28 @@ def send_campaign_report_email(
raise CampaignReportEmailError("At least one report recipient is required") raise CampaignReportEmailError("At least one report recipient is required")
version = _selected_version(session, campaign, version_id) version = _selected_version(session, campaign, version_id)
config = _load_config(version) config = _load_config(session, version)
smtp_config: SmtpConfig | None = config.server.smtp if not config.server.profile_capabilities.smtp_available:
if smtp_config is None:
raise SmtpConfigurationError("Campaign has no SMTP configuration") raise SmtpConfigurationError("Campaign has no SMTP configuration")
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
if not profile_id:
raise SmtpConfigurationError("Campaign has no Mail profile reference")
if not isinstance(version.execution_snapshot, dict):
raise CampaignReportEmailError(
"Report email requires a validated and built campaign version with stored Mail-profile evidence."
)
try:
snapshot = ensure_execution_snapshot(session, version)
except ExecutionSnapshotError as exc:
raise CampaignReportEmailError(
"Report email requires a validated and built campaign version with current Mail-profile evidence."
) from exc
if not snapshot.smtp_transport_revision:
raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision")
if not dry_run:
raise CampaignReportEmailError(
"Report email delivery is disabled until it uses a durable, idempotent Mail-owned outbox with unknown-outcome reconciliation."
)
report = generate_campaign_report( report = generate_campaign_report(
session, session,
@@ -171,6 +184,7 @@ def send_campaign_report_email(
campaign_id=campaign_id, campaign_id=campaign_id,
version_id=version.id, version_id=version.id,
include_jobs=include_jobs, include_jobs=include_jobs,
include_recent_failures=include_jobs,
) )
jobs_csv = ( jobs_csv = (
generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id) generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id)
@@ -186,9 +200,6 @@ def send_campaign_report_email(
jobs_csv=jobs_csv, jobs_csv=jobs_csv,
report_json=report_json, report_json=report_json,
) )
envelope_from, _ = _effective_from(config)
if dry_run:
return CampaignReportEmailResult( return CampaignReportEmailResult(
campaign_id=campaign.id, campaign_id=campaign.id,
version_id=version.id, version_id=version.id,
@@ -198,26 +209,4 @@ def send_campaign_report_email(
sent=False, sent=False,
attached_jobs_csv=jobs_csv is not None, attached_jobs_csv=jobs_csv is not None,
attached_report_json=report_json is not None, attached_report_json=report_json is not None,
smtp_host=smtp_config.host,
smtp_port=smtp_config.port,
)
result: SmtpSendResult = send_email_message(
message,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=to,
)
return CampaignReportEmailResult(
campaign_id=campaign.id,
version_id=version.id,
to=to,
subject=str(message["Subject"]),
dry_run=False,
sent=True,
attached_jobs_csv=jobs_csv is not None,
attached_report_json=report_json is not None,
smtp_host=result.host,
smtp_port=result.port,
accepted_count=result.accepted_count,
) )

View File

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

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
import copy
import hashlib
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
FINAL_VERSION_STATES = {
"completed",
"partially_completed",
"outcome_unknown",
"failed",
"cancelled",
"archived",
}
FINAL_EML_SEND_STATUSES = {
"smtp_accepted",
"sent",
"failed_permanent",
"cancelled",
"outcome_unknown",
}
def _cutoff(days: int | None, *, now: datetime) -> datetime | None:
if days is None:
return None
return now - timedelta(days=days)
def _is_before_cutoff(value: datetime | None, cutoff: datetime | None) -> bool:
if value is None or cutoff is None:
return False
candidate = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
return candidate < cutoff
def _json_sha256(value: Any) -> str:
payload = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _summary_was_redacted(summary: Any) -> bool:
return isinstance(summary, dict) and bool(summary.get("_retention", {}).get("report_detail_redacted"))
def _redacted_report_summary(summary: Any, *, now: datetime) -> tuple[dict[str, Any] | None, bool]:
if not isinstance(summary, dict):
return summary, False
if _summary_was_redacted(summary):
return summary, False
detail_keys = {"messages", "issues", "recent_failures", "jobs"}
if not any(key in summary for key in detail_keys):
return summary, False
next_summary = copy.deepcopy(summary)
for key in detail_keys:
next_summary.pop(key, None)
retention = dict(next_summary.get("_retention") or {})
retention.update({"report_detail_redacted": True, "redacted_at": now.isoformat()})
next_summary["_retention"] = retention
return next_summary, True
def _apply_raw_json_retention(
session: Session,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> dict[str, int]:
result = {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0}
versions = session.query(CampaignVersion).order_by(CampaignVersion.updated_at.asc()).all()
for version in versions:
policy = policy_for_campaign_id(version.campaign_id)
cutoff = _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now)
if not _is_before_cutoff(version.updated_at, cutoff):
continue
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
if raw_json.get("_retention", {}).get("raw_json_redacted"):
result["already_redacted"] += 1
continue
if version.workflow_state not in FINAL_VERSION_STATES:
result["skipped_not_final"] += 1
continue
result["eligible"] += 1
if dry_run:
continue
snapshot = version.execution_snapshot if isinstance(version.execution_snapshot, dict) else {}
version.raw_json = {
"version": version.schema_version or "1.0",
"_retention": {
"raw_json_redacted": True,
"redacted_at": now.isoformat(),
"original_sha256": snapshot.get("campaign_json_sha256") or _json_sha256(raw_json),
},
}
session.add(version)
result["redacted"] += 1
return result
def _apply_eml_retention(
session: Session,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> dict[str, int]:
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}
jobs = (
session.query(CampaignJob)
.filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None)))
.order_by(CampaignJob.updated_at.asc())
.all()
)
for job in jobs:
policy = policy_for_campaign_id(job.campaign_id)
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
if not _is_before_cutoff(job.updated_at, cutoff):
continue
if job.queue_status in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value} or job.send_status not in FINAL_EML_SEND_STATUSES:
result["skipped_not_final"] += 1
continue
if job.imap_status in {
JobImapStatus.PENDING.value,
JobImapStatus.APPENDING.value,
JobImapStatus.OUTCOME_UNKNOWN.value,
}:
result["skipped_not_final"] += 1
continue
result["eligible"] += 1
if dry_run:
continue
if job.eml_local_path:
path = Path(job.eml_local_path)
if path.exists():
path.unlink()
result["files_deleted"] += 1
else:
result["files_missing"] += 1
job.eml_local_path = None
job.eml_storage_key = None
session.add(job)
result["metadata_cleared"] += 1
return result
def _apply_report_detail_retention(
session: Session,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> dict[str, int]:
result = {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0}
versions = session.query(CampaignVersion).order_by(CampaignVersion.updated_at.asc()).all()
for version in versions:
policy = policy_for_campaign_id(version.campaign_id)
cutoff = _cutoff(policy.stored_report_detail_retention_days, now=now)
if not _is_before_cutoff(version.updated_at, cutoff):
continue
next_validation, validation_changed = _redacted_report_summary(version.validation_summary, now=now)
next_build, build_changed = _redacted_report_summary(version.build_summary, now=now)
if not validation_changed and not build_changed:
if _summary_was_redacted(version.validation_summary) or _summary_was_redacted(version.build_summary):
result["already_redacted"] += 1
continue
result["eligible_versions"] += 1
if dry_run:
continue
version.validation_summary = next_validation
version.build_summary = next_build
session.add(version)
result["summaries_redacted"] += int(validation_changed) + int(build_changed)
return result
def apply_campaign_retention(
session: Session,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> dict[str, dict[str, int]]:
return {
"raw_campaign_json": _apply_raw_json_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
"generated_eml": _apply_eml_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
"stored_report_detail": _apply_report_detail_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Any
_runtime_registry: object | None = None
_runtime_settings: object | None = None
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
global _runtime_registry, _runtime_settings
if registry is not None:
_runtime_registry = registry
if settings is not None:
_runtime_settings = settings
def get_registry() -> object | None:
return _runtime_registry
def get_settings() -> object | None:
return _runtime_settings
def capability(name: str) -> Any | None:
registry = get_registry()
if registry is None or not hasattr(registry, "capability"):
return None
return registry.capability(name)
def has_capability(name: str) -> bool:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability"):
return False
return bool(registry.has_capability(name))

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://multimailer.local/schema/campaign.schema.json", "$id": "https://govoplan.local/schema/campaign.schema.json",
"title": "MultiMailer Campaign", "title": "GovOPlaN Campaign",
"type": "object", "type": "object",
"required": [ "required": [
"version", "version",
@@ -90,91 +90,9 @@
"type": "object", "type": "object",
"properties": { "properties": {
"mail_profile_id": { "mail_profile_id": {
"type": "string"
},
"inherit_smtp_credentials": {
"type": "boolean",
"default": true
},
"inherit_imap_credentials": {
"type": "boolean",
"default": true
},
"smtp": {
"type": "object",
"properties": {
"host": {
"type": "string"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"username": {
"type": "string"
},
"password": {
"type": "string"
},
"security": {
"type": "string", "type": "string",
"enum": [ "minLength": 1,
"plain", "description": "Stable reference to an authorized profile owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials."
"tls",
"starttls"
],
"default": "starttls"
},
"timeout_seconds": {
"type": "integer",
"minimum": 1,
"default": 30
}
},
"additionalProperties": false
},
"imap": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"host": {
"type": "string"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"username": {
"type": "string"
},
"password": {
"type": "string"
},
"security": {
"type": "string",
"enum": [
"plain",
"tls",
"starttls"
],
"default": "tls"
},
"sent_folder": {
"type": "string",
"default": "auto"
},
"timeout_seconds": {
"type": "integer",
"minimum": 1,
"default": 30
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -301,6 +219,16 @@
"string", "string",
"null" "null"
] ]
},
"body_mode": {
"type": "string",
"enum": [
"text",
"html",
"both"
],
"default": "both",
"description": "Which body parts should be generated for campaign messages."
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -336,6 +264,16 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"body_mode": {
"type": "string",
"enum": [
"text",
"html",
"both"
],
"default": "both",
"description": "Which body parts should be generated for campaign messages."
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -365,7 +303,19 @@
"send_without_attachments": { "send_without_attachments": {
"type": "boolean", "type": "boolean",
"default": true, "default": true,
"description": "Legacy compatibility flag. Prefer validation_policy and per-config missing_behavior for new campaigns." "description": "Legacy compatibility flag. Use send_without_attachments_behavior for new campaigns."
},
"send_without_attachments_behavior": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "continue",
"description": "Campaign-wide behavior when no attachment file is resolved for an active message."
}, },
"zip": { "zip": {
"$ref": "#/$defs/zip_collection_config", "$ref": "#/$defs/zip_collection_config",
@@ -428,6 +378,13 @@
}, },
"defaults": { "defaults": {
"$ref": "#/$defs/entry" "$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -451,6 +408,13 @@
}, },
"defaults": { "defaults": {
"$ref": "#/$defs/entry" "$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -745,6 +709,22 @@
"string", "string",
"null" "null"
] ]
},
"message_filename_template": {
"type": [
"string",
"null"
],
"default": null,
"description": "Optional recipient-rendered filename template used when this rule sends files directly as message attachments. If omitted, the source filename is used."
},
"zip_entry_name_template": {
"type": [
"string",
"null"
],
"default": null,
"description": "Optional recipient-rendered filename template used for this rule's files inside recipient ZIP archives. If omitted, the source filename is used."
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -957,6 +937,174 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"import_provenance": {
"type": "object",
"required": [
"id",
"imported_at",
"mode",
"source_type",
"header_rows",
"rows_total",
"valid_rows",
"invalid_rows",
"imported_rows"
],
"properties": {
"id": {
"type": "string"
},
"imported_at": {
"type": "string",
"format": "date-time"
},
"mode": {
"type": "string",
"enum": [
"append",
"replace"
]
},
"source_type": {
"type": "string",
"enum": [
"csv",
"xlsx",
"text",
"addresses"
]
},
"source_id": {
"type": [
"string",
"null"
]
},
"source_label": {
"type": [
"string",
"null"
]
},
"source_revision": {
"type": [
"string",
"null"
]
},
"source_provenance": {
"type": "object",
"default": {}
},
"filename": {
"type": [
"string",
"null"
]
},
"sheet_name": {
"type": [
"string",
"null"
]
},
"encoding": {
"type": [
"string",
"null"
]
},
"delimiter": {
"type": [
"string",
"null"
]
},
"header_rows": {
"type": "integer",
"minimum": 0,
"default": 0
},
"quoted": {
"type": [
"boolean",
"null"
]
},
"value_separators": {
"type": [
"string",
"null"
]
},
"rows_total": {
"type": "integer",
"minimum": 0
},
"valid_rows": {
"type": "integer",
"minimum": 0
},
"invalid_rows": {
"type": "integer",
"minimum": 0
},
"imported_rows": {
"type": "integer",
"minimum": 0
},
"field_names_created": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"attachment_patterns": {
"type": "integer",
"minimum": 0,
"default": 0
},
"mapping": {
"type": "array",
"items": {
"type": "object",
"required": [
"column_index",
"header",
"kind"
],
"properties": {
"column_index": {
"type": "integer",
"minimum": 0
},
"header": {
"type": "string"
},
"kind": {
"type": "string"
},
"field_name": {
"type": [
"string",
"null"
]
},
"new_field_name": {
"type": [
"string",
"null"
]
}
},
"additionalProperties": false
},
"default": []
}
},
"additionalProperties": false
},
"attachment_base_path": { "attachment_base_path": {
"type": "object", "type": "object",
"required": [ "required": [

View File

@@ -0,0 +1,137 @@
{
"$schema": "https://govoplan.local/schema/campaign.schema.ui.json",
"$id": "https://govoplan.local/schema/campaign.schema.ui.json",
"schema": "campaign.schema.json",
"version": 1,
"sections": [
{
"id": "basics",
"label": "Basics",
"path": "/campaign",
"order": 10
},
{
"id": "fields",
"label": "Fields",
"path": "/fields",
"order": 20
},
{
"id": "global_values",
"label": "Global Values",
"path": "/global_values",
"order": 30
},
{
"id": "recipients",
"label": "Recipients",
"path": "/recipients",
"order": 40
},
{
"id": "template",
"label": "Template",
"path": "/template",
"order": 50
},
{
"id": "attachments",
"label": "Attachments",
"path": "/attachments",
"order": 60
},
{
"id": "mail",
"label": "Mail",
"path": "/server",
"order": 70,
"requiresCapability": "mail.campaign_delivery"
},
{
"id": "delivery",
"label": "Delivery",
"path": "/delivery",
"order": 80
},
{
"id": "review",
"label": "Review & Send",
"path": "/review",
"order": 90
}
],
"fields": {
"/attachments/global[]/message_filename_template": {
"label": "Direct attachment filename",
"control": "text",
"placeholder": "{{ file.name }}",
"templateContext": [
"global values",
"recipient fields",
"file.name",
"file.stem",
"file.suffix",
"file.ext",
"file.index",
"attachment.id",
"attachment.label"
],
"description": "Optional filename template used when files are attached directly to the message."
},
"/attachments/global[]/zip_entry_name_template": {
"label": "ZIP entry filename",
"control": "text",
"placeholder": "{{ file.name }}",
"templateContext": [
"global values",
"recipient fields",
"file.name",
"file.stem",
"file.suffix",
"file.ext",
"file.index",
"attachment.id",
"attachment.label"
],
"description": "Optional filename template used for files inside generated ZIP archives."
},
"/entries/inline[]/attachments[]/message_filename_template": {
"label": "Direct attachment filename",
"control": "text",
"placeholder": "{{ file.name }}",
"templateContext": [
"global values",
"recipient fields",
"file.name",
"file.stem",
"file.suffix",
"file.ext",
"file.index",
"attachment.id",
"attachment.label"
],
"description": "Optional filename template used when files are attached directly to the message."
},
"/entries/inline[]/attachments[]/zip_entry_name_template": {
"label": "ZIP entry filename",
"control": "text",
"placeholder": "{{ file.name }}",
"templateContext": [
"global values",
"recipient fields",
"file.name",
"file.stem",
"file.suffix",
"file.ext",
"file.index",
"attachment.id",
"attachment.label"
],
"description": "Optional filename template used for files inside generated ZIP archives."
}
},
"capabilities": {
"files.campaign_attachments": "Enable managed file chooser and frozen file-version evidence.",
"mail.campaign_delivery": "Enable mail profile selection, SMTP/IMAP validation, queueing, and delivery."
}
}

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from govoplan_core.auth import ApiPrincipal, require_any_scope
from govoplan_campaign.backend.campaign.loader import load_campaign_schema, load_campaign_schema_ui
router = APIRouter(prefix="/schemas", tags=["schemas"])
@router.get("/campaign")
def get_campaign_schema(
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
) -> dict[str, Any]:
"""Return the authoritative campaign JSON Schema used by the backend."""
del principal
return load_campaign_schema()
@router.get("/campaign/ui")
def get_campaign_schema_ui(
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
) -> dict[str, Any]:
"""Return UI metadata paired with the campaign JSON Schema."""
del principal
return load_campaign_schema_ui()

View File

@@ -3,9 +3,18 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from govoplan_mail.backend.config import ImapConfig, SmtpConfig from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
public_campaign_editor_state,
validate_campaign_editor_state,
)
from govoplan_campaign.backend.response_security import (
public_campaign_configuration,
public_campaign_payload,
public_source_filename,
)
class CampaignCreateRequest(BaseModel): class CampaignCreateRequest(BaseModel):
@@ -48,6 +57,12 @@ class CampaignVersionUpdateRequest(BaseModel):
editor_state: dict[str, Any] | None = None editor_state: dict[str, Any] | None = None
source_filename: str | None = None source_filename: str | None = None
source_base_path: str | None = None source_base_path: str | None = None
migrate_legacy_mail_settings: bool = False
@field_validator("editor_state")
@classmethod
def validate_editor_state(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
return validate_campaign_editor_state(value) if value is not None else None
class CampaignVersionSetStepRequest(BaseModel): class CampaignVersionSetStepRequest(BaseModel):
@@ -79,7 +94,6 @@ class CampaignVersionResponse(BaseModel):
version_number: int version_number: int
schema_version: str schema_version: str
source_filename: str | None = None source_filename: str | None = None
source_base_path: str | None = None
workflow_state: str = "editing" workflow_state: str = "editing"
current_flow: str = "manual" current_flow: str = "manual"
current_step: str | None = None current_step: str | None = None
@@ -99,9 +113,36 @@ class CampaignVersionResponse(BaseModel):
execution_snapshot_hash: str | None = None execution_snapshot_hash: str | None = None
execution_snapshot_at: datetime | None = None execution_snapshot_at: datetime | None = None
@field_validator("editor_state", mode="before")
@classmethod
def remove_unsupported_editor_state(cls, value: Any, info: ValidationInfo) -> dict[str, Any]:
return public_campaign_editor_state(
value,
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
)
@field_validator("source_filename", mode="before")
@classmethod
def remove_source_directory(cls, value: Any) -> str | None:
return public_source_filename(value)
@field_validator("validation_summary", "build_summary", mode="before")
@classmethod
def remove_internal_summary_fields(cls, value: Any, info: ValidationInfo) -> Any:
return public_campaign_payload(
value,
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
)
class CampaignVersionDetailResponse(CampaignVersionResponse): class CampaignVersionDetailResponse(CampaignVersionResponse):
raw_json: dict[str, Any] raw_json: dict[str, Any]
mail_profile_migration_required: bool = False
@field_validator("raw_json", mode="before")
@classmethod
def remove_internal_configuration_fields(cls, value: Any) -> Any:
return public_campaign_configuration(value)
class CampaignPartialValidationResponse(BaseModel): class CampaignPartialValidationResponse(BaseModel):
@@ -137,6 +178,29 @@ class CampaignListResponse(BaseModel):
campaigns: list[CampaignResponse] campaigns: list[CampaignResponse]
class CampaignWorkspaceResponse(BaseModel):
campaign: CampaignResponse | None = None
versions: list[CampaignVersionResponse] = Field(default_factory=list)
current_version: CampaignVersionDetailResponse | None = None
summary: dict[str, Any] | None = None
selected_version_id: str | None = None
class CampaignDeltaResponse(BaseModel):
campaigns: list[CampaignResponse] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CampaignShareItem(BaseModel): class CampaignShareItem(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -178,6 +242,129 @@ class CampaignOwnerUpdateRequest(BaseModel):
owner_group_id: str | None = None owner_group_id: str | None = None
RecipientImportColumnKind = Literal[
"ignore",
"id",
"active",
"name",
"from",
"to",
"cc",
"bcc",
"reply_to",
"field",
"new_field",
"attachment_pattern",
]
class RecipientImportColumnMappingPayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
column_index: int = Field(ge=0, alias="columnIndex")
kind: RecipientImportColumnKind
field_name: str | None = Field(default=None, max_length=255, alias="fieldName")
new_field_name: str | None = Field(default=None, max_length=255, alias="newFieldName")
class RecipientImportMappingProfilePayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
name: str = Field(min_length=1, max_length=255)
column_count: int = Field(ge=0, le=500, alias="columnCount")
headers: list[str] = Field(default_factory=list, max_length=500)
normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders")
ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint")
unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint")
delimiter: Literal[",", ";", "\t"]
header_rows: int = Field(ge=0, le=10, alias="headerRows")
quoted: bool = True
value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators")
mappings: list[RecipientImportColumnMappingPayload] = Field(default_factory=list, max_length=500)
@model_validator(mode="after")
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
if len(self.headers) != self.column_count:
raise ValueError("headers length must match columnCount")
if len(self.normalized_headers) != self.column_count:
raise ValueError("normalizedHeaders length must match columnCount")
for mapping in self.mappings:
if mapping.column_index >= self.column_count:
raise ValueError("mapping columnIndex exceeds columnCount")
return self
class RecipientImportMappingProfileResponse(RecipientImportMappingProfilePayload):
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: str
created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt")
class RecipientImportMappingProfileListResponse(BaseModel):
profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list)
class CampaignAddressLookupCandidate(BaseModel):
contact_id: str
address_book_id: str
display_name: str
email: str | None = None
email_label: str | None = None
organization: str | None = None
role_title: str | None = None
tags: list[str] = Field(default_factory=list)
source_kind: str = "local"
source_ref: str | None = None
source_revision: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignAddressLookupResponse(BaseModel):
available: bool = False
candidates: list[CampaignAddressLookupCandidate] = Field(default_factory=list)
class CampaignRecipientAddressSource(BaseModel):
source_id: str
source_label: str
source_kind: str
source_revision: str
recipient_count: int = 0
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientAddressSourcesResponse(BaseModel):
available: bool = False
sources: list[CampaignRecipientAddressSource] = Field(default_factory=list)
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: str = Field(min_length=1)
class CampaignRecipientSnapshotItem(BaseModel):
contact_id: str
display_name: str
email: str
email_label: str | None = None
fields: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_id: str
source_label: str
source_kind: str
source_revision: str
generated_at: str
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list)
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignJobsResponse(BaseModel): class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]] jobs: list[dict[str, Any]]
page: int = 1 page: int = 1
@@ -185,16 +372,34 @@ class CampaignJobsResponse(BaseModel):
total: int = 0 total: int = 0
total_unfiltered: int = 0 total_unfiltered: int = 0
pages: int = 0 pages: int = 0
cursor: str | None = None
next_cursor: str | None = None
counts: dict[str, dict[str, int]] = Field(default_factory=dict) counts: dict[str, dict[str, int]] = Field(default_factory=dict)
filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict) filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict)
review: dict[str, Any] = Field(default_factory=dict) review: dict[str, Any] = Field(default_factory=dict)
class CampaignJobsDeltaResponse(CampaignJobsResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CampaignJobDetailResponse(BaseModel): class CampaignJobDetailResponse(BaseModel):
job: dict[str, Any] job: dict[str, Any]
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignJobDiagnosticsResponse(BaseModel):
job_id: str
campaign_id: str
campaign_version_id: str
storage: dict[str, Any] = Field(default_factory=dict)
worker_claim: dict[str, Any] = Field(default_factory=dict)
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignRetryJobsRequest(BaseModel): class CampaignRetryJobsRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -215,17 +420,39 @@ class CampaignSendUnattemptedRequest(BaseModel):
dry_run: bool = False dry_run: bool = False
class CampaignSendJobRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
include_warnings: bool = True
dry_run: bool = False
use_rate_limit: bool = True
enqueue_imap_task: bool = False
class CampaignResolveOutcomeRequest(BaseModel): class CampaignResolveOutcomeRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
decision: Literal["smtp_accepted", "not_sent"] decision: Literal[
note: str | None = None "smtp_accepted",
"not_sent",
"imap_appended",
"imap_not_appended",
]
note: str | None = Field(default=None, max_length=2000)
@model_validator(mode="after")
def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest":
self.note = (self.note or "").strip()
if not self.note:
raise ValueError("Reconciliation requires an evidence note")
return self
class ValidateCampaignRequest(BaseModel): class ValidateCampaignRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
check_files: bool = False check_files: bool = False
link_unshared_matches: bool = False
class BuildCampaignRequest(BaseModel): class BuildCampaignRequest(BaseModel):
@@ -234,127 +461,6 @@ class BuildCampaignRequest(BaseModel):
write_eml: bool = True write_eml: bool = True
class MailSmtpTestRequest(SmtpConfig):
"""SMTP settings supplied directly from the WebUI mail settings form."""
class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form."""
enabled: bool = True
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
class MailCredentialPolicyPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
inherit: bool | None = None
allow_override: bool | None = None
class MailProfilePolicyPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
allowed_profile_ids: list[str] = Field(default_factory=list)
allow_user_profiles: bool | None = None
allow_group_profiles: bool | None = None
allow_campaign_profiles: bool | None = None
smtp_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
whitelist: dict[str, list[str]] = Field(default_factory=dict)
blacklist: dict[str, list[str]] = Field(default_factory=dict)
class MailProfilePolicyUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
class MailProfilePolicyResponse(BaseModel):
scope_type: MailProfileScope
scope_id: str | None = None
policy: dict[str, Any]
effective_policy: dict[str, Any] | None = None
class MailServerProfileCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
slug: str | None = Field(default=None, max_length=100)
description: str | None = None
is_active: bool = True
scope_type: MailProfileScope = "tenant"
scope_id: str | None = None
smtp: SmtpConfig
imap: ImapConfig | None = None
class MailServerProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, max_length=255)
slug: str | None = Field(default=None, max_length=100)
description: str | None = None
is_active: bool | None = None
smtp: SmtpConfig | None = None
imap: ImapConfig | None = None
clear_imap: bool = False
class MailServerProfileResponse(BaseModel):
id: str
tenant_id: str | None = None
scope_type: MailProfileScope = "tenant"
scope_id: str | None = None
name: str
slug: str
description: str | None = None
is_active: bool
smtp: dict[str, Any]
imap: dict[str, Any] | None = None
smtp_password_configured: bool = False
imap_password_configured: bool = False
created_at: datetime
updated_at: datetime
class MailServerProfileListResponse(BaseModel):
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]
host: str | None = None
port: int | None = None
security: str | None = None
message: str
details: dict[str, Any] = Field(default_factory=dict)
class MailImapFolderResponse(BaseModel):
name: str
flags: list[str] = Field(default_factory=list)
class MailImapFolderListResponse(BaseModel):
ok: bool
protocol: Literal["imap"] = "imap"
host: str | None = None
port: int | None = None
security: str | None = None
message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class ApiKeyCreateRequest(BaseModel): class ApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -426,130 +532,71 @@ class AppendSentRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
enqueue_celery: bool = True enqueue_celery: bool = True
run_inline: bool = False
dry_run: bool = False dry_run: bool = False
class CampaignActionResponse(BaseModel): class CampaignActionResponse(BaseModel):
result: dict[str, Any] result: dict[str, Any]
class ReportEmailRequest(BaseModel): class ReportEmailRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
to: list[str] to: list[str] = Field(min_length=1, max_length=50)
version_id: str | None = None version_id: str | None = None
include_jobs: bool = False include_jobs: bool = False
attach_jobs_csv: bool = True attach_jobs_csv: bool = False
attach_report_json: bool = False attach_report_json: bool = False
dry_run: bool = False dry_run: bool = False
@field_validator("to", mode="before")
@classmethod
def normalize_and_validate_recipients(cls, value: Any) -> Any:
if not isinstance(value, list):
return value
if not 1 <= len(value) <= 50:
raise ValueError("report email requires between 1 and 50 recipients")
recipients: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
raise ValueError("report recipients must be email-address strings")
recipient = item.strip()
if len(recipient) > 320:
raise ValueError("report recipient addresses must be at most 320 characters")
if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
raise ValueError("report recipient addresses must not contain control characters")
if recipient.count("@") != 1:
raise ValueError("report recipients must be email addresses")
local, domain = recipient.split("@", 1)
if (
not local
or not domain
or any(character.isspace() for character in recipient)
or any(character in ',;:<>[]()\\"' for character in recipient)
or local.startswith(".")
or local.endswith(".")
or ".." in local
or domain.startswith(".")
or domain.endswith(".")
or ".." in domain
or any(
not label
or label.startswith("-")
or label.endswith("-")
or not all(character.isalnum() or character == "-" for character in label)
for label in domain.split(".")
)
):
raise ValueError("report recipients must be email addresses")
key = recipient.casefold()
if key in seen:
continue
seen.add(key)
recipients.append(recipient)
return recipients
class ReportEmailResponse(BaseModel): class ReportEmailResponse(BaseModel):
result: dict[str, Any] result: dict[str, Any]
class AuditLogItemResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
user_id: str | None = None
api_key_id: str | None = None
action: str
object_type: str | None = None
object_id: str | None = None
details: dict[str, Any] | None = None
created_at: datetime
class AuditLogListResponse(BaseModel):
items: list[AuditLogItemResponse]
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
password: str
# Kept optional for backwards compatibility and future tenant-switch login flows.
# The WebUI no longer sends it. If omitted, the backend resolves the user by email.
tenant_slug: str | None = None
class SwitchTenantRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str
class TenantInfo(BaseModel):
id: str
slug: str
name: str
is_active: bool = True
default_locale: str = "en"
class TenantMembershipInfo(TenantInfo):
roles: list[str] = Field(default_factory=list)
is_active: bool = True
class UserInfo(BaseModel):
id: str
account_id: str
email: str
# Global account identity used by the title bar and account settings.
display_name: str | None = None
# Optional tenant-local alias used in tenant administration and ownership.
tenant_display_name: str | None = None
is_tenant_admin: bool = False
password_reset_required: bool = False
class ProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
tenant_display_name: str | None = Field(default=None, max_length=255)
class RoleInfo(BaseModel):
id: str
slug: str
name: str
permissions: list[str] = Field(default_factory=list)
level: Literal["tenant", "system"] = "tenant"
class GroupInfo(BaseModel):
id: str
slug: str
name: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_at: datetime
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)
class MeResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)

View File

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

@@ -1,13 +0,0 @@
from __future__ import annotations
from pathlib import Path
from govoplan_campaign.backend.domain.campaign import MailAttachmentConfig
def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]:
directory = base_path / config.base_dir
if not directory.exists():
return []
iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter)
return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0)

View File

@@ -1,135 +0,0 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from pathlib import Path
from typing import Iterable
from govoplan_campaign.backend.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity
from govoplan_campaign.backend.domain.queue import MailQueue
from govoplan_campaign.backend.domain.recipients import Recipient, RecipientType
from govoplan_campaign.backend.services.attachment_matching import match_files
from govoplan_campaign.backend.services.zip_service import create_encrypted_zip
def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str:
return ", ".join(r.formatted() for r in recipients if r.type == recipient_type)
def _recipient_values(recipients: list[Recipient]) -> dict[str, str]:
def rows(recipient_type: RecipientType) -> list[Recipient]:
return [r for r in recipients if r.type == recipient_type]
def joined(recipient_type: RecipientType, mode: str) -> str:
selected = rows(recipient_type)
if mode == "address":
return ", ".join(r.address for r in selected)
if mode == "name":
return ", ".join(r.name or r.address for r in selected)
return ", ".join(r.formatted() for r in selected)
return {
"mm_recipients": joined(RecipientType.TO, "formatted"),
"mm_recipients_address": joined(RecipientType.TO, "address"),
"mm_recipients_name": joined(RecipientType.TO, "name"),
"mm_cc": joined(RecipientType.CC, "formatted"),
"mm_cc_address": joined(RecipientType.CC, "address"),
"mm_cc_name": joined(RecipientType.CC, "name"),
"mm_bcc": joined(RecipientType.BCC, "formatted"),
"mm_bcc_address": joined(RecipientType.BCC, "address"),
"mm_bcc_name": joined(RecipientType.BCC, "name"),
}
def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]:
paths: list[Path] = []
if entry.combine_attachments:
for config in campaign.global_attachment_configs:
paths.extend(match_files(campaign.base_attachment_path, config))
if campaign.individual_attachments:
for config in entry.attachment_configs:
paths.extend(match_files(campaign.base_attachment_path, config))
return paths
def get_not_sent_files(campaign: MailCampaign) -> set[Path]:
"""Return files matching campaign attachment configs that are not referenced by any built entry."""
all_files: set[Path] = set()
used_files: set[Path] = set()
for config in campaign.global_attachment_configs:
all_files.update(match_files(campaign.base_attachment_path, config))
for entry in campaign.mail_entries:
files = set(_message_attachment_paths(campaign, entry))
used_files.update(files)
all_files.update(files)
return all_files - used_files
def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None:
recipients = campaign.all_recipients_for(entry)
attachments = _message_attachment_paths(campaign, entry)
if not attachments and not campaign.send_without_attachments:
return None
values = {}
values.update(_recipient_values(recipients))
values.update(campaign.field_contents.as_value_map("global"))
values.update(entry.field_contents.as_value_map("local"))
message = EmailMessage()
sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from
if sender:
message["From"] = sender.formatted()
to_header = _recipient_header(recipients, RecipientType.TO)
cc_header = _recipient_header(recipients, RecipientType.CC)
if to_header:
message["To"] = to_header
if cc_header:
message["Cc"] = cc_header
message["Subject"] = campaign.subject_template.apply_values(values)
message.set_content(campaign.mail_template.apply_values(values))
attachment_paths = attachments
if attachments and zip_attachments:
number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments"
password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else ""
zip_path = campaign.base_attachment_path / f"{number}.zip"
attachment_paths = [create_encrypted_zip(zip_path, attachments, password)]
for attachment_path in attachment_paths:
data = attachment_path.read_bytes()
message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name)
return message
def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue:
queue = MailQueue()
for entry in campaign.mail_entries:
if not entry.is_active:
continue
message = build_message(campaign, entry, zip_attachments=zip_attachments)
if message is not None:
queue.add_mail(message)
return queue
def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue:
retry_queue = MailQueue()
if settings.transport_security == TransportSecurity.TLS:
smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60)
else:
smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60)
try:
if settings.transport_security == TransportSecurity.STARTTLS:
smtp.starttls()
if settings.username:
smtp.login(settings.username, settings.password)
for message in queue:
try:
smtp.send_message(message)
except Exception:
retry_queue.add_mail(message)
finally:
smtp.quit()
return retry_queue

View File

@@ -1,8 +1,14 @@
from __future__ import annotations from __future__ import annotations
import binascii
from datetime import datetime
import secrets
import stat
import struct
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
import zlib
try: try:
import pyzipper import pyzipper
@@ -10,6 +16,8 @@ except ImportError: # pragma: no cover
pyzipper = None pyzipper = None
ArchiveMember = tuple[Path, str] ArchiveMember = tuple[Path, str]
ZIP_METHOD_AES = "aes"
ZIP_METHOD_STANDARD = "zip_standard"
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]: def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
@@ -33,15 +41,35 @@ def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMe
def create_zip_archive( def create_zip_archive(
output_path: Path, output_path: Path,
files: Iterable[Path | ArchiveMember], files: Iterable[Path | ArchiveMember],
password: str = "", password: str = "", # nosec B107 - empty means an unencrypted archive.
method: str = ZIP_METHOD_AES,
) -> Path: ) -> Path:
"""Create a ZIP archive, using AES encryption when a password is supplied.""" """Create a ZIP archive, optionally using AES or legacy ZipCrypto encryption."""
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
members = _normalized_members(files) members = _normalized_members(files)
if password: if password:
if method == ZIP_METHOD_STANDARD:
_create_zipcrypto_archive(output_path, members, password)
return output_path
_create_aes_archive(output_path, members, password)
return output_path
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
for file_path, archive_name in members:
zip_file.write(file_path, arcname=archive_name)
return output_path
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
return create_zip_archive(output_path, files, password, method)
def _create_aes_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None:
if pyzipper is None: if pyzipper is None:
raise RuntimeError("pyzipper is required for writing password-protected ZIP files") raise RuntimeError("pyzipper is required for writing AES password-protected ZIP files")
with pyzipper.AESZipFile( with pyzipper.AESZipFile(
output_path, output_path,
"w", "w",
@@ -51,15 +79,196 @@ def create_zip_archive(
zip_file.setpassword(password.encode("utf-8")) zip_file.setpassword(password.encode("utf-8"))
for file_path, archive_name in members: for file_path, archive_name in members:
zip_file.write(file_path, arcname=archive_name) zip_file.write(file_path, arcname=archive_name)
return output_path
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
def _create_zipcrypto_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None:
entries: list[_CentralDirectoryEntry] = []
password_bytes = password.encode("utf-8")
with output_path.open("wb") as zip_file:
for file_path, archive_name in members: for file_path, archive_name in members:
zip_file.write(file_path, arcname=archive_name) local_header_offset = zip_file.tell()
return output_path file_data = file_path.read_bytes()
crc = binascii.crc32(file_data) & 0xFFFFFFFF
compressed_data = _deflate(file_data)
encrypted_data = _zipcrypto_encrypt(compressed_data, password_bytes, crc)
compressed_size = len(encrypted_data)
uncompressed_size = len(file_data)
modified_at = datetime.fromtimestamp(file_path.stat().st_mtime)
dos_time, dos_date = _dos_timestamp(modified_at)
filename_bytes, flags = _filename_bytes(archive_name, encrypted=True)
zip_file.write(
struct.pack(
"<IHHHHHIIIHH",
0x04034B50,
20,
flags,
zipfile.ZIP_DEFLATED,
dos_time,
dos_date,
crc,
compressed_size,
uncompressed_size,
len(filename_bytes),
0,
)
)
zip_file.write(filename_bytes)
zip_file.write(encrypted_data)
entries.append(
_CentralDirectoryEntry(
filename_bytes=filename_bytes,
flags=flags,
dos_time=dos_time,
dos_date=dos_date,
crc=crc,
compressed_size=compressed_size,
uncompressed_size=uncompressed_size,
external_attributes=_external_attributes(file_path),
local_header_offset=local_header_offset,
)
)
central_directory_offset = zip_file.tell()
for entry in entries:
zip_file.write(
struct.pack(
"<IHHHHHHIIIHHHHHII",
0x02014B50,
20,
20,
entry.flags,
zipfile.ZIP_DEFLATED,
entry.dos_time,
entry.dos_date,
entry.crc,
entry.compressed_size,
entry.uncompressed_size,
len(entry.filename_bytes),
0,
0,
0,
0,
entry.external_attributes,
entry.local_header_offset,
)
)
zip_file.write(entry.filename_bytes)
central_directory_size = zip_file.tell() - central_directory_offset
zip_file.write(
struct.pack(
"<IHHHHIIH",
0x06054B50,
0,
0,
len(entries),
len(entries),
central_directory_size,
central_directory_offset,
0,
)
)
def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path: class _CentralDirectoryEntry:
"""Backward-compatible wrapper for the original per-rule ZIP helper.""" def __init__(
self,
*,
filename_bytes: bytes,
flags: int,
dos_time: int,
dos_date: int,
crc: int,
compressed_size: int,
uncompressed_size: int,
external_attributes: int,
local_header_offset: int,
) -> None:
self.filename_bytes = filename_bytes
self.flags = flags
self.dos_time = dos_time
self.dos_date = dos_date
self.crc = crc
self.compressed_size = compressed_size
self.uncompressed_size = uncompressed_size
self.external_attributes = external_attributes
self.local_header_offset = local_header_offset
return create_zip_archive(output_path, files, password)
def _deflate(data: bytes) -> bytes:
compressor = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -zlib.MAX_WBITS)
return compressor.compress(data) + compressor.flush()
def _zipcrypto_encrypt(data: bytes, password: bytes, crc: int) -> bytes:
cipher = _ZipCrypto(password)
header = secrets.token_bytes(11) + bytes([(crc >> 24) & 0xFF])
return cipher.encrypt(header + data)
class _ZipCrypto:
def __init__(self, password: bytes) -> None:
self.key0 = 0x12345678
self.key1 = 0x23456789
self.key2 = 0x34567890
for value in password:
self._update_keys(value)
def encrypt(self, data: bytes) -> bytes:
encrypted = bytearray()
for value in data:
encrypted.append(value ^ self._decrypt_byte())
self._update_keys(value)
return bytes(encrypted)
def _decrypt_byte(self) -> int:
temp = self.key2 | 2
return ((temp * (temp ^ 1)) >> 8) & 0xFF
def _update_keys(self, value: int) -> None:
self.key0 = _zipcrypto_crc32_byte(value, self.key0)
self.key1 = (self.key1 + (self.key0 & 0xFF)) & 0xFFFFFFFF
self.key1 = (self.key1 * 134775813 + 1) & 0xFFFFFFFF
self.key2 = _zipcrypto_crc32_byte((self.key1 >> 24) & 0xFF, self.key2)
def _build_zipcrypto_crc(value: int) -> int:
for _ in range(8):
if value & 1:
value = 0xEDB88320 ^ (value >> 1)
else:
value >>= 1
return value
_ZIPCRYPTO_CRC_TABLE = [_build_zipcrypto_crc(value) for value in range(256)]
def _zipcrypto_crc32_byte(value: int, crc: int) -> int:
return ((crc >> 8) ^ _ZIPCRYPTO_CRC_TABLE[(crc ^ value) & 0xFF]) & 0xFFFFFFFF
def _filename_bytes(filename: str, *, encrypted: bool) -> tuple[bytes, int]:
flags = 0x1 if encrypted else 0
try:
return filename.encode("ascii"), flags
except UnicodeEncodeError:
return filename.encode("utf-8"), flags | 0x800
def _dos_timestamp(value: datetime) -> tuple[int, int]:
year = min(max(value.year, 1980), 2107)
month = value.month if value.year == year else 1
day = value.day if value.year == year else 1
dos_time = value.hour << 11 | value.minute << 5 | value.second // 2
dos_date = (year - 1980) << 9 | month << 5 | day
return dos_time, dos_date
def _external_attributes(path: Path) -> int:
try:
permissions = stat.S_IMODE(path.stat().st_mode)
except OSError:
return 0
return permissions << 16

View File

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

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.capabilities import CampaignAccessService
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.base import Base
TENANT_ID = "tenant-1"
USER_ID = "user-1"
OTHER_USER_ID = "user-2"
GROUP_ID = "group-1"
class CampaignAccessProviderTests(unittest.TestCase):
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
owned = Campaign(id="campaign-owned", tenant_id=TENANT_ID, owner_user_id=USER_ID, external_id="owned", name="Owned campaign")
shared = Campaign(id="campaign-shared", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="shared", name="Shared campaign")
session.add_all([
owned,
shared,
CampaignShare(id="share-group", tenant_id=TENANT_ID, campaign_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"),
])
session.commit()
service = CampaignAccessService()
owned_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id=owned.id, action="campaigns:campaign:read")
shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
admin_items = service.explain_resource_provenance(session, _principal(scopes={"tenant:*"}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
missing_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id="missing-campaign", action="campaigns:campaign:read")
self.assertTrue(any(item.kind == "resource" and item.source == "campaigns.campaign" and item.id == owned.id for item in owned_items))
self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items))
self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items))
self.assertTrue(any(item.kind == "policy" and item.id == "tenant:*" for item in admin_items))
self.assertEqual("campaigns.not_found", missing_items[0].source)
self.assertIs(missing_items[0].details["found"], False)
def test_campaign_access_provider_requires_write_share_for_write_actions(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(id="campaign-write", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="write", name="Write campaign")
session.add_all([
campaign,
CampaignShare(id="share-read", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="group", target_id=GROUP_ID, permission="read"),
CampaignShare(id="share-write", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="user", target_id=USER_ID, permission="write"),
])
session.commit()
service = CampaignAccessService()
items = service.explain_resource_provenance(
session,
_principal(group_ids={GROUP_ID}),
resource_type="campaign",
resource_id=campaign.id,
action="campaigns:campaign:update",
)
self.assertTrue(any(item.kind == "share" and item.id == "share-write" for item in items))
self.assertFalse(any(item.kind == "share" and item.id == "share-read" for item in items))
def _session():
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, Campaign.__table__, CampaignShare.__table__])
return sessionmaker(bind=engine, future=True)()
def _close_session(session) -> None:
engine = session.get_bind()
session.close()
engine.dispose()
def _seed_access_subjects(session) -> None:
session.add_all([
Account(id="account-1", email="one@example.test", normalized_email="one@example.test"),
Account(id="account-2", email="two@example.test", normalized_email="two@example.test"),
User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"),
User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"),
Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"),
])
session.commit()
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
return PrincipalRef(
account_id="account-1",
membership_id=USER_ID,
tenant_id=TENANT_ID,
scopes=frozenset(scopes or {"campaigns:campaign:read"}),
group_ids=frozenset(group_ids or set()),
)

View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import io
import tempfile
import unittest
import zipfile
from pathlib import Path
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.messages.builder import build_campaign_messages
class CampaignAttachmentBuildTests(unittest.TestCase):
def _no_attachment_config(
self,
behavior: str | None = None,
legacy_allow: bool | None = None,
*,
configure_missing_rule: bool = False,
) -> CampaignConfig:
attachments: dict[str, object] = {
"base_path": ".",
"global": [],
"zip": {"enabled": False, "archives": []},
}
if configure_missing_rule:
attachments["global"] = [
{
"id": "missing",
"label": "Missing attachment",
"base_dir": ".",
"file_filter": "missing.pdf",
"required": False,
"missing_behavior": "continue",
}
]
if behavior is not None:
attachments["send_without_attachments_behavior"] = behavior
if legacy_allow is not None:
attachments["send_without_attachments"] = legacy_allow
return CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": f"no-attachment-{behavior or legacy_allow}", "name": "No attachment policy", "mode": "test"},
"fields": [],
"global_values": {},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Subject", "text": "Body"},
"attachments": attachments,
"entries": {"inline": [{"id": "recipient-1", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
"validation_policy": {"missing_email": "block", "template_error": "block"},
"delivery": {"imap_append_sent": {"enabled": False}},
})
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = self._no_attachment_config(legacy_allow=False)
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
self.assertTrue(validation.ok)
self.assertNotIn("missing_attachment_coverage", {issue.code for issue in validation.issues})
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
self.assertEqual(result.report.build_failed_count, 0)
self.assertEqual(result.report.queueable_count, 1)
message = result.report.messages[0]
self.assertEqual(message.attachment_count, 0)
self.assertIsNotNone(message.eml_path)
self.assertNotIn("missing_attachment_coverage", {issue.code for issue in message.issues})
self.assertIsNotNone(result.built_messages[0].mime)
def test_configured_attachment_rule_blocks_generation_when_no_files_resolve(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = self._no_attachment_config(behavior="block", configure_missing_rule=True)
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
self.assertFalse(validation.ok)
self.assertIn("missing_attachment_coverage", {issue.code for issue in validation.issues})
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
self.assertEqual(result.report.build_failed_count, 1)
self.assertEqual(result.report.queueable_count, 0)
message = result.report.messages[0]
self.assertEqual(message.attachment_count, 0)
self.assertIsNone(message.eml_path)
self.assertIn("missing_attachment_coverage", {issue.code for issue in message.issues})
self.assertIsNone(result.built_messages[0].mime)
def test_send_without_attachments_behavior_modes(self) -> None:
cases = {
"block": ("build_failed", "blocked", 0, "block", False),
"ask": ("built", "needs_review", 0, "ask", True),
"drop": ("built", "excluded", 0, "drop", True),
"warn": ("built", "warning", 1, "warn", True),
"continue": ("built", "ready", 1, None, True),
}
for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
with self.subTest(behavior=behavior):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = self._no_attachment_config(behavior=behavior, configure_missing_rule=True)
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
if behavior == "block":
self.assertFalse(validation.ok)
else:
self.assertTrue(validation.ok)
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
self.assertEqual(result.report.queueable_count, queueable_count)
message = result.report.messages[0]
self.assertEqual(message.build_status.value, build_status)
self.assertEqual(message.validation_status.value, validation_status)
coverage_issues = [issue for issue in message.issues if issue.code == "missing_attachment_coverage"]
if issue_behavior is None:
self.assertEqual(coverage_issues, [])
else:
self.assertEqual(coverage_issues[0].behavior, issue_behavior)
self.assertEqual(result.built_messages[0].mime is not None, has_mime)
def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
documents = root / "documents"
documents.mkdir()
(documents / "matched.xlsx").write_bytes(b"matched workbook")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": "zip-missing-pattern", "name": "ZIP missing pattern", "mode": "test"},
"fields": [],
"global_values": {},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Subject", "text": "Body"},
"attachments": {
"base_path": ".",
"allow_individual": True,
"send_without_attachments": False,
"zip": {
"enabled": True,
"archives": [{"id": "bundle", "name": "recipient-bundle.zip", "standard": True}],
},
"global": [],
},
"entries": {
"inline": [{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "type": "to"}],
"attachments": [
{"id": "matched", "base_dir": "documents", "file_filter": "matched.xlsx", "required": True},
{
"id": "missing",
"base_dir": "documents",
"file_filter": "missing.xlsx",
"required": False,
"missing_behavior": "warn",
},
],
}]
},
"validation_policy": {
"missing_email": "block",
"template_error": "block",
"missing_optional_attachment": "warn",
},
"delivery": {"imap_append_sent": {"enabled": False}},
})
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
self.assertTrue(validation.ok)
self.assertIn("missing_optional_attachment", {issue.code for issue in validation.issues})
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=False)
self.assertEqual(result.report.built_count, 1)
self.assertEqual(result.report.queueable_count, 1)
message = result.report.messages[0]
self.assertEqual(message.attachment_count, 1)
summaries = {attachment.attachment_id: attachment for attachment in message.attachments}
self.assertEqual(summaries["matched"].zip_filename, "recipient-bundle.zip")
self.assertEqual(summaries["matched"].zip_entry_names, ["matched.xlsx"])
self.assertIsNone(summaries["missing"].zip_filename)
self.assertEqual(summaries["missing"].zip_entry_names, [])
self.assertEqual(summaries["missing"].matches, [])
mime = result.built_messages[0].mime
self.assertIsNotNone(mime)
attachments = {part.get_filename(): part.get_payload(decode=True) for part in mime.iter_attachments()}
self.assertEqual(set(attachments), {"recipient-bundle.zip"})
with zipfile.ZipFile(io.BytesIO(attachments["recipient-bundle.zip"])) as archive:
self.assertEqual(archive.namelist(), ["matched.xlsx"])
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
if __name__ == "__main__":
unittest.main()

View File

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

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from govoplan_campaign.backend.reports.campaigns import (
_job_evidence_row,
_latest_by_job_id,
_load_delivery_info,
generate_campaign_report,
)
def _dt() -> datetime:
return datetime(2026, 7, 8, 12, 30, tzinfo=UTC)
def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
job = SimpleNamespace(
id="job-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=0,
entry_id="recipient-1",
recipient_email="recipient@govoplan.test",
subject="Evidence test",
message_id_header="<message-1@govoplan.test>",
eml_storage_key="eml/job-1.eml",
eml_local_path="/tmp/job-1.eml",
eml_size_bytes=1234,
eml_sha256="abc123",
build_status="built",
validation_status="ready",
queue_status="queued",
send_status="smtp_accepted",
imap_status="appended",
attempt_count=2,
queued_at=_dt(),
claimed_at=None,
smtp_started_at=_dt(),
outcome_unknown_at=None,
sent_at=_dt(),
last_error=None,
resolved_recipients={
"from": [{"name": "Sender", "email": "sender@govoplan.test"}],
"to": [{"name": "Recipient", "email": "recipient@govoplan.test"}],
"cc": [],
"bcc": [],
"reply_to": [{"email": "reply@govoplan.test"}],
},
resolved_attachments=[
{
"matches": [{"filename": "notice.pdf"}, "/tmp/evidence.xlsx"],
"zip_filename": "bundle.zip",
"message_filenames": ["notice.pdf"],
}
],
issues_snapshot=[],
created_at=_dt(),
updated_at=_dt(),
)
smtp = SimpleNamespace(
job_id="job-1",
attempt_number=2,
status="success",
smtp_status_code=250,
smtp_response="2.0.0 queued",
error_type=None,
error_message=None,
started_at=_dt(),
finished_at=_dt(),
)
imap = SimpleNamespace(
job_id="job-1",
attempt_number=1,
status="appended",
folder="Sent",
error_message=None,
created_at=_dt(),
updated_at=_dt(),
)
row = _job_evidence_row(job, latest_smtp=smtp, latest_imap=imap)
assert row["campaign_id"] == "campaign-1"
assert row["campaign_version_id"] == "version-1"
assert row["message_id_header"] == "<message-1@govoplan.test>"
assert row["from"] == "Sender <sender@govoplan.test>"
assert row["to"] == "Recipient <recipient@govoplan.test>"
assert row["reply_to"] == "reply@govoplan.test"
assert "bundle.zip" in row["attachment_names"]
assert "notice.pdf" in row["attachment_names"]
assert row["latest_smtp_status_code"] == 250
assert "latest_smtp_response" not in row
assert "latest_smtp_error_type" not in row
assert "latest_smtp_error_message" not in row
assert row["latest_imap_status"] == "appended"
assert row["latest_imap_folder"] == "Sent"
assert "latest_imap_error_message" not in row
assert "eml_storage_key" not in row
assert "eml_local_path" not in row
def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
attempts = [
SimpleNamespace(job_id="job-1", attempt_number=1),
SimpleNamespace(job_id="job-1", attempt_number=3),
SimpleNamespace(job_id="job-2", attempt_number=2),
]
latest = _latest_by_job_id(attempts)
assert latest["job-1"].attempt_number == 3
assert latest["job-2"].attempt_number == 2
def test_invalid_legacy_snapshot_never_echoes_validation_details() -> None:
version = SimpleNamespace(
execution_snapshot={
"snapshot_version": "legacy",
"smtp": {"host": "smtp.internal.example", "password": "provider-secret"},
},
execution_snapshot_hash=None,
execution_snapshot_at=None,
)
delivery = _load_delivery_info(version, [])
assert delivery["load_error"] == "Execution snapshot is invalid; rebuild the selected version before delivery."
assert "provider-secret" not in repr(delivery)
assert "smtp.internal.example" not in repr(delivery)
assert "background_workers_enabled" not in delivery
assert "smtp_transport_revision" not in delivery
operator_delivery = _load_delivery_info(version, [], include_diagnostics=True)
assert "background_workers_enabled" in operator_delivery
assert "smtp_transport_revision" in operator_delivery
def test_aggregate_report_omits_recipient_level_failures_by_default() -> None:
campaign = SimpleNamespace(id="campaign-1")
version = SimpleNamespace(id="version-1")
with (
patch("govoplan_campaign.backend.reports.campaigns._get_campaign", return_value=campaign),
patch("govoplan_campaign.backend.reports.campaigns._selected_version", return_value=version),
patch("govoplan_campaign.backend.reports.campaigns._report_jobs", return_value=[]),
patch(
"govoplan_campaign.backend.reports.campaigns._campaign_report_payload",
return_value={"cards": {}},
) as payload,
):
report = generate_campaign_report(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
)
assert report == {"cards": {}}
assert payload.call_args.kwargs["include_recent_failures"] is False
class CampaignReportProjectionTests(unittest.TestCase):
def test_evidence_projection(self) -> None:
test_job_evidence_row_contains_transport_and_message_evidence()
def test_latest_attempt_projection(self) -> None:
test_latest_by_job_id_keeps_highest_attempt_number()
if __name__ == "__main__":
unittest.main()

375
tests/test_documentation.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,174 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from govoplan_campaign.backend import router
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.persistence.versions import validate_campaign_partial
class CampaignPartialValidationTests(unittest.TestCase):
def test_partial_validation_reports_counts_across_sections(self) -> None:
result = validate_campaign_partial({
"campaign": {},
"recipients": {"from": {}},
"entries": {"source": {}, "mapping": {}},
"template": {"body_mode": "both"},
"attachments": {},
"delivery": {"rate_limit": {"messages_per_minute": 0}},
})
codes = {item["code"] for item in result["issues"]}
self.assertFalse(result["ok"])
self.assertEqual(3, result["error_count"])
self.assertIn("missing_campaign_id", codes)
self.assertIn("missing_email_mapping", codes)
self.assertIn("missing_template_text_body", codes)
self.assertIn("missing_attachment_base_path", codes)
self.assertIn("invalid_rate_limit", codes)
def test_partial_validation_filters_to_requested_section(self) -> None:
result = validate_campaign_partial(
{
"campaign": {},
"template": {"body_mode": "text"},
"delivery": {"rate_limit": {"messages_per_minute": "fast"}},
},
section="template",
)
self.assertTrue(result["ok"])
self.assertEqual("template", result["section"])
self.assertEqual(0, result["error_count"])
self.assertEqual({"missing_subject", "missing_template_text_body"}, {item["code"] for item in result["issues"]})
def test_address_lookup_returns_unavailable_when_addresses_capability_absent(self) -> None:
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(router, "_registry_capability", return_value=None):
response = router.lookup_campaign_addresses("campaign-1", query="", session=object(), principal=object())
self.assertFalse(response.available)
self.assertEqual(response.candidates, [])
def test_address_lookup_accepts_empty_query_for_suggestion_preload(self) -> None:
class LookupCapability:
def lookup(self, _session, _principal, *, query: str, limit: int):
self.query = query
self.limit = limit
return [{
"contact_id": "contact-1",
"address_book_id": "book-1",
"display_name": "Ada Lovelace",
"email": "ada@example.local",
"email_label": "work",
"tags": [],
"source_kind": "local",
"provenance": {"module": "addresses"},
}]
capability = LookupCapability()
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(router, "_registry_capability", return_value=capability):
response = router.lookup_campaign_addresses("campaign-1", query="", limit=100, session=object(), principal=object())
self.assertEqual(capability.query, "")
self.assertEqual(capability.limit, 100)
self.assertTrue(response.available)
self.assertEqual(response.candidates[0].email, "ada@example.local")
class CampaignSemanticValidationTests(unittest.TestCase):
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {"allow_individual_from": True},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"entries": {
"inline": [
{"id": "ready", "from": [{"email": "sender@example.local"}]},
{"id": "missing"},
]
},
})
report = validate_campaign_config(config)
missing_sender = [issue for issue in report.issues if issue.code == "missing_sender"]
self.assertEqual(1, len(missing_sender))
self.assertEqual("/entries/inline/1/from", missing_sender[0].path)
def test_semantic_validation_reports_zip_delivery_and_external_mapping_issues(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "recipients.csv").write_text("email\nada@example.local\n", encoding="utf-8")
campaign_file = root / "campaign.json"
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"fields": [
{"name": "pin", "type": "string", "can_override": False},
{"name": "secret", "type": "password"},
],
"global_values": {"unknown": "value"},
"recipients": {
"from": [{"email": "sender@example.local"}],
"to": [{"email": "recipient@example.local"}],
},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"attachments": {
"base_path": "attachments",
"zip": {
"enabled": True,
"archives": [
{
"id": "main",
"name": "bundle.zip",
"standard": True,
"password_enabled": True,
"password_field": "secret",
"password_scope": "global",
}
],
},
},
"entries": {
"source": {"type": "csv", "path": "recipients.csv", "has_header": True},
"mapping": {"fields.pin": "missing_pin", "fields.unknown": "missing_unknown"},
"defaults": {
"attachments": [
{
"base_dir": "",
"file_filter": "*.pdf",
"zip": {"archive_id": "missing-archive"},
}
]
},
},
"delivery": {"imap_append_sent": {"enabled": True}},
})
report = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
codes = {issue.code for issue in report.issues}
self.assertFalse(report.ok)
self.assertEqual("external:csv", report.entries_mode)
self.assertIsNone(report.entries_count)
self.assertIn("unknown_global_value", codes)
self.assertIn("zip_global_password_value_missing", codes)
self.assertIn("zip_archive_unknown", codes)
self.assertIn("missing_mail_profile", codes)
self.assertIn("unknown_mapping_target", codes)
self.assertIn("mapping_target_not_overridable", codes)
self.assertIn("mapping_columns_missing", codes)
self.assertIn("attachments_base_path_not_found", codes)
if __name__ == "__main__":
unittest.main()

View File

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

View File

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

195
tests/test_sending_jobs.py Normal file
View File

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

View File

@@ -0,0 +1,279 @@
from __future__ import annotations
import copy
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_campaign.backend.attachments.resolver import (
MessageAttachmentStatus,
resolve_entry_attachments,
)
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.path_security import (
CampaignPathSecurityError,
assert_server_safe_campaign_paths,
)
from govoplan_campaign.backend.persistence.campaigns import (
create_campaign_version_from_json,
load_version_config,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
def _campaign_json() -> dict[str, object]:
return {
"version": "1.0",
"campaign": {"id": "path-security", "name": "Path security", "mode": "test"},
"template": {"subject": "Subject", "text": "Body"},
"attachments": {"base_path": ".", "base_paths": [], "global": []},
"entries": {"inline": []},
}
class ServerCampaignPathSecurityTests(unittest.TestCase):
def test_api_import_rejects_absolute_source_filename_before_persistence(self) -> None:
with patch(
"govoplan_campaign.backend.persistence.campaigns.files_integration",
return_value=SimpleNamespace(available=False),
):
with self.assertRaisesRegex(CampaignPathSecurityError, "source_filename"):
create_campaign_version_from_json(
None, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
raw_json=_campaign_json(),
source_filename="/etc/passwd",
)
def test_template_parent_traversal_is_rejected(self) -> None:
raw = _campaign_json()
raw["template"] = {"source": {"text_path": "../../etc/passwd"}}
with self.assertRaisesRegex(CampaignPathSecurityError, "template source paths"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
def test_existing_unsafe_version_cannot_reuse_a_delivery_snapshot(self) -> None:
raw = _campaign_json()
raw["template"] = {"source": {"text_path": "/etc/passwd"}}
version = SimpleNamespace(raw_json=raw, execution_snapshot={})
with patch(
"govoplan_campaign.backend.sending.execution.files_integration",
return_value=SimpleNamespace(available=True),
):
with self.assertRaisesRegex(ExecutionSnapshotError, "template source paths"):
ensure_execution_snapshot(None, version) # type: ignore[arg-type]
def test_existing_unsafe_version_is_rejected_before_config_loading(self) -> None:
raw = _campaign_json()
raw["template"] = {"source": {"text_path": "/etc/passwd"}}
version = SimpleNamespace(campaign_id="campaign-1", raw_json=raw)
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
session = SimpleNamespace(get=lambda _model, key: version if key == "version-1" else campaign)
with (
patch(
"govoplan_campaign.backend.persistence.campaigns.files_integration",
return_value=SimpleNamespace(available=True),
),
patch("govoplan_campaign.backend.persistence.campaigns._write_campaign_snapshot") as write_snapshot,
patch("govoplan_campaign.backend.persistence.campaigns.load_campaign_config_from_json") as load_config,
):
with self.assertRaisesRegex(CampaignPathSecurityError, "template source paths"):
load_version_config(session, "version-1") # type: ignore[arg-type]
write_snapshot.assert_not_called()
load_config.assert_not_called()
def test_source_base_symlink_escape_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
outside = root / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("secret", encoding="utf-8")
trusted = root / "trusted"
trusted.mkdir()
(trusted / "escape").symlink_to(outside, target_is_directory=True)
raw = _campaign_json()
raw["template"] = {"source": {"text_path": "escape/secret.txt"}}
with self.assertRaisesRegex(CampaignPathSecurityError, "source_base_path"):
assert_server_safe_campaign_paths(
raw,
source_base_path=str(trusted),
managed_files_available=True,
)
def test_attachment_absolute_path_and_parent_filter_are_rejected(self) -> None:
raw = _managed_attachment_campaign()
attachments = raw["attachments"]
assert isinstance(attachments, dict)
base_paths = attachments["base_paths"]
assert isinstance(base_paths, list)
assert isinstance(base_paths[0], dict)
base_paths[0]["path"] = "/etc"
with self.assertRaisesRegex(CampaignPathSecurityError, "relative managed-file path"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
raw = _managed_attachment_campaign()
attachments = raw["attachments"]
assert isinstance(attachments, dict)
rules = attachments["global"]
assert isinstance(rules, list)
assert isinstance(rules[0], dict)
rules[0]["file_filter"] = "../../etc/passwd"
with self.assertRaisesRegex(CampaignPathSecurityError, "parent-directory traversal"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
def test_local_attachment_rule_is_rejected_in_server_mode(self) -> None:
raw = _managed_attachment_campaign()
attachments = raw["attachments"]
assert isinstance(attachments, dict)
base_paths = attachments["base_paths"]
assert isinstance(base_paths, list)
assert isinstance(base_paths[0], dict)
base_paths[0].pop("source")
with self.assertRaisesRegex(CampaignPathSecurityError, "backed by managed Files"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
def test_default_attachment_rule_must_use_managed_files(self) -> None:
raw = _managed_attachment_campaign()
attachments = raw["attachments"]
assert isinstance(attachments, dict)
attachments["global"] = []
entries = raw["entries"]
assert isinstance(entries, dict)
entries["defaults"] = {
"attachments": [{"base_dir": "invoices", "file_filter": "invoice.pdf"}]
}
with self.assertRaisesRegex(CampaignPathSecurityError, "entries.defaults.attachments"):
assert_server_safe_campaign_paths(raw, managed_files_available=True)
def test_normal_managed_file_campaign_is_allowed(self) -> None:
raw = _managed_attachment_campaign()
assert_server_safe_campaign_paths(raw, managed_files_available=True)
copied = copy.deepcopy(raw)
attachments = copied["attachments"]
assert isinstance(attachments, dict)
rules = attachments["global"]
assert isinstance(rules, list)
assert isinstance(rules[0], dict)
rules[0]["file_filter"] = "**/{{local:invoice_number}}-report.XLSX"
assert_server_safe_campaign_paths(copied, managed_files_available=True)
def test_rendered_managed_filter_cannot_traverse_materialized_source(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
managed_root = root / "managed"
managed_root.mkdir()
(root / "secret.pdf").write_bytes(b"server secret")
config = _runtime_managed_attachment_config(
managed_root,
file_filter="{{local:file_name}}",
file_name="../secret.pdf",
)
resolution = resolve_entry_attachments(
config=config,
campaign_file=root / "campaign.json",
entry=config.entries.inline[0], # type: ignore[index]
entry_index=1,
)
self.assertEqual(resolution.status, MessageAttachmentStatus.BLOCKED)
self.assertEqual(resolution.attachments[0].matches, [])
self.assertIn(
"unsafe_managed_attachment_path",
{issue.code for issue in resolution.issues},
)
def test_managed_match_symlink_cannot_escape_materialized_source(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
managed_root = root / "managed"
managed_root.mkdir()
outside = root / "secret.pdf"
outside.write_bytes(b"server secret")
(managed_root / "escape.pdf").symlink_to(outside)
config = _runtime_managed_attachment_config(
managed_root,
file_filter="escape.pdf",
file_name="unused.pdf",
)
resolution = resolve_entry_attachments(
config=config,
campaign_file=root / "campaign.json",
entry=config.entries.inline[0], # type: ignore[index]
entry_index=1,
)
self.assertEqual(resolution.status, MessageAttachmentStatus.BLOCKED)
self.assertEqual(resolution.attachments[0].matches, [])
self.assertIn(
"managed_attachment_path_escape",
{issue.code for issue in resolution.issues},
)
def _managed_attachment_campaign() -> dict[str, object]:
raw = _campaign_json()
raw["attachments"] = {
"base_path": "invoices",
"base_paths": [
{
"id": "personal-invoices",
"name": "My invoices",
"source": "managed:user:user-1",
"path": "invoices",
}
],
"global": [
{
"base_path_id": "personal-invoices",
"base_dir": "invoices",
"file_filter": "invoice.pdf",
}
],
}
return raw
def _runtime_managed_attachment_config(
managed_root: Path,
*,
file_filter: str,
file_name: str,
) -> CampaignConfig:
raw = _managed_attachment_campaign()
raw["fields"] = [{"name": "file_name", "type": "string", "required": True}]
attachments = raw["attachments"]
assert isinstance(attachments, dict)
base_paths = attachments["base_paths"]
assert isinstance(base_paths, list)
assert isinstance(base_paths[0], dict)
base_paths[0]["path"] = str(managed_root)
rules = attachments["global"]
assert isinstance(rules, list)
assert isinstance(rules[0], dict)
rules[0]["file_filter"] = file_filter
entries = raw["entries"]
assert isinstance(entries, dict)
entries["inline"] = [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.test", "type": "to"}],
"fields": {"file_name": file_name},
}
]
return CampaignConfig.model_validate(raw)
if __name__ == "__main__":
unittest.main()

View File

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

68
tests/test_zip_service.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
import zipfile
try:
import pyzipper
except ImportError: # pragma: no cover
pyzipper = None
from govoplan_campaign.backend.services.zip_service import create_zip_archive
class ZipServiceTests(unittest.TestCase):
def test_standard_password_zip_uses_zipcrypto_readable_by_stdlib(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source = root / "source.txt"
source.write_text("Hello Windows ZIP", encoding="utf-8")
output = root / "standard.zip"
create_zip_archive(output, [(source, "message.txt")], "secret", "zip_standard")
with zipfile.ZipFile(output) as archive:
info = archive.getinfo("message.txt")
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
self.assertTrue(info.flag_bits & 0x1)
self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP")
@unittest.skipIf(pyzipper is None, "pyzipper is not installed")
def test_aes_password_zip_keeps_aes_encryption(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source = root / "source.txt"
source.write_text("Hello AES ZIP", encoding="utf-8")
output = root / "aes.zip"
create_zip_archive(output, [(source, "message.txt")], "secret", "aes")
with zipfile.ZipFile(output) as archive:
info = archive.getinfo("message.txt")
self.assertEqual(info.compress_type, 99)
self.assertTrue(info.flag_bits & 0x1)
with pyzipper.AESZipFile(output) as archive:
archive.setpassword(b"secret")
self.assertEqual(archive.read("message.txt"), b"Hello AES ZIP")
def test_unprotected_zip_ignores_encryption_mode(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source = root / "source.txt"
source.write_text("Plain ZIP", encoding="utf-8")
output = root / "plain.zip"
create_zip_archive(output, [(source, "message.txt")], "", "zip_standard")
with zipfile.ZipFile(output) as archive:
info = archive.getinfo("message.txt")
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
self.assertFalse(info.flag_bits & 0x1)
self.assertEqual(archive.read("message.txt"), b"Plain ZIP")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.1", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,18 +14,20 @@
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css" "./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
}, },
"dependencies": { "dependencies": {
"@govoplan/files-webui": "file:../../govoplan-files/webui", "read-excel-file": "9.2.0"
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^0.555.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.1.1"
}, },
"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",
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"

View File

@@ -1,519 +0,0 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type TenantOwnerCandidate = {
account_id: string;
email: string;
display_name?: string | null;
};
export type RoleSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_builtin: boolean;
is_assignable: boolean;
user_assignments: number;
group_assignments: number;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type GroupSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
member_count: number;
member_ids: string[];
roles: RoleSummary[];
created_at: string;
updated_at: string;
system_template_id?: string | null;
system_required?: boolean;
};
export type UserAdminItem = {
id: string;
account_id: string;
tenant_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
account_is_active: boolean;
password_reset_required: boolean;
last_login_at?: string | null;
groups: GroupSummary[];
roles: RoleSummary[];
effective_scopes: string[];
is_owner: boolean;
is_last_active_owner: boolean;
created_at: string;
updated_at: string;
};
export type SystemAccountItem = {
account_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>;
roles: RoleSummary[];
last_login_at?: string | null;
};
export type SystemMembershipDraft = {
tenant_id: string;
is_active: boolean;
role_ids: string[];
group_ids: string[];
is_owner?: boolean;
is_last_active_owner?: boolean;
};
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
};
export type SystemSettingsItem = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy;
settings: Record<string, unknown>;
};
export type RetentionRunResponse = {
result: {
dry_run: boolean;
policy: PrivacyRetentionPolicy;
cutoffs: Record<string, string | null>;
effective_policy_scope?: string;
counts: Record<string, Record<string, number>>;
};
};
export type GovernanceAssignment = {
tenant_id: string;
mode: "available" | "required";
};
export type GovernanceTemplateItem = {
id: string;
kind: "group" | "role";
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_active: boolean;
assignments: GovernanceAssignment[];
created_at: string;
updated_at: string;
};
export type ApiKeyAdminItem = {
id: string;
user_id: string;
user_email: string;
name: string;
prefix: string;
scopes: string[];
expires_at?: string | null;
last_used_at?: string | null;
revoked_at?: string | null;
created_at: string;
};
export type AuditAdminItem = {
id: string;
scope: "tenant" | "system";
tenant_id?: string | null;
actor_email?: string | null;
action: string;
object_type?: string | null;
object_id?: string | null;
details: Record<string, unknown>;
created_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
return response.tenants;
}
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
return response.accounts;
}
export function createTenant(settings: ApiSettings, payload: {
slug: string;
name: string;
owner_account_id?: string | null;
description?: string | null;
default_locale?: string;
settings?: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
}): Promise<TenantAdminItem> {
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
}
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
name: string;
description: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
}>): Promise<TenantAdminItem> {
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
return response.users;
}
export function createUser(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
group_ids?: string[];
role_ids?: string[];
}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) });
}
export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{
display_name: string | null;
is_active: boolean;
group_ids: string[];
role_ids: string[];
}>): Promise<UserAdminItem> {
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
return response.groups;
}
export function createGroup(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
is_active?: boolean;
member_ids?: string[];
role_ids?: string[];
}): Promise<GroupSummary> {
return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{
name: string;
description: string | null;
is_active: boolean;
member_ids: string[];
role_ids: string[];
}>): Promise<GroupSummary> {
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
return response.roles;
}
export function createRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
return response.roles;
}
export function createSystemRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> {
return apiFetch(settings, "/api/v1/admin/system/accounts");
}
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
display_name?: string | null;
is_active?: boolean;
role_ids?: string[];
}): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, {
method: "PUT",
body: JSON.stringify({ role_ids: roleIds })
});
}
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
const params = new URLSearchParams();
if (includeRevoked) params.set("include_revoked", "true");
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
return response.api_keys;
}
export function createApiKey(settings: ApiSettings, payload: {
name: string;
user_id?: string | null;
scopes: string[];
expires_at?: string | null;
}): Promise<ApiKeyAdminItem & { secret: string }> {
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
}
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
}
export type AuditQueryOptions = {
tenantId?: string | null;
allTenants?: boolean;
scope?: "tenant" | "system";
limit?: number;
offset?: number;
page?: number;
pageSize?: number;
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
sortDirection?: "asc" | "desc";
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
};
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
}
export function createSystemAccount(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
role_ids?: string[];
memberships?: SystemMembershipDraft[];
}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
method: "PUT", body: JSON.stringify({ memberships })
});
}
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings");
}
export type SystemSettingsUpdatePayload = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null;
};
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
}
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
}
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
return response.templates;
}
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
}

View File

@@ -1,9 +1,15 @@
import type { ApiSettings, CampaignListItem } from "../types"; import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
import { apiDownload, apiFetch } from "./client"; import { apiDownload, apiFetch } from "./client";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type {
AccessDecisionProvenanceItem,
ResourceAccessExplanationUser as AccessExplanationUser,
ResourceAccessExplanationResponse
} from "@govoplan/core-webui";
export type CampaignListResponse = export type CampaignListResponse =
| CampaignListItem[] CampaignListItem[] |
| { {
campaigns?: CampaignListItem[]; campaigns?: CampaignListItem[];
items?: CampaignListItem[]; items?: CampaignListItem[];
results?: CampaignListItem[]; results?: CampaignListItem[];
@@ -20,8 +26,8 @@ export type CampaignShare = {
revoked_at?: string | null; revoked_at?: string | null;
}; };
export type CampaignShareTarget = { id: string; name: string; secondary?: string | null }; export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
export type CampaignShareTargets = { users: CampaignShareTarget[]; groups: CampaignShareTarget[] }; export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
export type CampaignUpdatePayload = { export type CampaignUpdatePayload = {
external_id?: string | null; external_id?: string | null;
@@ -75,6 +81,124 @@ export type CampaignVersionListItem = {
export type CampaignVersionDetail = CampaignVersionListItem & { export type CampaignVersionDetail = CampaignVersionListItem & {
raw_json: Record<string, unknown>; raw_json: Record<string, unknown>;
campaign_json?: Record<string, unknown>; campaign_json?: Record<string, unknown>;
mail_profile_migration_required?: boolean;
};
export type CampaignWorkspaceResponse = {
campaign: CampaignListItem | null;
versions: CampaignVersionListItem[];
current_version: CampaignVersionDetail | null;
summary: CampaignSummary | null;
selected_version_id?: string | null;
};
export type CampaignDeltaResponse = {
campaigns: CampaignListItem[];
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CampaignWorkspaceQuery = {
versionId?: string | null;
includeCurrentVersion?: boolean;
includeSummary?: boolean;
includeVersions?: boolean;
since?: string | null;
limit?: number;
};
export type RecipientImportColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
export type RecipientImportColumnMappingProfileItem = {
columnIndex: number;
kind: RecipientImportColumnKind;
fieldName?: string | null;
newFieldName?: string | null;
};
export type RecipientImportMappingProfile = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
columnCount: number;
headers: string[];
normalizedHeaders: string[];
orderedHeaderFingerprint: string;
unorderedHeaderFingerprint: string;
delimiter: "," | ";" | "\t";
headerRows: number;
quoted: boolean;
valueSeparators: string;
mappings: RecipientImportColumnMappingProfileItem[];
};
export type RecipientImportMappingProfilePayload = Omit<RecipientImportMappingProfile, "id" | "createdAt" | "updatedAt">;
export type RecipientImportMappingProfileListResponse = {
profiles: RecipientImportMappingProfile[];
};
export type CampaignAddressLookupCandidate = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
email_label?: string | null;
organization?: string | null;
role_title?: string | null;
tags: string[];
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
};
export type CampaignAddressLookupResponse = {
available: boolean;
candidates: CampaignAddressLookupCandidate[];
};
export type CampaignRecipientAddressSource = {
source_id: string;
source_label: string;
source_kind: string;
source_revision: string;
recipient_count: number;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourcesResponse = {
available: boolean;
sources: CampaignRecipientAddressSource[];
};
export type CampaignRecipientSnapshotItem = {
contact_id: string;
display_name: string;
email: string;
email_label?: string | null;
fields: Record<string, unknown>;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourceSnapshot = {
source_id: string;
source_label: string;
source_kind: string;
source_revision: string;
generated_at: string;
recipients: CampaignRecipientSnapshotItem[];
provenance: Record<string, unknown>;
}; };
export type CampaignVersionUpdatePayload = { export type CampaignVersionUpdatePayload = {
@@ -86,6 +210,7 @@ export type CampaignVersionUpdatePayload = {
editor_state?: Record<string, unknown> | null; editor_state?: Record<string, unknown> | null;
source_filename?: string | null; source_filename?: string | null;
source_base_path?: string | null; source_base_path?: string | null;
migrate_legacy_mail_settings?: boolean;
}; };
export type CampaignPartialValidationPayload = { export type CampaignPartialValidationPayload = {
@@ -157,6 +282,12 @@ export type CampaignSendNowPayload = {
enqueue_imap_task?: boolean; enqueue_imap_task?: boolean;
}; };
export type CampaignAppendSentPayload = {
dry_run?: boolean;
enqueue_celery?: boolean;
run_inline?: boolean;
};
export type CampaignAttachmentPreviewFile = { export type CampaignAttachmentPreviewFile = {
id: string; id: string;
@@ -169,6 +300,8 @@ export type CampaignAttachmentPreviewFile = {
checksum_sha256?: string; checksum_sha256?: string;
size_bytes?: number; size_bytes?: number;
content_type?: string | null; content_type?: string | null;
linked_to_campaign?: boolean;
share?: Record<string, unknown> | null;
}; };
export type CampaignAttachmentPreviewRule = { export type CampaignAttachmentPreviewRule = {
@@ -190,6 +323,8 @@ export type CampaignAttachmentPreviewRule = {
zip_filename?: string | null; zip_filename?: string | null;
matches: CampaignAttachmentPreviewFile[]; matches: CampaignAttachmentPreviewFile[];
match_count: number; match_count: number;
linked_match_count?: number;
unlinked_match_count?: number;
issues: Record<string, unknown>[]; issues: Record<string, unknown>[];
}; };
@@ -197,15 +332,37 @@ export type CampaignAttachmentPreviewResponse = {
campaign_id: string; campaign_id: string;
version_id: string; version_id: string;
shared_file_count: number; shared_file_count: number;
candidate_file_count?: number;
matched_file_count?: number;
linked_file_count?: number;
unlinked_file_count?: number;
rules: CampaignAttachmentPreviewRule[]; rules: CampaignAttachmentPreviewRule[];
linkable_files?: CampaignAttachmentPreviewFile[];
unused_shared_files: CampaignAttachmentPreviewFile[]; unused_shared_files: CampaignAttachmentPreviewFile[];
}; };
export type CampaignAttachmentPreviewPayload = { export type CampaignAttachmentPreviewPayload = {
include_unmatched?: boolean; include_unmatched?: boolean;
include_unlinked_candidates?: boolean;
campaign_json?: Record<string, unknown>; campaign_json?: Record<string, unknown>;
}; };
export type CampaignAttachmentLinkMatchesPayload = {
campaign_json?: Record<string, unknown> | null;
dry_run?: boolean;
};
export type CampaignAttachmentLinkMatchesResponse = {
campaign_id: string;
version_id: string;
matched_file_count: number;
already_linked_file_count: number;
linked_file_count: number;
dry_run?: boolean;
linked_files: CampaignAttachmentPreviewFile[];
linkable_files: CampaignAttachmentPreviewFile[];
};
export type CampaignMockSendPayload = { export type CampaignMockSendPayload = {
version_id?: string | null; version_id?: string | null;
send?: boolean; send?: boolean;
@@ -221,11 +378,19 @@ export type CampaignReviewStatePayload = {
reviewed_message_keys: string[]; reviewed_message_keys: string[];
}; };
export type CampaignSendJobPayload = {
include_warnings?: boolean;
dry_run?: boolean;
use_rate_limit?: boolean;
enqueue_imap_task?: boolean;
};
export type CampaignJobsQuery = { export type CampaignJobsQuery = {
versionId?: string; versionId?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
cursor?: string | null;
sendStatus?: string[]; sendStatus?: string[];
validationStatus?: string[]; validationStatus?: string[];
imapStatus?: string[]; imapStatus?: string[];
@@ -239,6 +404,8 @@ export type CampaignJobsResponse = {
total: number; total: number;
total_unfiltered: number; total_unfiltered: number;
pages: number; pages: number;
cursor?: string | null;
next_cursor?: string | null;
counts: Record<string, Record<string, number>>; counts: Record<string, Record<string, number>>;
filtered_counts: Record<string, Record<string, number>>; filtered_counts: Record<string, Record<string, number>>;
review: { review: {
@@ -250,6 +417,13 @@ export type CampaignJobsResponse = {
}; };
}; };
export type CampaignJobsDeltaResponse = CampaignJobsResponse & {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CampaignJobDetailResponse = { export type CampaignJobDetailResponse = {
job: Record<string, unknown>; job: Record<string, unknown>;
attempts: { attempts: {
@@ -277,6 +451,75 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
return response.campaigns ?? response.items ?? response.results ?? []; return response.campaigns ?? response.items ?? response.results ?? [];
} }
export async function listCampaignsDelta(
settings: ApiSettings,
options: {since?: string | null;limit?: number;} = {})
: Promise<CampaignDeltaResponse> {
const params = new URLSearchParams();
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignDeltaResponse>(settings, `/api/v1/campaigns/delta${suffix}`);
}
export async function listRecipientImportMappingProfiles(settings: ApiSettings): Promise<RecipientImportMappingProfile[]> {
const response = await apiFetch<RecipientImportMappingProfileListResponse>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles");
return response.profiles ?? [];
}
export async function createRecipientImportMappingProfile(
settings: ApiSettings,
payload: RecipientImportMappingProfilePayload)
: Promise<RecipientImportMappingProfile> {
return apiFetch<RecipientImportMappingProfile>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function updateRecipientImportMappingProfile(
settings: ApiSettings,
profileId: string,
payload: RecipientImportMappingProfilePayload)
: Promise<RecipientImportMappingProfile> {
return apiFetch<RecipientImportMappingProfile>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function deleteRecipientImportMappingProfile(settings: ApiSettings, profileId: string): Promise<void> {
await apiFetch<void>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
}
export async function lookupCampaignAddresses(
settings: ApiSettings,
campaignId: string,
query: string,
limit = 25)
: Promise<CampaignAddressLookupResponse> {
const params = new URLSearchParams({ query, limit: String(limit) });
return apiFetch<CampaignAddressLookupResponse>(settings, `/api/v1/campaigns/${campaignId}/address-lookup?${params.toString()}`);
}
export async function listCampaignRecipientAddressSources(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignRecipientAddressSourcesResponse> {
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
}
export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings,
campaignId: string,
sourceId: string)
: Promise<CampaignRecipientAddressSourceSnapshot> {
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
method: "POST",
body: JSON.stringify({ source_id: sourceId })
});
}
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> { export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`); return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
} }
@@ -284,8 +527,8 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
export async function updateCampaignMetadata( export async function updateCampaignMetadata(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignUpdatePayload payload: CampaignUpdatePayload)
): Promise<CampaignListItem> { : Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, { return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -294,13 +537,13 @@ export async function updateCampaignMetadata(
export async function createNewCampaign( export async function createNewCampaign(
settings: ApiSettings, settings: ApiSettings,
overrides: CampaignCreateMinimalPayload = {} overrides: CampaignCreateMinimalPayload = {})
): Promise<CampaignCreateResponse> { : Promise<CampaignCreateResponse> {
const now = new Date(); const now = new Date();
const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, ""); const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
const payload = { const payload = {
external_id: overrides.external_id ?? `new-campaign-${stamp}`, external_id: overrides.external_id ?? `new-campaign-${stamp}`,
name: overrides.name ?? "New Campaign", name: overrides.name ?? "i18n:govoplan-campaign.new_campaign.1f0d021c",
description: overrides.description ?? "", description: overrides.description ?? "",
current_flow: overrides.current_flow ?? "create", current_flow: overrides.current_flow ?? "create",
current_step: overrides.current_step ?? "basics" current_step: overrides.current_step ?? "basics"
@@ -316,26 +559,60 @@ export async function getCampaignSchema(settings: ApiSettings): Promise<unknown>
return apiFetch(settings, "/api/v1/schemas/campaign"); return apiFetch(settings, "/api/v1/schemas/campaign");
} }
export async function getCampaignSchemaUi(settings: ApiSettings): Promise<unknown> {
return apiFetch(settings, "/api/v1/schemas/campaign/ui");
}
export async function getCampaignWorkspace(
settings: ApiSettings,
campaignId: string,
options: CampaignWorkspaceQuery = {})
: Promise<CampaignWorkspaceResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
if (options.includeSummary !== undefined) params.set("include_summary", String(options.includeSummary));
if (options.includeVersions !== undefined) params.set("include_versions", String(options.includeVersions));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignWorkspaceResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace${suffix}`);
}
export async function getCampaignWorkspaceDelta(
settings: ApiSettings,
campaignId: string,
options: CampaignWorkspaceQuery = {})
: Promise<CampaignWorkspaceDeltaResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
if (options.includeSummary !== undefined) params.set("include_summary", String(options.includeSummary));
if (options.includeVersions !== undefined) params.set("include_versions", String(options.includeVersions));
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`);
}
export async function listCampaignVersions( export async function listCampaignVersions(
settings: ApiSettings, settings: ApiSettings,
campaignId: string campaignId: string)
): Promise<CampaignVersionListItem[]> { : Promise<CampaignVersionListItem[]> {
return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`); return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`);
} }
export async function getCampaignVersion( export async function getCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`); return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
} }
export async function unlockCampaignVersionValidation( export async function unlockCampaignVersionValidation(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
method: "POST" method: "POST"
}); });
@@ -344,8 +621,8 @@ export async function unlockCampaignVersionValidation(
export async function lockCampaignVersionTemporarily( export async function lockCampaignVersionTemporarily(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
method: "POST" method: "POST"
}); });
@@ -354,8 +631,8 @@ export async function lockCampaignVersionTemporarily(
export async function unlockCampaignVersionUserLock( export async function unlockCampaignVersionUserLock(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
method: "POST" method: "POST"
}); });
@@ -364,8 +641,8 @@ export async function unlockCampaignVersionUserLock(
export async function lockCampaignVersionPermanently( export async function lockCampaignVersionPermanently(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
method: "POST" method: "POST"
}); });
@@ -375,8 +652,8 @@ export async function updateCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignVersionUpdatePayload payload: CampaignVersionUpdatePayload)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -387,8 +664,8 @@ export async function forkCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignVersionUpdatePayload = {} payload: CampaignVersionUpdatePayload = {})
): Promise<CampaignCreateResponse> { : Promise<CampaignCreateResponse> {
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, { return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -399,8 +676,8 @@ export async function autosaveCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignVersionUpdatePayload payload: CampaignVersionUpdatePayload)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -412,8 +689,8 @@ export async function setCampaignVersionStep(
campaignId: string, campaignId: string,
versionId: string, versionId: string,
currentStep: string, currentStep: string,
currentFlow?: string | null currentFlow?: string | null)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, {
method: "POST", method: "POST",
body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep }) body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep })
@@ -424,8 +701,8 @@ export async function validatePartial(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignPartialValidationPayload = {} payload: CampaignPartialValidationPayload = {})
): Promise<CampaignPartialValidationResponse> { : Promise<CampaignPartialValidationResponse> {
return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, { return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -435,8 +712,8 @@ export async function validatePartial(
export async function publishCampaignVersion( export async function publishCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, {
method: "POST" method: "POST"
}); });
@@ -445,19 +722,20 @@ export async function publishCampaignVersion(
export async function validateVersion( export async function validateVersion(
settings: ApiSettings, settings: ApiSettings,
versionId: string, versionId: string,
checkFiles = false checkFiles = false,
): Promise<Record<string, unknown>> { linkUnsharedMatches = false)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
method: "POST", method: "POST",
body: JSON.stringify({ check_files: checkFiles }) body: JSON.stringify({ check_files: checkFiles, link_unshared_matches: linkUnsharedMatches })
}); });
} }
export async function buildVersion( export async function buildVersion(
settings: ApiSettings, settings: ApiSettings,
versionId: string, versionId: string,
writeEml = true writeEml = true)
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
method: "POST", method: "POST",
body: JSON.stringify({ write_eml: writeEml }) body: JSON.stringify({ write_eml: writeEml })
@@ -469,8 +747,8 @@ export function previewCampaignAttachments(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignAttachmentPreviewPayload = {} payload: CampaignAttachmentPreviewPayload = {})
): Promise<CampaignAttachmentPreviewResponse> { : Promise<CampaignAttachmentPreviewResponse> {
return apiFetch<CampaignAttachmentPreviewResponse>( return apiFetch<CampaignAttachmentPreviewResponse>(
settings, settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`, `/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
@@ -478,11 +756,24 @@ export function previewCampaignAttachments(
); );
} }
export function linkCampaignAttachmentMatches(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignAttachmentLinkMatchesPayload = {})
: Promise<CampaignAttachmentLinkMatchesResponse> {
return apiFetch<CampaignAttachmentLinkMatchesResponse>(
settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/link-matches`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function getCampaignSummary( export async function getCampaignSummary(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId?: string versionId?: string)
): Promise<CampaignSummary> { : Promise<CampaignSummary> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`); return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
} }
@@ -490,12 +781,13 @@ export async function getCampaignSummary(
export async function getCampaignJobs( export async function getCampaignJobs(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
options: CampaignJobsQuery = {} options: CampaignJobsQuery = {})
): Promise<CampaignJobsResponse> { : Promise<CampaignJobsResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId); if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page)); if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize)); if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
for (const value of options.sendStatus ?? []) params.append("send_status", value); for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value); for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value); for (const value of options.imapStatus ?? []) params.append("imap_status", value);
@@ -504,19 +796,39 @@ export async function getCampaignJobs(
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`); return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
} }
export async function getCampaignJobsDelta(
settings: ApiSettings,
campaignId: string,
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
: Promise<CampaignJobsDeltaResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
if (options.query?.trim()) params.set("q", options.query.trim());
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
}
export async function getCampaignJobDetail( export async function getCampaignJobDetail(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
jobId: string jobId: string)
): Promise<CampaignJobDetailResponse> { : Promise<CampaignJobDetailResponse> {
return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`); return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`);
} }
export async function getCampaignReport( export async function getCampaignReport(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId?: string versionId?: string)
): Promise<CampaignSummary> { : Promise<CampaignSummary> {
const params = new URLSearchParams({ include_jobs: "false" }); const params = new URLSearchParams({ include_jobs: "false" });
if (versionId) params.set("version_id", versionId); if (versionId) params.set("version_id", versionId);
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`); return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
@@ -525,8 +837,8 @@ export async function getCampaignReport(
export async function downloadCampaignJobsCsv( export async function downloadCampaignJobsCsv(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId?: string versionId?: string)
): Promise<void> { : Promise<void> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
await apiDownload( await apiDownload(
settings, settings,
@@ -538,8 +850,8 @@ export async function downloadCampaignJobsCsv(
export async function emailCampaignReport( export async function emailCampaignReport(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignReportEmailPayload payload: CampaignReportEmailPayload)
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -549,8 +861,8 @@ export async function emailCampaignReport(
export async function retryCampaignJobs( export async function retryCampaignJobs(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: Record<string, unknown> = {} payload: Record<string, unknown> = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -560,21 +872,33 @@ export async function retryCampaignJobs(
export async function sendUnattemptedCampaignJobs( export async function sendUnattemptedCampaignJobs(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: Record<string, unknown> = {} payload: Record<string, unknown> = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
} }
export async function sendCampaignJob(
settings: ApiSettings,
campaignId: string,
jobId: string,
payload: CampaignSendJobPayload = {})
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function resolveCampaignJobOutcome( export async function resolveCampaignJobOutcome(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
jobId: string, jobId: string,
decision: "smtp_accepted" | "not_sent", decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended",
note?: string note?: 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 })
@@ -585,8 +909,8 @@ export async function updateCampaignReviewState(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignReviewStatePayload payload: CampaignReviewStatePayload)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -596,8 +920,8 @@ export async function updateCampaignReviewState(
export async function queueCampaign( export async function queueCampaign(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignQueuePayload = {} payload: CampaignQueuePayload = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -607,8 +931,8 @@ export async function queueCampaign(
export async function sendCampaignNow( export async function sendCampaignNow(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignSendNowPayload = {} payload: CampaignSendNowPayload = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -619,8 +943,8 @@ export async function sendCampaignNow(
export async function mockSendCampaign( export async function mockSendCampaign(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignMockSendPayload = {} payload: CampaignMockSendPayload = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -642,11 +966,15 @@ export async function cancelCampaign(settings: ApiSettings, campaignId: string):
export async function appendSent( export async function appendSent(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
dryRun = false payload: CampaignAppendSentPayload = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
method: "POST", method: "POST",
body: JSON.stringify({ dry_run: dryRun }) body: JSON.stringify({
dry_run: payload.dry_run ?? false,
enqueue_celery: payload.enqueue_celery ?? true,
run_inline: payload.run_inline ?? false
})
}); });
} }
@@ -655,23 +983,23 @@ 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 response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
return response.shares; return response.shares;
} }
export async function updateCampaignOwner( export async function updateCampaignOwner(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: { owner_user_id?: string | null; owner_group_id?: string | null } payload: {owner_user_id?: string | null;owner_group_id?: string | null;})
): Promise<CampaignListItem> { : Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) }); return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) });
} }
export async function upsertCampaignShare( export async function upsertCampaignShare(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: { target_type: "user" | "group"; target_id: string; permission: "read" | "write" } payload: {target_type: "user" | "group";target_id: string;permission: "read" | "write";})
): Promise<CampaignShare> { : Promise<CampaignShare> {
return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) }); return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) });
} }

View File

@@ -1 +1 @@
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui"; export { apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "@govoplan/core-webui";

View File

@@ -1 +0,0 @@
export * from "@govoplan/files-webui";

View File

@@ -1 +1,50 @@
export * from "@govoplan/mail-webui"; import type {
ApiSettings,
MailConnectionTestResponse,
MailImapFolderListResponse,
MailServerProfile,
MockMailboxMessageResponse
} from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPost } from "./client";
const profileActionEndpoints = {
smtp: "test-smtp",
imap: "test-imap",
folders: "list-imap-folders"
} as const;
function runProfileAction<TResponse>(
settings: ApiSettings,
profileId: string,
action: keyof typeof profileActionEndpoints
): Promise<TResponse> {
return apiPost<TResponse>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`
);
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
include_inactive: includeInactive ? true : undefined,
campaign_id: campaignId
});
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
}
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
}
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
}
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
}
export type { MailConnectionTestResponse, MailImapFolderListResponse, MailImapFolderResponse, MailServerProfile, MailServerProfileListResponse, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";

View File

@@ -1,2 +0,0 @@
import { Button } from "@govoplan/core-webui";
export default Button;

View File

@@ -1,2 +0,0 @@
import { Card } from "@govoplan/core-webui";
export default Card;

View File

@@ -1,2 +0,0 @@
import { ConfirmDialog } from "@govoplan/core-webui";
export default ConfirmDialog;

View File

@@ -1,2 +0,0 @@
import { Dialog } from "@govoplan/core-webui";
export default Dialog;

View File

@@ -1,2 +0,0 @@
import { DismissibleAlert } from "@govoplan/core-webui";
export default DismissibleAlert;

View File

@@ -1,2 +0,0 @@
import { FormField } from "@govoplan/core-webui";
export default FormField;

View File

@@ -1,2 +0,0 @@
import { LoadingFrame } from "@govoplan/core-webui";
export default LoadingFrame;

View File

@@ -1,2 +0,0 @@
import { LoadingIndicator } from "@govoplan/core-webui";
export default LoadingIndicator;

View File

@@ -1,2 +0,0 @@
import { MetricCard } from "@govoplan/core-webui";
export default MetricCard;

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