Compare commits

...
70 Commits
Author SHA1 Message Date
zemion 2630498026 feat(campaigns): add accountable work assignments
Module Package Release / publish-packages (push) Successful in 14s
2026-08-22 00:50:03 +02:00
zemion c2f083e5f6 feat(campaigns): add governed collaboration thread
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 00:09:34 +02:00
zemion 5a21067e44 feat(campaigns): add Case Quick Access selector
Module Package Release / publish-packages (push) Successful in 13s
2026-08-21 16:05:45 +02:00
zemion 73cfad209a feat: add governed Campaign DSAR coverage 2026-08-20 23:15:11 +02:00
zemion 2a00d910df feat: govern autonomous campaign delivery schedules 2026-08-20 21:10:11 +02:00
zemion c846c249b8 feat: pause campaign batches on SMTP failure 2026-08-20 17:39:08 +02:00
zemion 69e6588a89 test: align campaign checks with archive governance 2026-08-20 17:39:03 +02:00
zemion 75e8f864a7 feat: govern legacy archive encryption 2026-08-20 12:24:36 +02:00
zemion d5b874c469 feat: select users in campaign access explanations 2026-08-20 06:16:43 +02:00
zemion 5c27527725 feat(campaign): explain governed child access 2026-08-20 05:38:00 +02:00
zemion 112ef9dc31 feat(campaign): govern attachment reuse 2026-08-20 05:20:07 +02:00
zemion f4fe534ee1 feat(campaign): record unmatched file disposition 2026-08-20 01:57:12 +02:00
zemion b6af7665f4 feat(campaign): complete reusable template workflow 2026-08-19 21:56:08 +02:00
zemion 14e94873a9 feat(security): explain Campaign child evidence 2026-08-19 20:27:42 +02:00
zemion 8e2f9d743d feat(webui): add campaign metric drill-downs 2026-08-19 18:47:45 +02:00
zemion 039ce35e78 Adopt semantic campaign page actions 2026-08-19 14:26:26 +02:00
zemion 7bc5e4a35c feat: align campaign with shared UI foundations 2026-08-18 21:32:34 +02:00
zemion 06f773e4eb Adopt shared WebUI structural primitives 2026-08-18 13:17:29 +02:00
zemion 9a2f13bc9a Adopt shared WebUI layout primitives 2026-08-18 11:30:39 +02:00
zemion cf8d7fab11 Adopt shared WebUI layout primitives 2026-08-18 10:42:51 +02:00
zemion 68459fde15 refactor: adopt shared page and workspace layouts 2026-08-18 02:17:16 +02:00
zemion c2efd6b7bd feat: add campaign copying scheduling and residual handling 2026-08-07 14:54:04 +02:00
zemion 696f8f6385 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:44 +02:00
zemion 6562484d32 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:58 +02:00
zemion 2db99eaf6a Release v0.1.16
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 19:51:59 +02:00
zemion ea0efe661f Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:18:11 +02:00
zemion 6bde11a286 Make package publication retries hash-safe 2026-08-04 14:32:18 +02:00
zemion 585493fe7a Harden module package publication 2026-08-04 14:02:39 +02:00
zemion 4133de86cd Enable generated campaign passwords 2026-08-04 11:07:55 +02:00
zemion 9137300780 Add bulk recipient activation controls 2026-08-04 10:51:44 +02:00
zemion f98fe06143 Resolve campaign worker jobs to tenants 2026-08-04 09:29:35 +02:00
zemion dc63e35550 Declare legacy operator route surface 2026-08-04 05:20:46 +02:00
zemion 2dac5570cd Add protected package release workflow 2026-08-04 04:14:02 +02:00
zemion 91890fdaf5 Add native permission-aware campaign search source 2026-08-04 03:03:26 +02:00
zemion 8f5231147d Complete governed campaign lifecycle actions 2026-08-04 01:04:39 +02:00
zemion df5a93d6a3 Close Campaign interface audit gaps 2026-08-04 01:04:39 +02:00
zemion 3f3545f080 feat(campaign): expose governed archive action 2026-08-03 20:39:06 +02:00
zemion d9195a2d2b Reconcile orphaned Campaign artifacts 2026-08-03 09:23:27 +02:00
zemion d635f3a5fc Link campaign interventions to configured help 2026-08-03 07:35:39 +02:00
zemion 1d6c745991 Clarify campaign review interventions 2026-08-03 07:22:27 +02:00
zemion 5df26be074 Bind Mail effects to stable delivery attempts 2026-08-03 05:00:30 +02:00
zemion 50ce8b0acb Fence generated artifact retention 2026-08-03 03:54:58 +02:00
zemion 9da03090a7 Fence Campaign delivery effects in recovery ledger 2026-08-03 03:48:03 +02:00
zemion dd09b06c47 Adopt recovery ledger for Campaign builds 2026-08-03 03:03:00 +02:00
zemion c6bbdae2e1 Fix Campaign CSV evidence projection 2026-08-03 03:02:52 +02:00
zemion bf6e07f307 Add bulk calendar invitation delivery 2026-08-02 16:38:48 +02:00
zemion 7733265cc8 Implement governed hybrid campaign delivery 2026-08-02 13:58:37 +02:00
zemion b38597f2be Integrate Distribution Lists with Campaign recipients 2026-08-02 11:53:35 +02:00
zemion 4eeba62bbc feat: contribute governed campaign reports 2026-08-02 05:29:34 +02:00
zemion 2afdd38128 Add institutional provenance and approval gates 2026-08-01 20:57:26 +02:00
zemion cec3d17bff feat: govern shared campaign artifact storage 2026-08-01 17:48:24 +02:00
zemion 82ddc0c34c Consume governed recipients and add operational checks 2026-07-31 22:48:07 +02:00
zemion fa4eb39e0b feat: complete campaign wizards and retention reporting 2026-07-31 04:21:34 +02:00
zemion e689fdf495 Refactor campaign recipient and review presentation 2026-07-31 02:48:56 +02:00
zemion 5f7503598c feat: govern attachment exceptions and ownership transfers 2026-07-30 17:42:10 +02:00
zemion cd223cbb95 feat: harden campaign delivery and editing 2026-07-30 14:27:04 +02:00
zemion 4a120e8009 Complete recipient search and delivery evidence 2026-07-30 05:22:09 +02:00
zemion c769be39da perf(campaign): search share targets server-side 2026-07-30 01:30:13 +02:00
zemion 46df12c025 perf(campaign): batch tenant summaries 2026-07-30 01:15:38 +02:00
zemion 961d5d1130 refactor(campaign): split review and recipient boundaries 2026-07-30 01:01:26 +02:00
zemion 23b9a531d5 fix: remove duplicate Campaign translation key 2026-07-30 00:39:58 +02:00
zemion cc93945f79 feat: integrate reports into Campaign navigation 2026-07-29 22:19:24 +02:00
zemion 101f3ccd7d feat: integrate operator queue into Campaign 2026-07-29 21:10:37 +02:00
zemion 2199187e8b refactor(api): split campaign workflow routers 2026-07-29 20:07:11 +02:00
zemion dd9592a192 Refactor campaign delivery decision paths 2026-07-29 17:06:15 +02:00
zemion 5240749ae1 feat: add governed postbox delivery and report hardening 2026-07-29 14:16:28 +02:00
zemion f11c56e890 fix: make direct campaign pages scrollable 2026-07-28 22:50:11 +02:00
zemion 8d0a2608ed Declare campaign route View surfaces 2026-07-28 21:04:55 +02:00
zemion 89ae14c032 Integrate campaign delivery with hierarchical mail profiles 2026-07-28 19:33:12 +02:00
zemion 38af25ee88 docs: update GovOPlaN repository links 2026-07-27 15:46:51 +02:00
220 changed files with 54769 additions and 9138 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Campaign Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Campaign internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## 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`.
+51 -3
View File
@@ -15,12 +15,32 @@ This repository owns:
- 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
- WebUI package `@govoplan/campaign-webui`
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, and `/templates`
- route contributions for `/campaigns`, the integrated `/campaigns/queue` view,
`/campaigns/reports`, `/campaigns/:campaignId/*`, and `/templates`
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.
When the optional Reporting module is enabled, Campaign contributes its
recipient-free aggregate delivery report through the versioned Core report
provider contract. Reporting owns the global `/reports` route. Campaign keeps
its module-local `/campaigns/reports` view and does not claim the global route
when Reporting is absent.
Generated EML and printable artifacts are durable execution material, not a
node-local runtime cache. Campaign stores EML through Core's shared
object-storage contract under opaque Campaign-owned keys. Templates returns a
bounded artifact or a Files-managed artifact for printable output. Database job
rows retain the expected hashes and provenance. Workers resolve and verify the
frozen evidence before delivery. Build failure compensates objects written
before database commit. Retention uses a fenced forward-recovery operation and
independently verifies both artifact absence and the committed locator update;
partial or unobservable cleanup remains visible in Ops.
An operator-only, dry-run-first reconciler inventories bounded tenant-prefix
pages and removes only old objects that remain unreferenced after an active
build-fence check. Applied runs are idempotent, fenced, audited, and preserve
database references on every storage failure.
## Dependencies
@@ -28,10 +48,30 @@ The module has one required runtime dependency:
- `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration
Files and mail are optional module integrations declared in the campaign manifest:
Files, Mail, Distribution Lists, Templates, Postbox, and Calendar 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.
- `govoplan-dist-lists` expands reusable governed audiences. Campaign freezes the exact list revision, provider evidence, candidates, and explicit per-recipient primary/fallback route into its own version.
- `govoplan-templates` validates and renders published label, envelope, letter, and list-layout templates for postal or internal-mail delivery. Generated output is hash-bound to its template, inputs, actor, route decisions, and Campaign version.
- `govoplan-postbox` resolves exact or organization-derived Postbox targets and records provider acceptance and receipt evidence. It remains optional; Mail-only and print-only campaigns do not require it.
- `govoplan-calendar` renders and mirrors individualized VEVENT invitations through the versioned `calendar.invitations` capability. Campaign freezes one METHOD:REQUEST attachment per recipient during build, creates the Calendar mirror only after delivery acceptance, and reads live RSVP state in bounded report batches. Mail may forward METHOD:REPLY parts from an authorized IMAP source. Calendar absence leaves ordinary Campaign authoring and delivery usable.
Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery.
Recurring schedules have two immutable modes. Manual mode remains the default
and prepares independent drafts without Mail. Autonomous mode is explicit and
Mail-only: it seals an already built and explicitly approved execution snapshot,
rechecks approval, policy, credential/transport revision, live SMTP health,
recipient and attachment evidence before each occurrence, and submits one
Mail-owned durable command per frozen message. Occurrence-scoped idempotency is
allocated before delivery. Accepted and outcome-unknown effects are never
retried automatically; uncertain or systemic failures pause the schedule,
notify its accountable operator, and retain non-secret recovery evidence.
Generated EML retention excludes source versions while an autonomous schedule
has a remaining occurrence, including while it is paused; once the schedule
finishes, already accepted Mail commands retain their own encrypted payload and
evidence under Mail policy.
Public campaign, version, job, and report responses expose business data and
delivery evidence, but never process-local paths, storage-backend keys, or
@@ -52,6 +92,9 @@ services can cooperate without importing campaign internals:
- `campaigns.policyContext` for retention/policy provenance
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
- `campaigns.retention` for campaign-owned retention cleanup
- `privacy.dsar.campaigns` for tenant-scoped recipient, version, delivery,
report-projection, and artifact-metadata discovery plus governed erasure
planning
Keep these capability payloads narrow: stable ids, policy payloads, and task
results only.
@@ -94,7 +137,12 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
- Immediate delivery is bounded to 25 exact eligible recipient jobs by default. Deployments may set `GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0500), and tenants may narrow that ceiling through `campaign_delivery_policy.synchronous_send_max_recipients` in tenant settings.
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
- Immediate Mail delivery preflights the selected SMTP transport before the
first effect and reuses a healthy bounded connection through Mail. Review and
send reports the batch state, connection/reconnect counts, and paused count.
A systemic authentication, sender, or connectivity failure pauses remaining
jobs; correct and test the Mail profile before explicitly resuming them.
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
- [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.
+67
View File
@@ -0,0 +1,67 @@
# Campaign Accessibility Review
The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form,
alert, and segmented-control components. Its page frames use Core `PageLayout`
and its full-canvas navigation/content shells use `WorkspaceLayout`, so pane
scrolling, sticky headings, action collapse, narrow-layout behavior, and help
scope remain platform-owned. Core also owns focus trapping, focus return,
Escape handling, labels, disabled state, and keyboard behavior for the shared
primitives.
## Repeatable review matrix
Run this matrix for Campaign overview, wizard, sender and recipients,
attachments, template editing, review and send, operator queue, and reports:
1. Navigate all actions with Tab and Shift+Tab; focus must remain visible and
follow the visual reading order.
2. Activate buttons and links with Enter, and native buttons with Space.
3. Open every dialog, verify initial focus remains within it, close with Escape,
and verify focus returns to the opener.
4. Use DataGrid sorting, filtering, pagination, row selection, and action menus
without a pointer.
5. At 200 percent browser zoom and a 320 CSS-pixel viewport, verify that content
reflows or scrolls without hiding actions.
6. With reduced motion enabled, verify that workflow state does not depend on
animation.
7. With a screen reader, verify page headings, field labels, validation errors,
workflow states, message navigation, and attachment evidence.
## Automated structural guard
`npm run test:accessibility-contract` rejects non-semantic click handlers,
unlabelled icon-only buttons in the message preview, and Campaign-local modal
implementations that bypass Core's `Dialog`. It complements rather than replaces
browser and assistive-technology testing.
The guard also verifies that Campaign retains narrow-viewport layouts, visible
keyboard focus for domain-specific controls, and an explicit reduced-motion
override. Shared dialog focus trapping and restoration are tested in Core;
Campaign tests verify that overlays continue to use that shared primitive.
## Release evidence
The feature implementation can be closed once the structural contract, shared
Core component tests, TypeScript graph, and representative responsive overlay
tests pass. The seven-step matrix above remains a release-candidate checklist:
it must be repeated for the exact browser, language packages, theme, density,
and assistive-technology combination being certified. Closing the implementation
ticket does not make a general WCAG-conformance claim for future releases.
Implementation closure evidence recorded on 2026-08-03 used headless Chromium
against the configured development system. It covered all 13 Campaign routes at
1440 CSS pixels, the list, overview, recipients, and review routes at 320 CSS
pixels, reduced-motion rendering, semantic accessibility-tree snapshots, HTTP
500/page-error capture, and 42 sampled Tab stops across desktop and narrow
Review & Send. The run found no unnamed interactive controls, hidden focus,
page-level horizontal overflow, titlebar overlap, or unhandled runtime errors.
Representative list, overview, review, and narrow-review screenshots were also
inspected. This is implementation evidence; release certification still uses
the complete matrix above with the selected screen reader and browser versions.
## Known boundary
Translation keys may be visible in source because Core resolves them at runtime.
The review must use a built application with current language packages. WCAG
conformance is a release-level claim and still requires a bounded manual audit
of the release candidate.
+87
View File
@@ -0,0 +1,87 @@
# Campaign Access Explanation Coverage
Campaign access explanations are resource-specific evidence. They inherit the
parent Campaign decision only where the child has no independent grant model,
and they must identify that inheritance explicitly.
## Implemented
- Campaign
- Campaign version
- Campaign delivery job / built message
- Computed Campaign report, identified by Campaign, version, and report kind
- Recipient row, identified by `<version UUID>:<job UUID>`
- Frozen recipient source snapshot, identified by its Campaign-version UUID
- Campaign attachment binding and version-bound frozen attachment resolution
- Persisted validation issue, version-bound review decision, and attachment-policy override
- SMTP, IMAP append, Postbox, and printable-output attempts
- Message action, message-action attempt, and job reconciliation decision
- Campaign share and Core-owned Campaign ownership-transfer record
- Independently user-owned recipient import mapping profile
- Saved recipient import execution, identified by `<version UUID>:<import UUID>`
- Persisted validation, build, execution-snapshot, and review evidence, identified
by `<version UUID>:<artifact kind>`
All persisted child IDs are random UUIDs. Embedded build/review children use a
version UUID plus a random job UUID, so callers cannot enumerate a recipient
index or infer an address. A version mismatch is reported as a stale reference.
Missing and cross-tenant children use the same non-disclosing not-found
provenance. Explanations never include recipient addresses, source rows,
filenames, object locators, transport responses, worker claims, target
snapshots, diagnostic text, or reconciliation notes.
## Permission matrix
| Evidence | Parent boundary | Further restriction |
| --- | --- | --- |
| Recipient row or source snapshot | Campaign read/owner/share | `campaigns:recipient:read` |
| Attachment binding/resolution, validation, review, override | Campaign read/owner/share | Campaign review and `campaigns:diagnostic:read` |
| Delivery status | Campaign read/owner/share | `campaigns:report:read` |
| Transport or worker diagnostics | Campaign read/owner/share | `campaigns:diagnostic:read` |
| Exported delivery evidence | Campaign read/owner/share | `campaigns:report:export` |
| Reconciliation decision | Campaign read/owner/share | Campaign reconcile and diagnostic read |
| Share or ownership transfer | Campaign governance | Campaign share, transfer-participant, group-acceptance, or recovery authority; content access remains a separate decision |
| Import mapping profile | Independent user owner | `campaigns:recipient:import`; no Campaign share is inherited |
| Import execution | Campaign read/owner/share | Recipient read and import authority |
| Persisted protocol artifact | Campaign read/owner/share | Recipient, review, report, diagnostic, or export authority appropriate to the artifact |
Postbox, Mail/IMAP, and printable attempts keep bounded Campaign-owned evidence
after provider acceptance. Their explanation therefore remains available when
an optional provider module is later disabled. A missing attempt reports only
the optional owner and `unavailable_or_hidden`; it does not distinguish absence
from hidden data.
## Optional and unsupported owner boundaries
Reusable templates and template revisions are independently governed by the
optional Templates module; Campaign never treats a Campaign share as a template
grant. Durable export packages are independently governed by the optional
Reporting module. Asking the Campaign provider to explain either class therefore
fails closed with `independently_governed_by_optional_module` and
`unavailable_or_hidden`. The response does not reveal whether the optional
module is absent, the object does not exist, or the caller cannot see it.
Campaign reports generated on demand remain non-persisted, version-bound
resources. Their explanation names the report kind and its Campaign/version
parent, and keeps report read, export, and diagnostic permissions distinct.
Each child explanation must include:
- the child resource identity and current state;
- the parent Campaign and version where applicable;
- whether access is inherited, independently granted, or further restricted;
- effective owner/share/policy provenance;
- missing-module or unavailable-evidence reasons without leaking the hidden
object;
- a stable resource identifier suitable for audit and support links.
Delivery attempts, review decisions, reports, and exports can contain more
sensitive evidence than the Campaign summary. Their read and diagnostic/export
permissions therefore remain independently enforceable even when the parent
Campaign is readable.
Import explanations include only the stable import identity, source type,
opaque source identity, source revision, and whether additional provenance was
recorded. They never return imported rows, filenames, column mappings, or source
metadata. Mapping-profile explanations expose only non-reversible header
fingerprints and shape information; headers and mappings remain hidden.
+75
View File
@@ -0,0 +1,75 @@
# Campaign Build Recovery
Campaign build is fenced per tenant and Campaign version. Before rendering or
writing generated artifacts, Campaign commits a Core recovery operation with
the canonical source and validation hashes, the runtime fence, and a reserved
opaque object prefix. A repeated idempotency key can replay only a verified
successful build; it cannot start a second active build.
Generated EML and bounded print output are written to shared storage and checked
for exact size and SHA-256 content. Campaign renews the build fence after that
check and before changing jobs, then commits its jobs and execution snapshot,
compares the stored object and database manifests, and records verified success.
A managed Files output makes the operation forward-recoverable because Campaign
cannot undo a Files-owned artifact; object-only builds use explicit
compensation.
If the Campaign database transaction fails, Campaign deletes every object it
recorded and verifies absence before recording recovered state. Failed deletion,
an unavailable storage check, process loss, or superseded-object cleanup failure
leaves a recovery-required operation visible in Ops. Do not retry such an
operation as a normal build. Verify its checkpoint chain and reserved prefix,
then reconcile it through the owning-module procedure.
## Orphan inventory and cleanup
The operator-only endpoint
`POST /api/v1/campaigns/operations/artifacts/reconcile` requires
`system:settings:write`. It never scans outside
`campaign-artifacts/{tenant_id}/`, and one request reads at most `page_size`
objects. The default request is a dry run:
```json
{}
```
The response reports each eligible key, size, modification time, age, reason,
page totals, and `next_cursor`. Continue with that cursor to inspect the next
bounded page. Objects are not eligible when they are referenced by a committed
EML or print-output row, belong to an actively fenced build, have no trustworthy
modification time, have an invalid build-key shape, or are younger than the
grace period. The minimum and default grace period is 24 hours.
Apply an inspected page with a new idempotency key:
```json
{
"apply": true,
"idempotency_key": "incident-2026-08-03-page-1",
"grace_period_hours": 24,
"page_size": 250
}
```
An applied run rechecks committed references and active build leases before
each bounded deletion batch. A Core distributed lease prevents two nodes from
committing the same tenant cleanup concurrently. Every attempted deletion is
probed afterward. `recovery_required` means an object was verified to remain;
`outcome_unknown` means storage could not prove whether the delete took effect.
Use a new idempotency key to retry after the storage problem is corrected. A
successful repeated request with the same key returns `already_completed` and
does not delete again.
Cleanup never clears Campaign database references. Audit and recovery evidence
records counts and hashed manifests rather than object keys. Exact keys are
returned only by this privileged endpoint and must stay in restricted incident
records.
No recovery checkpoint contains message bodies, recipients, credentials, or
resolved provider secrets. Object keys remain restricted diagnostics rather
than Campaign business data.
Database rows, `campaign-artifacts/` objects, and the encryption/key service are
one coordinated backup and recovery boundary. After any partial restore, pause
delivery, run a dry inventory, reconcile Campaign recovery operations in Ops,
and verify referenced object hashes before workers resume.
+54
View File
@@ -62,14 +62,39 @@ Before the first live send for a sender domain or mail-server profile:
6. If a synchronous request is used, keep Review and send open: it polls the
durable counters while the request runs. A rejection occurs before SMTP and
directs oversized runs to workers.
7. Review the SMTP batch line. `ready` means DNS/connectivity/TLS/auth preflight
succeeded. Connection and reconnect counts explain reuse. `paused` means a
systemic transport failure stopped the remaining jobs before their SMTP
effect; test/correct the Mail profile and explicitly resume the queue.
## Outcome Handling
Each real Campaign job delivery is represented by a Core recovery-ledger
operation before the worker claims the job or invokes Mail, Postbox, or print.
Synchronous batches use the same boundary after their batch-wide preflight.
Explicit test/resend actions and post-acceptance IMAP appends use separate
action-fenced operations. Operations store only opaque IDs, digests, channel
policy, and bounded status evidence. A verified acceptance becomes
`succeeded`, a definitive pre-effect or provider rejection becomes `rejected`,
and uncertain or stranded effects remain `outcome_unknown` or
`recovery_required` in Ops. Campaign jobs, message actions, and channel-attempt
records remain the business source of truth.
For SMTP and Sent-folder APPEND, Campaign also passes the stable job/action and
attempt identifier into Mail. Mail establishes its own provider-bound recovery
fence after profile authorization and policy checks but before network I/O.
This nested ownership is intentional: Campaign proves its business transition,
while Mail proves the transport effect. Neither layer replays a completed or
unknown provider attempt merely to repair the other layer's state.
- `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.
- `paused` after a systemic SMTP failure: do not resume until the shared Mail
profile passes its connection test. Authentication, sender rejection, and
unavailable connectivity affect the batch rather than one recipient.
- `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.
@@ -134,6 +159,35 @@ production process manager. Repeat the worker-loss drill under the selected
systemd, container, Kubernetes, or other production supervisor and the target
Redis/SMTP infrastructure before deployment approval.
## Shared Build Artifacts
Generated EML is stored through Core's configured object-storage backend under
opaque Campaign-owned keys. The job records expected byte size, SHA-256 digest,
and Message-ID. A worker on another node must retrieve and verify those values
before attempting delivery.
- Do not expose object keys to ordinary campaign users or copy them into
business fields.
- A build failure deletes objects written before the database transaction can
commit.
- Retention starts a job-fenced forward-recovery operation before deletion,
commits metadata changes in the Campaign-owned boundary, and independently
probes the original locator before reporting success. An unavailable backend
leaves an outcome-unknown operation; a deletion/metadata mismatch becomes
recovery-required in Ops.
- A hard process loss between object creation and metadata commit can leave an
orphan object. Use the operator-only, dry-run-first Campaign artifact
reconciler documented in `CAMPAIGN_BUILD_RECOVERY.md`; it scans one bounded
tenant-prefix page, enforces a minimum 24-hour grace period, protects active
build fences, and rechecks committed EML and print-output references before
deletion.
- Restore Campaign rows, object storage, and the encryption key to one
coordinated recovery point before resuming workers.
For a scaled-runtime drill, build on one API replica, consume from another
worker, compare the stored evidence, and inject storage failures during build
and retention.
## Reporting Checks
- Partial delivery must show accepted, failed, and unknown counts separately.
+213 -14
View File
@@ -30,12 +30,13 @@ The shorter task documents remain useful companions:
## 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 turns governed source data into individually built messages or
printable output 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, Distribution Lists, Templates, Postbox,
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:
@@ -44,7 +45,8 @@ Campaign owns:
- 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-specific reports, shares, frozen execution evidence, and governed
human collaboration entries.
Campaign does not own:
@@ -66,9 +68,9 @@ The supported process is a controlled progression, not a single "send" call:
create/edit
-> validate and resolve policy/integrations
-> review warnings and blockers
-> build exact recipient messages
-> build exact recipient messages and/or printable artifacts
-> complete review and queue
-> SMTP attempt per job
-> selected Mail, Postbox, or print effect per job
-> optional IMAP append per accepted job
-> report, retry, reconcile, or correct
-> archive when no active/uncertain delivery remains
@@ -95,9 +97,47 @@ Important distinctions:
- **Archive** preserves evidence. Draft-only campaigns without built, locked,
or delivery evidence may be deleted where policy allows; evidence-bearing
campaigns are archived instead.
- **Copy campaign** creates a new campaign and one fresh editable version from
the selected source version. It copies configuration, but never delivery
jobs, outcomes, shares, locks, or audit evidence.
- **Archive historical version** hides only a non-current version from the
default history. It does not change the version's workflow state or remove
configuration, reports, delivery results, or audit evidence.
Campaign lifecycle confirmations are bound to the state shown in the UI. If a
job, version, share, or campaign state changes before confirmation, the server
rejects the stale action and requires a reload. The lifecycle-policy response
states the applicable built-in rule and the reason for every unavailable
action.
## User tasks
### Discuss campaign work
Open **Collaboration** inside a Campaign to keep human coordination beside the
work without changing its version history. Discussion access is independent
from Campaign editing: parent Campaign read access remains mandatory, while
`campaigns:discussion:read`, `campaigns:discussion:post`, and
`campaigns:discussion:moderate` separately control reading, posting, and
moderation. A read share is sufficient as the parent grant and a comment never
turns that share into write access.
Comments are append-only and bounded to 8,000 characters. They can carry one
validated reference to an immutable Campaign version, saved recipient import,
attachment rule, delivery job, or report. References to version-bound evidence
include the exact version ID and never edit that version. Authors can withdraw
their own comments; moderators can redact comments and use moderator-only
visibility. Both operations remove displayed text but preserve a tombstone,
content hash, actor snapshot, timestamp, reference context, and bounded Audit
event. There is deliberately no comment-edit API.
Mentions are limited to 20 active users who already have Campaign ownership or
share access. When Notifications is installed and healthy, Campaign emits a
content-free in-app mention notification. Collaboration remains usable without
Notifications. The thread displays only human discussion; approvals, workflow
state, delivery events, and durable system evidence remain on their owning
surfaces and in Tenant audit.
### Prepare a campaign
1. Create a campaign and confirm its owner or owning group.
@@ -111,6 +151,12 @@ Important distinctions:
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.
If the selected profile permits a campaign-scoped Mail credential, that
credential remains Mail-owned even though it is created from this surface.
For that credential and for password-valued campaign fields, the shared
password generator keeps its candidate separate from the form until **Use
password** is explicitly confirmed. Copying a candidate does not save or
submit it.
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,
@@ -135,9 +181,31 @@ just the authoring form:
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.
surface. Validation, build, review, and exception evidence records the actor,
timestamp, and immutable build token/message digest where applicable. If
content, recipients, attachment inputs, owner context, or non-secret
transport identity changes, revalidate and rebuild.
The Review & Send surface separates three kinds of attention. Critical
blockers must be corrected before delivery, individual review items require a
recorded message decision, and non-critical group items may be acknowledged
together after individual review is complete. Each warning or blocker names
the required action, the responsible role, and the workspace to open. The
review summary keeps reviewed and remaining counts visible; a completed review
acknowledges the group items and remains bound to the current build token.
This evidence is the Campaign input to separation-of-duties policy. Generic
approve/reject chains, delegation, substitutions, escalation, and signatures
belong to the optional Approvals capability. Campaign must not claim an
approval merely because validation, building, or message review completed.
When a campaign has an Approval request reference, mock and real delivery
resolve `approvals.requests` and require an approved request for the exact
`campaign_version` subject and current version id. A missing Approvals module,
unknown request, pending/rejected chain, or approval for an older version blocks
delivery with an explicit requirement. Campaign stores the approval reference,
not Approval tables; changing the campaign version requires a new exact-subject
approval.
Normal readers and reviewers see business state and safe evidence. Process-local
paths, storage keys, worker claim tokens, and raw provider diagnostics require
@@ -153,6 +221,24 @@ every action control that the actor lacks. The server authorizes each action,
but permission-aware action visibility on that detailed surface remains open
work; do not confuse it with the aggregate reader experience.
The module-local aggregate surface remains at `/campaigns/reports`. When the
optional Reporting module is enabled, Campaign also contributes the same
recipient-free projection as the `campaigns/delivery-outcomes` report provider.
Reporting owns `/reports`, records the run purpose, effective audience, source
campaign/version revision, privacy transformations, retention, actor/time,
output hash, and export history, and applies Policy before returning the
result. Campaign does not register a fallback `/reports` route when Reporting
is absent.
The detailed Campaign Report includes the selected campaign's effective
retention policy, its system/tenant/owner/campaign provenance, and the current
evidence state. It distinguishes retained, redacted, expired, partially
minimized, unavailable, and not-applicable source JSON, stored report detail,
generated EML, and Postbox-copy evidence. When Policy is absent, the report
shows the platform defaults and explicitly warns that automated retention
enforcement is unavailable. Retention removes or minimizes detail; aggregate
counters and audit references may remain so outcomes can still be explained.
### Deliver and resolve outcomes
Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed
@@ -180,6 +266,8 @@ At a minimum:
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.
9. Use **Delete draft** only for an untouched draft. If retained evidence or an
active share exists, revoke the share where appropriate or archive instead.
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;
@@ -242,6 +330,8 @@ as an executable Mail configuration:
- public responses remove legacy transport fields and secrets;
- validation, build, queue, retry, and delivery fail closed;
- computed previews that require a current Campaign configuration return an
actionable validation problem instead of a server error;
- 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.
@@ -356,6 +446,12 @@ 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.
For unreferenced generated objects after process loss, platform operators first
run the bounded Campaign artifact inventory in dry-run mode. Apply only an
inspected page with a unique incident idempotency key. The cleanup keeps exact
keys out of ordinary Campaign responses, does not clear database references,
and leaves storage failures in the Core recovery ledger for explicit retry.
### Incident handling
1. Pause new delivery when duplicate or unknown effects are possible.
@@ -380,6 +476,8 @@ Current principal contracts include:
| `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 |
| `dist_lists.source` / `dist_lists.expand` 0.1.x | Distribution Lists -> Campaign | Discover, preview, and freeze reusable audiences without importing module internals |
| `templates.catalog` / `templates.renderer` 0.1.x | Templates -> Campaign | Select compatible published printable templates and produce deterministic, evidence-bearing artifacts |
| `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 |
@@ -389,6 +487,67 @@ 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.
### Bulk recipient activation
Recipient data can activate all currently inactive rows or deactivate all
currently active rows. The action displays the exact affected count and
requires confirmation. It changes only the local Campaign draft until the
operator saves. Saving follows the ordinary versioned Campaign update path, so
the resulting recipient state is retained in version and protocol evidence and
any stale validation, build, or review evidence is invalidated.
### Reusable Distribution Lists
When Distribution Lists is available, Recipient data offers a separate import
dialog. The author selects a visible list revision, supplies declared
parameters, requests candidate channels, and previews included, excluded,
stale, ambiguous, suppressed, policy-blocked, and provider-unavailable results.
The final action freezes an idempotent Distribution Lists snapshot and copies
the resulting rows into the editable Campaign version.
Each copied row retains the list and revision IDs, definition and expansion
hashes, snapshot ID, source entry IDs, provider references, channel candidates,
the explicitly selected primary route, optional fallback, and the decision
explanation. Campaign-only fields, attachment rules, review state, and outcomes
remain local to Campaign and never mutate the reusable list.
A later list revision only raises a drift warning. Refresh is deliberate and
uses append or replace; saving that changed Campaign version clears prior
validation, build, review, and execution state through the normal content
invalidation path. Preferred or single usable candidates are preselected
visibly; ambiguous rows must be decided before freezing. Postal and
internal-mail routes remain active when a compatible published Templates output
is selected.
### Governed hybrid and printable delivery
Campaign supports Mail, Postbox, printable output, and bounded ordered
fallbacks without making any of those provider modules mandatory. Opt-in and
channel-preference data are inputs to the visible routing decision; they never
silently cause duplicate delivery.
For a printable route, select a published label, envelope, serial-letter,
form-letter, list-layout, or generic template on the Template page. Validation
checks the selected revision, output format, and required fields. Build sends
one deterministic item collection to `templates.renderer`, records template,
input, output, actor, route, and artifact hashes, and stores the resulting
artifact through Files when configured. The review stage exposes that exact
artifact and its hashes before execution.
Each recipient job records an idempotent print acceptance attempt for its item
in the frozen artifact. `mail_then_print` and `postbox_then_print` invoke print
only after a confirmed rejection before acceptance. An accepted or
outcome-unknown digital effect never falls through to print because that could
produce duplicate delivery. Reports and CSV exports include route provenance,
print state, attempts, artifact reference, and hashes.
Without Templates, Campaign still loads and Mail/Postbox authoring remains
available; validation explains why a configured print route cannot proceed.
Without Files, Templates may return a bounded artifact instead of a managed
file. Campaign copies that payload into shared object storage and exposes it
through the Campaign ACL plus `campaigns:recipient:read`; it never redistributes
the broader Templates URL. A print-only Campaign does not require Mail or Postbox.
### External API expectations
- Tenant and campaign access are evaluated for every operation.
@@ -425,6 +584,35 @@ purpose, lawful basis, minimization, export control, and retention before the
campaign starts; do not use Campaign as a substitute consent or address-master
system.
The Core data-subject-request workflow discovers Campaign through the optional
`privacy.dsar.campaigns` capability. After the request's email, membership, and
namespaced Campaign references have been independently authorized and
corroborated, the provider searches only the effective tenant and isolates the
matching recipient entries and jobs. Its JSON result includes safe Campaign,
version, delivery-attempt, schedule, report-projection, share, import-mapping,
attachment, generated-artifact, and relevant collaboration metadata. It also
finds collaboration entries authored, mentioned, or moderated by the subject.
Text authored by the subject is included; somebody else's text is not copied
merely because the subject was mentioned. Generated EML bytes and paths,
storage locators, delivery target snapshots, worker claims, idempotency
material, credentials, secret-like values, and unrelated recipients are never
embedded in that result. Authorized Campaign and Files review surfaces remain
the source for content that cannot safely be copied into the DSAR case.
Built, locked, published, terminal, delivered, or corrected records are
retained with an explicit reason and continue through Campaign's configured
retention and redaction process. Draft recipient content and user-owned
attachment content require coordinated manual review because copies may span
version JSON, jobs, generated messages, and managed files. The provider can
idempotently revoke an active share aimed at the subject and delete the
subject's personal recipient-import mapping profile. It does not rewrite
delivery evidence, delete generated artifacts, or report derived Campaign
counts as a separate store. Collaboration withdrawal and redaction retain the
tombstone, content hash, context, and Audit evidence; the DSAR workflow does
not rewrite these append-only records. Re-running an approved action is safe: already
revoked or absent data is reported as unchanged, and tenant, subject, and row
ownership are revalidated immediately before mutation.
### Audit and destructive actions
Material authoring, validation, locking, review, queueing, send, retry,
@@ -437,6 +625,18 @@ lock exists. Evidence-bearing campaigns are archived. Destructive module
retirement remains a separately confirmed installer operation with backup and
retirement evidence.
The Campaign **Audit** page is currently an explained handoff, not a second
audit store: Campaign emits bounded platform audit records and authorized
readers inspect them in Administration > Tenant audit. A future object-scoped
projection may improve that navigation without duplicating Audit ownership.
Evidence-bundle export and offline verification remain owned by Audit #3.
The advanced **JSON** page displays and downloads the complete campaign
configuration available to the current campaign reader. It contains no inline
transport secrets, but recipient, message, and attachment fields may contain
personal data. The UI therefore identifies it as sensitive expert output;
campaign access and export purpose remain the governing controls.
## Reference-composition acceptance
Campaign is ready to serve as the demonstration module only when all of the
@@ -467,9 +667,8 @@ 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));
([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/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);
+1 -1
View File
@@ -49,7 +49,7 @@ Non-dry Campaign report email currently fails closed. It must not bypass the
durable job/effect model through a direct SMTP call. Re-enabling it requires the
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).
[`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17).
Report generation and dry-run validation remain separate from an external
effect; recipient-level exports require recipient-export authorization.
+8
View File
@@ -64,6 +64,14 @@ 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.
Distribution Lists is a separate, provider-neutral audience boundary. Campaign
uses `dist_lists.source` and `dist_lists.expand` to preview and freeze mixed
email, postal, internal-mail, and portal candidates. It stores exact list,
revision, source-entry, provider, policy, route, exclusion, and expansion-hash
evidence with the Campaign version. Addresses remains the contact/contact-point
owner; Distribution Lists remains the reusable audience owner; Campaign owns
only its copied recipients, enrichment, route choices, review, and outcomes.
## Non-Goals For Campaign
Campaign should not become the global address book. It should not own:
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.11",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -22,11 +22,11 @@
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.12",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.11"
version = "0.1.21"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-core>=0.1.18",
"jsonschema>=4,<5",
"pydantic>=2,<3",
"SQLAlchemy>=2,<3",
@@ -0,0 +1,265 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Mapping
from sqlalchemy.orm import Session
from govoplan_core.core.approvals import (
ApprovalRequestCreateCommand,
ApprovalRequestRef,
ApprovalStepDefinition,
)
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_campaign.backend.integrations import (
ApprovalGateUnavailable,
approvals_integration,
)
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
APPROVAL_GATE_KEY = "approval_gate"
SUBJECT_MODULE = "campaigns"
SUBJECT_TYPE = "campaign_execution"
class CampaignApprovalGateError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class _TenantPrincipal:
tenant_id: str
account_id: str | None = None
def campaign_approval_gate(version: CampaignVersion) -> dict[str, object] | None:
state = version.editor_state if isinstance(version.editor_state, dict) else {}
gate = state.get(APPROVAL_GATE_KEY)
return dict(gate) if isinstance(gate, dict) else None
def request_campaign_approval(
session: Session,
principal: object,
*,
campaign: Campaign,
version: CampaignVersion,
title: str,
description: str | None,
steps: tuple[ApprovalStepDefinition, ...],
idempotency_key: str,
template_id: str | None = None,
template_revision: int | None = None,
unique_actors_across_steps: bool = True,
expires_at: datetime | None = None,
policy_refs: tuple[str, ...] = (),
) -> ApprovalRequestRef:
if campaign.tenant_id != str(getattr(principal, "tenant_id", "") or ""):
raise CampaignApprovalGateError("Campaign approval tenant mismatch.")
if version.campaign_id != campaign.id:
raise CampaignApprovalGateError("Campaign approval version mismatch.")
snapshot = ensure_execution_snapshot(session, version)
digest = str(version.execution_snapshot_hash or "")
if len(digest) != 64:
raise CampaignApprovalGateError(
"Build a valid Campaign execution snapshot before requesting approval."
)
subject_version = _subject_version(version, snapshot.build_token)
evidence_actors = _campaign_evidence_actors(campaign, version)
try:
request = approvals_integration().create_request(
session,
principal,
command=ApprovalRequestCreateCommand(
title=title,
description=description,
subject_module=SUBJECT_MODULE,
subject_type=SUBJECT_TYPE,
subject_id=version.id,
subject_version=subject_version,
subject_digest=digest,
steps=steps,
separation_of_duties=True,
unique_actors_across_steps=unique_actors_across_steps,
expires_at=expires_at,
policy_refs=policy_refs,
evidence_actors=evidence_actors,
template_id=template_id,
template_revision=template_revision,
metadata={
"campaign_id": campaign.id,
"campaign_version_number": version.version_number,
"execution_snapshot_version": snapshot.snapshot_version,
},
),
idempotency_key=idempotency_key,
)
except ApprovalGateUnavailable as exc:
raise CampaignApprovalGateError(str(exc)) from exc
state = copy.deepcopy(version.editor_state or {})
state[APPROVAL_GATE_KEY] = {
"request_id": request.id,
"request_revision": request.revision,
"subject_version": subject_version,
"subject_digest": digest,
"requested_at": datetime.now(UTC).isoformat(),
"requested_by_user_id": _actor_id(principal),
}
version.editor_state = state
session.add(version)
session.flush()
return request
def assert_campaign_approval(
session: Session,
*,
tenant_id: str,
version: CampaignVersion,
) -> None:
gate = campaign_approval_gate(version)
if gate is None:
return
snapshot = ensure_execution_snapshot(session, version)
digest = str(version.execution_snapshot_hash or "")
subject_version = _subject_version(version, snapshot.build_token)
if (
gate.get("subject_digest") != digest
or gate.get("subject_version") != subject_version
):
raise CampaignApprovalGateError(
"Campaign execution changed after approval was requested. Request approval for the current build."
)
request_id = str(gate.get("request_id") or "").strip()
if not request_id:
raise CampaignApprovalGateError(
"Campaign approval gate has no request reference."
)
try:
check = approvals_integration().check_approved(
session,
_TenantPrincipal(tenant_id=tenant_id),
request_id=request_id,
subject_module=SUBJECT_MODULE,
subject_type=SUBJECT_TYPE,
subject_id=version.id,
subject_version=subject_version,
subject_digest=digest,
)
except (ApprovalGateUnavailable, LookupError, ValueError) as exc:
raise CampaignApprovalGateError(str(exc)) from exc
if not check.approved:
raise CampaignApprovalGateError(
f"Campaign delivery requires Approval request {request_id}, which is {check.state}."
)
def campaign_approval_status(
session: Session,
*,
tenant_id: str,
version: CampaignVersion,
) -> dict[str, object]:
gate = campaign_approval_gate(version)
integration = approvals_integration()
if gate is None:
return {
"configured": False,
"available": integration.available,
"approved": False,
"state": "not_required",
"explanation": None,
}
try:
assert_campaign_approval(session, tenant_id=tenant_id, version=version)
except CampaignApprovalGateError as exc:
return {
**gate,
"configured": True,
"available": integration.available,
"approved": False,
"state": "unavailable" if not integration.available else "pending",
"explanation": str(exc),
}
return {
**gate,
"configured": True,
"available": True,
"approved": True,
"state": "approved",
"explanation": None,
}
def clear_campaign_approval_gate(version: CampaignVersion) -> None:
state = copy.deepcopy(version.editor_state or {})
state.pop(APPROVAL_GATE_KEY, None)
version.editor_state = state
def _campaign_evidence_actors(
campaign: Campaign,
version: CampaignVersion,
) -> Mapping[str, tuple[str, ...]]:
validation = (
version.validation_summary
if isinstance(version.validation_summary, dict)
else {}
)
build = version.build_summary if isinstance(version.build_summary, dict) else {}
editor = version.editor_state if isinstance(version.editor_state, dict) else {}
review = (
editor.get("review_send") if isinstance(editor.get("review_send"), dict) else {}
)
review_decisions = (
review.get("issue_decisions")
if isinstance(review.get("issue_decisions"), list)
else []
)
values = {
"author": (campaign.created_by_user_id,),
"owner": (campaign.owner_user_id,),
"validator": (
validation.get("validated_by_user_id"),
version.locked_by_user_id,
),
"builder": (build.get("built_by_user_id"),),
"reviewer": (
review.get("updated_by_user_id"),
*(
item.get("actor_user_id")
for item in review_decisions
if isinstance(item, dict)
),
),
}
return {
role: tuple(dict.fromkeys(str(item) for item in actors if item))
for role, actors in values.items()
if any(actors)
}
def _subject_version(version: CampaignVersion, build_token: str | None) -> str:
return str(build_token or f"campaign-version-{version.version_number}")[:120]
def _actor_id(principal: object) -> str | None:
for name in ("account_id", "user_id", "identity_id", "membership_id"):
value = str(getattr(principal, name, "") or "").strip()
if value:
return value
return None
__all__ = [
"CampaignApprovalGateError",
"assert_campaign_approval",
"campaign_approval_gate",
"campaign_approval_status",
"clear_campaign_approval_gate",
"request_campaign_approval",
]
@@ -0,0 +1,78 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.core.approvals import ApprovalActorSelector, ApprovalStepDefinition
class CampaignApprovalSelectorInput(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["account", "group", "role", "function_assignment", "any_account"]
value: str = Field(min_length=1, max_length=255)
label: str | None = Field(default=None, max_length=255)
class CampaignApprovalStepInput(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=255)
selectors: list[CampaignApprovalSelectorInput] = Field(min_length=1, max_length=500)
required_approvals: int = Field(default=1, ge=1, le=500)
rejection_policy: Literal["fail_fast", "collect"] = "fail_fast"
due_at: datetime | None = None
signature_required: bool = False
forbidden_evidence_roles: list[
Literal["author", "owner", "validator", "builder", "reviewer"]
] = Field(default_factory=list, max_length=5)
def to_definition(self) -> ApprovalStepDefinition:
return ApprovalStepDefinition(
key=self.key,
label=self.label,
selectors=tuple(
ApprovalActorSelector(item.kind, item.value, item.label)
for item in self.selectors
),
required_approvals=self.required_approvals,
rejection_policy=self.rejection_policy,
due_at=self.due_at,
signature_required=self.signature_required,
forbidden_evidence_roles=tuple(self.forbidden_evidence_roles),
)
class CampaignApprovalRequestInput(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(
default="Approve Campaign delivery", min_length=1, max_length=255
)
description: str | None = Field(default=None, max_length=10_000)
steps: list[CampaignApprovalStepInput] = Field(default_factory=list, max_length=100)
template_id: str | None = Field(default=None, max_length=36)
template_revision: int | None = Field(default=None, ge=1)
unique_actors_across_steps: bool = True
expires_at: datetime | None = None
policy_refs: list[str] = Field(default_factory=list, max_length=500)
idempotency_key: str = Field(min_length=1, max_length=160)
@model_validator(mode="after")
def validate_source(self) -> "CampaignApprovalRequestInput":
template = self.template_id is not None or self.template_revision is not None
if template and (self.template_id is None or self.template_revision is None):
raise ValueError("Template id and revision must be supplied together.")
if template and self.steps:
raise ValueError("Use either an Approval template or inline steps.")
if not template and not self.steps:
raise ValueError(
"At least one Approval step is required without a template."
)
return self
__all__ = ["CampaignApprovalRequestInput"]
@@ -0,0 +1,281 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
from datetime import UTC, datetime
import hashlib
import json
from typing import Any, Mapping
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.policy import (
CampaignArchiveEncryptionDecision,
CampaignArchiveEncryptionRequest,
campaign_archive_encryption_policy,
)
from govoplan_campaign.backend.db.models import Campaign
from govoplan_campaign.backend.runtime import get_registry
LEGACY_ZIPCRYPTO_SCOPE = "campaigns:archive:use_legacy_zipcrypto"
LEGACY_ZIPCRYPTO_LABEL = "Legacy ZipCrypto — Windows-compatible, weak encryption"
class CampaignArchiveEncryptionError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class EffectiveArchiveEncryptionPolicy:
available: bool
allowed_password_encryption_methods: frozenset[str]
allowed_password_delivery_channels: frozenset[str]
policy_hash: str
source_path: tuple[Mapping[str, Any], ...]
reason: str
diagnostics: tuple[Mapping[str, Any], ...] = ()
def to_dict(self) -> dict[str, Any]:
return {
"available": self.available,
"allowed_password_encryption_methods": sorted(
self.allowed_password_encryption_methods
),
"allowed_password_delivery_channels": sorted(
self.allowed_password_delivery_channels
),
"policy_hash": self.policy_hash,
"source_path": [dict(item) for item in self.source_path],
"reason": self.reason,
"diagnostics": [dict(item) for item in self.diagnostics],
"legacy_label": LEGACY_ZIPCRYPTO_LABEL,
}
def effective_archive_encryption_policy(
session: Session,
campaign: Campaign,
) -> EffectiveArchiveEncryptionPolicy:
provider = campaign_archive_encryption_policy(get_registry())
if provider is None:
payload = {
"available": False,
"allowed_password_encryption_methods": ["aes"],
"allowed_password_delivery_channels": [
"in_person",
"letter",
"phone",
"separate_mail",
"sms",
],
"source_path": [
{
"scope_type": "system",
"scope_id": None,
"path": "system",
"label": "Secure local fallback",
"applied_fields": ["allowed_password_encryption_methods"],
"policy": {
"allowed_password_encryption_methods": ["aes"],
"policy_provider": "unavailable",
},
}
],
}
return EffectiveArchiveEncryptionPolicy(
available=False,
allowed_password_encryption_methods=frozenset({"aes"}),
allowed_password_delivery_channels=frozenset(
{"separate_mail", "sms", "letter", "phone", "in_person"}
),
policy_hash=_hash(payload),
source_path=tuple(payload["source_path"]),
reason=(
"Policy is unavailable. AES remains available through the secure "
"local baseline; legacy ZipCrypto fails closed."
),
)
owner_type: str | None = None
owner_id: str | None = None
if campaign.owner_group_id:
owner_type, owner_id = "group", campaign.owner_group_id
elif campaign.owner_user_id:
owner_type, owner_id = "user", campaign.owner_user_id
decision: CampaignArchiveEncryptionDecision = (
provider.resolve_campaign_archive_encryption(
session,
request=CampaignArchiveEncryptionRequest(
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
owner_type=owner_type, # type: ignore[arg-type]
owner_id=owner_id,
),
)
)
return EffectiveArchiveEncryptionPolicy(
available=True,
allowed_password_encryption_methods=frozenset(
decision.allowed_password_encryption_methods
),
allowed_password_delivery_channels=frozenset(
decision.allowed_password_delivery_channels
),
policy_hash=decision.policy_hash,
source_path=tuple(step.to_dict() for step in decision.source_path),
reason=decision.reason or "Effective archive-encryption policy resolved.",
diagnostics=decision.diagnostics,
)
def assert_archive_encryption_allowed(
session: Session,
campaign: Campaign,
raw_json: Mapping[str, Any],
*,
principal: ApiPrincipal | None = None,
) -> EffectiveArchiveEncryptionPolicy:
policy = effective_archive_encryption_policy(session, campaign)
for archive in _archive_configs(raw_json):
method = str(archive.get("method") or "aes")
if method not in policy.allowed_password_encryption_methods:
raise CampaignArchiveEncryptionError(
f"{_method_label(method)} is blocked. {policy.reason}"
)
if method == "zip_standard":
if not policy.available:
raise CampaignArchiveEncryptionError(
"Legacy ZipCrypto cannot be used while Policy is unavailable."
)
if principal is not None and not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
raise CampaignArchiveEncryptionError(
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
)
if not archive.get("legacy_zipcrypto_acknowledged"):
raise CampaignArchiveEncryptionError(
f"{LEGACY_ZIPCRYPTO_LABEL} requires explicit acknowledgement."
)
if len(str(archive.get("legacy_zipcrypto_reason") or "").strip()) < 10:
raise CampaignArchiveEncryptionError(
f"{LEGACY_ZIPCRYPTO_LABEL} requires a reason of at least 10 characters."
)
if not archive.get("legacy_zipcrypto_acknowledged_by") or not archive.get(
"legacy_zipcrypto_acknowledged_at"
):
raise CampaignArchiveEncryptionError(
"Legacy ZipCrypto acknowledgement has no server-recorded actor or time. Save the campaign again."
)
if archive.get("password_enabled"):
# Existing campaign revisions predate the explicit field. Their
# model default is the separate-mail channel; apply the same
# normalization before policy enforcement so saved revisions do
# not become unusable merely because the field was omitted.
channel = str(
archive.get("password_delivery_channel") or "separate_mail"
)
if channel not in policy.allowed_password_delivery_channels:
raise CampaignArchiveEncryptionError(
f"Password-delivery channel {channel!r} is blocked by the effective policy."
)
return policy
def stamp_legacy_zipcrypto_acknowledgements(
session: Session,
campaign: Campaign,
current_raw_json: Mapping[str, Any],
candidate_raw_json: dict[str, Any] | None,
*,
principal: ApiPrincipal,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
if candidate_raw_json is None:
return None, []
candidate = copy.deepcopy(candidate_raw_json)
current_by_id = {
str(item.get("id") or index): item
for index, item in enumerate(_archive_configs(current_raw_json))
}
acknowledgements: list[dict[str, Any]] = []
policy = effective_archive_encryption_policy(session, campaign)
archives = _archive_configs(candidate)
for index, archive in enumerate(archives):
if str(archive.get("method") or "aes") != "zip_standard":
archive.pop("legacy_zipcrypto_acknowledged_by", None)
archive.pop("legacy_zipcrypto_acknowledged_at", None)
continue
if not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
raise CampaignArchiveEncryptionError(
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
)
if not policy.available or "zip_standard" not in policy.allowed_password_encryption_methods:
raise CampaignArchiveEncryptionError(
f"{LEGACY_ZIPCRYPTO_LABEL} is blocked. {policy.reason}"
)
reason = str(archive.get("legacy_zipcrypto_reason") or "").strip()
if not archive.get("legacy_zipcrypto_acknowledged") or len(reason) < 10:
raise CampaignArchiveEncryptionError(
f"{LEGACY_ZIPCRYPTO_LABEL} requires acknowledgement and a reason of at least 10 characters."
)
key = str(archive.get("id") or index)
previous = current_by_id.get(key, {})
unchanged = (
previous.get("method") == "zip_standard"
and previous.get("legacy_zipcrypto_acknowledged") is True
and str(previous.get("legacy_zipcrypto_reason") or "").strip() == reason
and previous.get("legacy_zipcrypto_acknowledged_by")
and previous.get("legacy_zipcrypto_acknowledged_at")
)
if unchanged:
archive["legacy_zipcrypto_acknowledged_by"] = previous[
"legacy_zipcrypto_acknowledged_by"
]
archive["legacy_zipcrypto_acknowledged_at"] = previous[
"legacy_zipcrypto_acknowledged_at"
]
else:
archive["legacy_zipcrypto_acknowledged_by"] = principal.user.id
archive["legacy_zipcrypto_acknowledged_at"] = datetime.now(UTC).isoformat()
acknowledgements.append(
{
"archive_id": key,
"reason": reason,
"policy_hash": policy.policy_hash,
}
)
return candidate, acknowledgements
def has_password_archives(raw_json: Mapping[str, Any]) -> bool:
return any(bool(item.get("password_enabled")) for item in _archive_configs(raw_json))
def _archive_configs(raw_json: Mapping[str, Any]) -> list[dict[str, Any]]:
attachments = raw_json.get("attachments")
zip_config = attachments.get("zip") if isinstance(attachments, Mapping) else None
archives = zip_config.get("archives") if isinstance(zip_config, Mapping) else None
if isinstance(archives, list):
return [item for item in archives if isinstance(item, dict)]
return []
def _method_label(method: str) -> str:
return LEGACY_ZIPCRYPTO_LABEL if method == "zip_standard" else method.upper()
def _hash(value: object) -> str:
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
).hexdigest()
__all__ = [
"CampaignArchiveEncryptionError",
"EffectiveArchiveEncryptionPolicy",
"LEGACY_ZIPCRYPTO_LABEL",
"LEGACY_ZIPCRYPTO_SCOPE",
"assert_archive_encryption_allowed",
"effective_archive_encryption_policy",
"has_password_archives",
"stamp_legacy_zipcrypto_acknowledgements",
]
@@ -0,0 +1,734 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import json
from typing import Any, Callable
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignVersion,
)
from govoplan_core.core.object_storage import (
StorageBackend,
StorageBackendError,
StorageObjectInfo,
)
from govoplan_core.core.recovery import (
RecoveryMode,
RecoveryOperation,
RecoveryPlan,
RecoveryStatus,
TERMINAL_RECOVERY_STATUSES,
)
from govoplan_core.core.recovery_runtime import begin_durable_recovery_operation
from govoplan_core.core.runtime_coordination import (
DistributedLease,
RuntimeIdentity,
)
CAMPAIGN_ARTIFACT_NAMESPACE = "campaign-artifacts"
MINIMUM_GRACE_HOURS = 24
MAXIMUM_PAGE_SIZE = 1000
_CHECKPOINT_BATCH_SIZE = 25
SessionFactory = Callable[[], Session]
class CampaignArtifactReconciliationError(RuntimeError):
pass
@dataclass(slots=True)
class ArtifactCandidate:
key: str
size_bytes: int
modified_at: datetime
age_seconds: int
reason: str = "unreferenced_after_grace_period"
disposition: str = "candidate"
failure_type: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"size_bytes": self.size_bytes,
"modified_at": self.modified_at.isoformat(),
"age_seconds": self.age_seconds,
"reason": self.reason,
"disposition": self.disposition,
"failure_type": self.failure_type,
}
@dataclass(slots=True)
class ArtifactInventory:
tenant_prefix: str
cursor: str | None
next_cursor: str | None
scanned_count: int
scanned_bytes: int
referenced_count: int
active_build_count: int
young_count: int
unknown_age_count: int
invalid_shape_count: int
candidates: list[ArtifactCandidate]
manifest_sha256: str
@property
def candidate_bytes(self) -> int:
return sum(candidate.size_bytes for candidate in self.candidates)
def response(
self,
*,
apply: bool,
status: str,
recovery_operation_id: str | None = None,
) -> dict[str, Any]:
deleted = [
candidate
for candidate in self.candidates
if candidate.disposition == "deleted"
]
failures = [
candidate
for candidate in self.candidates
if candidate.disposition
in {"delete_failed", "delete_outcome_unknown"}
]
return {
"apply": apply,
"status": status,
"recovery_operation_id": recovery_operation_id,
"tenant_prefix": self.tenant_prefix,
"cursor": self.cursor,
"next_cursor": self.next_cursor,
"scanned_count": self.scanned_count,
"scanned_bytes": self.scanned_bytes,
"referenced_count": self.referenced_count,
"active_build_count": self.active_build_count,
"young_count": self.young_count,
"unknown_age_count": self.unknown_age_count,
"invalid_shape_count": self.invalid_shape_count,
"candidate_count": len(self.candidates),
"candidate_bytes": self.candidate_bytes,
"deleted_count": len(deleted),
"deleted_bytes": sum(candidate.size_bytes for candidate in deleted),
"failure_count": len(failures),
"manifest_sha256": self.manifest_sha256,
"candidates": [candidate.as_dict() for candidate in self.candidates],
}
def campaign_artifact_inventory(
session: Session,
*,
storage: StorageBackend,
tenant_id: str,
grace_period: timedelta,
cursor: str | None = None,
page_size: int = 250,
now: datetime | None = None,
) -> ArtifactInventory:
observed_at = _as_utc(now or datetime.now(timezone.utc))
if grace_period < timedelta(hours=MINIMUM_GRACE_HOURS):
raise ValueError(
f"Campaign artifact grace period must be at least {MINIMUM_GRACE_HOURS} hours"
)
bounded_page_size = max(1, min(int(page_size), MAXIMUM_PAGE_SIZE))
prefix = _tenant_artifact_prefix(tenant_id)
if cursor is not None and not cursor.startswith(prefix):
raise ValueError("Campaign artifact cursor is outside the tenant namespace")
page = storage.list_objects(
prefix=prefix,
after=cursor,
limit=bounded_page_size,
)
page_keys = {info.key for info in page.objects}
referenced_keys = _referenced_artifact_keys(
session,
tenant_id=tenant_id,
prefix=prefix,
artifact_keys=page_keys,
)
active_build_ids = _active_build_ids(
session,
tenant_id=tenant_id,
now=observed_at,
)
candidates: list[ArtifactCandidate] = []
referenced_count = 0
active_build_count = 0
young_count = 0
unknown_age_count = 0
invalid_shape_count = 0
scanned_bytes = 0
manifest_rows: list[dict[str, Any]] = []
for info in page.objects:
scanned_bytes += info.size_bytes
build_id = _build_id_for_key(info.key, prefix=prefix)
modified_at = _object_modified_at(info)
manifest_rows.append(
{
"key_sha256": _sha256(info.key),
"size_bytes": info.size_bytes,
"modified_at": modified_at.isoformat() if modified_at else None,
}
)
if build_id is None:
invalid_shape_count += 1
continue
if info.key in referenced_keys:
referenced_count += 1
continue
if build_id in active_build_ids:
active_build_count += 1
continue
if modified_at is None:
unknown_age_count += 1
continue
age = observed_at - modified_at
if age < grace_period:
young_count += 1
continue
candidates.append(
ArtifactCandidate(
key=info.key,
size_bytes=info.size_bytes,
modified_at=modified_at,
age_seconds=max(0, int(age.total_seconds())),
)
)
return ArtifactInventory(
tenant_prefix=prefix,
cursor=cursor,
next_cursor=page.next_cursor,
scanned_count=len(page.objects),
scanned_bytes=scanned_bytes,
referenced_count=referenced_count,
active_build_count=active_build_count,
young_count=young_count,
unknown_age_count=unknown_age_count,
invalid_shape_count=invalid_shape_count,
candidates=candidates,
manifest_sha256=_canonical_sha256(manifest_rows),
)
def reconcile_campaign_artifacts(
session_factory: SessionFactory,
*,
storage: StorageBackend,
identity: RuntimeIdentity,
tenant_id: str,
apply: bool = False,
idempotency_key: str | None = None,
grace_period_hours: int = MINIMUM_GRACE_HOURS,
cursor: str | None = None,
page_size: int = 250,
now: datetime | None = None,
) -> dict[str, Any]:
if apply and not (idempotency_key or "").strip():
raise ValueError("Applied Campaign artifact cleanup requires an idempotency key")
if grace_period_hours < MINIMUM_GRACE_HOURS:
raise ValueError(
f"Campaign artifact grace period must be at least {MINIMUM_GRACE_HOURS} hours"
)
observed_at = _as_utc(now or datetime.now(timezone.utc))
grace_period = timedelta(hours=grace_period_hours)
if not apply:
with session_factory() as session:
inventory = campaign_artifact_inventory(
session,
storage=storage,
tenant_id=tenant_id,
grace_period=grace_period,
cursor=cursor,
page_size=page_size,
now=observed_at,
)
return inventory.response(apply=False, status="dry_run")
prefix = _tenant_artifact_prefix(tenant_id)
request = {
"tenant_id": tenant_id,
"prefix": prefix,
"cursor_sha256": _sha256(cursor) if cursor else None,
"page_size": max(1, min(int(page_size), MAXIMUM_PAGE_SIZE)),
"grace_period_hours": grace_period_hours,
}
recovery_start = begin_durable_recovery_operation(
session_factory,
identity=identity,
module_id="campaigns",
operation_type="artifact-orphan-reconciliation",
idempotency_key=str(idempotency_key).strip(),
request=request,
recovery_plan=RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=(
"inventory is bounded to one tenant Campaign artifact prefix",
"objects younger than the conservative grace period are excluded",
),
forward_recovery_steps=(
"retry only objects still unreferenced by committed Campaign state",
),
verification_steps=(
"probe every attempted object after deletion",
"preserve database references without mutation",
),
),
precondition_evidence={
"tenant_prefix_sha256": _sha256(prefix),
"grace_period_hours": grace_period_hours,
"page_size": request["page_size"],
},
lease_resource_key=f"campaign:artifact-reconcile:{tenant_id}",
lease_ttl_seconds=15 * 60,
resource_type="campaign_artifact_namespace",
resource_id=tenant_id,
metadata={"tenant_id": tenant_id},
)
if recovery_start.replayed:
return _replayed_response(
prefix=prefix,
cursor=cursor,
operation_id=recovery_start.operation_id,
)
operation = recovery_start.operation
if operation is None: # pragma: no cover - guarded by replay branch
raise CampaignArtifactReconciliationError(
"Campaign artifact cleanup authority was not created"
)
try:
try:
with session_factory() as session:
inventory = campaign_artifact_inventory(
session,
storage=storage,
tenant_id=tenant_id,
grace_period=grace_period,
cursor=cursor,
page_size=page_size,
now=observed_at,
)
except Exception as exc:
operation.fail(
summary="Campaign artifact inventory failed before deletion",
evidence={
"effect_started": False,
"failure_type": type(exc).__name__,
},
)
raise
operation.checkpoint(
kind="artifact-inventory",
summary="The bounded Campaign artifact inventory was classified",
evidence={
"manifest_sha256": inventory.manifest_sha256,
"scanned_count": inventory.scanned_count,
"candidate_count": len(inventory.candidates),
"candidate_bytes": inventory.candidate_bytes,
"next_page": inventory.next_cursor is not None,
},
)
if inventory.candidates:
_apply_inventory(
session_factory,
storage=storage,
operation=operation,
inventory=inventory,
tenant_id=tenant_id,
now=observed_at,
)
failed = [
item
for item in inventory.candidates
if item.disposition == "delete_failed"
]
unknown = [
item
for item in inventory.candidates
if item.disposition == "delete_outcome_unknown"
]
evidence = _cleanup_evidence(inventory)
if unknown:
operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="Campaign artifact deletion could not be verified",
evidence=evidence,
failure_summary=(
"One or more Campaign artifact deletion outcomes are unknown"
),
)
status = "outcome_unknown"
elif failed:
operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="Campaign artifact deletion requires a retry",
evidence=evidence,
failure_summary=(
"One or more unreferenced Campaign artifacts remain"
),
)
status = "recovery_required"
else:
operation.succeed(evidence=evidence)
status = "applied"
return inventory.response(
apply=True,
status=status,
recovery_operation_id=recovery_start.operation_id,
)
except Exception:
if not operation.closed:
try:
operation.release_unresolved()
except Exception:
pass
raise
def _apply_inventory(
session_factory: SessionFactory,
*,
storage: StorageBackend,
operation: Any,
inventory: ArtifactInventory,
tenant_id: str,
now: datetime,
) -> None:
for index in range(0, len(inventory.candidates), _CHECKPOINT_BATCH_SIZE):
batch = inventory.candidates[index : index + _CHECKPOINT_BATCH_SIZE]
with session_factory() as session:
referenced = _referenced_artifact_keys(
session,
tenant_id=tenant_id,
prefix=inventory.tenant_prefix,
artifact_keys={candidate.key for candidate in batch},
)
active_build_ids = _active_build_ids(
session,
tenant_id=tenant_id,
now=now,
)
operation.checkpoint(
kind="artifact-delete-batch-authorized",
summary="Deletion authority was renewed for a bounded object batch",
evidence={
"batch_index": index // _CHECKPOINT_BATCH_SIZE,
"batch_count": len(batch),
"batch_manifest_sha256": _canonical_sha256(
[_sha256(candidate.key) for candidate in batch]
),
},
)
for candidate in batch:
build_id = _build_id_for_key(
candidate.key,
prefix=inventory.tenant_prefix,
)
if candidate.key in referenced or build_id in active_build_ids:
candidate.disposition = "protected_before_delete"
candidate.reason = "reference_or_active_build_appeared"
continue
_delete_and_verify(storage, candidate)
def _delete_and_verify(
storage: StorageBackend,
candidate: ArtifactCandidate,
) -> None:
delete_failure: Exception | None = None
try:
storage.delete(candidate.key)
except (OSError, StorageBackendError) as exc:
delete_failure = exc
try:
remains = storage.exists(candidate.key)
except (OSError, StorageBackendError) as exc:
candidate.disposition = "delete_outcome_unknown"
candidate.failure_type = type(exc).__name__
return
if not remains:
candidate.disposition = "deleted"
return
candidate.disposition = "delete_failed"
candidate.failure_type = (
type(delete_failure).__name__ if delete_failure is not None else None
)
def _referenced_artifact_keys(
session: Session,
*,
tenant_id: str,
prefix: str,
artifact_keys: set[str] | None = None,
) -> set[str]:
if artifact_keys == set():
return set()
keys: set[str] = set()
version_ids = {
identity[1]
for key in artifact_keys or ()
if (identity := _artifact_identity(key, prefix=prefix)) is not None
}
job_query = session.query(
CampaignJob.eml_storage_key,
CampaignJob.resolved_print_output,
).filter(CampaignJob.tenant_id == tenant_id)
if artifact_keys is not None:
filters = [CampaignJob.eml_storage_key.in_(artifact_keys)]
if version_ids:
filters.append(CampaignJob.campaign_version_id.in_(version_ids))
job_query = job_query.filter(or_(*filters))
job_rows = job_query.yield_per(1000)
for eml_storage_key, print_output in job_rows:
_add_key(
keys,
eml_storage_key,
prefix=prefix,
allowed_keys=artifact_keys,
)
_add_key(
keys,
_print_output_storage_key(print_output),
prefix=prefix,
allowed_keys=artifact_keys,
)
version_query = session.query(CampaignVersion.build_summary).join(
Campaign,
Campaign.id == CampaignVersion.campaign_id,
).filter(Campaign.tenant_id == tenant_id)
if artifact_keys is not None:
if not version_ids:
return keys
version_query = version_query.filter(CampaignVersion.id.in_(version_ids))
version_rows = version_query.yield_per(500)
for (build_summary,) in version_rows:
print_output = (
build_summary.get("print_output")
if isinstance(build_summary, dict)
else None
)
_add_key(
keys,
_print_output_storage_key(print_output),
prefix=prefix,
allowed_keys=artifact_keys,
)
return keys
def _artifact_identity(
key: str,
*,
prefix: str,
) -> tuple[str, str, str] | None:
if not key.startswith(prefix):
return None
parts = key[len(prefix) :].split("/")
if len(parts) < 4 or any(not part for part in parts[:4]):
return None
return parts[0], parts[1], parts[2]
def _active_build_ids(
session: Session,
*,
tenant_id: str,
now: datetime,
) -> set[str]:
operations = (
session.query(RecoveryOperation)
.join(
CampaignVersion,
CampaignVersion.id == RecoveryOperation.resource_id,
)
.join(Campaign, Campaign.id == CampaignVersion.campaign_id)
.filter(
Campaign.tenant_id == tenant_id,
RecoveryOperation.module_id == "campaigns",
RecoveryOperation.operation_type == "build-artifacts",
RecoveryOperation.status.not_in(TERMINAL_RECOVERY_STATUSES),
)
.all()
)
resource_keys = {
operation.lease_resource_key
for operation in operations
if operation.lease_resource_key
}
if not resource_keys:
return set()
installation_ids = {operation.installation_id for operation in operations}
leases = (
session.query(DistributedLease)
.filter(
DistributedLease.installation_id.in_(installation_ids),
DistributedLease.resource_key.in_(resource_keys),
)
.all()
)
leases_by_key = {
(lease.installation_id, lease.resource_key): lease for lease in leases
}
active: set[str] = set()
for operation in operations:
lease = leases_by_key.get(
(operation.installation_id, operation.lease_resource_key or "")
)
if (
lease is not None
and lease.holder_node_id == operation.holder_node_id
and lease.holder_incarnation == operation.holder_incarnation
and lease.fencing_token == operation.fencing_token
and _as_utc(lease.expires_at) > now
):
active.add(operation.id)
return active
def _cleanup_evidence(inventory: ArtifactInventory) -> dict[str, Any]:
dispositions: dict[str, int] = {}
for candidate in inventory.candidates:
dispositions[candidate.disposition] = (
dispositions.get(candidate.disposition, 0) + 1
)
verified = not any(
key in dispositions for key in ("delete_failed", "delete_outcome_unknown")
)
return {
"verified": verified,
"checks": {
"candidate_objects": (
"absent-or-newly-protected" if verified else "incomplete"
),
"database_references": "unchanged",
"lease_fence": "renewed-before-each-batch",
},
"inventory_manifest_sha256": inventory.manifest_sha256,
"candidate_manifest_sha256": _canonical_sha256(
[_sha256(candidate.key) for candidate in inventory.candidates]
),
"candidate_count": len(inventory.candidates),
"candidate_bytes": inventory.candidate_bytes,
"dispositions": dispositions,
"database_references_mutated": False,
}
def _replayed_response(
*,
prefix: str,
cursor: str | None,
operation_id: str,
) -> dict[str, Any]:
return {
"apply": True,
"status": "already_completed",
"recovery_operation_id": operation_id,
"tenant_prefix": prefix,
"cursor": cursor,
"next_cursor": None,
"scanned_count": 0,
"scanned_bytes": 0,
"referenced_count": 0,
"active_build_count": 0,
"young_count": 0,
"unknown_age_count": 0,
"invalid_shape_count": 0,
"candidate_count": 0,
"candidate_bytes": 0,
"deleted_count": 0,
"deleted_bytes": 0,
"failure_count": 0,
"manifest_sha256": None,
"candidates": [],
}
def _tenant_artifact_prefix(tenant_id: str) -> str:
normalized = str(tenant_id or "").strip()
if not normalized or "/" in normalized or normalized in {".", ".."}:
raise ValueError("Campaign artifact inventory requires a valid tenant id")
return f"{CAMPAIGN_ARTIFACT_NAMESPACE}/{normalized}/"
def _build_id_for_key(key: str, *, prefix: str) -> str | None:
identity = _artifact_identity(key, prefix=prefix)
return identity[2] if identity is not None else None
def _print_output_storage_key(value: object) -> str | None:
if not isinstance(value, dict):
return None
artifact = value.get("artifact")
if not isinstance(artifact, dict):
return None
key = artifact.get("storage_key")
return str(key) if key else None
def _add_key(
keys: set[str],
value: object,
*,
prefix: str,
allowed_keys: set[str] | None = None,
) -> None:
if value is None:
return
key = str(value)
if key.startswith(prefix) and (allowed_keys is None or key in allowed_keys):
keys.add(key)
def _object_modified_at(info: StorageObjectInfo) -> datetime | None:
return _as_utc(info.modified_at) if info.modified_at is not None else None
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _sha256(value: object) -> str:
return hashlib.sha256(str(value).encode("utf-8")).hexdigest()
def _canonical_sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
).hexdigest()
__all__ = [
"CAMPAIGN_ARTIFACT_NAMESPACE",
"CampaignArtifactReconciliationError",
"campaign_artifact_inventory",
"reconcile_campaign_artifacts",
]
@@ -53,6 +53,17 @@ class AttachmentIssue(BaseModel):
code: str
message: str
behavior: Behavior | None = None
details: dict[str, Any] = Field(default_factory=dict)
class AttachmentPolicyDecision(BaseModel):
model_config = ConfigDict(extra="forbid")
requirement_policy: Behavior
campaign_policy: Behavior
rule_policy: Behavior | None = None
effective_behavior: Behavior
legacy_drop_normalized: bool = False
class ResolvedAttachment(BaseModel):
@@ -82,6 +93,7 @@ class ResolvedAttachment(BaseModel):
zip_entry_names: list[str] = Field(default_factory=list)
status: AttachmentMatchStatus
behavior: Behavior | None = None
missing_policy: AttachmentPolicyDecision | None = None
matches: list[str] = Field(default_factory=list)
issues: list[AttachmentIssue] = Field(default_factory=list)
@@ -193,12 +205,48 @@ def _rule_allows_multiple(config: AttachmentConfig, rendered_file_filter: str) -
return config.allow_multiple or any(char in rendered_file_filter for char in "*?[")
def _missing_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
_MISSING_BEHAVIOR_STRENGTH = {
Behavior.CONTINUE: 0,
Behavior.WARN: 1,
Behavior.ASK: 2,
Behavior.DROP: 2,
Behavior.BLOCK: 3,
}
def _missing_policy_decision(
campaign_config: CampaignConfig,
config: AttachmentConfig,
) -> AttachmentPolicyDecision:
requirement_policy = (
campaign_config.validation_policy.missing_required_attachment
if config.required
else campaign_config.validation_policy.missing_optional_attachment
)
candidates = [
requirement_policy,
campaign_config.attachments.missing_behavior,
]
if config.missing_behavior is not None:
return config.missing_behavior
if config.required:
return campaign_config.validation_policy.missing_required_attachment
return campaign_config.validation_policy.missing_optional_attachment
candidates.append(config.missing_behavior)
configured = max(
candidates,
key=lambda behavior: _MISSING_BEHAVIOR_STRENGTH[behavior],
)
legacy_drop_normalized = configured == Behavior.DROP
if legacy_drop_normalized:
configured = Behavior.BLOCK if config.required else Behavior.ASK
return AttachmentPolicyDecision(
requirement_policy=requirement_policy,
campaign_policy=campaign_config.attachments.missing_behavior,
rule_policy=config.missing_behavior,
effective_behavior=configured,
legacy_drop_normalized=legacy_drop_normalized,
)
def _missing_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
return _missing_policy_decision(campaign_config, config).effective_behavior
def _ambiguous_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
@@ -356,14 +404,19 @@ def _confine_managed_matches(directory: Path, matches: list[Path]) -> tuple[list
return confined, rejected
def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue:
def _issue_for_missing(
config: AttachmentConfig,
policy: AttachmentPolicyDecision,
) -> AttachmentIssue:
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
behavior = policy.effective_behavior
severity = ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
return AttachmentIssue(
severity=severity,
code=code,
message=f"No file matched attachment filter {config.file_filter!r}",
behavior=behavior,
details={"effective_policy": policy.model_dump(mode="json")},
)
@@ -377,10 +430,13 @@ 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 (
def effective_send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
configured = config.attachments.send_without_attachments_behavior or (
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
)
# Recipient exclusion must be an explicit reviewed action, not an implicit
# consequence of a legacy attachment policy value.
return Behavior.ASK if configured == Behavior.DROP else configured
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
@@ -395,6 +451,7 @@ def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssu
code="missing_attachment_coverage",
message=messages.get(behavior, "No attachment file was resolved for this message."),
behavior=behavior,
details={"effective_behavior": behavior.value},
)
@@ -422,24 +479,13 @@ def _resolve_one_config(
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:
resolution_failed = False
try:
if managed_source:
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:
@@ -453,16 +499,44 @@ def _resolve_one_config(
behavior=Behavior.BLOCK,
)
)
else:
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
else:
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
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,
)
)
except (OSError, RuntimeError) as exc:
matches = []
resolution_failed = True
issues.append(
AttachmentIssue(
severity=ResolutionSeverity.ERROR,
code="attachment_resolution_failed",
message=f"Attachment source could not be read while resolving filter {config.file_filter!r}.",
behavior=Behavior.BLOCK,
details={"error_type": type(exc).__name__},
)
)
missing_policy: AttachmentPolicyDecision | None = None
if unsafe_managed_path:
status = AttachmentMatchStatus.MISSING
behavior = Behavior.BLOCK
elif resolution_failed:
status = AttachmentMatchStatus.MISSING
behavior = Behavior.BLOCK
elif not matches:
status = AttachmentMatchStatus.MISSING
behavior = _missing_behavior(campaign_config, config)
issues.append(_issue_for_missing(config, behavior))
missing_policy = _missing_policy_decision(campaign_config, config)
behavior = missing_policy.effective_behavior
issues.append(_issue_for_missing(config, missing_policy))
elif len(matches) > 1 and not allow_multiple:
status = AttachmentMatchStatus.AMBIGUOUS
behavior = _ambiguous_behavior(campaign_config, config)
@@ -494,6 +568,7 @@ def _resolve_one_config(
zip_entry_name_template=config.zip_entry_name_template,
status=status,
behavior=behavior,
missing_policy=missing_policy,
matches=[str(path) for path in matches],
issues=issues,
)
@@ -540,7 +615,7 @@ def resolve_entry_attachments(
)
issues = [issue for item in resolved for issue in item.issues]
missing_coverage_behavior = _send_without_attachments_behavior(config)
missing_coverage_behavior = effective_send_without_attachments_behavior(config)
if (
entry.active
and resolved
@@ -0,0 +1,184 @@
from __future__ import annotations
import hashlib
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from govoplan_campaign.backend.campaign.models import (
AttachmentReuseAction,
AttachmentReuseAllowance,
AttachmentReusePolicy,
)
from govoplan_campaign.backend.messages.models import MessageDraft, MessageIssue
@dataclass(frozen=True, slots=True)
class _AttachmentUse:
message: MessageDraft
message_key: str
recipient_key: tuple[str, ...]
source_identity: str
file_name: str
@dataclass(slots=True)
class AttachmentReuseEvaluation:
report: dict[str, object]
issues_by_entry_index: dict[int, list[MessageIssue]]
def evaluate_attachment_reuse(
messages: list[MessageDraft],
*,
policy: AttachmentReusePolicy,
) -> AttachmentReuseEvaluation:
"""Evaluate repeated resolved-file use without exposing source paths.
A use is one resolved file occurrence in one attachment rule. The same
source file can therefore be detected both across built messages and when
two rules add it to one message. Allowed findings remain in the build
protocol; policy violations additionally become recipient-level issues.
"""
uses_by_source: dict[str, list[_AttachmentUse]] = defaultdict(list)
for message in messages:
if not message.active:
continue
message_key = str(message.entry_id or message.entry_index)
recipient_key = _recipient_key(message, fallback=message_key)
for attachment in message.attachments:
for match in attachment.matches:
source_identity = _source_identity(match)
uses_by_source[source_identity].append(
_AttachmentUse(
message=message,
message_key=message_key,
recipient_key=recipient_key,
source_identity=source_identity,
file_name=Path(match).name,
)
)
findings: list[dict[str, object]] = []
issues_by_entry_index: dict[int, list[MessageIssue]] = defaultdict(list)
affected_entry_indexes: set[int] = set()
allowed_count = 0
violation_count = 0
for source_identity, uses in sorted(uses_by_source.items()):
if len(uses) < 2:
continue
fingerprint = hashlib.sha256(source_identity.encode("utf-8")).hexdigest()
message_keys = {item.message_key for item in uses}
recipient_keys = {item.recipient_key for item in uses}
allowed, explanation = _is_allowed(
policy,
message_count=len(message_keys),
recipient_count=len(recipient_keys),
)
disposition = "allowed" if allowed else policy.action.value
finding = {
"file_fingerprint": fingerprint,
"file_name": uses[0].file_name,
"use_count": len(uses),
"message_count": len(message_keys),
"recipient_count": len(recipient_keys),
"disposition": disposition,
"explanation": explanation,
}
findings.append(finding)
if allowed:
allowed_count += 1
continue
violation_count += 1
behavior = _issue_behavior(policy.action)
severity = (
"error" if policy.action == AttachmentReuseAction.BLOCK else "warning"
)
for use in _unique_message_uses(uses):
affected_entry_indexes.add(use.message.entry_index)
issues_by_entry_index[use.message.entry_index].append(
MessageIssue(
severity=severity,
code="duplicate_attachment_reuse",
message=(
f"Attachment {use.file_name!r} is reused {len(uses)} times "
f"across {len(message_keys)} built message(s); the configured "
f"policy requires {disposition}."
),
behavior=behavior,
source="attachments:reuse_policy",
details={
**finding,
"policy": policy.model_dump(mode="json"),
},
)
)
return AttachmentReuseEvaluation(
report={
"contract_version": "1",
"policy": policy.model_dump(mode="json"),
"duplicate_file_count": len(findings),
"allowed_file_count": allowed_count,
"violation_file_count": violation_count,
"affected_message_count": len(affected_entry_indexes),
"findings": findings,
},
issues_by_entry_index=dict(issues_by_entry_index),
)
def _source_identity(value: str) -> str:
return str(Path(value).resolve(strict=False))
def _recipient_key(message: MessageDraft, *, fallback: str) -> tuple[str, ...]:
addresses = message.to or message.bcc or message.cc
normalized = sorted(
{
item.email.strip().casefold()
for item in addresses
if item.email and item.email.strip()
}
)
return tuple(normalized) if normalized else (f"entry:{fallback}",)
def _is_allowed(
policy: AttachmentReusePolicy,
*,
message_count: int,
recipient_count: int,
) -> tuple[bool, str]:
if policy.action == AttachmentReuseAction.ALLOW:
return True, "The campaign policy explicitly allows attachment reuse."
if (
policy.allow_within == AttachmentReuseAllowance.SAME_MESSAGE
and message_count == 1
):
return True, "Reuse is confined to one built message as allowed by policy."
if (
policy.allow_within == AttachmentReuseAllowance.SAME_RECIPIENT
and recipient_count == 1
):
return True, "Reuse is confined to one recipient as allowed by policy."
return False, (
"Reuse crosses the configured allowance and is handled by the "
f"{policy.action.value} policy."
)
def _issue_behavior(action: AttachmentReuseAction) -> str:
if action == AttachmentReuseAction.REVIEW:
return "ask"
return action.value
def _unique_message_uses(uses: list[_AttachmentUse]) -> list[_AttachmentUse]:
unique: dict[int, _AttachmentUse] = {}
for use in uses:
unique.setdefault(use.message.entry_index, use)
return list(unique.values())
@@ -0,0 +1,44 @@
from __future__ import annotations
import copy
from collections.abc import Mapping
DEFAULT_COPY_OPTIONS: dict[str, bool] = {
"include_recipients": True,
"include_files": True,
"include_shares": False,
"include_policies": True,
"include_mail_profile": True,
}
def campaign_copy_configuration(
source: Mapping[str, object],
options: Mapping[str, object],
) -> dict[str, object]:
"""Return an editable configuration copy without operational evidence."""
selected = {**DEFAULT_COPY_OPTIONS, **dict(options)}
raw_json = copy.deepcopy(dict(source))
if not selected["include_recipients"]:
raw_json["recipients"] = {}
raw_json["entries"] = {"inline": [], "imports": []}
if not selected["include_files"]:
raw_json["attachments"] = {}
entries = raw_json.get("entries")
if isinstance(entries, dict):
inline = entries.get("inline")
if isinstance(inline, list):
for entry in inline:
if isinstance(entry, dict):
entry["attachments"] = []
entry["combine_attachments"] = True
if not selected["include_policies"]:
raw_json["validation_policy"] = {}
if not selected["include_mail_profile"]:
raw_json["server"] = {}
return raw_json
__all__ = ["DEFAULT_COPY_OPTIONS", "campaign_copy_configuration"]
@@ -44,6 +44,7 @@ def _parse_scalar_for_target(target: str, value: Any) -> Any:
"merge_reply_to",
"merge_bounce_to",
"merge_disposition_notification_to",
"merge_postbox_targets",
"combine_to",
"combine_cc",
"combine_bcc",
@@ -0,0 +1,276 @@
from __future__ import annotations
import hashlib
import hmac
import json
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignSchedule,
CampaignShare,
CampaignVersion,
)
from govoplan_core.auth import ApiPrincipal, has_scope
POLICY_ID = "campaign.lifecycle"
POLICY_VERSION = "2"
_ACTIVE_QUEUE_STATES = {"queued", "sending"}
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
_ACTIVE_POSTBOX_STATES = {"pending", "delivering", "outcome_unknown"}
_ACTIVE_PRINT_STATES = {"ready", "accepting"}
_ACTIVE_IMAP_STATES = {"pending", "appending", "outcome_unknown"}
@dataclass(frozen=True)
class LifecycleDecision:
allowed: bool
reason: str | None = None
def as_dict(self) -> dict[str, Any]:
return {"allowed": self.allowed, "reason": self.reason}
def _timestamp(value: datetime | None) -> str | None:
return value.isoformat() if value is not None else None
def _canonical_hash(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
def _protected_version(version: CampaignVersion) -> bool:
return any(
value is not None
for value in (
version.locked_at,
version.user_lock_state,
version.published_at,
version.execution_snapshot_at,
)
)
def _active_delivery(job: CampaignJob) -> bool:
return any(
(
job.queue_status in _ACTIVE_QUEUE_STATES,
job.send_status in _ACTIVE_SEND_STATES,
job.postbox_status in _ACTIVE_POSTBOX_STATES,
job.print_status in _ACTIVE_PRINT_STATES,
job.imap_status in _ACTIVE_IMAP_STATES,
)
)
def campaign_lifecycle_policy(
session: Session,
*,
campaign: Campaign,
principal: ApiPrincipal,
version_id: str | None = None,
) -> dict[str, Any]:
versions = (
session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.asc())
.all()
)
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_id == campaign.id)
.order_by(CampaignJob.id.asc())
.all()
)
shares = (
session.query(CampaignShare)
.filter(
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
)
.order_by(CampaignShare.id.asc())
.all()
)
schedules = (
session.query(CampaignSchedule)
.filter(CampaignSchedule.campaign_id == campaign.id)
.order_by(CampaignSchedule.id.asc())
.all()
)
selected_version = next(
(version for version in versions if version.id == version_id),
None,
)
snapshot = {
"policy_id": POLICY_ID,
"policy_version": POLICY_VERSION,
"campaign": {
"id": campaign.id,
"status": campaign.status,
"current_version_id": campaign.current_version_id,
"settings_sha256": _canonical_hash(campaign.settings or {}),
"mail_profile_policy_sha256": _canonical_hash(
campaign.mail_profile_policy or {}
),
"updated_at": _timestamp(campaign.updated_at),
},
"versions": [
{
"id": version.id,
"version_number": version.version_number,
"edit_revision": version.edit_revision,
"workflow_state": version.workflow_state,
"locked_at": _timestamp(version.locked_at),
"user_lock_state": version.user_lock_state,
"published_at": _timestamp(version.published_at),
"execution_snapshot_at": _timestamp(version.execution_snapshot_at),
"archived_at": _timestamp(version.archived_at),
"configuration_sha256": _canonical_hash(version.raw_json or {}),
"updated_at": _timestamp(version.updated_at),
}
for version in versions
],
"jobs": [
{
"id": job.id,
"queue_status": job.queue_status,
"send_status": job.send_status,
"postbox_status": job.postbox_status,
"print_status": job.print_status,
"imap_status": job.imap_status,
"updated_at": _timestamp(job.updated_at),
}
for job in jobs
],
"active_shares": [
{
"id": share.id,
"target_type": share.target_type,
"target_id": share.target_id,
"permission": share.permission,
"updated_at": _timestamp(share.updated_at),
}
for share in shares
],
"schedules": [
{
"id": schedule.id,
"active": schedule.active,
"resource_revision": schedule.resource_revision,
"next_fire_at": _timestamp(schedule.next_fire_at),
"occurrence_count": schedule.occurrence_count,
"updated_at": _timestamp(schedule.updated_at),
}
for schedule in schedules
],
"selected_version_id": version_id,
}
token = _canonical_hash(snapshot)
active_delivery = any(_active_delivery(job) for job in jobs)
protected_versions = any(_protected_version(version) for version in versions)
active_schedules = any(schedule.active for schedule in schedules)
archive = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:archive"):
archive = LifecycleDecision(False, "Missing campaign archive permission.")
elif campaign.status in {"archived", "deleted"}:
archive = LifecycleDecision(False, "The campaign is already archived or deleted.")
elif active_delivery:
archive = LifecycleDecision(
False,
"Active or uncertain delivery must be resolved before archiving.",
)
elif active_schedules:
archive = LifecycleDecision(
False,
"Pause active Campaign schedules before archiving.",
)
delete = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:delete"):
delete = LifecycleDecision(False, "Missing campaign delete permission.")
elif campaign.status != "draft":
delete = LifecycleDecision(False, "Only untouched draft campaigns can be deleted.")
elif jobs:
delete = LifecycleDecision(
False,
"Campaigns with built or delivery jobs must be archived instead of deleted.",
)
elif protected_versions:
delete = LifecycleDecision(
False,
"Audit-relevant campaign versions must be archived instead of deleted.",
)
elif shares:
delete = LifecycleDecision(
False,
"Revoke active campaign shares before deleting the untouched draft.",
)
elif schedules:
delete = LifecycleDecision(
False,
"Campaigns with schedule evidence must be archived instead of deleted.",
)
copy = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:copy"):
copy = LifecycleDecision(False, "Missing campaign copy permission.")
elif version_id is not None and selected_version is None:
copy = LifecycleDecision(False, "The selected source version does not exist.")
archive_version = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:archive"):
archive_version = LifecycleDecision(False, "Missing campaign archive permission.")
elif version_id is None or selected_version is None:
archive_version = LifecycleDecision(False, "Select a historical campaign version.")
elif selected_version.id == campaign.current_version_id:
archive_version = LifecycleDecision(False, "The current campaign version cannot be archived.")
elif selected_version.archived_at is not None:
archive_version = LifecycleDecision(False, "The historical version is already archived.")
return {
"policy_id": POLICY_ID,
"policy_version": POLICY_VERSION,
"state_token": token,
"actions": {
"archive_campaign": archive.as_dict(),
"delete_campaign": delete.as_dict(),
"copy_campaign": copy.as_dict(),
"archive_version": archive_version.as_dict(),
},
"provenance": {
"source": "built_in",
"rules": (
"permission",
"campaign_state",
"retained_evidence",
"active_delivery",
"scheduled_automation",
"optimistic_concurrency",
),
"evidence_retention": "Versions, schedule occurrences, delivery outcomes, reports, and audit records are never deleted by archival.",
},
}
def assert_lifecycle_state_token(actual: str, expected: str) -> None:
if not hmac.compare_digest(actual, expected):
raise ValueError(
"Campaign state changed after this action was prepared. Reload and review the lifecycle decision again."
)
@@ -4,8 +4,18 @@ 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_MAIL_SERVER_KEYS = frozenset(
{
"mail_profile_id",
"smtp_server_id",
"smtp_credential_id",
"imap_server_id",
"imap_credential_id",
}
)
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset(
{"created_from", "field_overrides", "opt_ins"}
)
CAMPAIGN_OPT_IN_KEYS = frozenset(
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
)
@@ -14,18 +24,29 @@ CAMPAIGN_REVIEW_STATE_KEYS = frozenset(
"build_token",
"inspection_complete",
"reviewed_message_keys",
"issue_decisions",
"updated_at",
"updated_by_user_id",
}
)
CAMPAIGN_APPROVAL_GATE_KEYS = frozenset(
{
"request_id",
"request_revision",
"subject_version",
"subject_digest",
"requested_at",
"requested_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.
may select Mail-owned profile, server, and credential identifiers, but it
must never copy or override transport configuration.
"""
@@ -85,10 +106,13 @@ def validate_campaign_editor_state(
if value is None:
return {}
if not isinstance(value, dict):
raise CampaignMailProfileBoundaryError("Campaign editor state must be an object")
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")
allowed.add("approval_gate")
if any(key not in allowed for key in value):
raise CampaignMailProfileBoundaryError(
"Campaign editor state contains unsupported or transport-owned fields"
@@ -104,11 +128,13 @@ def validate_campaign_editor_state(
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"]
)
result["field_overrides"] = _validated_field_overrides(value["field_overrides"])
if "review_send" in value:
result["review_send"] = _validated_server_review_state(value["review_send"])
if "approval_gate" in value:
result["approval_gate"] = _validated_server_approval_gate(
value["approval_gate"]
)
return result
@@ -134,9 +160,32 @@ def public_campaign_editor_state(
review_state = _validated_server_review_state(value["review_send"])
if not include_diagnostics:
review_state.pop("build_token", None)
review_state["issue_decisions"] = [
{
key: item[key]
for key in (
"job_id",
"review_key",
"decision",
"reason",
"actor_user_id",
"decided_at",
"issue_codes",
)
if key in item
}
for item in review_state.get("issue_decisions", [])
]
result["review_send"] = review_state
except CampaignMailProfileBoundaryError:
pass
if "approval_gate" in value:
try:
result["approval_gate"] = _validated_server_approval_gate(
value["approval_gate"]
)
except CampaignMailProfileBoundaryError:
pass
return result
@@ -145,9 +194,48 @@ def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
state = public_campaign_editor_state(value)
state.pop("review_send", None)
state.pop("approval_gate", None)
return state
def _validated_server_approval_gate(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or any(
key not in CAMPAIGN_APPROVAL_GATE_KEYS for key in value
):
raise CampaignMailProfileBoundaryError("Campaign approval gate is invalid")
required_strings = (
"request_id",
"subject_version",
"subject_digest",
"requested_at",
)
if any(
not isinstance(value.get(key), str) or not str(value.get(key)).strip()
for key in required_strings
):
raise CampaignMailProfileBoundaryError(
"Campaign approval gate references are invalid"
)
digest = str(value["subject_digest"])
if len(digest) != 64 or any(
character not in "0123456789abcdef" for character in digest
):
raise CampaignMailProfileBoundaryError(
"Campaign approval gate digest is invalid"
)
revision = value.get("request_revision")
if not isinstance(revision, int) or revision < 1:
raise CampaignMailProfileBoundaryError(
"Campaign approval gate revision is invalid"
)
requested_by = value.get("requested_by_user_id")
if requested_by is not None and not isinstance(requested_by, str):
raise CampaignMailProfileBoundaryError(
"Campaign approval gate actor is invalid"
)
return copy.deepcopy(value)
def _is_valid_reviewed_message_key(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip()) and len(value) <= 512
@@ -170,6 +258,98 @@ def _validated_review_actor(value: Any) -> str | None:
return value
def _validated_issue_decisions(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list) or len(value) > 100_000:
raise CampaignMailProfileBoundaryError(
"Campaign review issue decisions are invalid"
)
result: list[dict[str, Any]] = []
allowed_keys = {
"job_id",
"review_key",
"decision",
"reason",
"actor_user_id",
"decided_at",
"build_token",
"message_sha256",
"issue_fingerprint",
"issue_codes",
}
for item in value:
if not isinstance(item, dict) or any(key not in allowed_keys for key in item):
raise CampaignMailProfileBoundaryError(
"Campaign review issue decision is invalid"
)
normalized = {
"job_id": _validated_required_string(
item.get("job_id"),
max_length=36,
error="Campaign review decision job is invalid",
),
"review_key": _validated_required_string(
item.get("review_key"),
max_length=512,
error="Campaign review decision key is invalid",
),
"decision": _validated_required_string(
item.get("decision"),
max_length=30,
error="Campaign review decision is invalid",
),
"reason": item.get("reason"),
"actor_user_id": _validated_review_actor(item.get("actor_user_id")),
"decided_at": _validated_required_string(
item.get("decided_at"),
max_length=128,
error="Campaign review decision timestamp is invalid",
),
"build_token": _validated_required_string(
item.get("build_token"),
max_length=256,
error="Campaign review decision build token is invalid",
),
"message_sha256": item.get("message_sha256"),
"issue_fingerprint": _validated_required_string(
item.get("issue_fingerprint"),
max_length=64,
error="Campaign review issue fingerprint is invalid",
),
"issue_codes": item.get("issue_codes"),
}
if normalized["decision"] != "accept":
raise CampaignMailProfileBoundaryError(
"Campaign review decision outcome is invalid"
)
if normalized["reason"] is not None and (
not isinstance(normalized["reason"], str)
or len(normalized["reason"]) > 4_000
):
raise CampaignMailProfileBoundaryError(
"Campaign review decision reason is invalid"
)
if normalized["message_sha256"] is not None and (
not isinstance(normalized["message_sha256"], str)
or len(normalized["message_sha256"]) > 64
):
raise CampaignMailProfileBoundaryError(
"Campaign review decision message hash is invalid"
)
if (
not isinstance(normalized["issue_codes"], list)
or len(normalized["issue_codes"]) > 100
or any(
not isinstance(code, str) or not code or len(code) > 100
for code in normalized["issue_codes"]
)
):
raise CampaignMailProfileBoundaryError(
"Campaign review decision issue codes are invalid"
)
result.append(normalized)
return result
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
@@ -180,6 +360,7 @@ def _validated_server_review_state(value: Any) -> dict[str, Any]:
build_token = value.get("build_token")
inspected = value.get("inspection_complete")
keys = value.get("reviewed_message_keys", [])
issue_decisions = value.get("issue_decisions", [])
updated_at = value.get("updated_at")
updated_by = value.get("updated_by_user_id")
validated_build_token = _validated_required_string(
@@ -192,6 +373,7 @@ def _validated_server_review_state(value: Any) -> dict[str, Any]:
"Campaign review completion state is invalid"
)
validated_keys = _validated_reviewed_message_keys(keys)
validated_decisions = _validated_issue_decisions(issue_decisions)
validated_updated_at = _validated_required_string(
updated_at,
max_length=128,
@@ -202,6 +384,7 @@ def _validated_server_review_state(value: Any) -> dict[str, Any]:
"build_token": validated_build_token,
"inspection_complete": inspected,
"reviewed_message_keys": validated_keys,
"issue_decisions": validated_decisions,
"updated_at": validated_updated_at,
"updated_by_user_id": validated_updated_by,
}
@@ -218,16 +401,57 @@ def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None:
return normalized or None
def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]:
def campaign_mail_resource_ids(
raw_json: dict[str, Any] | None,
) -> dict[str, str | None]:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return {
"mail_profile_id": None,
"smtp_server_id": None,
"smtp_credential_id": None,
"imap_server_id": None,
"imap_credential_id": None,
}
return {
key: (
value.strip()
if isinstance((value := server.get(key)), str) and value.strip()
else None
)
for key in CAMPAIGN_MAIL_SERVER_KEYS
}
def campaign_mail_profile_boundary_violations(
raw_json: dict[str, Any] | None,
) -> tuple[str, ...]:
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")
violations = [
f"/server/{key}"
for key in sorted(server)
if key not in CAMPAIGN_MAIL_SERVER_KEYS
]
for key in CAMPAIGN_MAIL_SERVER_KEYS:
if key not in server:
continue
value = server[key]
if not isinstance(value, str) or not value.strip():
violations.append(f"/server/{key}")
references = campaign_mail_resource_ids(raw_json)
if references["mail_profile_id"] is None and any(
references[key] for key in references if key != "mail_profile_id"
):
violations.append("/server/mail_profile_id")
for protocol in ("smtp", "imap"):
if (
references[f"{protocol}_credential_id"]
and not references[f"{protocol}_server_id"]
):
violations.append(f"/server/{protocol}_server_id")
return tuple(violations)
@@ -240,9 +464,9 @@ def assert_campaign_uses_mail_profile_reference(
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."
"Campaign JSON may only reference Mail-owned profiles, servers, and credentials; "
f"remove campaign-local SMTP/IMAP settings or invalid references ({fields}), "
"select authorized Mail resources, and save a new campaign version."
)
if require_profile and campaign_mail_profile_id(raw_json) is None:
raise CampaignMailProfileBoundaryError(
@@ -254,5 +478,8 @@ def assert_campaign_uses_mail_profile_reference(
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 {}
return {
key: value
for key, value in campaign_mail_resource_ids(raw_json).items()
if value
}
@@ -23,6 +23,8 @@ class FieldType(StrEnum):
DOUBLE = "double"
DATE = "date"
PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary.
ORGANIZATION_UNIT = "organization_unit"
ORGANIZATION_FUNCTION = "organization_function"
class RecipientType(StrEnum):
@@ -74,6 +76,14 @@ class ZipPasswordScope(StrEnum):
GLOBAL = "global"
class ZipPasswordDeliveryChannel(StrEnum):
SEPARATE_MAIL = "separate_mail"
SMS = "sms"
LETTER = "letter"
PHONE = "phone"
IN_PERSON = "in_person"
class ZipPasswordMode(StrEnum):
NONE = "none"
DIRECT = "direct"
@@ -92,6 +102,131 @@ class SendStatus(StrEnum):
SKIPPED = "skipped"
class DeliveryChannelPolicy(StrEnum):
MAIL = "mail"
POSTBOX = "postbox"
PRINT = "print"
MAIL_AND_POSTBOX = "mail_and_postbox"
MAIL_THEN_POSTBOX = "mail_then_postbox"
POSTBOX_THEN_MAIL = "postbox_then_mail"
MAIL_THEN_PRINT = "mail_then_print"
POSTBOX_THEN_PRINT = "postbox_then_print"
@property
def uses_mail(self) -> bool:
return self in {
DeliveryChannelPolicy.MAIL,
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
DeliveryChannelPolicy.MAIL_THEN_PRINT,
}
@property
def uses_postbox(self) -> bool:
return self in {
DeliveryChannelPolicy.POSTBOX,
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
DeliveryChannelPolicy.POSTBOX_THEN_PRINT,
}
@property
def uses_print(self) -> bool:
return self in {
DeliveryChannelPolicy.PRINT,
DeliveryChannelPolicy.MAIL_THEN_PRINT,
DeliveryChannelPolicy.POSTBOX_THEN_PRINT,
}
class PostboxTargetMode(StrEnum):
DIRECT = "direct"
DERIVED = "derived"
class PostboxTargetMatch(StrEnum):
ID = "id"
SLUG = "slug"
class PostboxTargetConfig(StrictModel):
id: str = Field(min_length=1, max_length=120)
mode: PostboxTargetMode = PostboxTargetMode.DIRECT
label: str | None = Field(default=None, max_length=500)
postbox_id: str | None = Field(default=None, max_length=36)
address_key: str | None = Field(default=None, max_length=500)
template_id: str | None = Field(default=None, max_length=36)
organization_unit_id: str | None = Field(default=None, max_length=36)
organization_unit_field: str | None = Field(default=None, max_length=255)
organization_unit_match: PostboxTargetMatch = PostboxTargetMatch.ID
function_id: str | None = Field(default=None, max_length=36)
function_field: str | None = Field(default=None, max_length=255)
function_match: PostboxTargetMatch = PostboxTargetMatch.ID
context_key: str | None = Field(default=None, max_length=255)
context_field: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_target_shape(self) -> "PostboxTargetConfig":
direct_values = [self.postbox_id, self.address_key]
if self.mode == PostboxTargetMode.DIRECT:
if sum(bool(value) for value in direct_values) != 1:
raise ValueError(
"A direct Postbox target requires exactly one postbox_id "
"or address_key."
)
if any(
(
self.template_id,
self.organization_unit_id,
self.organization_unit_field,
self.function_id,
self.function_field,
self.context_key,
self.context_field,
)
):
raise ValueError(
"A direct Postbox target cannot contain derived target fields."
)
return self
if any(direct_values):
raise ValueError(
"A derived Postbox target cannot contain postbox_id or address_key."
)
if not self.template_id:
raise ValueError("A derived Postbox target requires template_id.")
if bool(self.organization_unit_id) == bool(self.organization_unit_field):
raise ValueError(
"A derived Postbox target requires exactly one fixed or "
"field-derived organization unit."
)
if bool(self.function_id) == bool(self.function_field):
raise ValueError(
"A derived Postbox target requires exactly one fixed or "
"field-derived function."
)
if self.context_key and self.context_field:
raise ValueError(
"A derived Postbox target may use a fixed context or a context "
"field, not both."
)
return self
class PrintTargetConfig(StrictModel):
channel: Literal["postal", "internal_mail"]
target: str = Field(min_length=1, max_length=4000)
target_key: str = Field(min_length=1, max_length=500)
contact_point_id: str | None = Field(default=None, max_length=36)
locale: str | None = Field(default=None, max_length=35)
decision_provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignMeta(StrictModel):
id: str
name: str
@@ -114,6 +249,10 @@ class MailProfileCapabilities(StrictModel):
class ServerConfig(StrictModel):
mail_profile_id: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
imap_server_id: str | None = None
imap_credential_id: str | None = None
profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities)
@@ -218,6 +357,13 @@ class ZipArchiveConfig(StrictModel):
password_field: str | None = None
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
method: ZipMethod = ZipMethod.AES
password_delivery_channel: ZipPasswordDeliveryChannel = (
ZipPasswordDeliveryChannel.SEPARATE_MAIL
)
legacy_zipcrypto_acknowledged: bool = False
legacy_zipcrypto_reason: str | None = Field(default=None, max_length=1000)
legacy_zipcrypto_acknowledged_by: str | None = Field(default=None, max_length=255)
legacy_zipcrypto_acknowledged_at: str | None = Field(default=None, max_length=80)
# Compatibility fields for campaigns created by the first single-archive
# implementation. New WebUI campaigns use password_enabled/field/scope.
@@ -245,6 +391,20 @@ class ZipArchiveConfig(StrictModel):
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
return normalized
@model_validator(mode="after")
def validate_legacy_zipcrypto_acknowledgement(self) -> "ZipArchiveConfig":
if self.method != ZipMethod.ZIP_STANDARD:
return self
if not self.legacy_zipcrypto_acknowledged:
raise ValueError(
"Legacy ZipCrypto requires explicit acknowledgement of its weak encryption"
)
if len((self.legacy_zipcrypto_reason or "").strip()) < 10:
raise ValueError(
"Legacy ZipCrypto requires an acknowledgement reason of at least 10 characters"
)
return self
class ZipCollectionConfig(StrictModel):
enabled: bool = False
@@ -337,6 +497,48 @@ class AttachmentBasePathConfig(StrictModel):
source: str | None = None
class ResidualFileMode(StrEnum):
NONE = "none"
REPORT = "report"
ATTACH = "attach"
class AttachmentReuseAction(StrEnum):
ALLOW = "allow"
WARN = "warn"
REVIEW = "review"
BLOCK = "block"
class AttachmentReuseAllowance(StrEnum):
NONE = "none"
SAME_RECIPIENT = "same_recipient"
SAME_MESSAGE = "same_message"
class AttachmentReusePolicy(StrictModel):
action: AttachmentReuseAction = AttachmentReuseAction.ALLOW
allow_within: AttachmentReuseAllowance = AttachmentReuseAllowance.NONE
class ResidualFileDispositionConfig(StrictModel):
mode: ResidualFileMode = ResidualFileMode.NONE
recipient: RecipientConfig | None = None
subject: str = "Unassigned files in campaign {{local:campaign_name}}"
text: str = (
"The campaign build found {{local:residual_file_count}} file(s) that "
"were not assigned to a recipient.\n\n{{local:residual_file_list}}"
)
@model_validator(mode="after")
def require_recipient_for_routing(self) -> "ResidualFileDispositionConfig":
if self.mode != ResidualFileMode.NONE and self.recipient is None:
raise ValueError(
"Residual-file report or attachment routing requires a recipient."
)
return self
class AttachmentConfig(StrictModel):
id: str | None = None
label: str | None = None
@@ -376,8 +578,14 @@ class AttachmentsConfig(StrictModel):
send_without_attachments_behavior: Behavior | None = None
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
missing_behavior: Behavior = Behavior.ASK
missing_behavior: Behavior = Behavior.WARN
ambiguous_behavior: Behavior = Behavior.ASK
reuse_policy: AttachmentReusePolicy = Field(
default_factory=AttachmentReusePolicy
)
residual_files: ResidualFileDispositionConfig = Field(
default_factory=ResidualFileDispositionConfig
)
@model_validator(mode="after")
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
@@ -446,10 +654,22 @@ class EntryConfig(StrictModel):
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
merge_disposition_notification_to: bool = True
channel_policy: DeliveryChannelPolicy | None = None
postbox_targets: list[PostboxTargetConfig] = Field(
default_factory=list,
max_length=50,
)
merge_postbox_targets: bool = True
print_target: PrintTargetConfig | None = None
attachments: list[AttachmentConfig] = Field(default_factory=list)
combine_attachments: bool = True
fields: dict[str, Any] = Field(default_factory=dict)
# Frozen channel candidates, source revisions and the explicit route
# decision imported from Distribution Lists. Campaign owns this snapshot;
# it never re-resolves the audience during build or delivery.
distribution_source: dict[str, Any] = Field(default_factory=dict)
last_sent: str | None = None
@@ -465,7 +685,7 @@ class ImportProvenance(StrictModel):
id: str
imported_at: str
mode: Literal["append", "replace"]
source_type: Literal["csv", "xlsx", "text", "addresses"]
source_type: Literal["csv", "xlsx", "text", "addresses", "distribution_list"]
source_id: str | None = None
source_label: str | None = None
source_revision: str | None = None
@@ -527,7 +747,7 @@ class EntriesConfig(StrictModel):
class ValidationPolicy(StrictModel):
missing_required_attachment: Behavior = Behavior.ASK
missing_required_attachment: Behavior = Behavior.BLOCK
missing_optional_attachment: Behavior = Behavior.WARN
ambiguous_attachment_match: Behavior = Behavior.ASK
ignore_empty_fields: bool = False
@@ -559,7 +779,43 @@ class RetryConfig(StrictModel):
return values
class PostboxDeliveryConfig(StrictModel):
targets: list[PostboxTargetConfig] = Field(default_factory=list, max_length=50)
classification: str = Field(default="internal", min_length=1, max_length=50)
unresolved_target: Behavior = Behavior.BLOCK
vacant_target: Behavior = Behavior.WARN
duplicate_target: Behavior = Behavior.WARN
class PrintDeliveryConfig(StrictModel):
template_id: str | None = Field(default=None, max_length=36)
template_revision: int | None = Field(default=None, ge=1)
usage: str = Field(default="campaign_print", min_length=1, max_length=100)
output_format: Literal["html", "text"] = "html"
profile_id: str | None = Field(default=None, max_length=120)
persist_to_files: bool = True
class CalendarInvitationDeliveryConfig(StrictModel):
enabled: bool = False
calendar_id: str | None = Field(default=None, max_length=36)
summary_template: str | None = Field(default=None, max_length=2_000)
description_template: str | None = Field(default=None, max_length=20_000)
location_template: str | None = Field(default=None, max_length=2_000)
start_at_template: str | None = Field(default=None, max_length=1_000)
end_at_template: str | None = Field(default=None, max_length=1_000)
timezone: str | None = Field(default=None, max_length=100)
classification: Literal["PUBLIC", "PRIVATE", "CONFIDENTIAL"] = "PUBLIC"
categories: list[str] = Field(default_factory=list, max_length=50)
class DeliveryConfig(StrictModel):
channel_policy: DeliveryChannelPolicy = DeliveryChannelPolicy.MAIL
postbox: PostboxDeliveryConfig = Field(default_factory=PostboxDeliveryConfig)
print: PrintDeliveryConfig = Field(default_factory=PrintDeliveryConfig)
calendar_invitation: CalendarInvitationDeliveryConfig = Field(
default_factory=CalendarInvitationDeliveryConfig
)
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
retry: RetryConfig = Field(default_factory=RetryConfig)
@@ -602,3 +858,23 @@ class CampaignConfig(StrictModel):
if path.is_absolute():
return path
return (campaign_file.parent / path).resolve()
def effective_delivery_channel_policy(
config: CampaignConfig,
entry: EntryConfig,
) -> DeliveryChannelPolicy:
return entry.channel_policy or config.delivery.channel_policy
def effective_postbox_targets(
config: CampaignConfig,
entry: EntryConfig,
) -> list[PostboxTargetConfig]:
global_targets = list(config.delivery.postbox.targets)
individual_targets = list(entry.postbox_targets)
if not individual_targets:
return global_targets
if entry.merge_postbox_targets:
return [*global_targets, *individual_targets]
return individual_targets
@@ -0,0 +1,321 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDirectoryEntryRef,
PostboxTargetRef,
)
from govoplan_campaign.backend.campaign.field_values import (
effective_entry_field_values,
)
from govoplan_campaign.backend.campaign.models import (
Behavior,
CampaignConfig,
EntryConfig,
PostboxTargetConfig,
PostboxTargetMatch,
PostboxTargetMode,
effective_postbox_targets,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.messages.models import (
MessageIssue,
MessageValidationStatus,
)
def _apply_behavior(
current: MessageValidationStatus,
behavior: Behavior,
) -> MessageValidationStatus:
if behavior == Behavior.BLOCK:
return MessageValidationStatus.BLOCKED
if behavior == Behavior.DROP:
return MessageValidationStatus.EXCLUDED
if behavior == Behavior.ASK and current not in {
MessageValidationStatus.BLOCKED,
MessageValidationStatus.EXCLUDED,
}:
return MessageValidationStatus.NEEDS_REVIEW
if behavior == Behavior.WARN and current == MessageValidationStatus.READY:
return MessageValidationStatus.WARNING
return current
def _issue(
*,
code: str,
message: str,
behavior: Behavior,
) -> MessageIssue:
return MessageIssue(
severity="error" if behavior == Behavior.BLOCK else "warning",
code=code,
message=message,
behavior=behavior.value,
source="postbox",
)
def _field_value(
values: dict[str, Any],
field_name: str | None,
) -> str | None:
if not field_name:
return None
value = values.get(field_name)
if value is None:
return None
text = str(value).strip()
return text or None
def _match_unit(
catalog: PostboxDeliveryCatalogRef,
value: str | None,
match: PostboxTargetMatch,
):
if not value:
return None
return next(
(
unit
for unit in catalog.organization_units
if (unit.id if match == PostboxTargetMatch.ID else unit.slug) == value
),
None,
)
def _match_function(unit, value: str | None, match: PostboxTargetMatch):
if unit is None or not value:
return None
return next(
(
function
for function in unit.functions
if (
function.id
if match == PostboxTargetMatch.ID
else function.slug
)
== value
),
None,
)
def _target_ref(
target: PostboxTargetConfig,
*,
values: dict[str, Any],
catalog: PostboxDeliveryCatalogRef,
) -> tuple[PostboxTargetRef | None, str | None]:
if target.mode == PostboxTargetMode.DIRECT:
return (
PostboxTargetRef(
postbox_id=target.postbox_id,
address_key=target.address_key,
),
None,
)
unit_value = target.organization_unit_id or _field_value(
values,
target.organization_unit_field,
)
unit_match = (
PostboxTargetMatch.ID
if target.organization_unit_id
else target.organization_unit_match
)
unit = _match_unit(catalog, unit_value, unit_match)
if unit is None:
return None, (
f"Organization unit {unit_value!r} could not be resolved by "
f"{unit_match.value}."
)
function_value = target.function_id or _field_value(
values,
target.function_field,
)
function_match = (
PostboxTargetMatch.ID
if target.function_id
else target.function_match
)
function = _match_function(unit, function_value, function_match)
if function is None:
return None, (
f"Organization function {function_value!r} could not be resolved "
f"inside {unit.name!r} by {function_match.value}."
)
context_key = target.context_key or _field_value(
values,
target.context_field,
)
return (
PostboxTargetRef(
template_id=target.template_id,
organization_unit_id=unit.id,
function_id=function.id,
context_key=context_key,
),
None,
)
def _resolved_target_payload(
target: PostboxTargetConfig,
entry: PostboxDirectoryEntryRef,
*,
position: int,
) -> dict[str, Any]:
return {
"target_id": target.id,
"position": position,
"mode": target.mode.value,
"requested": target.model_dump(mode="json", exclude_none=True),
"postbox_id": entry.id,
"address": entry.address,
"address_key": entry.address_key,
"name": entry.name,
"status": entry.status,
"classification": entry.classification,
"organization_unit_id": entry.organization_unit_id,
"organization_unit_name": entry.organization_unit_name,
"function_id": entry.function_id,
"function_name": entry.function_name,
"context_key": entry.context_key,
"template_revision_id": entry.template_revision_id,
"holder_count": entry.holder_count,
"vacant": entry.vacant,
}
def resolve_entry_postbox_targets(
session: Session,
*,
tenant_id: str,
config: CampaignConfig,
entry: EntryConfig,
validation_status: MessageValidationStatus,
materialize: bool,
) -> tuple[
list[dict[str, Any]],
list[MessageIssue],
MessageValidationStatus,
]:
integration = postbox_integration()
policy = config.delivery.postbox
targets = effective_postbox_targets(config, entry)
if not targets:
issue = _issue(
code="postbox_target_missing",
message="Postbox delivery requires at least one target.",
behavior=policy.unresolved_target,
)
return (
[],
[issue],
_apply_behavior(validation_status, policy.unresolved_target),
)
try:
catalog = integration.delivery_catalog(session, tenant_id=tenant_id)
except Exception as exc:
issue = _issue(
code="postbox_unavailable",
message=str(exc),
behavior=Behavior.BLOCK,
)
return [], [issue], MessageValidationStatus.BLOCKED
values = effective_entry_field_values(config, entry)
resolved: list[dict[str, Any]] = []
issues: list[MessageIssue] = []
status = validation_status
seen_postbox_ids: set[str] = set()
for position, target in enumerate(targets):
target_ref, resolution_error = _target_ref(
target,
values=values,
catalog=catalog,
)
if target_ref is None:
issue = _issue(
code="postbox_target_unresolved",
message=resolution_error or "Postbox target could not be resolved.",
behavior=policy.unresolved_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.unresolved_target)
continue
try:
entry_ref = integration.resolve_postbox(
session,
tenant_id=tenant_id,
target=target_ref,
materialize=materialize,
)
except Exception as exc:
issue = _issue(
code="postbox_target_unresolved",
message=f"Postbox target {target.id!r} could not be resolved: {exc}",
behavior=policy.unresolved_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.unresolved_target)
continue
if entry_ref is None:
issue = _issue(
code="postbox_target_unresolved",
message=f"Postbox target {target.id!r} does not exist.",
behavior=policy.unresolved_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.unresolved_target)
continue
if entry_ref.id in seen_postbox_ids:
issue = _issue(
code="postbox_target_duplicate",
message=(
f"Postbox {entry_ref.address!r} is selected more than once; "
"it will receive one message."
),
behavior=policy.duplicate_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.duplicate_target)
continue
seen_postbox_ids.add(entry_ref.id)
resolved.append(
_resolved_target_payload(
target,
entry_ref,
position=position,
)
)
if entry_ref.vacant:
issue = _issue(
code="postbox_target_vacant",
message=(
f"Postbox {entry_ref.address!r} currently has no function "
"holder."
),
behavior=policy.vacant_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.vacant_target)
return resolved, issues, status
def delivery_catalog_payload(catalog: PostboxDeliveryCatalogRef) -> dict[str, Any]:
return asdict(catalog)
@@ -0,0 +1,964 @@
from __future__ import annotations
import calendar
import copy
import hashlib
import json
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
from email import policy
from email.parser import BytesParser
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignSchedule,
CampaignScheduleOccurrence,
CampaignShare,
CampaignVersion,
JobBuildStatus,
)
from govoplan_campaign.backend.approval_gate import (
assert_campaign_approval,
campaign_approval_gate,
)
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
from govoplan_campaign.backend.integrations import mail_integration
from govoplan_campaign.backend.persistence.campaigns import (
create_campaign_version_from_json,
)
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
from govoplan_campaign.backend.sending.jobs import (
_from_header_from_job,
_send_job_delivery_context,
_single_job_validation_allowed,
_synchronous_smtp_batch_manager,
)
from govoplan_core.audit.logging import audit_event
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
SCHEDULE_DELIVERY_MODES = frozenset({"manual", "autonomous"})
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
return hashlib.sha256(
json.dumps(
value,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
def campaign_schedule_source_snapshot(
*,
configuration: Mapping[str, object],
campaign_settings: Mapping[str, object],
mail_profile_policy: Mapping[str, object],
shares: list[Mapping[str, object]],
) -> dict[str, object]:
"""Seal every selected source domain so worker execution cannot drift."""
return {
"schema": SCHEDULE_SOURCE_SCHEMA,
"configuration": copy.deepcopy(dict(configuration)),
"campaign_settings": copy.deepcopy(dict(campaign_settings)),
"mail_profile_policy": copy.deepcopy(dict(mail_profile_policy)),
"shares": [copy.deepcopy(dict(item)) for item in shares],
}
def next_schedule_fire(
scheduled_for: datetime,
*,
recurrence_kind: str,
interval_count: int,
timezone_name: str,
) -> datetime | None:
if recurrence_kind == "once":
return None
if recurrence_kind not in RECURRENCE_KINDS:
raise ValueError(f"Unsupported campaign recurrence: {recurrence_kind}")
if interval_count < 1:
raise ValueError("Campaign recurrence interval must be positive")
try:
zone = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise ValueError(f"Unknown campaign schedule timezone: {timezone_name}") from exc
local = _as_utc(scheduled_for).astimezone(zone)
if recurrence_kind == "daily":
upcoming = local + timedelta(days=interval_count)
elif recurrence_kind == "weekly":
upcoming = local + timedelta(weeks=interval_count)
else:
month_index = local.year * 12 + local.month - 1 + interval_count
year, month_offset = divmod(month_index, 12)
month = month_offset + 1
day = min(local.day, calendar.monthrange(year, month)[1])
upcoming = local.replace(year=year, month=month, day=day)
return upcoming.astimezone(UTC)
def dispatch_due_campaign_schedules(
session: Session,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> dict[str, object]:
observed_at = _as_utc(now or datetime.now(UTC))
refreshed = refresh_autonomous_schedule_outcomes(
session,
tenant_id=tenant_id,
now=observed_at,
)
query = session.query(CampaignSchedule).filter(
CampaignSchedule.active.is_(True),
CampaignSchedule.next_fire_at.is_not(None),
CampaignSchedule.next_fire_at <= observed_at,
)
if tenant_id is not None:
query = query.filter(CampaignSchedule.tenant_id == tenant_id)
schedules = (
query.order_by(CampaignSchedule.next_fire_at.asc(), CampaignSchedule.id.asc())
.with_for_update(skip_locked=True)
.limit(max(1, min(limit, 250)))
.all()
)
result: dict[str, object] = {
"selected": len(schedules),
"prepared": 0,
"autonomous_prepared": 0,
"failed": 0,
"completed": 0,
"coalesced": 0,
"duplicates": 0,
"deferred": 0,
"campaign_ids": [],
"operator_actions": [],
"refreshed": refreshed,
}
for schedule in schedules:
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
if schedule.delivery_mode == "autonomous" and _has_open_occurrence(
session, schedule_id=schedule.id
):
result["deferred"] = int(result["deferred"]) + 1
continue
try:
with session.begin_nested():
if schedule.delivery_mode == "autonomous":
_occurrence, skipped = _prepare_autonomous_occurrence(
session,
schedule=schedule,
scheduled_for=scheduled_for,
observed_at=observed_at,
)
campaign_id = schedule.campaign_id
result["autonomous_prepared"] = (
int(result["autonomous_prepared"]) + 1
)
else:
campaign, _version, skipped = _prepare_occurrence(
session,
schedule=schedule,
scheduled_for=scheduled_for,
observed_at=observed_at,
)
campaign_id = campaign.id
result["prepared"] = int(result["prepared"]) + 1
result["coalesced"] = int(result["coalesced"]) + skipped
result["campaign_ids"].append(campaign_id) # type: ignore[union-attr]
if not schedule.active:
result["completed"] = int(result["completed"]) + 1
except Exception as exc: # noqa: BLE001 - persist bounded operator evidence
session.expire_all()
recorded = (
session.query(CampaignScheduleOccurrence)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule.id,
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
)
.one_or_none()
)
if recorded is not None:
result["duplicates"] = int(result["duplicates"]) + 1
if (
schedule.active
and schedule.next_fire_at is not None
and _as_utc(schedule.next_fire_at) == scheduled_for
and recorded.status not in {"failed", "uncertain"}
):
_advance_schedule(
session,
schedule=schedule,
occurrence=recorded,
scheduled_for=scheduled_for,
observed_at=observed_at,
sequence=schedule.occurrence_count + 1,
)
continue
session.add(
CampaignScheduleOccurrence(
tenant_id=schedule.tenant_id,
schedule_id=schedule.id,
scheduled_for=scheduled_for,
status="failed",
idempotency_key=_occurrence_idempotency_key(
schedule.id, scheduled_for
),
error=str(exc)[:4000],
recovery_state="failed",
evidence={"delivery_mode": schedule.delivery_mode},
last_checked_at=observed_at,
)
)
schedule.active = False
schedule.last_error = str(exc)[:4000]
schedule.last_outcome = "failed"
schedule.last_recovery_state = "operator_required"
schedule.resource_revision += 1
session.add(schedule)
result["failed"] = int(result["failed"]) + 1
result["operator_actions"].append( # type: ignore[union-attr]
{
"schedule_id": schedule.id,
"campaign_id": schedule.campaign_id,
"reason": "draft_preparation_failed",
"delivery_mode": schedule.delivery_mode,
}
)
_notify_schedule_operator(
session,
schedule=schedule,
reason="policy_or_systemic_preflight_failed",
)
session.flush()
return result
def _prepare_occurrence(
session: Session,
*,
schedule: CampaignSchedule,
scheduled_for: datetime,
observed_at: datetime,
) -> tuple[Campaign, CampaignVersion, int]:
existing = (
session.query(CampaignScheduleOccurrence)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule.id,
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
)
.one_or_none()
)
if existing is not None:
raise RuntimeError("Campaign schedule occurrence was already recorded")
source_campaign = session.get(Campaign, schedule.campaign_id)
if source_campaign is None or source_campaign.tenant_id != schedule.tenant_id:
raise RuntimeError("Campaign schedule source is no longer available")
source_version = session.get(CampaignVersion, schedule.source_version_id)
if source_version is None or source_version.campaign_id != source_campaign.id:
raise RuntimeError("Campaign schedule source version is no longer available")
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
snapshot = _schedule_snapshot(schedule.source_snapshot)
sequence = schedule.occurrence_count + 1
external_id = _scheduled_external_id(
source_campaign.external_id,
schedule.id,
sequence,
)
local_date = scheduled_for.astimezone(ZoneInfo(schedule.timezone)).date().isoformat()
generated_name = f"{schedule.name} - {local_date}"
raw_json = campaign_copy_configuration(
snapshot["configuration"],
schedule.copy_options,
)
metadata = raw_json.get("campaign")
if not isinstance(metadata, dict):
raise RuntimeError("Campaign schedule snapshot has no campaign metadata")
metadata["id"] = external_id
metadata["name"] = generated_name
metadata["mode"] = "draft"
generated_campaign, generated_version = create_campaign_version_from_json(
session,
tenant_id=schedule.tenant_id,
user_id=schedule.created_by_user_id,
raw_json=raw_json,
source_filename=None,
source_base_path=schedule.source_base_path,
commit=False,
)
if bool(schedule.copy_options.get("include_policies", True)):
generated_campaign.settings = copy.deepcopy(snapshot["campaign_settings"])
if bool(schedule.copy_options.get("include_mail_profile", True)):
generated_campaign.mail_profile_policy = copy.deepcopy(
snapshot["mail_profile_policy"]
)
if bool(schedule.copy_options.get("include_shares", False)):
_copy_snapshot_shares(
session,
schedule=schedule,
generated_campaign=generated_campaign,
shares=snapshot["shares"],
)
occurrence = CampaignScheduleOccurrence(
tenant_id=schedule.tenant_id,
schedule_id=schedule.id,
scheduled_for=scheduled_for,
status="prepared",
idempotency_key=_occurrence_idempotency_key(schedule.id, scheduled_for),
generated_campaign_id=generated_campaign.id,
generated_version_id=generated_version.id,
recovery_state="none",
evidence={"delivery_mode": "manual"},
last_checked_at=observed_at,
)
session.add(occurrence)
session.flush()
schedule.last_campaign_id = generated_campaign.id
schedule.last_outcome = "prepared"
schedule.last_recovery_state = "none"
coalesced = _advance_schedule(
session,
schedule=schedule,
occurrence=occurrence,
scheduled_for=scheduled_for,
observed_at=observed_at,
sequence=sequence,
)
audit_event(
session,
tenant_id=schedule.tenant_id,
user_id=schedule.created_by_user_id,
action="campaign.schedule.draft_prepared",
object_type="campaign_schedule",
object_id=schedule.id,
details={
"source_campaign_id": source_campaign.id,
"source_version_id": source_version.id,
"scheduled_for": scheduled_for.isoformat(),
"generated_campaign_id": generated_campaign.id,
"generated_version_id": generated_version.id,
"occurrence": sequence,
"coalesced_missed_intervals": coalesced,
"delivery_started": False,
},
commit=False,
)
return generated_campaign, generated_version, coalesced
def validate_autonomous_schedule_source(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
) -> dict[str, object]:
"""Validate the exact immutable execution that an autonomous schedule reuses."""
gate = campaign_approval_gate(version)
if gate is None:
raise RuntimeError(
"Autonomous delivery requires an explicit Approval request for the built source version."
)
assert_campaign_approval(session, tenant_id=campaign.tenant_id, version=version)
snapshot = ensure_execution_snapshot(session, version)
snapshot_hash = str(version.execution_snapshot_hash or "")
if len(snapshot_hash) != 64:
raise RuntimeError("The approved Campaign execution snapshot is incomplete.")
jobs = _autonomous_source_jobs(
session,
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
version=version,
)
mail = mail_integration()
if not mail.durable_delivery_available:
raise RuntimeError(
"Autonomous delivery requires Mail's durable delivery-command outbox."
)
if not snapshot.mail_profile_id or not snapshot.smtp_transport_revision:
raise RuntimeError(
"The approved Campaign execution has no immutable Mail transport evidence."
)
summary = mail.campaign_profile_delivery_summary(
session,
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
profile_id=snapshot.mail_profile_id,
smtp_server_id=snapshot.smtp_server_id,
smtp_credential_id=snapshot.smtp_credential_id,
)
if not summary.get("smtp_available"):
raise RuntimeError("The approved Campaign Mail transport is unavailable.")
if summary.get("smtp_transport_revision") != snapshot.smtp_transport_revision:
raise RuntimeError(
"The Campaign Mail transport changed after approval; rebuild and approve a new source version."
)
return {
"execution_snapshot_hash": snapshot_hash,
"approval_request_id": str(gate.get("request_id") or ""),
"approval_subject_digest": str(gate.get("subject_digest") or ""),
"job_count": len(jobs),
"job_manifest_sha256": canonical_configuration_hash(
{"jobs": [{"id": job.id, "eml_sha256": job.eml_sha256} for job in jobs]}
),
}
def _autonomous_source_jobs(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion,
) -> list[CampaignJob]:
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id,
CampaignJob.campaign_version_id == version.id,
)
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
.all()
)
if not jobs:
raise RuntimeError(
"Autonomous delivery requires a built source version with recipient jobs."
)
for job in jobs:
if job.build_status != JobBuildStatus.BUILT.value:
raise RuntimeError(
"Autonomous delivery requires every source message to be built."
)
if not _single_job_validation_allowed(version, job, include_warnings=True):
raise RuntimeError(
"Autonomous delivery requires every source message to pass its reviewed recipient and attachment gates."
)
if DeliveryChannelPolicy(job.delivery_channel_policy) != DeliveryChannelPolicy.MAIL:
raise RuntimeError(
"Autonomous schedules currently support Mail-only delivery; use manual mode for hybrid, Postbox, or print delivery."
)
return jobs
def _prepare_autonomous_occurrence(
session: Session,
*,
schedule: CampaignSchedule,
scheduled_for: datetime,
observed_at: datetime,
) -> tuple[CampaignScheduleOccurrence, int]:
existing = (
session.query(CampaignScheduleOccurrence)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule.id,
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
)
.one_or_none()
)
if existing is not None:
raise RuntimeError("Campaign schedule occurrence was already recorded")
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
campaign = session.get(Campaign, schedule.campaign_id)
version = session.get(CampaignVersion, schedule.source_version_id)
if campaign is None or campaign.tenant_id != schedule.tenant_id:
raise RuntimeError("Campaign schedule source is no longer available")
if version is None or version.campaign_id != campaign.id:
raise RuntimeError("Campaign schedule source version is no longer available")
validation = validate_autonomous_schedule_source(
session,
campaign=campaign,
version=version,
)
if (
not schedule.approved_execution_snapshot_hash
or validation["execution_snapshot_hash"]
!= schedule.approved_execution_snapshot_hash
):
raise RuntimeError(
"The approved Campaign execution changed after the autonomous schedule was created."
)
jobs = _autonomous_source_jobs(
session,
tenant_id=schedule.tenant_id,
campaign_id=campaign.id,
version=version,
)
occurrence_key = _occurrence_idempotency_key(schedule.id, scheduled_for)
occurrence = CampaignScheduleOccurrence(
tenant_id=schedule.tenant_id,
schedule_id=schedule.id,
scheduled_for=scheduled_for,
status="preparing",
idempotency_key=occurrence_key,
recovery_state="prepared",
evidence={
"delivery_mode": "autonomous",
"source_campaign_id": campaign.id,
"source_version_id": version.id,
"source_snapshot_hash": schedule.source_snapshot_hash,
**validation,
},
last_checked_at=observed_at,
)
session.add(occurrence)
session.flush()
contexts = {job.id: _send_job_delivery_context(session, job) for job in jobs}
with _synchronous_smtp_batch_manager(session, jobs=jobs, contexts=contexts):
pass
mail = mail_integration()
commands: list[dict[str, object]] = []
for job in jobs:
context = contexts[job.id]
if context.envelope_from is None or not context.envelope_recipients:
raise RuntimeError("A frozen Campaign message has no delivery envelope.")
message = BytesParser(policy=policy.default).parsebytes(context.message_bytes)
commands.append(
mail.submit_delivery_command(
session,
tenant_id=schedule.tenant_id,
command_type="campaign_schedule_occurrence",
source_module="campaigns",
source_resource_type="campaign",
source_resource_id=campaign.id,
source_version_id=version.id,
idempotency_key=f"{occurrence_key}:{job.id}",
profile_id=context.snapshot.mail_profile_id,
message_bytes=context.message_bytes,
envelope_from=context.envelope_from,
envelope_recipients=context.envelope_recipients,
from_header=_from_header_from_job(job) or str(message.get("From") or ""),
expected_smtp_transport_revision=(
context.snapshot.smtp_transport_revision or ""
),
smtp_server_id=context.snapshot.smtp_server_id,
smtp_credential_id=context.snapshot.smtp_credential_id,
created_by_user_id=schedule.created_by_user_id,
)
)
occurrence.delivery_command_ids = [str(item["id"]) for item in commands]
occurrence.status = "prepared"
occurrence.recovery_state = "pending"
occurrence.evidence = {
**occurrence.evidence,
"command_count": len(commands),
"duplicate_command_count": sum(bool(item.get("duplicate")) for item in commands),
"command_status_counts": _status_counts(commands),
}
occurrence.last_checked_at = observed_at
sequence = schedule.occurrence_count + 1
schedule.last_campaign_id = campaign.id
schedule.last_outcome = "prepared"
schedule.last_recovery_state = "pending"
coalesced = _advance_schedule(
session,
schedule=schedule,
occurrence=occurrence,
scheduled_for=scheduled_for,
observed_at=observed_at,
sequence=sequence,
)
audit_event(
session,
tenant_id=schedule.tenant_id,
user_id=schedule.created_by_user_id,
action="campaign.schedule.delivery_prepared",
object_type="campaign_schedule_occurrence",
object_id=occurrence.id,
details={
"schedule_id": schedule.id,
"campaign_id": campaign.id,
"source_version_id": version.id,
"scheduled_for": scheduled_for.isoformat(),
"occurrence_idempotency_key": occurrence_key,
"delivery_command_count": len(commands),
"execution_snapshot_hash": validation["execution_snapshot_hash"],
"approval_request_id": validation["approval_request_id"],
"coalesced_missed_intervals": coalesced,
},
commit=False,
)
return occurrence, coalesced
def _advance_schedule(
session: Session,
*,
schedule: CampaignSchedule,
occurrence: CampaignScheduleOccurrence,
scheduled_for: datetime,
observed_at: datetime,
sequence: int,
) -> int:
schedule.occurrence_count = sequence
schedule.last_fired_at = scheduled_for
schedule.last_error = None
next_fire = next_schedule_fire(
scheduled_for,
recurrence_kind=schedule.recurrence_kind,
interval_count=schedule.interval_count,
timezone_name=schedule.timezone,
)
coalesced = 0
while next_fire is not None and next_fire <= observed_at:
session.add(
CampaignScheduleOccurrence(
tenant_id=schedule.tenant_id,
schedule_id=schedule.id,
scheduled_for=next_fire,
status="superseded",
idempotency_key=_occurrence_idempotency_key(schedule.id, next_fire),
recovery_state="superseded",
evidence={
"delivery_mode": schedule.delivery_mode,
"reason": "coalesced_missed_interval",
"superseded_by_occurrence_id": occurrence.id,
},
last_checked_at=observed_at,
)
)
next_fire = next_schedule_fire(
next_fire,
recurrence_kind=schedule.recurrence_kind,
interval_count=schedule.interval_count,
timezone_name=schedule.timezone,
)
coalesced += 1
if (
next_fire is None
or sequence >= schedule.max_occurrences
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
):
schedule.active = False
schedule.next_fire_at = None
else:
schedule.next_fire_at = next_fire
schedule.resource_revision += 1
session.add(schedule)
return coalesced
def refresh_autonomous_schedule_outcomes(
session: Session,
*,
tenant_id: str | None = None,
now: datetime | None = None,
) -> dict[str, int]:
observed_at = _as_utc(now or datetime.now(UTC))
query = session.query(CampaignScheduleOccurrence).filter(
CampaignScheduleOccurrence.status.in_(("prepared", "uncertain")),
)
if tenant_id is not None:
query = query.filter(CampaignScheduleOccurrence.tenant_id == tenant_id)
counts = {
"checked": 0,
"accepted": 0,
"uncertain": 0,
"failed": 0,
"skipped": 0,
}
mail = mail_integration()
if not mail.durable_delivery_available:
for occurrence in query.order_by(
CampaignScheduleOccurrence.created_at
).limit(250):
if not occurrence.delivery_command_ids:
continue
counts["checked"] += 1
counts["uncertain"] += 1
_mark_occurrence_uncertain(
session,
occurrence=occurrence,
observed_at=observed_at,
reason="mail_delivery_outbox_unavailable",
)
return counts
for occurrence in query.order_by(CampaignScheduleOccurrence.created_at).limit(250):
if not occurrence.delivery_command_ids:
continue
summaries: list[dict[str, object]] = []
try:
summaries = [
mail.delivery_command_summary(
session,
tenant_id=occurrence.tenant_id,
command_id=command_id,
)
for command_id in occurrence.delivery_command_ids
]
except Exception:
counts["checked"] += 1
counts["uncertain"] += 1
_mark_occurrence_uncertain(
session,
occurrence=occurrence,
observed_at=observed_at,
reason="mail_delivery_status_unavailable",
)
continue
counts["checked"] += 1
outcome, recovery_state = _aggregate_command_outcome(summaries)
previous_outcome = occurrence.status
previous_recovery_state = occurrence.recovery_state
occurrence.status = outcome
occurrence.recovery_state = recovery_state
occurrence.last_checked_at = observed_at
occurrence.evidence = {
**(occurrence.evidence or {}),
"command_status_counts": _status_counts(summaries),
"accepted_recipient_count": sum(
int(item.get("accepted_count") or 0) for item in summaries
),
"refused_recipient_count": sum(
int(item.get("refused_count") or 0) for item in summaries
),
"failure_codes": sorted(
{
str(item["failure_code"])
for item in summaries
if item.get("failure_code")
}
),
}
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
if schedule is not None:
schedule.last_outcome = outcome
schedule.last_recovery_state = recovery_state
transitioned_to_operator_required = (
outcome in {"uncertain", "failed"}
and (
previous_outcome != outcome
or previous_recovery_state != recovery_state
or schedule.active
)
)
if transitioned_to_operator_required:
schedule.active = False
schedule.last_error = (
"Autonomous delivery needs operator review; automatic recurrence is paused."
)
schedule.resource_revision += 1
_notify_schedule_operator(
session,
schedule=schedule,
reason=f"delivery_{outcome}",
)
session.add(schedule)
session.add(occurrence)
if outcome in counts:
counts[outcome] += 1
return counts
def _mark_occurrence_uncertain(
session: Session,
*,
occurrence: CampaignScheduleOccurrence,
observed_at: datetime,
reason: str,
) -> None:
previous_outcome = occurrence.status
previous_recovery_state = occurrence.recovery_state
occurrence.status = "uncertain"
occurrence.recovery_state = "operator_required"
occurrence.last_checked_at = observed_at
occurrence.evidence = {
**(occurrence.evidence or {}),
"recovery_reason": reason,
}
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
if schedule is not None:
transitioned = (
previous_outcome != "uncertain"
or previous_recovery_state != "operator_required"
or schedule.active
)
schedule.active = False
schedule.last_outcome = "uncertain"
schedule.last_recovery_state = "operator_required"
schedule.last_error = (
"Autonomous delivery status is unavailable; automatic recurrence is paused."
)
if transitioned:
schedule.resource_revision += 1
_notify_schedule_operator(
session,
schedule=schedule,
reason=reason,
)
session.add(schedule)
session.add(occurrence)
def _has_open_occurrence(session: Session, *, schedule_id: str) -> bool:
rows = (
session.query(CampaignScheduleOccurrence.delivery_command_ids)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule_id,
CampaignScheduleOccurrence.status == "prepared",
)
.limit(1000)
.all()
)
return any(bool(command_ids) for (command_ids,) in rows)
def _aggregate_command_outcome(
summaries: list[dict[str, object]],
) -> tuple[str, str]:
statuses = {str(item.get("status") or "") for item in summaries}
if statuses and statuses <= {"accepted", "reconciled_accepted"}:
return "accepted", "complete"
if statuses and statuses <= {"reconciled_not_accepted"}:
return "skipped", "reconciled"
if statuses & {"outcome_unknown", "in_progress"}:
return "uncertain", "operator_required"
if statuses & {"permanent_failure", "partially_refused", "reconciled_not_accepted"}:
return "failed", "operator_required"
return "prepared", "pending"
def _status_counts(items: list[dict[str, object]]) -> dict[str, int]:
result: dict[str, int] = {}
for item in items:
status = str(item.get("status") or "unknown")
result[status] = result.get(status, 0) + 1
return result
def _occurrence_idempotency_key(schedule_id: str, scheduled_for: datetime) -> str:
return f"campaign-schedule:{schedule_id}:{_as_utc(scheduled_for).isoformat()}"
def _notify_schedule_operator(
session: Session,
*,
schedule: CampaignSchedule,
reason: str,
) -> None:
from govoplan_core.core.notifications import (
NotificationDispatchRequest,
notification_dispatch_provider,
)
from govoplan_campaign.backend.runtime import get_registry
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
try:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=schedule.tenant_id,
source_module="campaigns",
source_resource_type="campaign_schedule",
source_resource_id=schedule.id,
event_kind="campaign.schedule.operator_required",
channel="inbox",
recipient_type="user" if schedule.created_by_user_id else None,
recipient_id=schedule.created_by_user_id,
subject=f"Campaign schedule paused: {schedule.name}",
body_text=(
"Autonomous Campaign delivery was paused before another occurrence. "
"Review its recovery evidence before resuming."
),
action_url=f"/campaigns/{schedule.campaign_id}",
priority=2,
payload={"schedule_id": schedule.id, "reason": reason},
),
enqueue_delivery=False,
)
except Exception:
return
def _copy_snapshot_shares(
session: Session,
*,
schedule: CampaignSchedule,
generated_campaign: Campaign,
shares: object,
) -> None:
if not isinstance(shares, list):
raise RuntimeError("Campaign schedule share snapshot is invalid")
for source in shares:
if not isinstance(source, Mapping):
raise RuntimeError("Campaign schedule share snapshot is invalid")
target_type = str(source.get("target_type") or "")
target_id = str(source.get("target_id") or "")
permission = str(source.get("permission") or "read")
if not target_type or not target_id:
raise RuntimeError("Campaign schedule share snapshot is incomplete")
session.add(
CampaignShare(
tenant_id=schedule.tenant_id,
campaign_id=generated_campaign.id,
target_type=target_type,
target_id=target_id,
permission=permission,
created_by_user_id=schedule.created_by_user_id,
)
)
def _schedule_snapshot(value: object) -> dict[str, object]:
if not isinstance(value, Mapping) or value.get("schema") != SCHEDULE_SOURCE_SCHEMA:
raise RuntimeError("Campaign schedule source snapshot schema is invalid")
configuration = value.get("configuration")
settings = value.get("campaign_settings")
mail_policy = value.get("mail_profile_policy")
shares = value.get("shares")
if (
not isinstance(configuration, Mapping)
or not isinstance(settings, Mapping)
or not isinstance(mail_policy, Mapping)
or not isinstance(shares, list)
):
raise RuntimeError("Campaign schedule source snapshot is incomplete")
return {
"configuration": dict(configuration),
"campaign_settings": dict(settings),
"mail_profile_policy": dict(mail_policy),
"shares": shares,
}
def _scheduled_external_id(source: str, schedule_id: str, sequence: int) -> str:
suffix = f"-scheduled-{schedule_id[:8]}-{sequence}"
return f"{source[:255 - len(suffix)]}{suffix}"
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
__all__ = [
"RECURRENCE_KINDS",
"SCHEDULE_SOURCE_SCHEMA",
"campaign_schedule_source_snapshot",
"canonical_configuration_hash",
"dispatch_due_campaign_schedules",
"next_schedule_fire",
"refresh_autonomous_schedule_outcomes",
"validate_autonomous_schedule_source",
]
@@ -2,15 +2,31 @@ from __future__ import annotations
import csv
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from pathlib import Path
from typing import Iterable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, Field
from .addressing import effective_address_lists
from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
from .models import (
AttachmentConfig,
CampaignConfig,
DeliveryChannelPolicy,
EntryConfig,
FieldType,
PostboxTargetConfig,
SourceType,
ZipArchiveConfig,
ZipPasswordMode,
ZipPasswordScope,
ZipRuleMode,
effective_delivery_channel_policy,
effective_postbox_targets,
)
from ..attachments.resolver import resolve_campaign_attachments
@@ -90,6 +106,8 @@ def _mapping_target_known(target: str, field_names: set[str]) -> bool:
"merge_reply_to",
"merge_bounce_to",
"merge_disposition_notification_to",
"merge_postbox_targets",
"channel_policy",
"combine_to",
"combine_cc",
"combine_bcc",
@@ -337,10 +355,150 @@ def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> li
]
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
def _active_delivery_entries(config: CampaignConfig) -> list[EntryConfig]:
if config.entries.is_inline:
return [
entry
for entry in (config.entries.inline or [])
if entry.active
]
return [config.entries.defaults or EntryConfig()]
def _delivery_policies(config: CampaignConfig) -> set[DeliveryChannelPolicy]:
return {
effective_delivery_channel_policy(config, entry)
for entry in _active_delivery_entries(config)
}
def _postbox_target_field_issues(
config: CampaignConfig,
target: PostboxTargetConfig,
path: str,
) -> list[SemanticIssue]:
definitions = {field.name: field for field in config.fields}
checks = (
(
target.organization_unit_field,
FieldType.ORGANIZATION_UNIT,
"organization unit",
"organization_unit_field",
),
(
target.function_field,
FieldType.ORGANIZATION_FUNCTION,
"organization function",
"function_field",
),
(target.context_field, None, "context", "context_field"),
)
issues: list[SemanticIssue] = []
for field_name, expected_type, label, key in checks:
if not field_name:
continue
definition = definitions.get(field_name)
if definition is None:
issues.append(
_issue(
Severity.ERROR,
"postbox_target_field_missing",
f"Postbox {label} field {field_name!r} is not declared.",
f"{path}/{key}",
)
)
elif expected_type is not None and definition.type != expected_type:
issues.append(
_issue(
Severity.WARNING,
"postbox_target_field_type",
(
f"Postbox {label} field {field_name!r} should use "
f"field type {expected_type.value!r}."
),
f"{path}/{key}",
)
)
return issues
def _postbox_delivery_issues(
config: CampaignConfig,
*,
postbox_available: bool,
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
policies = _delivery_policies(config)
if not any(policy.uses_postbox for policy in policies):
return issues
if not postbox_available:
issues.append(
_issue(
Severity.ERROR,
"postbox_unavailable",
(
"This campaign uses Postbox delivery, but the Postbox "
"module and its delivery directory are not active."
),
"/delivery/channel_policy",
)
)
for entry_index, entry in enumerate(_active_delivery_entries(config)):
policy = effective_delivery_channel_policy(config, entry)
if not policy.uses_postbox:
continue
targets = effective_postbox_targets(config, entry)
if not targets:
issues.append(
_issue(
Severity.ERROR,
"postbox_target_missing",
"Postbox delivery requires at least one target.",
f"/entries/inline/{entry_index}/postbox_targets",
)
)
continue
seen_ids: set[str] = set()
for target_index, target in enumerate(targets):
target_path = (
f"/entries/inline/{entry_index}/postbox_targets/"
f"{target_index}"
)
if target.id in seen_ids:
issues.append(
_issue(
Severity.WARNING,
"postbox_target_id_duplicate",
f"Postbox target id {target.id!r} is repeated.",
f"{target_path}/id",
)
)
seen_ids.add(target.id)
issues.extend(
_postbox_target_field_issues(config, target, target_path)
)
return issues
def _delivery_issues(
config: CampaignConfig,
*,
postbox_available: bool,
templates_available: bool,
calendar_available: bool,
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
policies = _delivery_policies(config)
uses_mail = any(policy.uses_mail for policy in policies)
profile_id = (config.server.mail_profile_id or "").strip()
if (config.campaign.mode == "send" or config.delivery.imap_append_sent.enabled) and not profile_id:
if (
(
config.campaign.mode == "send"
and uses_mail
or config.delivery.imap_append_sent.enabled
)
and not profile_id
):
issues.append(
_issue(
Severity.ERROR,
@@ -350,7 +508,12 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
)
)
capabilities = config.server.profile_capabilities
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available:
if (
config.campaign.mode == "send"
and uses_mail
and profile_id
and not capabilities.smtp_available
):
issues.append(
_issue(
Severity.ERROR,
@@ -368,13 +531,163 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
"/server/mail_profile_id",
)
)
issues.extend(
_postbox_delivery_issues(
config,
postbox_available=postbox_available,
)
)
if any(policy.uses_print for policy in policies):
if not templates_available:
issues.append(
_issue(
Severity.ERROR,
"templates_unavailable",
"Printable Campaign delivery requires the optional Templates renderer.",
"/delivery/print/template_id",
)
)
if not config.delivery.print.template_id:
issues.append(
_issue(
Severity.ERROR,
"print_template_missing",
"Select a published compatible template for printable Campaign output.",
"/delivery/print/template_id",
)
)
elif config.delivery.print.template_revision is None:
issues.append(
_issue(
Severity.ERROR,
"print_template_revision_missing",
"Printable delivery must pin one published template revision.",
"/delivery/print/template_revision",
)
)
for entry_index, entry in enumerate(_active_delivery_entries(config)):
if not effective_delivery_channel_policy(config, entry).uses_print:
continue
if entry.print_target is None:
issues.append(
_issue(
Severity.ERROR,
"print_target_missing",
"Printable delivery requires an explicit postal or internal-mail target.",
f"/entries/inline/{entry_index}/print_target",
)
)
invitation = config.delivery.calendar_invitation
if invitation.enabled:
if not calendar_available:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_unavailable",
"Calendar invitations require the optional Calendar module.",
"/delivery/calendar_invitation/enabled",
)
)
if not policies or any(not policy.uses_mail for policy in policies):
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_requires_mail",
"Calendar invitations require Mail delivery for every active recipient.",
"/delivery/channel_policy",
)
)
if not invitation.calendar_id:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_calendar_missing",
"Select a writable calendar for campaign invitation tracking.",
"/delivery/calendar_invitation/calendar_id",
)
)
if not invitation.start_at_template:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_start_missing",
"Calendar invitations require a start date and time or a field template.",
"/delivery/calendar_invitation/start_at_template",
)
)
if invitation.timezone:
try:
ZoneInfo(invitation.timezone)
except ZoneInfoNotFoundError:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_timezone_invalid",
f"Unknown calendar invitation timezone: {invitation.timezone}",
"/delivery/calendar_invitation/timezone",
)
)
valid_start, fixed_start = _fixed_invitation_datetime(
invitation.start_at_template
)
valid_end, fixed_end = _fixed_invitation_datetime(invitation.end_at_template)
if invitation.start_at_template and not valid_start:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_start_invalid",
"The fixed invitation start must be ISO 8601; recipient field templates are also supported.",
"/delivery/calendar_invitation/start_at_template",
)
)
if invitation.end_at_template and not valid_end:
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_end_invalid",
"The fixed invitation end must be ISO 8601; recipient field templates are also supported.",
"/delivery/calendar_invitation/end_at_template",
)
)
if fixed_start and fixed_end and _invitation_range_invalid(
fixed_start,
fixed_end,
):
issues.append(
_issue(
Severity.ERROR,
"calendar_invitation_range_invalid",
"Calendar invitation end must be after its start.",
"/delivery/calendar_invitation/end_at_template",
)
)
return issues
def _fixed_invitation_datetime(value: str | None) -> tuple[bool, datetime | None]:
if not value:
return True, None
if "${" in value or "{{" in value:
return True, None
try:
return True, datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return False, None
def _invitation_range_invalid(start_at: datetime, end_at: datetime) -> bool:
if (start_at.tzinfo is None) != (end_at.tzinfo is None):
return True
return end_at <= start_at
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
"""Require Campaign-owned sender data before a send-mode build."""
if config.campaign.mode != "send":
if (
config.campaign.mode != "send"
or not any(policy.uses_mail for policy in _delivery_policies(config))
):
return []
if config.entries.is_inline:
return [
@@ -385,7 +698,11 @@ def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
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 (
entry.active
and effective_delivery_channel_policy(config, entry).uses_mail
and not effective_address_lists(config, entry)["from"]
)
]
if config.recipients.from_:
return []
@@ -611,6 +928,9 @@ def validate_campaign_config(
*,
campaign_file: str | Path | None = None,
check_files: bool = False,
postbox_available: bool = False,
templates_available: bool = False,
calendar_available: bool = False,
) -> SemanticReport:
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
issues: list[SemanticIssue] = []
@@ -622,7 +942,14 @@ def validate_campaign_config(
issues.extend(_global_value_issues(config, declared_names))
issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config))
issues.extend(_delivery_issues(config))
issues.extend(
_delivery_issues(
config,
postbox_available=postbox_available,
templates_available=templates_available,
calendar_available=calendar_available,
)
)
issues.extend(_sender_issues(config))
entries = _entries_validation(
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,8 @@ from govoplan_campaign.backend.db.models import (
CampaignShare,
CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt,
new_uuid,
)
@@ -55,7 +57,10 @@ def _record_campaign_changes(session: OrmSession, _flush_context: object, _insta
_record_job_change(session, obj)
elif isinstance(obj, CampaignIssue):
_record_issue_change(session, obj)
elif isinstance(obj, (SendAttempt, ImapAppendAttempt)):
elif isinstance(
obj,
(SendAttempt, ImapAppendAttempt, PostboxDeliveryAttempt, PrintOutputAttempt),
):
_record_attempt_change(session, obj)
@@ -182,8 +187,13 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"validation_status",
"queue_status",
"send_status",
"delivery_channel_policy",
"postbox_status",
"print_status",
"imap_status",
"attempt_count",
"postbox_attempt_count",
"print_attempt_count",
"last_error",
"queued_at",
"claimed_at",
@@ -191,6 +201,9 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"outcome_unknown_at",
"sent_at",
"resolved_recipients",
"delivery_provenance",
"resolved_postbox_targets",
"resolved_print_output",
"resolved_attachments",
"issues_snapshot",
),
@@ -217,6 +230,9 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"delivery_channel_policy": job.delivery_channel_policy,
"postbox_status": job.postbox_status,
"print_status": job.print_status,
"imap_status": job.imap_status,
},
)
@@ -247,10 +263,27 @@ def _record_issue_change(session: OrmSession, issue: CampaignIssue) -> None:
)
def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppendAttempt) -> None:
def _record_attempt_change(
session: OrmSession,
attempt: SendAttempt | ImapAppendAttempt | PostboxDeliveryAttempt | PrintOutputAttempt,
) -> None:
operation = _operation_for_object(
attempt,
changed_attrs=("status", "claim_token", "smtp_status_code", "smtp_response", "error_type", "error_message", "folder"),
changed_attrs=(
"status",
"claim_token",
"smtp_status_code",
"smtp_response",
"error_type",
"error_message",
"folder",
"provider_delivery_id",
"provider_message_id",
"postbox_id",
"render_id",
"artifact_sha256",
"evidence",
),
)
if operation is None:
return
@@ -270,7 +303,15 @@ def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppen
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",
"attempt_kind": (
"postbox"
if isinstance(attempt, PostboxDeliveryAttempt)
else "print"
if isinstance(attempt, PrintOutputAttempt)
else "imap"
if isinstance(attempt, ImapAppendAttempt)
else "smtp"
),
"job_id": job.id,
"version_id": job.campaign_version_id,
},
+597
View File
@@ -8,6 +8,7 @@ from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.core.concurrency import strong_resource_etag
from govoplan_core.db.base import Base, TimestampMixin
@@ -83,6 +84,10 @@ class JobSendStatus(StrEnum):
CLAIMED = "claimed"
SENDING = "sending"
SMTP_ACCEPTED = "smtp_accepted"
POSTBOX_ACCEPTED = "postbox_accepted"
PRINT_ACCEPTED = "print_accepted"
DELIVERED = "delivered"
PARTIALLY_ACCEPTED = "partially_accepted"
SENT = "sent" # legacy value retained for existing databases/reports
OUTCOME_UNKNOWN = "outcome_unknown"
FAILED_TEMPORARY = "failed_temporary"
@@ -90,6 +95,28 @@ class JobSendStatus(StrEnum):
CANCELLED = "cancelled"
class JobPostboxStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
DELIVERING = "delivering"
ACCEPTED = "accepted"
ACCEPTED_VACANT = "accepted_vacant"
PARTIALLY_ACCEPTED = "partially_accepted"
REJECTED_TEMPORARY = "rejected_temporary"
REJECTED_PERMANENT = "rejected_permanent"
OUTCOME_UNKNOWN = "outcome_unknown"
SKIPPED = "skipped"
class JobPrintStatus(StrEnum):
NOT_REQUESTED = "not_requested"
READY = "ready"
ACCEPTING = "accepting"
ACCEPTED = "accepted"
FAILED = "failed"
SKIPPED = "skipped"
class JobImapStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
@@ -141,6 +168,262 @@ class CampaignShare(Base, TimestampMixin):
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class CampaignCollaborationEntry(Base, TimestampMixin):
__tablename__ = "campaign_collaboration_entries"
__table_args__ = (
Index(
"ix_campaign_collaboration_entries_thread",
"tenant_id",
"campaign_id",
"created_at",
"id",
),
)
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)
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="RESTRICT"),
nullable=True,
index=True,
)
reference_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
reference_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
reference_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
actor_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
actor_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
visibility: Mapped[str] = mapped_column(
String(30),
default="collaborators",
nullable=False,
index=True,
)
content: Mapped[str | None] = mapped_column(Text, nullable=True)
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
mention_user_ids: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
withdrawn_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
withdrawn_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
)
redacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
redacted_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
)
tombstone_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
class CampaignWorkAssignment(Base, TimestampMixin):
__tablename__ = "campaign_work_assignments"
__table_args__ = (
Index(
"ix_campaign_work_assignments_campaign_status",
"tenant_id",
"campaign_id",
"status",
"due_at",
"id",
),
)
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)
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="RESTRICT"),
nullable=True,
index=True,
)
reference_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
reference_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
reference_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
purpose: Mapped[str] = mapped_column(String(500), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="open", nullable=False, index=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
assignee_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
assignee_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
assignee_current_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
assignee_resolution_state: Mapped[str] = mapped_column(
String(30), default="resolved", nullable=False, index=True
)
resolution_provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
resolution_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
assigned_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True
)
assigned_by_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
task_mirror_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
task_mirror_status: Mapped[str] = mapped_column(
String(30), default="not_configured", nullable=False
)
task_mirror_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
task_mirrored_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
class CampaignWorkAssignmentEvent(Base, TimestampMixin):
__tablename__ = "campaign_work_assignment_events"
__table_args__ = (
Index(
"ix_campaign_work_assignment_events_history",
"tenant_id",
"assignment_id",
"created_at",
"id",
),
)
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)
campaign_id: Mapped[str] = mapped_column(
ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True
)
assignment_id: Mapped[str] = mapped_column(
ForeignKey("campaign_work_assignments.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
event_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
actor_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True
)
actor_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
status_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
assignee_type_snapshot: Mapped[str] = mapped_column(String(40), nullable=False)
assignee_id_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
resolution_state_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class CampaignSchedule(Base, TimestampMixin):
__tablename__ = "campaign_schedules"
__table_args__ = (
Index("ix_campaign_schedules_due", "tenant_id", "active", "next_fire_at"),
)
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)
campaign_id: Mapped[str] = mapped_column(
ForeignKey("campaigns.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
source_version_id: Mapped[str] = mapped_column(
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
delivery_mode: Mapped[str] = mapped_column(
String(20),
default="manual",
nullable=False,
index=True,
)
recurrence_kind: Mapped[str] = mapped_column(
String(20),
default="once",
nullable=False,
index=True,
)
interval_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
next_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
max_occurrences: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
occurrence_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
approved_execution_snapshot_hash: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True
)
source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_campaign_id: Mapped[str | None] = mapped_column(
ForeignKey("campaigns.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
last_outcome: Mapped[str | None] = mapped_column(String(30), nullable=True)
last_recovery_state: Mapped[str | None] = mapped_column(
String(30), nullable=True
)
class CampaignScheduleOccurrence(Base, TimestampMixin):
__tablename__ = "campaign_schedule_occurrences"
__table_args__ = (
UniqueConstraint(
"schedule_id",
"scheduled_for",
name="uq_campaign_schedule_occurrence",
),
Index("ix_campaign_schedule_occurrences_schedule", "schedule_id", "scheduled_for"),
)
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)
schedule_id: Mapped[str] = mapped_column(
ForeignKey("campaign_schedules.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True)
idempotency_key: Mapped[str | None] = mapped_column(
String(200), nullable=True, index=True
)
generated_campaign_id: Mapped[str | None] = mapped_column(
ForeignKey("campaigns.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
generated_version_id: Mapped[str | None] = mapped_column(
ForeignKey("campaign_versions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
delivery_command_ids: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
recovery_state: Mapped[str] = mapped_column(
String(30), default="none", nullable=False, index=True
)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
last_checked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class RecipientImportMappingProfile(Base, TimestampMixin):
__tablename__ = "campaign_recipient_import_mapping_profiles"
__table_args__ = (
@@ -172,6 +455,11 @@ class CampaignVersion(Base, TimestampMixin):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
edit_revision: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False)
source_filename: Mapped[str | None] = mapped_column(String(500))
@@ -215,9 +503,31 @@ class CampaignVersion(Base, TimestampMixin):
execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
delivery_mode: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
delivery_mode_selected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
archived_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
campaign: Mapped[Campaign] = relationship(back_populates="versions")
__mapper_args__ = {
"version_id_col": edit_revision,
}
@property
def strong_etag(self) -> str:
return strong_resource_etag(
"campaign_version",
self.id,
self.edit_revision,
)
@property
def mail_profile_migration_required(self) -> bool:
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_boundary_violations
@@ -249,9 +559,37 @@ class CampaignJob(Base, TimestampMixin):
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True)
send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True)
delivery_channel_policy: Mapped[str] = mapped_column(
String(30),
default="mail",
nullable=False,
index=True,
)
postbox_status: Mapped[str] = mapped_column(
String(50),
default=JobPostboxStatus.NOT_REQUESTED.value,
nullable=False,
index=True,
)
print_status: Mapped[str] = mapped_column(
String(50),
default=JobPrintStatus.NOT_REQUESTED.value,
nullable=False,
index=True,
)
imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
postbox_attempt_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
)
print_attempt_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
)
last_error: Mapped[str | None] = mapped_column(Text)
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -263,6 +601,20 @@ class CampaignJob(Base, TimestampMixin):
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
delivery_provenance: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
resolved_postbox_targets: Mapped[list[dict[str, Any]]] = mapped_column(
JSON,
default=list,
nullable=False,
)
resolved_print_output: Mapped[dict[str, Any] | None] = mapped_column(
JSON,
nullable=True,
)
resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
@@ -327,6 +679,139 @@ class SendAttempt(Base, TimestampMixin):
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class CampaignMessageAction(Base, TimestampMixin):
__tablename__ = "campaign_message_actions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_campaign_message_actions_idempotency",
),
Index(
"ix_campaign_message_actions_job_created",
"job_id",
"created_at",
),
Index(
"ix_campaign_message_actions_campaign_kind",
"campaign_id",
"kind",
"status",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
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,
)
job_id: Mapped[str] = mapped_column(
ForeignKey("campaign_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
context: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
actor_api_key_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
message_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
message_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
recipient_manifest_sha256: Mapped[str] = mapped_column(
String(64),
nullable=False,
)
recipient_count: Mapped[int] = mapped_column(Integer, nullable=False)
prior_send_status: Mapped[str] = mapped_column(String(50), nullable=False)
prior_attempt_count: Mapped[int] = mapped_column(Integer, nullable=False)
final_send_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
status: Mapped[str] = mapped_column(
String(50),
default="initiated",
nullable=False,
index=True,
)
accepted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
refused_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
refusal_summary: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
error_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
linked_send_attempt_id: Mapped[str | None] = mapped_column(
ForeignKey("send_attempts.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
effect_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
class CampaignMessageActionAttempt(Base, TimestampMixin):
__tablename__ = "campaign_message_action_attempts"
__table_args__ = (
UniqueConstraint(
"action_id",
"attempt_number",
name="uq_campaign_message_action_attempt_number",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
action_id: Mapped[str] = mapped_column(
ForeignKey("campaign_message_actions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
status: Mapped[str] = mapped_column(
String(50),
default="initiated",
nullable=False,
index=True,
)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
effect_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
accepted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
refused_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
outcome_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
diagnostic_summary: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
)
class ImapAppendAttempt(Base, TimestampMixin):
__tablename__ = "imap_append_attempts"
__table_args__ = (
@@ -342,6 +827,112 @@ class ImapAppendAttempt(Base, TimestampMixin):
error_message: Mapped[str | None] = mapped_column(Text)
class PostboxDeliveryAttempt(Base, TimestampMixin):
__tablename__ = "campaign_postbox_delivery_attempts"
__table_args__ = (
UniqueConstraint(
"job_id",
"target_key",
"attempt_number",
name="uq_campaign_postbox_attempt_target_number",
),
Index(
"ix_campaign_postbox_attempt_idempotency",
"tenant_id",
"idempotency_key",
),
Index(
"ix_campaign_postbox_attempt_job_status",
"job_id",
"status",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=new_uuid,
)
tenant_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
index=True,
)
job_id: Mapped[str] = mapped_column(
ForeignKey("campaign_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
target_key: Mapped[str] = mapped_column(String(64), nullable=False)
target_index: Mapped[int] = mapped_column(Integer, nullable=False)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True,
)
target_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
provider_delivery_id: Mapped[str | None] = mapped_column(String(36))
provider_message_id: Mapped[str | None] = mapped_column(String(36))
postbox_id: Mapped[str | None] = mapped_column(String(36), index=True)
address: Mapped[str | None] = mapped_column(String(500))
holder_count: Mapped[int | None] = mapped_column(Integer)
vacant: Mapped[bool | None] = mapped_column(Boolean)
duplicate: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
error_type: Mapped[str | None] = mapped_column(String(255))
error_code: Mapped[str | None] = mapped_column(String(100))
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
)
class PrintOutputAttempt(Base, TimestampMixin):
__tablename__ = "campaign_print_output_attempts"
__table_args__ = (
UniqueConstraint(
"job_id",
"attempt_number",
name="uq_campaign_print_attempt_job_number",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_campaign_print_attempt_idempotency",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
job_id: Mapped[str] = mapped_column(
ForeignKey("campaign_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
render_id: Mapped[str | None] = mapped_column(String(36), index=True)
artifact_sha256: Mapped[str | None] = mapped_column(String(64), index=True)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__all__ = [
@@ -355,12 +946,18 @@ __all__ = [
"CampaignVersion",
"CampaignVersionFlow",
"CampaignVersionWorkflowState",
"CampaignWorkAssignment",
"CampaignWorkAssignmentEvent",
"ImapAppendAttempt",
"IssueSeverity",
"JobBuildStatus",
"JobImapStatus",
"JobPostboxStatus",
"JobPrintStatus",
"JobQueueStatus",
"JobSendStatus",
"JobValidationStatus",
"SendAttempt",
"PostboxDeliveryAttempt",
"PrintOutputAttempt",
]
+464 -9
View File
@@ -25,6 +25,12 @@ _CAMPAIGN_USER_SCOPES = (
"campaigns:campaign:send",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:discussion:read",
"campaigns:discussion:post",
"campaigns:discussion:moderate",
"campaigns:assignment:read",
"campaigns:assignment:manage",
"campaigns:assignment:complete",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
@@ -43,7 +49,16 @@ _FILES_INTEGRATION = "files.campaign_attachments"
_MAIL_INTEGRATION = "mail.campaign_delivery"
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
_TEMPLATE_CATALOG_INTEGRATION = "templates.catalog"
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library"
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
_TASKS_INTEGRATION = "tasks.commands"
_ORGANIZATIONS_INTEGRATION = "organizations.directory"
_IDM_FUNCTION_ASSIGNMENTS_INTEGRATION = "idm.function_assignments"
def _workflow_topic(
@@ -68,6 +83,7 @@ def _workflow_topic(
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
related_modules: tuple[str, ...] = (),
limitations: tuple[str, ...] = (),
translations: dict[str, dict[str, str]] | None = None,
) -> DocumentationTopic:
metadata: dict[str, object] = {
"kind": "workflow",
@@ -101,6 +117,7 @@ def _workflow_topic(
links=links,
related_modules=related_modules,
unlocks=(outcome,),
translations=translations or {},
source_module_id="campaigns",
metadata=metadata,
)
@@ -111,7 +128,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
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.",
body="A new campaign starts with one editable working version. Campaign editors report saved, unsaved, and saving state in the page action bar; Discard remains immediately before Save, and leaving a dirty draft invokes the shared save-or-discard guard. Destructive campaign lifecycle actions are visually separated from ordinary actions. Creating a campaign 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"),
@@ -123,7 +140,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
"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.",
"Continue through the preparation sections; use the stable Discard and Save actions while the bar reports the draft state.",
),
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.",
@@ -154,6 +171,207 @@ CAMPAIGN_USER_DOCUMENTATION = (
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.copy-campaign",
title="Copy a campaign into a new draft",
summary="Reuse a selected campaign version as configuration for a new campaign without copying operational or audit evidence.",
body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a chosen or generated identifier and one editable version. Recipients, attachment rules, active shares, campaign policies, and the Mail profile reference are explicit independent choices. Delivery jobs, outcomes, locks, reports, and audit evidence always stay exclusively with the source campaign.",
order=32,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy"),
route="/campaigns/{campaign_id}",
screen="Campaign overview",
help_contexts=("campaign.overview",),
prerequisites=(
"You may read the selected campaign. Copying recipient data additionally requires recipient-read authority.",
"You may create campaign copies in the active tenant.",
),
steps=(
"Open the campaign overview and choose the current or a historical source version.",
"Choose Copy campaign or Copy as new campaign, enter the new identity, and select recipients, files, shares, policies, and Mail profile independently.",
"If a choice is unavailable, obtain the corresponding recipient-read or campaign-share authority or leave that content excluded.",
"Confirm while the lifecycle state token is current; reload if another actor changed the source state.",
"Open the newly created campaign, review its generated identifier and ownership, and validate all inherited configuration before use.",
),
outcome="A new editable campaign draft containing only the explicitly selected configuration and no copied operational evidence.",
verification="The destination has a distinct campaign ID and owner, one editable version, the selected configuration domains, and no source jobs, outcomes, locks, reports, or audit evidence.",
related_topic_ids=("campaigns.workflow.create-editable-successor", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Kampagne als neuen Entwurf kopieren",
"summary": "Eine ausgewählte Kampagnenversion als Konfiguration wiederverwenden, ohne Betriebs- oder Auditnachweise zu kopieren.",
"body": "Kampagne kopieren erzeugt eine eigenständige Kampagne mit eigener Kennung und einer bearbeitbaren Version. Empfänger, Dateiregeln, aktive Freigaben, Kampagnenrichtlinien und die Mailprofil-Referenz werden unabhängig ausgewählt. Sendeaufträge, Ergebnisse, Sperren, Berichte und Auditnachweise verbleiben immer ausschließlich bei der Quellkampagne.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.collaborate-on-campaign",
title="Discuss campaign work without changing its evidence",
summary="Use the governed collaboration thread for human discussion linked to stable Campaign evidence.",
body="Collaboration is governed independently from Campaign editing. A discussion reader still needs access to the parent Campaign; posting and moderation use separate permissions. Posted text is append-only. Authors can withdraw their own entry and moderators can redact an entry, but both actions leave the actor, timestamp, reference, evidence hash, tombstone, and audit record. A comment may reference a Campaign version, recipient import batch, attachment rule, delivery job, or report. The reference never edits the historical version. Mentions create an in-app notification only when Notifications is available and only for active users who already have Campaign access. Human discussion is not system state and never replaces Audit evidence.",
order=33,
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
required_scopes=("campaigns:campaign:read", "campaigns:discussion:read"),
route="/campaigns/{campaign_id}/activity",
screen="Campaign collaboration",
help_contexts=(
"campaign.activity",
"campaign.activity.composer",
"campaign.activity.action.post",
"campaign.activity.action.withdraw",
"campaign.activity.action.redact",
),
prerequisites=(
"You can read the Campaign and its discussion.",
"Posting requires the separate campaign discussion-post permission.",
),
steps=(
"Open Collaboration in the selected Campaign workspace.",
"Optionally select a stable version or enter the stable ID of another supported Campaign evidence reference.",
"Mention only collaborators who already have access, then post the bounded comment.",
"Withdraw your own mistaken entry or ask an authorized moderator to redact content that must no longer be displayed.",
"Use Tenant audit for system events and durable action evidence; do not treat discussion as workflow state.",
),
outcome="An attributable human discussion entry that does not mutate Campaign versions or impersonate audit evidence.",
verification="Reload Collaboration, follow the typed reference, and confirm any withdrawal or redaction appears as a tombstone while the referenced version remains unchanged.",
required_capabilities=(),
related_modules=("notifications", "audit"),
limitations=(
"Notifications is optional; the discussion remains available when mention delivery is not configured.",
"Comments do not approve, validate, build, queue, send, or otherwise transition a Campaign.",
),
translations={
"de": {
"title": "Kampagnenarbeit besprechen, ohne Nachweise zu verändern",
"summary": "Den geregelten Diskussionsverlauf für menschliche Abstimmung mit stabilen Kampagnennachweisen verwenden.",
"body": "Die Zusammenarbeit wird unabhängig von der Kampagnenbearbeitung berechtigt. Lesende benötigen weiterhin Zugriff auf die übergeordnete Kampagne; Veröffentlichung und Moderation verwenden eigene Berechtigungen. Veröffentlichter Text ist unveränderlich. Verfassende können eigene Einträge zurücknehmen, Moderierende können Einträge schwärzen. Dabei bleiben Person, Zeitstempel, Referenz, Nachweis-Hash, Platzhalter und Auditnachweis erhalten. Kommentare können Kampagnenversionen, Empfänger-Importläufe, Anlagenregeln, Sendeaufträge oder Berichte referenzieren, ohne historische Versionen zu verändern. Erwähnungen erzeugen nur bei verfügbarem Benachrichtigungsmodul eine interne Benachrichtigung und nur für aktive Personen mit bestehendem Kampagnenzugriff. Diskussion ist kein Systemzustand und ersetzt keinen Auditnachweis.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.assign-accountable-work",
title="Assign accountable Campaign work without granting access",
summary="Record bounded work for an account, group, or organization function while keeping authorization and Campaign ownership separate.",
body="Campaign work assignments record responsibility, not authority. Every reader and actor must still pass the parent Campaign access check, and a new account, group, or organization-function target is accepted only when it already resolves to active principals with Campaign access. Each assignment retains its purpose, optional due date, assigner, typed assignee reference, human-readable snapshot, current resolution state, stable Campaign or child reference, optimistic revision, and append-only transition history. Assignees with the separate completion permission can start and complete their own work; managers can reassign or cancel it. Reconciliation records vacancy, deactivation, or restored resolution without deleting history or transferring ownership. Notifications and Tasks mirroring are optional and cannot make the Campaign transaction fail.",
order=34,
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
required_scopes=("campaigns:campaign:read", "campaigns:assignment:read"),
route="/campaigns/{campaign_id}/work",
screen="Campaign work",
help_contexts=(
"campaign.work",
"campaign.work.create",
"campaign.work.action.start",
"campaign.work.action.complete",
"campaign.work.action.reassign",
"campaign.work.action.cancel",
"campaign.work.history",
),
prerequisites=(
"You can read the Campaign and its work assignments.",
"Creating, reassigning, cancelling, or reconciling requires the assignment-manage permission.",
"The target account, group, or all current function incumbents already have Campaign access.",
),
steps=(
"Open Work in the selected Campaign workspace and choose Add assignment.",
"Enter a bounded purpose, optional due date, typed target, and optional stable Campaign evidence reference.",
"Resolve any authorization-neutral rejection by granting access through the separate Campaign sharing workflow or choosing another assignee; creating the assignment itself never grants access.",
"Start and complete your own assignment, or use manager actions to reassign or cancel open work.",
"Reload and reconcile assignments after account, group, organization-function, or incumbency changes; inspect the retained history before acting on unavailable work.",
),
outcome="A durable accountability record whose lifecycle is independent from Campaign ownership, authorization, and delivery state.",
verification="Reload Work, inspect the typed target, resolution provenance, revision and history, and confirm Campaign shares and ownership did not change. When Tasks is installed, confirm the optional mirror links back to this Campaign assignment.",
related_modules=("access", "organizations", "idm", "tasks", "notifications", "audit", "policy"),
limitations=(
"Organizations and IDM are optional; organization-function assignment is unavailable until both directory and incumbency capabilities are active.",
"Tasks mirroring is a convenience projection. Campaign remains the authoritative assignment and history owner.",
"Ownership transfer continues to use its separate two-party governance protocol.",
),
translations={
"de": {
"title": "Verantwortliche Kampagnenarbeit zuweisen, ohne Zugriff zu vergeben",
"summary": "Begrenzte Arbeit für Konto, Gruppe oder Organisationsfunktion erfassen und Berechtigung sowie Kampagneneigentum getrennt halten.",
"body": "Kampagnenzuweisungen dokumentieren Verantwortung, nicht Berechtigung. Lesende und Handelnde müssen weiterhin den Zugriff auf die übergeordnete Kampagne nachweisen. Neue Ziele werden nur angenommen, wenn Konto, Gruppe oder alle aktuellen Funktionsinhabenden bereits Kampagnenzugriff besitzen. Zweck, optionale Fälligkeit, zuweisende Person, typisierte Referenz, lesbarer Schnappschuss, aktueller Auflösungszustand, Revision und unveränderliche Übergangshistorie bleiben erhalten. Deaktivierung oder Vakanz wird beim Abgleich als nicht verfügbar dokumentiert. Optionale Benachrichtigungen und Tasks-Spiegelungen dürfen die Kampagnentransaktion nicht blockieren.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.reuse-content-library",
title="Reuse Campaign content through Templates",
summary="Insert scoped, versioned fragments or complete message parts and save new content as an unpublished Templates draft.",
body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body only after explicit confirmation. Required fields are compared with the current Campaign fields and shown before content is applied. Saving from Campaign creates a personal or tenant Templates draft, retains placeholder requirements as its data contract, and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.",
order=33,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update"),
required_modules=("campaigns", "templates"),
required_capabilities=(
_TEMPLATE_CATALOG_INTEGRATION,
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION,
),
route="/campaigns/{campaign_id}/template",
screen="Campaign template",
help_contexts=("campaign.template", "campaign.template.content-library"),
prerequisites=(
"The Campaign version is editable.",
"Templates is active and you may read its library; saving additionally requires Template write authority.",
),
steps=(
"Open Template and choose Load from library to search content visible in the active Templates scope.",
"Review any missing or incompatible required fields, then insert a fragment into its declared subject, text, or HTML target, or explicitly confirm replacement by a complete Campaign part.",
"Review placeholders and save the Campaign draft normally.",
"To retain new content, choose Save to library, select fragment or complete part plus personal or tenant visibility, and create the unpublished draft.",
"Open Templates to review, revise, and publish shared content.",
),
outcome="Reusable content remains centrally versioned while each Campaign records its own deliberate draft changes.",
verification="The Campaign draft shows the inserted content, and saved library content appears in Templates as an unpublished revision with Campaign provenance.",
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
related_modules=("templates",),
translations={
"de": {
"title": "Kampagneninhalte über Templates wiederverwenden",
"summary": "Bereichsbezogene, versionierte Bausteine oder vollständige Nachrichtenteile einfügen und neue Inhalte als unveröffentlichten Templates-Entwurf speichern.",
"body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext erst nach ausdrücklicher Bestätigung. Pflichtfelder werden vor dem Anwenden mit den aktuellen Kampagnenfeldern verglichen und angezeigt. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an, bewahrt Platzhalteranforderungen als Datenvertrag und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.schedule-drafts",
title="Schedule bounded manual or autonomous campaigns",
summary="Prepare fresh drafts or explicitly opt in to governed delivery of an exact approved build.",
body="Every schedule is bounded, timezone-aware, and fixed to either manual or autonomous mode. Manual mode stores an integrity-sealed configuration snapshot and prepares a separately owned draft per due occurrence without requiring Mail. Autonomous mode never rebuilds or silently changes approved content: it requires a built Mail-only source version with an explicit valid Approval request, seals its execution-snapshot hash, and rechecks approval, policy, credential selection, SMTP transport revision and live transport health, recipient gates, attachment evidence, and snapshot integrity before every occurrence. It then creates one Mail-owned durable command per frozen message with occurrence-scoped idempotency before delivery. Mail never automatically retries accepted or outcome-unknown effects. Campaign records prepared, accepted, uncertain, failed, skipped, and superseded recovery evidence; uncertain, policy, configuration, and systemic failures pause the recurrence and notify the accountable operator. Missed intervals are coalesced instead of causing a catch-up storm, and pause/resume rejects stale browser state.",
order=34,
audience=("campaign_manager", "campaign_author", "operator"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
route="/campaigns/{campaign_id}",
screen="Campaign overview",
help_contexts=("campaign.overview", "campaigns.action.schedule-drafts"),
prerequisites=(
"Choose the exact campaign version whose configuration should seed future drafts.",
"Recipient data and active shares require their corresponding read or share authority.",
"A worker and scheduler process must be running for automatic due-time preparation.",
"Autonomous mode additionally requires campaigns:campaign:queue, campaigns:campaign:send, mail:profile:use, Mail's durable outbox, and an explicitly approved built source version.",
),
steps=(
"Open the campaign overview and choose Schedule.",
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
"Choose manual draft preparation or autonomous approved delivery; mode cannot be changed in place.",
"For manual mode, select which configuration domains may be copied and review each generated draft independently.",
"For autonomous mode, confirm that the selected version is built, Mail-only, and explicitly approved; the API rejects missing or stale evidence.",
"Review next occurrence, last outcome, and recovery state. Resolve uncertain or failed Mail commands explicitly before creating or resuming a replacement schedule.",
),
outcome="A bounded sequence of manual drafts or at-most-once autonomous Mail commands with durable occurrence and recovery evidence.",
verification="The Schedules section shows mode, next occurrence, last outcome, recovery state, and any automatic pause; manual occurrences link to distinct drafts while autonomous occurrences retain Mail command identifiers and non-secret outcome totals.",
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Begrenzte manuelle oder autonome Kampagnen planen",
"summary": "Neue Entwürfe vorbereiten oder den Versand eines exakt freigegebenen Builds ausdrücklich autonom ausführen.",
"body": "Jeder Zeitplan ist begrenzt, zeitzonenfest und dauerhaft manuell oder autonom. Der manuelle Modus erzeugt eigenständige Entwürfe und funktioniert ohne Mail. Der autonome Modus verlangt eine gebaute, ausschließlich per Mail versendete und ausdrücklich freigegebene Quellversion. Vor jeder Ausführung werden Freigabe, Richtlinie, Zugangsdatenauswahl, Transportrevision und -erreichbarkeit, Empfänger, Anlagen und Snapshot-Integrität erneut geprüft. Pro eingefrorener Nachricht entsteht vor dem Versand ein dauerhafter Mail-Auftrag mit ausführungsspezifischem Idempotenzschlüssel. Angenommene oder unklare Ergebnisse werden nie automatisch wiederholt; unklare oder systemische Fehler pausieren den Zeitplan und benachrichtigen Verantwortliche. Verpasste Intervalle werden zusammengefasst und als Nachweis erhalten.",
"outcome": "Eine begrenzte Folge manueller Entwürfe oder höchstens einmal angenommener autonomer Mail-Aufträge mit dauerhaftem Wiederherstellungsnachweis.",
"verification": "Der Abschnitt Zeitpläne zeigt Modus, nächste Ausführung, letztes Ergebnis, Wiederherstellungsstatus und automatische Pausen.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.import-recipients",
title="Import recipients into a campaign",
@@ -219,6 +437,84 @@ CAMPAIGN_USER_DOCUMENTATION = (
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
related_modules=("addresses",),
),
_workflow_topic(
topic_id="campaigns.workflow.import-distribution-list",
title="Freeze a Distribution List into a campaign",
summary="Resolve a reusable audience, inspect its channel and policy decisions, and copy an immutable snapshot into the current campaign version.",
body="A Distribution List stays live and versioned in its owning module. Campaign freezes one exact expansion; later list or provider changes only produce a drift warning and never rewrite the saved Campaign recipients.",
order=33,
audience=("campaign_manager", "campaign_author"),
required_modules=("campaigns", "dist_lists"),
required_capabilities=(
_DISTRIBUTION_LIST_SOURCE_INTEGRATION,
_DISTRIBUTION_LIST_EXPAND_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=(
"A visible Distribution List can be expanded for campaign delivery.",
"The current Campaign version is editable.",
),
steps=(
"Open Recipient data and select Import Distribution List.",
"Choose the list, requested channels, and any declared parameters, then preview the expansion.",
"Review included and excluded recipients, stale provider evidence, diagnostics, and every visible primary and optional fallback route.",
"Choose append or replace, freeze and import the expansion, inspect the copied rows, and save the Campaign version.",
"Use the drift warning for a deliberate refresh when the reusable list changes later.",
),
outcome="A Campaign-local recipient snapshot with immutable audience, provider, policy, and channel-decision evidence.",
verification="The saved recipient rows retain the list revision and snapshot reference, and later list changes do not alter them automatically.",
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
related_modules=("dist_lists", "templates", "postbox"),
),
_workflow_topic(
topic_id="campaigns.workflow.prepare-printable-delivery",
title="Prepare governed printable delivery",
summary="Select a published output template, build one deterministic artifact, and review its route and hash evidence before postal or internal-mail distribution.",
body="Printable delivery is optional and provider-neutral. Campaign freezes recipient route decisions while Templates owns compatibility and rendering; Files may own the resulting managed artifact. Ordered fallback is used only after a confirmed rejection before acceptance and never after an accepted or outcome-unknown digital effect.",
order=34,
audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
required_modules=("campaigns", "templates"),
required_capabilities=(
_TEMPLATE_CATALOG_INTEGRATION,
_TEMPLATE_RENDERER_INTEGRATION,
),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:campaign:validate",
"campaigns:campaign:build",
"campaigns:recipient:read",
"templates:template:read",
"templates:template:render",
),
route="/campaigns/{campaign_id}/template",
screen="Template",
help_contexts=("campaign.template", "campaign.review-send"),
prerequisites=(
"At least one included recipient has an explicit postal or internal-mail route.",
"A compatible Templates definition is published and visible to you.",
),
steps=(
"Open Template and select the published printable template, output format, and storage choice.",
"Save, validate, and resolve every missing-field or compatibility error.",
"Build the Campaign to generate one deterministic artifact for the frozen printable recipients.",
"In Review and send, download and inspect the artifact and compare its template, input, and output hashes.",
"Complete review and execute delivery; use reports to verify per-recipient print acceptance and route provenance.",
),
outcome="A reviewed printable artifact and idempotent per-recipient distribution evidence.",
verification="The build summary exposes the artifact and hashes, and the Campaign report records print status and one acceptance attempt per routed recipient.",
related_topic_ids=("campaigns.workflow.import-distribution-list", "campaigns.workflow.prepare-validate-and-build"),
related_modules=("templates", "files", "dist_lists"),
),
_workflow_topic(
topic_id="campaigns.workflow.use-managed-attachments",
title="Use managed files as campaign attachments",
@@ -258,6 +554,112 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
related_modules=("files",),
),
_workflow_topic(
topic_id="campaigns.workflow.control-attachment-reuse",
title="Control repeated campaign attachment use",
summary="Choose whether one resolved file may be reused, produces a warning, requires a reasoned review decision, or blocks delivery.",
body="Attachment reuse is a campaign-owned policy. The action can allow and record every repeated file, warn, require explicit review, or block affected messages. An optional exception permits reuse confined to one recipient or one built message. Every repeated-file finding is represented in the build protocol by a path-safe fingerprint, display filename, use count, message count, disposition, and explanation. Review decisions require a reason, bind to the exact message and build fingerprint, and remain available in campaign protocol and audit evidence. Changing the policy requires a new validation and build; it never rewrites historical evidence.",
order=35,
audience=("campaign_manager", "campaign_author", "campaign_reviewer", "administrator"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build", "campaigns:campaign:review"),
route="/campaigns/{campaign_id}/files",
screen="Attachments",
help_contexts=("campaign.attachments", "campaign.attachments.reuse-policy"),
prerequisites=(
"Decide which repeated use is acceptable for the campaign's purpose and recipients.",
"The current campaign version is editable when changing the policy.",
),
steps=(
"Open Attachments and choose Allow, Warn, Require explicit review, or Block delivery.",
"Optionally allow reuse only within the same recipient or the same built message.",
"Save, validate, and build the campaign, then inspect the repeated-file summary and affected messages.",
"For Review findings, open every affected message and record an explicit reason; for Block findings, correct the rules or policy and rebuild.",
),
outcome="A campaign build whose repeated attachment use is governed and reviewable under an explicit policy.",
verification="Review and send shows the configured action and boundary, repeated-file counts and dispositions; affected messages show warning, review, or blocked state as configured.",
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Wiederholte Verwendung von Kampagnenanhängen steuern",
"summary": "Festlegen, ob dieselbe aufgelöste Datei wiederverwendet werden darf, eine Warnung erzeugt, eine begründete Prüfentscheidung erfordert oder den Versand sperrt.",
"body": "Die Wiederverwendung von Anhängen wird durch eine kampagneneigene Richtlinie gesteuert. Die Aktion kann jede Wiederverwendung zulassen und protokollieren, warnen, eine ausdrückliche Prüfung verlangen oder betroffene Nachrichten sperren. Optional darf die Wiederverwendung innerhalb desselben Empfängers oder derselben erzeugten Nachricht ausgenommen werden. Jeder Befund erscheint mit pfadsicherem Fingerabdruck, Anzeigename, Verwendungs- und Nachrichtenanzahl, Ergebnis und Begründung im Build-Protokoll. Prüfentscheidungen benötigen eine Begründung, sind an Nachricht und Build-Fingerabdruck gebunden und bleiben in Protokoll und Auditnachweis erhalten. Eine Richtlinienänderung erfordert eine neue Validierung und einen neuen Build und verändert keine historischen Nachweise.",
"outcome": "Ein Kampagnen-Build, dessen wiederholte Anhangsverwendung durch eine ausdrückliche Richtlinie gesteuert und prüfbar ist.",
"verification": "Prüfen und senden zeigt Aktion, Ausnahmegrenze, Anzahlen und Ergebnisse; betroffene Nachrichten tragen entsprechend Warn-, Prüf- oder Sperrstatus.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.route-unassigned-files",
title="Review and route unassigned campaign files",
summary="Turn files left in a watched source into an explicit report or reviewed attachment message instead of silently overlooking them.",
body="Campaign compares watched attachment sources with the exact files assigned to built recipient messages. The configurable validation behavior can block, require review, or explicitly ignore the remaining set. An optional residual-file disposition instead turns it into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The normalized action, observed file and source counts, routing mode, and configured recipient are visible in build review and retained in the campaign protocol; the audit event records the same policy and counts without copying the recipient address. A routed row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.",
order=35,
audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"),
route="/campaigns/{campaign_id}/files",
screen="Attachments",
help_contexts=("campaign.attachments", "campaign.attachments.residual-files"),
prerequisites=(
"Enable Unsent on every attachment source that must be checked.",
"Configure the ordinary warning or blocking policy even if no routed message is wanted.",
"For routing, provide a reviewed recipient, subject, and report body.",
),
steps=(
"Open Attachments and set the unassigned-file action to warning only, report, or report with attachments.",
"Save and build the campaign; Campaign compares resolved recipient files with every file in the watched sources.",
"Open the residual-file row in Review and send, inspect its exact list and any attached files, and record the review decision.",
"Queue or send it through the same controlled Campaign lifecycle, or correct the attachment rules and rebuild instead.",
),
outcome="Every watched file is either assigned, deliberately reported, or represented by a visible policy finding.",
verification="The build contains no hidden residual set: it shows either the configured warning/blocking issue or one needs-review row with residual-file provenance and the configured recipient.",
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Nicht zugeordnete Kampagnendateien prüfen und weiterleiten",
"summary": "Übrig gebliebene Dateien aus überwachten Quellen ausdrücklich melden oder als geprüfte Nachricht vorbereiten, statt sie unbemerkt zu übergehen.",
"body": "Campaign vergleicht überwachte Anhangsquellen mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Das konfigurierbare Validierungsverhalten kann die Restmenge sperren, zur Prüfung vorlegen oder ausdrücklich ignorieren. Eine optionale Restdatei-Behandlung erzeugt stattdessen eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die normalisierte Aktion, Datei- und Quellenanzahl, Routingart und der konfigurierte Empfänger sind in der Build-Prüfung sichtbar und bleiben im Kampagnenprotokoll erhalten; das Audit-Ereignis speichert Richtlinie und Anzahlen ohne die Empfängeradresse zu kopieren. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.",
"outcome": "Jede überwachte Datei ist zugeordnet, bewusst gemeldet oder durch einen sichtbaren Richtlinienbefund erfasst.",
"verification": "Der Build enthält keine verborgene Restmenge: Er zeigt entweder den konfigurierten Warn- oder Sperrbefund oder eine zu prüfende Zeile mit Restdatei-Provenienz und dem konfigurierten Empfänger.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.send-calendar-invitations",
title="Send individualized calendar invitations",
summary="Freeze one iCalendar request per recipient, deliver it through Mail, and review live attendee answers from Calendar.",
body="Campaign owns the recipient expansion, exact invitation request, message delivery evidence, and report. Calendar owns the mirrored VEVENT and attendee answer state. The mirror is created only after a delivery channel accepts the message; a Calendar failure never rewrites accepted Mail evidence. Mail can forward METHOD:REPLY parts from a configured IMAP delivery-status source.",
order=35,
audience=("campaign_manager", "campaign_sender"),
required_modules=("campaigns", "mail", "calendar"),
required_capabilities=(_MAIL_INTEGRATION, _CALENDAR_INVITATION_INTEGRATION),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:campaign:build",
"campaigns:campaign:send",
"campaigns:recipient:read",
"calendar:calendar:read",
),
route="/campaigns/{campaign_id}/global-settings",
screen="Campaign settings",
help_contexts=("campaign.global-settings", "campaign.report"),
prerequisites=(
"Mail and Calendar are active and you can select a writable calendar.",
"Every queueable recipient has a Mail To address and the invitation start template resolves to ISO 8601.",
),
steps=(
"Open Campaign settings, enable individualized Calendar invitations, and select the tracking calendar.",
"Enter summary, start, optional end, timezone, location, description, and category templates.",
"Validate and build; inspect the frozen METHOD:REQUEST attachment for each recipient before delivery.",
"Deliver the reviewed build and use Report to compare Mail delivery with the live RSVP state.",
"Configure a Mail IMAP delivery-status source when inbound METHOD:REPLY reconciliation is required.",
),
outcome="Individually delivered invitations with correlated Calendar events and recipient-level RSVP reporting.",
verification="The Campaign job shows accepted delivery, a mirrored Calendar event ID, and the current attendee status; repeated mailbox ingestion does not duplicate the response effect.",
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.view-delivery-report"),
related_modules=("mail", "calendar"),
limitations=("Recurring Campaign invitation series require a separate series workflow; this slice creates individual VEVENT requests."),
),
_workflow_topic(
topic_id="campaigns.workflow.queue-delivery",
title="Queue a campaign for controlled delivery",
@@ -292,7 +694,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
),
related_modules=("mail", "notifications"),
),
@@ -338,7 +740,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
order=37,
audience=("campaign_aggregate_reader", "campaign_reader", "campaign_manager"),
required_scopes=("campaigns:report:read",),
route="/reports",
route="/campaigns/reports",
screen="Reports",
help_contexts=("campaign.report",),
prerequisites=("The campaign is owned by or explicitly shared with you, or you hold tenant-wide authority.",),
@@ -351,7 +753,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
outcome="A business-level Campaign outcome view with small-group and recipient privacy preserved.",
verification="No row, address, message, attachment, diagnostic, filter, drill-down, or export action is available from the aggregate view.",
related_topic_ids=("campaigns.workflow.view-delivery-report",),
links=(DocumentationLink(label="Aggregate Campaign reports", href="/reports", kind="runtime"),),
links=(DocumentationLink(label="Aggregate Campaign reports", href="/campaigns/reports", kind="runtime"),),
),
_workflow_topic(
topic_id="campaigns.workflow.view-delivery-report",
@@ -449,14 +851,13 @@ CAMPAIGN_USER_DOCUMENTATION = (
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.",
"Open the campaign overview, choose Archive campaign, and confirm the retained-evidence consequence.",
"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.",
),
),
@@ -477,7 +878,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
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.",
"Choose Delete draft on the campaign overview 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.",
),
@@ -485,10 +886,33 @@ CAMPAIGN_USER_DOCUMENTATION = (
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.",
),
),
_workflow_topic(
topic_id="campaigns.workflow.archive-historical-version",
title="Archive a historical campaign version",
summary="Hide a non-current version from default history without changing or deleting retained evidence.",
body="Historical version archival is presentation lifecycle only. The original workflow state, configuration, reports, delivery outcomes, and audit evidence remain readable to authorized users and are included when archived versions are shown.",
order=42,
audience=("campaign_owner", "campaign_records_manager"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:archive"),
route="/campaigns/{campaign_id}",
screen="Campaign versions",
help_contexts=("campaign.overview", "campaign.report", "campaign.audit"),
prerequisites=(
"The selected version is historical rather than the current working version.",
"You have write access and campaign archive permission.",
),
steps=(
"Open Versions and select Archive historical version for the intended row.",
"Review the retained-evidence consequence and confirm while the lifecycle token remains current.",
"Use Show archived to include the version in history again when reviewing reports or evidence.",
),
outcome="The historical version is hidden from default history but remains intact and attributable.",
verification="Show archived displays the same version number and original workflow state with its archival timestamp.",
related_topic_ids=("campaigns.workflow.archive-campaign", "campaigns.workflow.view-delivery-report"),
),
)
@@ -562,6 +986,9 @@ def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str
_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.")
_append_if(capabilities, principal, ("campaigns:assignment:read",), "Read accountable work attached to campaigns you can already access.")
_append_if(capabilities, principal, ("campaigns:assignment:manage",), "Create, reassign, cancel, and reconcile authorization-neutral Campaign work.")
_append_if(capabilities, principal, ("campaigns:assignment:complete",), "Start and complete Campaign work assigned to your account, group, or organization function.")
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.")
@@ -669,6 +1096,34 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
else:
limitations.append("Automatic in-app Campaign status notifications are not configured.")
if _integration_available(registry, _TASKS_INTEGRATION):
configured.append("Installed composition: Campaign work assignments may be mirrored into Tasks while Campaign remains the authoritative lifecycle and access boundary.")
else:
limitations.append("Campaign work remains available, but optional Tasks mirroring is not configured.")
if _integration_available(registry, _ORGANIZATIONS_INTEGRATION) and _integration_available(
registry, _IDM_FUNCTION_ASSIGNMENTS_INTEGRATION
):
configured.append("Installed composition: Organization-function assignees can be resolved against active functions and their current IDM incumbencies.")
else:
limitations.append("Organization-function work assignment requires both Organizations directory and IDM incumbency capabilities; account and group assignment remain available.")
calendar_available = _integration_available(
registry,
_CALENDAR_INVITATION_INTEGRATION,
)
if calendar_available and mail_available:
configured.append(
"Installed composition: Campaign can freeze individualized iCalendar requests, mirror accepted deliveries into Calendar, and report live attendee answers."
)
elif calendar_available:
limitations.append(
"Calendar invitation tracking is active, but Mail is not available to deliver Campaign invitation messages."
)
else:
limitations.append(
"Calendar-backed invitation and RSVP tracking is not available in this composition."
)
return tuple(configured), tuple(limitations)
File diff suppressed because it is too large Load Diff
+702 -20
View File
@@ -7,11 +7,61 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from govoplan_core.core.approvals import (
ApprovalCheck,
ApprovalRequestCreateCommand,
ApprovalRequestProvider,
ApprovalRequestRef,
CAPABILITY_APPROVAL_REQUESTS,
)
from govoplan_core.core.calendar import (
CAPABILITY_CALENDAR_INVITATIONS,
CalendarInvitationAttendeeRequest,
CalendarInvitationCalendarRef,
CalendarInvitationProvider,
CalendarInvitationRef,
CalendarInvitationRequest,
)
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
PostboxDeliveryCatalogRef,
PostboxDeliveryProvider,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryRequest,
PostboxDeliveryResult,
PostboxDirectoryEntryRef,
PostboxDirectoryProvider,
PostboxEvidenceProvider,
PostboxTargetRef,
)
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER,
TemplateCatalogProvider,
TemplateCompatibility,
TemplateContentDraftRequest,
TemplateContentLibraryProvider,
TemplateRef,
TemplateRenderRequest,
TemplateRenderResult,
TemplateRendererProvider,
)
from govoplan_campaign.backend.runtime import capability
FILES_CAPABILITY = "files.campaign_attachments"
MAIL_CAPABILITY = "mail.campaign_delivery"
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
TEMPLATE_CONTENT_LIBRARY_CAPABILITY = CAPABILITY_TEMPLATE_CONTENT_LIBRARY
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS
class OptionalModuleUnavailable(RuntimeError):
@@ -23,10 +73,22 @@ class SmtpConfigurationError(RuntimeError):
class SmtpSendError(RuntimeError):
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False) -> None:
def __init__(
self,
message: str,
*,
temporary: bool = False,
outcome_unknown: bool = False,
systemic: bool = False,
reason_code: str | None = None,
phase: str = "send",
) -> None:
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
self.systemic = systemic
self.reason_code = reason_code
self.phase = phase
class ImapConfigurationError(RuntimeError):
@@ -50,6 +112,26 @@ class MailProfileError(OptionalModuleUnavailable):
pass
class MailDeliveryCommandError(RuntimeError):
pass
class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
pass
class ApprovalGateUnavailable(OptionalModuleUnavailable):
pass
class TemplateOutputUnavailable(OptionalModuleUnavailable):
pass
class CalendarInvitationUnavailable(OptionalModuleUnavailable):
pass
class _PreparedCampaignSnapshot:
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
self._directory = directory
@@ -78,20 +160,30 @@ class FilesCampaignIntegration:
yield prepared
return
raw_json = kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {}
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")
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]]:
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)
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:
@@ -102,20 +194,30 @@ class FilesCampaignIntegration:
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:
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)
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:
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)
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]]:
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)
@@ -129,7 +231,9 @@ class MailCampaignIntegration:
def __init__(self, delegate: Any | None = None) -> None:
self._delegate = delegate
if delegate is not None:
self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError)
self.MailProfileError = getattr(
delegate, "MailProfileError", MailProfileError
)
MailProfileError = MailProfileError
SmtpConfigurationError = SmtpConfigurationError
@@ -141,31 +245,49 @@ class MailCampaignIntegration:
def available(self) -> bool:
return self._delegate is not None
@property
def durable_delivery_available(self) -> bool:
return self._delegate is not None and callable(
getattr(self._delegate, "submit_delivery_command", 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:
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 {})
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")
raise MailProfileError(
"Campaign mail-server profiles require the mail module"
)
return None
try:
return self._delegate.assert_campaign_mail_policy_allows_json(session, **kwargs)
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:
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]:
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)
@@ -182,7 +304,40 @@ class MailCampaignIntegration:
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
raise SmtpSendError(
str(exc),
temporary=bool(getattr(exc, "temporary", False)),
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
systemic=bool(getattr(exc, "systemic", False)),
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
phase=str(getattr(exc, "phase", "send") or "send"),
) 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
@contextmanager
def campaign_smtp_batch(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
delegate = self._require()
method = getattr(delegate, "campaign_smtp_batch", None)
if not callable(method):
yield None
return
try:
with method(*args, **kwargs) as state:
yield state
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)),
systemic=bool(getattr(exc, "systemic", False)),
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
phase=str(getattr(exc, "phase", "preflight") or "preflight"),
) from exc
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
raise SmtpConfigurationError(str(exc)) from exc
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
@@ -198,20 +353,547 @@ class MailCampaignIntegration:
temporary=getattr(exc, "temporary", None),
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
) from exc
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as 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 submit_delivery_command(self, session: Any, **kwargs: Any) -> dict[str, Any]:
delegate = self._require()
method = getattr(delegate, "submit_delivery_command", None)
if not callable(method):
raise MailDeliveryCommandError(
"The installed Mail module does not provide durable delivery commands"
)
try:
return dict(method(session, **kwargs))
except Exception as exc:
raise MailDeliveryCommandError(str(exc)) from exc
def delivery_command_summary(
self,
session: Any,
*,
tenant_id: str,
command_id: str,
) -> dict[str, Any]:
delegate = self._require()
method = getattr(delegate, "delivery_command_summary", None)
if not callable(method):
raise MailDeliveryCommandError(
"The installed Mail module does not provide durable delivery status"
)
try:
return dict(
method(
session,
tenant_id=tenant_id,
command_id=command_id,
)
)
except Exception as exc:
raise MailDeliveryCommandError(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()
class PostboxCampaignIntegration:
def __init__(
self,
delivery_delegate: object | None = None,
directory_delegate: object | None = None,
evidence_delegate: object | None = None,
) -> None:
self._delivery_delegate = (
delivery_delegate
if isinstance(delivery_delegate, PostboxDeliveryProvider)
else None
)
self._directory_delegate = (
directory_delegate
if isinstance(directory_delegate, PostboxDirectoryProvider)
else None
)
self._evidence_delegate = (
evidence_delegate
if isinstance(evidence_delegate, PostboxEvidenceProvider)
else None
)
@property
def available(self) -> bool:
return (
self._delivery_delegate is not None and self._directory_delegate is not None
)
@property
def receipt_evidence_available(self) -> bool:
return self._evidence_delegate is not None
def delivery_catalog(
self,
session: object,
*,
tenant_id: str,
) -> PostboxDeliveryCatalogRef:
if self._directory_delegate is None:
raise PostboxDeliveryUnavailable(
"Postbox targets are unavailable because the Postbox module "
"is not active."
)
return self._directory_delegate.delivery_catalog(
session,
tenant_id=tenant_id,
)
def resolve_postbox(
self,
session: object,
*,
tenant_id: str,
target: PostboxTargetRef,
materialize: bool = False,
) -> PostboxDirectoryEntryRef | None:
if self._directory_delegate is None:
raise PostboxDeliveryUnavailable(
"Postbox targets are unavailable because the Postbox module "
"is not active."
)
return self._directory_delegate.resolve_postbox(
session,
tenant_id=tenant_id,
target=target,
materialize=materialize,
)
def deliver(
self,
session: object,
request: PostboxDeliveryRequest,
) -> PostboxDeliveryResult:
if self._delivery_delegate is None:
raise PostboxDeliveryUnavailable(
"Postbox delivery is unavailable because the Postbox module "
"is not active."
)
return self._delivery_delegate.deliver(session, request)
def delivery_receipt_summaries(
self,
session: object,
*,
tenant_id: str,
delivery_ids: list[str] | tuple[str, ...],
) -> dict[str, PostboxDeliveryReceiptSummaryRef]:
if self._evidence_delegate is None:
return {}
unique_ids = tuple(dict.fromkeys(delivery_ids))
summaries: dict[str, PostboxDeliveryReceiptSummaryRef] = {}
for offset in range(0, len(unique_ids), 500):
summaries.update(
self._evidence_delegate.delivery_receipt_summaries(
session,
tenant_id=tenant_id,
producer_module="campaigns",
delivery_ids=unique_ids[offset : offset + 500],
)
)
return summaries
class ApprovalCampaignIntegration:
def __init__(self, delegate: object | None = None) -> None:
self._delegate = (
delegate if isinstance(delegate, ApprovalRequestProvider) else None
)
@property
def available(self) -> bool:
return self._delegate is not None
def create_request(
self,
session: object,
principal: object,
*,
command: ApprovalRequestCreateCommand,
idempotency_key: str,
) -> ApprovalRequestRef:
if self._delegate is None:
raise ApprovalGateUnavailable(
"Campaign approval gates require the Approvals module."
)
return self._delegate.create_request(
session,
principal,
command=command,
idempotency_key=idempotency_key,
)
def check_approved(
self,
session: object,
principal: object,
*,
request_id: str,
subject_module: str,
subject_type: str,
subject_id: str,
subject_version: str | None,
subject_digest: str,
) -> ApprovalCheck:
if self._delegate is None:
raise ApprovalGateUnavailable(
"Campaign delivery is approval-gated, but the Approvals module is unavailable."
)
return self._delegate.check_approved(
session,
principal,
request_id=request_id,
subject_module=subject_module,
subject_type=subject_type,
subject_id=subject_id,
subject_version=subject_version,
subject_digest=subject_digest,
)
class TemplatesCampaignIntegration:
def __init__(
self,
catalog_delegate: object | None = None,
renderer_delegate: object | None = None,
content_library_delegate: object | None = None,
) -> None:
self._catalog = (
catalog_delegate
if isinstance(catalog_delegate, TemplateCatalogProvider)
else None
)
self._renderer = (
renderer_delegate
if isinstance(renderer_delegate, TemplateRendererProvider)
else None
)
self._content_library = (
content_library_delegate
if isinstance(content_library_delegate, TemplateContentLibraryProvider)
else None
)
@property
def available(self) -> bool:
return self._catalog is not None and self._renderer is not None
@property
def content_available(self) -> bool:
return self._catalog is not None
@property
def content_writable(self) -> bool:
return self._content_library is not None
def list_templates(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> tuple[TemplateRef, ...]:
if self._catalog is None:
return ()
return tuple(
self._catalog.list_templates(
session,
principal,
query=query,
usage="campaign_print",
limit=limit,
)
)
def list_content_templates(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> tuple[TemplateRef, ...]:
if self._catalog is None:
return ()
return tuple(
self._catalog.list_templates(
session,
principal,
query=query,
usage="campaign.content",
limit=limit,
)
)
def create_content_draft(
self,
session: object,
principal: object,
*,
request: TemplateContentDraftRequest,
) -> TemplateRef:
if self._content_library is None:
raise TemplateOutputUnavailable(
"Saving reusable Campaign content requires the Templates content-library capability."
)
return self._content_library.create_content_draft(
session,
principal,
request=request,
)
def check_compatibility(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int | None,
output_format: str,
available_fields: dict[str, str] | tuple[str, ...],
) -> TemplateCompatibility:
if self._catalog is None:
raise TemplateOutputUnavailable(
"Printable output is unavailable because Templates is not active."
)
return self._catalog.check_compatibility(
session,
principal,
template_id=template_id,
revision=revision,
usage="campaign_print",
output_format=output_format,
available_fields=available_fields,
)
def get_template(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int,
) -> TemplateRef | None:
if self._catalog is None:
return None
return self._catalog.get_template(
session,
principal,
template_id=template_id,
revision=revision,
)
def render(
self,
session: object,
principal: object,
*,
request: TemplateRenderRequest,
) -> TemplateRenderResult:
if self._renderer is None:
raise TemplateOutputUnavailable(
"Printable output is unavailable because Templates is not active."
)
return self._renderer.render(session, principal, request=request)
class CalendarCampaignIntegration:
def __init__(self, delegate: object | None = None) -> None:
self._delegate = (
delegate if isinstance(delegate, CalendarInvitationProvider) else None
)
@property
def available(self) -> bool:
return self._delegate is not None
def list_calendars(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
group_ids: tuple[str, ...] = (),
can_admin: bool = False,
) -> tuple[CalendarInvitationCalendarRef, ...]:
if self._delegate is None:
return ()
return tuple(
self._delegate.list_calendars(
session,
tenant_id=tenant_id,
user_id=user_id,
group_ids=group_ids,
can_admin=can_admin,
)
)
def render_invitation(self, request: CalendarInvitationRequest) -> str:
if self._delegate is None:
raise CalendarInvitationUnavailable(
"Calendar invitations require the optional Calendar module."
)
return self._delegate.render_invitation(request)
def upsert_invitation(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarInvitationRequest,
) -> CalendarInvitationRef:
if self._delegate is None:
raise CalendarInvitationUnavailable(
"Calendar invitations require the optional Calendar module."
)
return self._delegate.upsert_invitation(
session,
tenant_id=tenant_id,
user_id=user_id,
request=request,
)
def get_invitations(
self,
session: object,
*,
tenant_id: str,
correlation_ids: tuple[str, ...],
) -> dict[str, CalendarInvitationRef]:
if self._delegate is None or not correlation_ids:
return {}
result: dict[str, CalendarInvitationRef] = {}
unique_ids = tuple(dict.fromkeys(correlation_ids))
for offset in range(0, len(unique_ids), 500):
result.update(
self._delegate.get_invitations(
session,
tenant_id=tenant_id,
correlation_ids=unique_ids[offset : offset + 500],
)
)
return result
def summarize_invitations(
self,
session: object,
*,
tenant_id: str,
source_resource_id: str | None,
) -> dict[str, object]:
if self._delegate is None:
return {
"available": False,
"reason": "The Calendar invitation capability is not active.",
}
return dict(
self._delegate.summarize_invitations(
session,
tenant_id=tenant_id,
source_module="campaigns",
source_resource_type="campaign_version",
source_resource_id=source_resource_id,
)
)
@staticmethod
def request_from_payload(payload: dict[str, Any]) -> CalendarInvitationRequest:
from datetime import datetime
attendees = tuple(
CalendarInvitationAttendeeRequest(
address=str(item.get("address") or ""),
name=str(item["name"]) if item.get("name") else None,
role=str(item.get("role") or "REQ-PARTICIPANT"),
participation_status=str(
item.get("participation_status") or "NEEDS-ACTION"
),
rsvp=bool(item.get("rsvp", True)),
)
for item in payload.get("attendees") or []
if isinstance(item, dict)
)
return CalendarInvitationRequest(
correlation_id=str(payload.get("correlation_id") or ""),
source_module="campaigns",
source_resource_type="campaign_version",
source_resource_id=(
str(payload["source_resource_id"])
if payload.get("source_resource_id")
else None
),
calendar_id=(
str(payload["calendar_id"]) if payload.get("calendar_id") else None
),
summary=str(payload.get("summary") or ""),
description=(
str(payload["description"]) if payload.get("description") else None
),
location=str(payload["location"]) if payload.get("location") else None,
start_at=datetime.fromisoformat(str(payload.get("start_at") or "")),
end_at=(
datetime.fromisoformat(str(payload["end_at"]))
if payload.get("end_at")
else None
),
timezone=str(payload["timezone"]) if payload.get("timezone") else None,
organizer=(
dict(payload["organizer"])
if isinstance(payload.get("organizer"), dict)
else None
),
attendees=attendees,
classification=str(payload.get("classification") or "PUBLIC"),
categories=tuple(str(value) for value in payload.get("categories") or []),
metadata=(
dict(payload["metadata"])
if isinstance(payload.get("metadata"), dict)
else {}
),
)
def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
def mail_integration() -> MailCampaignIntegration:
return MailCampaignIntegration(capability(MAIL_CAPABILITY))
def postbox_integration() -> PostboxCampaignIntegration:
return PostboxCampaignIntegration(
capability(POSTBOX_CAPABILITY),
capability(POSTBOX_DIRECTORY_CAPABILITY),
capability(POSTBOX_EVIDENCE_CAPABILITY),
)
def approvals_integration() -> ApprovalCampaignIntegration:
return ApprovalCampaignIntegration(capability(APPROVALS_CAPABILITY))
def templates_integration() -> TemplatesCampaignIntegration:
return TemplatesCampaignIntegration(
capability(TEMPLATE_CATALOG_CAPABILITY),
capability(TEMPLATE_RENDERER_CAPABILITY),
capability(TEMPLATE_CONTENT_LIBRARY_CAPABILITY),
)
def calendar_integration() -> CalendarCampaignIntegration:
return CalendarCampaignIntegration(capability(CALENDAR_INVITATIONS_CAPABILITY))
File diff suppressed because it is too large Load Diff
+353 -38
View File
@@ -4,7 +4,7 @@ import mimetypes
import re
import tempfile
import time
from dataclasses import dataclass
from dataclasses import dataclass, field
from email.message import EmailMessage
from email.utils import make_msgid, formatdate
from pathlib import Path
@@ -16,26 +16,34 @@ from govoplan_campaign.backend.attachments.resolver import (
EntryAttachmentResolution,
MessageAttachmentStatus,
ResolvedAttachment,
effective_send_without_attachments_behavior,
resolve_entry_attachments,
)
from govoplan_campaign.backend.attachments.reuse import evaluate_attachment_reuse
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
from govoplan_campaign.backend.campaign.models import (
Behavior,
AttachmentConfig,
BuildStatus,
CampaignConfig,
EntryConfig,
MissingAddressBehavior,
RecipientConfig,
ResidualFileMode,
SendStatus,
TemplateBodyMode,
ZipArchiveConfig,
ZipPasswordMode,
ZipPasswordScope,
effective_delivery_channel_policy,
)
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,
zip_archive_evidence,
)
from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders as _find_unresolved_placeholders,
render_template as _render_template,
@@ -88,6 +96,14 @@ class _MimeBuildResult:
build_status: BuildStatus
validation_status: MessageValidationStatus
attachment_count: int
archive_evidence: list[dict[str, object]] = field(default_factory=list)
@dataclass(slots=True)
class _ResidualFileGroup:
source_name: str
directory: Path
files: list[Path]
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
@@ -171,6 +187,11 @@ def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[Message
label=attachment.label,
status=attachment.status.value,
behavior=attachment.behavior.value if attachment.behavior else None,
missing_policy=(
attachment.missing_policy.model_dump(mode="json")
if attachment.missing_policy
else None
),
required=attachment.required,
allow_multiple=attachment.allow_multiple,
zip_enabled=attachment.zip_enabled,
@@ -199,6 +220,7 @@ def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolu
message=issue.message,
behavior=issue.behavior.value if issue.behavior else None,
source="attachments",
details=issue.details,
)
for issue in resolution.issues
]
@@ -218,12 +240,6 @@ def _append_no_attachment_coverage_issue(issues: list[MessageIssue]) -> None:
)
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:
raw = value or fallback
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
@@ -360,8 +376,9 @@ def _attach_files(
resolution: EntryAttachmentResolution,
values: dict[str, Any],
work_dir: Path,
) -> int:
) -> tuple[int, list[dict[str, object]]]:
attached_count = 0
evidence: list[dict[str, object]] = []
archive_members: dict[str, list[tuple[Path, str]]] = {}
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
used_message_filenames: set[str] = set()
@@ -417,16 +434,47 @@ def _attach_files(
password,
archive.method.value,
)
archive_record = zip_archive_evidence(
archive_path,
members,
password_protected=bool(password),
method=archive.method.value,
)
archive_record.update(
{
"archive_id": archive.id,
"filename": filename,
"password_delivery_channel": (
archive.password_delivery_channel.value if password else None
),
"legacy_acknowledgement": (
{
"actor_id": archive.legacy_zipcrypto_acknowledged_by,
"reason": archive.legacy_zipcrypto_reason,
"recorded_at": archive.legacy_zipcrypto_acknowledged_at,
}
if archive.method.value == "zip_standard"
else None
),
}
)
evidence.append(archive_record)
data, maintype, subtype = _attachment_bytes(archive_path)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
attached_count += 1
for attachment in archive_attachments.get(archive.id, []):
attachment.zip_filename = filename
return attached_count
return attached_count, evidence
def _imap_initial_status(config: CampaignConfig) -> ImapStatus:
if config.delivery.imap_append_sent.enabled:
def _imap_initial_status(
config: CampaignConfig,
entry: EntryConfig,
) -> ImapStatus:
if (
effective_delivery_channel_policy(config, entry).uses_mail
and config.delivery.imap_append_sent.enabled
):
return ImapStatus.PENDING
return ImapStatus.NOT_REQUESTED
@@ -509,6 +557,7 @@ def _message_draft(
imap_status: ImapStatus | None = None,
subject: str | None = None,
attachment_count: int = 0,
archive_evidence: list[dict[str, object]] | None = None,
issues: list[MessageIssue] | None = None,
eml_path: str | None = None,
eml_size: int | None = None,
@@ -520,7 +569,11 @@ def _message_draft(
send_status = SendStatus.SKIPPED
imap_status = ImapStatus.SKIPPED
if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
imap_status = (
_imap_initial_status(config, entry)
if build_status == BuildStatus.BUILT
else ImapStatus.SKIPPED
)
return MessageDraft(
entry_index=entry_index,
entry_id=entry.id,
@@ -529,6 +582,10 @@ def _message_draft(
validation_status=validation_status,
send_status=send_status,
imap_status=imap_status,
delivery_channel_policy=effective_delivery_channel_policy(
config,
entry,
).value,
subject=subject,
from_=_message_address(context.sender),
from_all=_message_addresses(context.senders),
@@ -540,6 +597,7 @@ def _message_draft(
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
attachment_count=attachment_count,
attachments=_attachment_summaries(context.resolution),
archive_evidence=archive_evidence or [],
issues=issues if issues is not None else context.issues,
eml_path=eml_path,
eml_size_bytes=eml_size,
@@ -737,7 +795,7 @@ def _build_mime_message(
_populate_message_body(message, rendered)
if work_dir is None:
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
attachment_count = _attach_files(
attachment_count, archive_evidence = _attach_files(
message=message,
config=config,
entry=entry,
@@ -746,7 +804,11 @@ def _build_mime_message(
values=rendered.values,
work_dir=work_dir,
)
if attachment_count == 0 and context.resolution.attachments and _send_without_attachments_behavior(config) == Behavior.BLOCK:
if (
attachment_count == 0
and context.resolution.attachments
and effective_send_without_attachments_behavior(config) == Behavior.BLOCK
):
_append_no_attachment_coverage_issue(context.issues)
return _MimeBuildResult(
message=None,
@@ -759,6 +821,7 @@ def _build_mime_message(
build_status=BuildStatus.BUILT,
validation_status=context.validation_status,
attachment_count=attachment_count,
archive_evidence=archive_evidence,
)
except ZipBuildError as exc:
context.issues.append(
@@ -770,6 +833,16 @@ def _build_mime_message(
source="attachments",
)
)
except OSError as exc:
context.issues.append(
MessageIssue(
severity="error",
code="attachment_unreadable",
message="A resolved attachment could not be read while building the message.",
behavior="block",
source=f"attachments:{type(exc).__name__}",
)
)
except Exception as exc:
context.issues.append(
MessageIssue(
@@ -809,17 +882,19 @@ def build_entry_message(
if not entry.active:
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
context.validation_status = _validate_required_sender(
context.senders,
context.issues,
context.validation_status,
)
context.validation_status = _validate_required_recipients(
config,
context.recipients,
context.issues,
context.validation_status,
)
channel_policy = effective_delivery_channel_policy(config, entry)
if channel_policy.uses_mail:
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,
@@ -851,6 +926,7 @@ def build_entry_message(
validation_status=mime_result.validation_status,
subject=rendered.subject,
attachment_count=mime_result.attachment_count,
archive_evidence=mime_result.archive_evidence,
eml_path=eml_path,
eml_size=eml_size,
)
@@ -858,17 +934,13 @@ def build_entry_message(
def _unsent_attachment_issues(
def _residual_attachment_files(
*,
config: CampaignConfig,
campaign_file: str | Path,
built_messages: list[BuiltMessage],
attachment_match_index: AttachmentMatchIndex | None = None,
) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value
if behavior == Behavior.CONTINUE.value:
return []
) -> list[_ResidualFileGroup]:
matched_files = {
Path(match).resolve()
for built in built_messages
@@ -876,7 +948,7 @@ def _unsent_attachment_issues(
for match in attachment.matches
}
issues: list[MessageIssue] = []
groups: list[_ResidualFileGroup] = []
for base_path in config.attachments.base_paths:
if not base_path.unsent_warning:
continue
@@ -890,20 +962,216 @@ def _unsent_attachment_issues(
unsent = [path for path in all_files if path not in matched_files]
if not unsent:
continue
groups.append(
_ResidualFileGroup(
source_name=base_path.name,
directory=directory,
files=unsent,
)
)
return groups
def _unsent_attachment_issues(
*,
config: CampaignConfig,
residual_groups: list[_ResidualFileGroup],
) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value
if (
behavior == Behavior.CONTINUE.value
or config.attachments.residual_files.mode != ResidualFileMode.NONE
):
return []
issues: list[MessageIssue] = []
for group in residual_groups:
unsent = group.files
directory = group.directory
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
if len(unsent) > 10:
shown += f", … (+{len(unsent) - 10} more)"
issues.append(
_issue_from_behavior(
code="unsent_attachment_files",
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
message=f"{len(unsent)} file(s) in attachment source {group.source_name!r} are not used by any message: {shown}",
behavior=behavior,
source=f"attachments:{base_path.name}",
source=f"attachments:{group.source_name}",
)
)
return issues
def _build_residual_file_message(
*,
config: CampaignConfig,
campaign_file: Path,
residual_groups: list[_ResidualFileGroup],
entry_index: int,
output_dir: Path | None,
write_eml: bool,
work_dir: Path,
attachment_match_index: AttachmentMatchIndex,
) -> BuiltMessage | None:
disposition = config.attachments.residual_files
if disposition.mode == ResidualFileMode.NONE or disposition.recipient is None:
return None
files = sorted({path.resolve() for group in residual_groups for path in group.files})
if not files:
return None
file_lines = [
f"{group.source_name}: {path.relative_to(group.directory)}"
for group in residual_groups
for path in group.files
]
entry = EntryConfig(
id="__residual_files__",
to=[disposition.recipient],
merge_to=False,
combine_attachments=True,
fields={
"campaign_name": config.campaign.name,
"residual_file_count": len(files),
"residual_file_list": "\n".join(file_lines),
},
)
residual_config = config.model_copy(deep=True)
residual_config.attachments.global_ = []
residual_config.attachments.zip.enabled = False
if disposition.mode == ResidualFileMode.ATTACH:
residual_config.attachments.global_ = [
AttachmentConfig(
id=f"residual-file-{index}",
label=path.name,
base_dir=str(path.parent),
file_filter=path.name,
required=True,
allow_multiple=False,
)
for index, path in enumerate(files, start=1)
]
context = _entry_message_context(
config=residual_config,
campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
attachment_match_index=attachment_match_index,
)
context.validation_status = _validate_required_sender(
context.senders,
context.issues,
context.validation_status,
)
context.validation_status = _validate_required_recipients(
residual_config,
context.recipients,
context.issues,
context.validation_status,
)
values = build_template_values(residual_config, entry)
rendered = _RenderedMessageTemplate(
subject=_render_template(disposition.subject, values, keep_missing=True),
text_body=_render_template(disposition.text, values, keep_missing=True),
html_body=None,
body_mode=TemplateBodyMode.TEXT.value,
values=values,
)
context.validation_status = _validate_rendered_template(
residual_config,
rendered,
context.issues,
context.validation_status,
)
context.issues.append(
MessageIssue(
severity="warning",
code="residual_attachment_disposition",
message=(
f"{len(files)} unassigned file(s) are routed as a reviewed "
f"{disposition.mode.value} message."
),
behavior="ask",
source="attachments:residual_files",
details={
"mode": disposition.mode.value,
"file_count": len(files),
"source_count": len(residual_groups),
},
)
)
if context.validation_status not in {
MessageValidationStatus.BLOCKED,
MessageValidationStatus.EXCLUDED,
}:
context.validation_status = MessageValidationStatus.NEEDS_REVIEW
mime_result = _build_mime_message(
config=residual_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_size: int | None = None
if write_eml and output_dir is not None and mime_result.message is not None:
eml_path, eml_size = _write_eml(
mime_result.message,
output_dir,
entry,
entry_index,
)
return BuiltMessage(
draft=_message_draft(
config=residual_config,
entry=entry,
entry_index=entry_index,
context=context,
build_status=mime_result.build_status,
validation_status=mime_result.validation_status,
subject=rendered.subject,
attachment_count=mime_result.attachment_count,
archive_evidence=mime_result.archive_evidence,
eml_path=eml_path,
eml_size=eml_size,
),
mime=mime_result.message,
)
def _residual_file_disposition_evidence(
*,
config: CampaignConfig,
residual_groups: list[_ResidualFileGroup],
) -> dict[str, object]:
disposition = config.attachments.residual_files
behavior = config.validation_policy.unsent_attachment_files.value
if disposition.mode == ResidualFileMode.REPORT:
action = "route_report"
elif disposition.mode == ResidualFileMode.ATTACH:
action = "route_with_files"
elif behavior == Behavior.BLOCK.value:
action = "block"
elif behavior in {Behavior.CONTINUE.value, Behavior.DROP.value}:
action = "ignore"
else:
action = "review"
recipient = (
disposition.recipient.model_dump(mode="json", by_alias=True)
if disposition.recipient is not None
else None
)
return {
"contract_version": "1",
"action": action,
"routing_mode": disposition.mode.value,
"validation_behavior": behavior,
"watched_source_count": len(residual_groups),
"residual_file_count": sum(len(group.files) for group in residual_groups),
"recipient": recipient,
}
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
if not issues:
return
@@ -917,6 +1185,28 @@ def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: lis
status = _apply_behavior(status, issue.behavior)
built.draft.validation_status = status
def _apply_attachment_reuse_policy(
config: CampaignConfig,
built_messages: list[BuiltMessage],
) -> dict[str, object]:
evaluation = evaluate_attachment_reuse(
[built.draft for built in built_messages],
policy=config.attachments.reuse_policy,
)
for built in built_messages:
issues = evaluation.issues_by_entry_index.get(built.draft.entry_index, [])
if not issues:
continue
built.draft.issues.extend(issues)
for issue in issues:
if issue.behavior:
built.draft.validation_status = _apply_behavior(
built.draft.validation_status,
issue.behavior,
)
return evaluation.report
def build_campaign_messages(
config: CampaignConfig,
*,
@@ -946,15 +1236,35 @@ def build_campaign_messages(
for index, entry in enumerate(entries, start=1)
if entry.active
]
residual_groups = _residual_attachment_files(
config=config,
campaign_file=campaign_path,
built_messages=built_messages,
attachment_match_index=attachment_match_index,
)
_apply_campaign_level_issues(
built_messages,
_unsent_attachment_issues(
config=config,
campaign_file=campaign_path,
built_messages=built_messages,
attachment_match_index=attachment_match_index,
residual_groups=residual_groups,
),
)
residual_message = _build_residual_file_message(
config=config,
campaign_file=campaign_path,
residual_groups=residual_groups,
entry_index=len(entries) + 1,
output_dir=output_path,
write_eml=write_eml,
work_dir=work_dir,
attachment_match_index=attachment_match_index,
)
if residual_message is not None:
built_messages.append(residual_message)
attachment_reuse = _apply_attachment_reuse_policy(
config,
built_messages,
)
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
report = CampaignBuildReport(
@@ -968,5 +1278,10 @@ def build_campaign_messages(
duration_ms=(time.perf_counter() - started) * 1000,
rules_resolved=rules_resolved,
),
attachment_reuse=attachment_reuse,
residual_file_disposition=_residual_file_disposition_evidence(
config=config,
residual_groups=residual_groups,
),
)
return CampaignBuildResult(report=report, built_messages=built_messages)
@@ -33,6 +33,7 @@ class MessageIssue(BaseModel):
message: str
behavior: str | None = None
source: str | None = None
details: dict[str, object] = Field(default_factory=dict)
class MessageAddress(BaseModel):
@@ -49,6 +50,7 @@ class MessageAttachmentSummary(BaseModel):
label: str | None = None
status: str
behavior: str | None = None
missing_policy: dict[str, object] | None = None
required: bool
allow_multiple: bool
zip_enabled: bool
@@ -78,6 +80,7 @@ class MessageDraft(BaseModel):
validation_status: MessageValidationStatus
send_status: SendStatus
imap_status: ImapStatus
delivery_channel_policy: str = "mail"
subject: str | None = None
from_: MessageAddress | None = Field(default=None, alias="from")
@@ -91,6 +94,7 @@ class MessageDraft(BaseModel):
attachment_count: int = 0
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
archive_evidence: list[dict[str, object]] = Field(default_factory=list)
issues: list[MessageIssue] = Field(default_factory=list)
eml_path: str | None = None
@@ -114,6 +118,8 @@ class CampaignBuildReport(BaseModel):
inactive_entries_count: int = 0
messages: list[MessageDraft] = Field(default_factory=list)
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
attachment_reuse: dict[str, object] = Field(default_factory=dict)
residual_file_disposition: dict[str, object] = Field(default_factory=dict)
@property
def built_count(self) -> int:
@@ -0,0 +1,27 @@
"""add campaign schedule optimistic-concurrency revisions
Revision ID: a5b6c7d8e9f0
Revises: f4a5b6c7d8e9
Create Date: 2026-08-07 12:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
_migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"a5b6c7d8e9f0_v0120_campaign_schedule_revisions"
)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
def upgrade() -> None:
_migration.upgrade()
def downgrade() -> None:
_migration.downgrade()
@@ -0,0 +1,17 @@
"""Development wrapper for the canonical printable-delivery migration."""
from __future__ import annotations
from importlib import import_module
_migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"b7c8d9e0f1a2_campaign_print_delivery"
)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
upgrade = _migration.upgrade
downgrade = _migration.downgrade
@@ -0,0 +1,22 @@
"""add audit-proof single-message action ledger
Revision ID: c1a69e4f2b70
Revises: f0a1b2c3d4e5
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
message_actions = import_module(
"govoplan_campaign.backend.migrations.versions.c1a69e4f2b70_campaign_message_actions"
)
revision = message_actions.revision
down_revision = message_actions.down_revision
branch_labels = message_actions.branch_labels
depends_on = message_actions.depends_on
upgrade = message_actions.upgrade
downgrade = message_actions.downgrade
@@ -0,0 +1,22 @@
"""add monotonic campaign version edit revision
Revision ID: d2b7af503c81
Revises: c1a69e4f2b70
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
edit_revision = import_module(
"govoplan_campaign.backend.migrations.versions.d2b7af503c81_campaign_version_edit_revision"
)
revision = edit_revision.revision
down_revision = edit_revision.down_revision
branch_labels = edit_revision.branch_labels
depends_on = edit_revision.depends_on
upgrade = edit_revision.upgrade
downgrade = edit_revision.downgrade
@@ -0,0 +1,85 @@
"""add non-destructive historical campaign version archival
Revision ID: e3c8f4a5b6d7
Revises: b7c8d9e0f1a2, d2b7af503c81
Create Date: 2026-08-03 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e3c8f4a5b6d7"
down_revision = ("b7c8d9e0f1a2", "d2b7af503c81")
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {
column["name"]
for column in inspector.get_columns("campaign_versions")
}
with op.batch_alter_table("campaign_versions") as batch_op:
if "archived_at" not in columns:
batch_op.add_column(
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True)
)
if "archived_by_user_id" not in columns:
batch_op.add_column(
sa.Column("archived_by_user_id", sa.String(length=36), nullable=True)
)
batch_op.create_foreign_key(
"fk_campaign_versions_archived_by_user_id_access_users",
"access_users",
["archived_by_user_id"],
["id"],
ondelete="SET NULL",
)
indexes = {
index["name"]
for index in sa.inspect(op.get_bind()).get_indexes("campaign_versions")
}
if "ix_campaign_versions_archived_at" not in indexes:
op.create_index(
"ix_campaign_versions_archived_at",
"campaign_versions",
["archived_at"],
unique=False,
)
if "ix_campaign_versions_archived_by_user_id" not in indexes:
op.create_index(
"ix_campaign_versions_archived_by_user_id",
"campaign_versions",
["archived_by_user_id"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
indexes = {
index["name"]
for index in inspector.get_indexes("campaign_versions")
}
for index_name in (
"ix_campaign_versions_archived_by_user_id",
"ix_campaign_versions_archived_at",
):
if index_name in indexes:
op.drop_index(index_name, table_name="campaign_versions")
columns = {
column["name"]
for column in sa.inspect(op.get_bind()).get_columns("campaign_versions")
}
with op.batch_alter_table("campaign_versions") as batch_op:
if "archived_by_user_id" in columns:
batch_op.drop_constraint(
"fk_campaign_versions_archived_by_user_id_access_users",
type_="foreignkey",
)
batch_op.drop_column("archived_by_user_id")
if "archived_at" in columns:
batch_op.drop_column("archived_at")
@@ -0,0 +1,32 @@
"""repair a missing IMAP append attempt claim token
Revision ID: e9f0a1b2c3d4
Revises: d8b3e2c1f4a5
Create Date: 2026-07-28 23:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f0a1b2c3d4"
down_revision = "d8b3e2c1f4a5"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
columns = {column["name"] for column in sa.inspect(bind).get_columns("imap_append_attempts")}
if "claim_token" not in columns:
op.add_column(
"imap_append_attempts",
sa.Column("claim_token", sa.String(length=36), nullable=True),
)
def downgrade() -> None:
# The column belongs to revision 3c4d5e6f8192. This repair revision only
# restores drift, so downgrading to d8b3e2c1f4a5 must retain it.
pass
@@ -0,0 +1,22 @@
"""add governed campaign Postbox delivery
Revision ID: f0a1b2c3d4e5
Revises: e9f0a1b2c3d4
Create Date: 2026-07-29 02:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
_migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"f0a1b2c3d4e5_v0115_postbox_delivery"
)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
upgrade = _migration.upgrade
downgrade = _migration.downgrade
@@ -0,0 +1,27 @@
"""add durable campaign schedules and occurrence evidence
Revision ID: f4a5b6c7d8e9
Revises: e3c8f4a5b6d7
Create Date: 2026-08-07 10:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
_migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"f4a5b6c7d8e9_v0119_campaign_schedules"
)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
def upgrade() -> None:
_migration.upgrade()
def downgrade() -> None:
_migration.downgrade()
@@ -0,0 +1,40 @@
"""add campaign schedule optimistic-concurrency revisions
Revision ID: a5b6c7d8e9f0
Revises: f4a5b6c7d8e9
Create Date: 2026-08-07 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a5b6c7d8e9f0"
down_revision = "f4a5b6c7d8e9"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_schedules"):
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
if "resource_revision" not in columns:
op.add_column(
"campaign_schedules",
sa.Column(
"resource_revision",
sa.Integer(),
nullable=False,
server_default="1",
),
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_schedules"):
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
if "resource_revision" in columns:
op.drop_column("campaign_schedules", "resource_revision")
@@ -0,0 +1,100 @@
"""add governed autonomous Campaign schedule evidence
revision = "b6c7d8e9f0a1"
down_revision = "a5b6c7d8e9f0"
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "b6c7d8e9f0a1"
down_revision = "a5b6c7d8e9f0"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaign_schedules",
sa.Column("delivery_mode", sa.String(length=20), nullable=False, server_default="manual"),
)
op.create_index(
"ix_campaign_schedules_delivery_mode",
"campaign_schedules",
["delivery_mode"],
)
op.add_column(
"campaign_schedules",
sa.Column("approved_execution_snapshot_hash", sa.String(length=64), nullable=True),
)
op.create_index(
"ix_campaign_schedules_approved_execution_snapshot_hash",
"campaign_schedules",
["approved_execution_snapshot_hash"],
)
op.add_column(
"campaign_schedules",
sa.Column("last_outcome", sa.String(length=30), nullable=True),
)
op.add_column(
"campaign_schedules",
sa.Column("last_recovery_state", sa.String(length=30), nullable=True),
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("idempotency_key", sa.String(length=200), nullable=True),
)
op.create_index(
"ix_campaign_schedule_occurrences_idempotency_key",
"campaign_schedule_occurrences",
["idempotency_key"],
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("delivery_command_ids", sa.JSON(), nullable=False, server_default="[]"),
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("recovery_state", sa.String(length=30), nullable=False, server_default="none"),
)
op.create_index(
"ix_campaign_schedule_occurrences_recovery_state",
"campaign_schedule_occurrences",
["recovery_state"],
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("evidence", sa.JSON(), nullable=False, server_default="{}"),
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("campaign_schedule_occurrences", "last_checked_at")
op.drop_column("campaign_schedule_occurrences", "evidence")
op.drop_index(
"ix_campaign_schedule_occurrences_recovery_state",
table_name="campaign_schedule_occurrences",
)
op.drop_column("campaign_schedule_occurrences", "recovery_state")
op.drop_column("campaign_schedule_occurrences", "delivery_command_ids")
op.drop_index(
"ix_campaign_schedule_occurrences_idempotency_key",
table_name="campaign_schedule_occurrences",
)
op.drop_column("campaign_schedule_occurrences", "idempotency_key")
op.drop_column("campaign_schedules", "last_recovery_state")
op.drop_column("campaign_schedules", "last_outcome")
op.drop_index(
"ix_campaign_schedules_approved_execution_snapshot_hash",
table_name="campaign_schedules",
)
op.drop_column("campaign_schedules", "approved_execution_snapshot_hash")
op.drop_index("ix_campaign_schedules_delivery_mode", table_name="campaign_schedules")
op.drop_column("campaign_schedules", "delivery_mode")
@@ -0,0 +1,112 @@
"""add governed campaign printable delivery
Revision ID: b7c8d9e0f1a2
Revises: f0a1b2c3d4e5
Create Date: 2026-08-02 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b7c8d9e0f1a2"
down_revision = "f0a1b2c3d4e5"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaign_jobs",
sa.Column(
"print_status",
sa.String(length=50),
nullable=False,
server_default="not_requested",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"print_attempt_count",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"campaign_jobs",
sa.Column("resolved_print_output", sa.JSON(), nullable=True),
)
op.add_column(
"campaign_jobs",
sa.Column(
"delivery_provenance",
sa.JSON(),
nullable=False,
server_default="{}",
),
)
op.create_index(
op.f("ix_campaign_jobs_print_status"),
"campaign_jobs",
["print_status"],
unique=False,
)
op.create_table(
"campaign_print_output_attempts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("job_id", sa.String(length=36), nullable=False),
sa.Column("attempt_number", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("render_id", sa.String(length=36), nullable=True),
sa.Column("artifact_sha256", sa.String(length=64), nullable=True),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["job_id"],
["campaign_jobs.id"],
name=op.f("fk_campaign_print_output_attempts_job_id_campaign_jobs"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_campaign_print_output_attempts"),
),
sa.UniqueConstraint(
"job_id",
"attempt_number",
name="uq_campaign_print_attempt_job_number",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_campaign_print_attempt_idempotency",
),
)
for column in ("tenant_id", "job_id", "status", "render_id", "artifact_sha256"):
op.create_index(
op.f(f"ix_campaign_print_output_attempts_{column}"),
"campaign_print_output_attempts",
[column],
unique=False,
)
def downgrade() -> None:
op.drop_table("campaign_print_output_attempts")
op.drop_index(
op.f("ix_campaign_jobs_print_status"),
table_name="campaign_jobs",
)
op.drop_column("campaign_jobs", "resolved_print_output")
op.drop_column("campaign_jobs", "delivery_provenance")
op.drop_column("campaign_jobs", "print_attempt_count")
op.drop_column("campaign_jobs", "print_status")
@@ -0,0 +1,186 @@
"""add audit-proof single-message action ledger
Revision ID: c1a69e4f2b70
Revises: f0a1b2c3d4e5
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c1a69e4f2b70"
down_revision = "f0a1b2c3d4e5"
branch_labels = None
depends_on = "c91f0a72be34"
def upgrade() -> None:
tables = set(sa.inspect(op.get_bind()).get_table_names())
if "campaign_message_actions" not in tables:
op.create_table(
"campaign_message_actions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("campaign_id", sa.String(length=36), nullable=False),
sa.Column("campaign_version_id", sa.String(length=36), nullable=False),
sa.Column("job_id", sa.String(length=36), nullable=False),
sa.Column("kind", sa.String(length=30), nullable=False),
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("context", sa.JSON(), nullable=False),
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
sa.Column("actor_api_key_id", sa.String(length=36), nullable=True),
sa.Column("message_sha256", sa.String(length=64), nullable=False),
sa.Column("message_size_bytes", sa.Integer(), nullable=True),
sa.Column(
"recipient_manifest_sha256",
sa.String(length=64),
nullable=False,
),
sa.Column("recipient_count", sa.Integer(), nullable=False),
sa.Column("prior_send_status", sa.String(length=50), nullable=False),
sa.Column("prior_attempt_count", sa.Integer(), nullable=False),
sa.Column("final_send_status", sa.String(length=50), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("accepted_count", sa.Integer(), nullable=False),
sa.Column("refused_count", sa.Integer(), nullable=False),
sa.Column("refusal_summary", sa.JSON(), nullable=False),
sa.Column("error_type", sa.String(length=120), nullable=True),
sa.Column("error_message", sa.String(length=500), nullable=True),
sa.Column("linked_send_attempt_id", sa.String(length=36), nullable=True),
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["campaign_id"],
["campaigns.id"],
name=op.f(
"fk_campaign_message_actions_campaign_id_campaigns"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["campaign_version_id"],
["campaign_versions.id"],
name=op.f(
"fk_campaign_message_actions_campaign_version_id_campaign_versions"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["job_id"],
["campaign_jobs.id"],
name=op.f(
"fk_campaign_message_actions_job_id_campaign_jobs"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["actor_user_id"],
["access_users.id"],
name=op.f(
"fk_campaign_message_actions_actor_user_id_access_users"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["linked_send_attempt_id"],
["send_attempts.id"],
name=op.f(
"fk_campaign_message_actions_linked_send_attempt_id_send_attempts"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_campaign_message_actions"),
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_campaign_message_actions_idempotency",
),
)
op.create_index(
"ix_campaign_message_actions_job_created",
"campaign_message_actions",
["job_id", "created_at"],
unique=False,
)
op.create_index(
"ix_campaign_message_actions_campaign_kind",
"campaign_message_actions",
["campaign_id", "kind", "status"],
unique=False,
)
for column in (
"tenant_id",
"campaign_id",
"campaign_version_id",
"job_id",
"kind",
"actor_user_id",
"status",
"linked_send_attempt_id",
):
op.create_index(
op.f(f"ix_campaign_message_actions_{column}"),
"campaign_message_actions",
[column],
unique=False,
)
tables = set(sa.inspect(op.get_bind()).get_table_names())
if "campaign_message_action_attempts" not in tables:
op.create_table(
"campaign_message_action_attempts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("action_id", sa.String(length=36), nullable=False),
sa.Column("attempt_number", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("accepted_count", sa.Integer(), nullable=False),
sa.Column("refused_count", sa.Integer(), nullable=False),
sa.Column("outcome_code", sa.String(length=80), nullable=True),
sa.Column("diagnostic_summary", sa.String(length=500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["action_id"],
["campaign_message_actions.id"],
name=op.f(
"fk_campaign_message_action_attempts_action_id_campaign_message_actions"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_campaign_message_action_attempts"),
),
sa.UniqueConstraint(
"action_id",
"attempt_number",
name="uq_campaign_message_action_attempt_number",
),
)
for column in ("action_id", "status"):
op.create_index(
op.f(f"ix_campaign_message_action_attempts_{column}"),
"campaign_message_action_attempts",
[column],
unique=False,
)
def downgrade() -> None:
tables = set(sa.inspect(op.get_bind()).get_table_names())
if "campaign_message_action_attempts" in tables:
op.drop_table("campaign_message_action_attempts")
if "campaign_message_actions" in tables:
op.drop_table("campaign_message_actions")
@@ -0,0 +1,89 @@
"""add governed campaign collaboration entries
revision = "c7d8e9f0a1b2"
down_revision = "b6c7d8e9f0a1"
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "c7d8e9f0a1b2"
down_revision = "b6c7d8e9f0a1"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_collaboration_entries"):
return
op.create_table(
"campaign_collaboration_entries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("campaign_id", sa.String(length=36), nullable=False),
sa.Column("campaign_version_id", sa.String(length=36), nullable=True),
sa.Column("reference_kind", sa.String(length=40), nullable=True),
sa.Column("reference_id", sa.String(length=500), nullable=True),
sa.Column("reference_label", sa.String(length=255), nullable=True),
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
sa.Column(
"visibility",
sa.String(length=30),
nullable=False,
server_default="collaborators",
),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("content_sha256", sa.String(length=64), nullable=False),
sa.Column("mention_user_ids", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("withdrawn_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("withdrawn_by_user_id", sa.String(length=36), nullable=True),
sa.Column("redacted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("redacted_by_user_id", sa.String(length=36), nullable=True),
sa.Column("tombstone_reason", sa.String(length=500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["campaign_version_id"],
["campaign_versions.id"],
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["redacted_by_user_id"],
["access_users.id"],
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["withdrawn_by_user_id"],
["access_users.id"],
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_campaign_collaboration_entries_tenant_id", ["tenant_id"]),
("ix_campaign_collaboration_entries_campaign_id", ["campaign_id"]),
("ix_campaign_collaboration_entries_campaign_version_id", ["campaign_version_id"]),
("ix_campaign_collaboration_entries_reference_kind", ["reference_kind"]),
("ix_campaign_collaboration_entries_actor_user_id", ["actor_user_id"]),
("ix_campaign_collaboration_entries_visibility", ["visibility"]),
("ix_campaign_collaboration_entries_withdrawn_at", ["withdrawn_at"]),
("ix_campaign_collaboration_entries_redacted_at", ["redacted_at"]),
(
"ix_campaign_collaboration_entries_thread",
["tenant_id", "campaign_id", "created_at", "id"],
),
):
op.create_index(name, "campaign_collaboration_entries", columns, unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_collaboration_entries"):
op.drop_table("campaign_collaboration_entries")
@@ -0,0 +1,45 @@
"""add monotonic campaign version edit revision
Revision ID: d2b7af503c81
Revises: c1a69e4f2b70
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d2b7af503c81"
down_revision = "c1a69e4f2b70"
branch_labels = None
depends_on = "c91f0a72be34"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {
column["name"]
for column in inspector.get_columns("campaign_versions")
}
if "edit_revision" not in columns:
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.add_column(
sa.Column(
"edit_revision",
sa.Integer(),
server_default="1",
nullable=False,
)
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {
column["name"]
for column in inspector.get_columns("campaign_versions")
}
if "edit_revision" in columns:
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.drop_column("edit_revision")
@@ -0,0 +1,113 @@
"""add accountable campaign work assignments
revision = "d8e9f0a1b2c3"
down_revision = "c7d8e9f0a1b2"
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "d8e9f0a1b2c3"
down_revision = "c7d8e9f0a1b2"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("campaign_work_assignments"):
op.create_table(
"campaign_work_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("campaign_id", sa.String(length=36), nullable=False),
sa.Column("campaign_version_id", sa.String(length=36), nullable=True),
sa.Column("reference_kind", sa.String(length=40), nullable=True),
sa.Column("reference_id", sa.String(length=500), nullable=True),
sa.Column("reference_label", sa.String(length=255), nullable=True),
sa.Column("purpose", sa.String(length=500), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False, server_default="open"),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("assignee_type", sa.String(length=40), nullable=False),
sa.Column("assignee_id", sa.String(length=255), nullable=False),
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
sa.Column("assignee_current_label", sa.String(length=500), nullable=True),
sa.Column("assignee_resolution_state", sa.String(length=30), nullable=False, server_default="resolved"),
sa.Column("resolution_provenance", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("resolution_checked_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("assigned_by_user_id", sa.String(length=36), nullable=True),
sa.Column("assigned_by_label_snapshot", sa.String(length=255), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("task_mirror_id", sa.String(length=255), nullable=True),
sa.Column("task_mirror_status", sa.String(length=30), nullable=False, server_default="not_configured"),
sa.Column("task_mirror_error", sa.String(length=500), nullable=True),
sa.Column("task_mirrored_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("resource_revision", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["campaign_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_campaign_work_assignments_tenant_id", ["tenant_id"]),
("ix_campaign_work_assignments_campaign_id", ["campaign_id"]),
("ix_campaign_work_assignments_campaign_version_id", ["campaign_version_id"]),
("ix_campaign_work_assignments_reference_kind", ["reference_kind"]),
("ix_campaign_work_assignments_status", ["status"]),
("ix_campaign_work_assignments_due_at", ["due_at"]),
("ix_campaign_work_assignments_assignee_type", ["assignee_type"]),
("ix_campaign_work_assignments_assignee_id", ["assignee_id"]),
("ix_campaign_work_assignments_assignee_resolution_state", ["assignee_resolution_state"]),
("ix_campaign_work_assignments_assigned_by_user_id", ["assigned_by_user_id"]),
("ix_campaign_work_assignments_campaign_status", ["tenant_id", "campaign_id", "status", "due_at", "id"]),
):
op.create_index(name, "campaign_work_assignments", columns, unique=False)
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("campaign_work_assignment_events"):
op.create_table(
"campaign_work_assignment_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("campaign_id", sa.String(length=36), nullable=False),
sa.Column("assignment_id", sa.String(length=36), nullable=False),
sa.Column("event_kind", sa.String(length=40), nullable=False),
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
sa.Column("status_snapshot", sa.String(length=30), nullable=False),
sa.Column("assignee_type_snapshot", sa.String(length=40), nullable=False),
sa.Column("assignee_id_snapshot", sa.String(length=255), nullable=False),
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
sa.Column("resolution_state_snapshot", sa.String(length=30), nullable=False),
sa.Column("details", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["assignment_id"], ["campaign_work_assignments.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_campaign_work_assignment_events_tenant_id", ["tenant_id"]),
("ix_campaign_work_assignment_events_campaign_id", ["campaign_id"]),
("ix_campaign_work_assignment_events_assignment_id", ["assignment_id"]),
("ix_campaign_work_assignment_events_event_kind", ["event_kind"]),
("ix_campaign_work_assignment_events_actor_user_id", ["actor_user_id"]),
("ix_campaign_work_assignment_events_history", ["tenant_id", "assignment_id", "created_at", "id"]),
):
op.create_index(name, "campaign_work_assignment_events", columns, unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_work_assignment_events"):
op.drop_table("campaign_work_assignment_events")
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_work_assignments"):
op.drop_table("campaign_work_assignments")
@@ -0,0 +1,85 @@
"""add non-destructive historical campaign version archival
Revision ID: e3c8f4a5b6d7
Revises: b7c8d9e0f1a2, d2b7af503c81
Create Date: 2026-08-03 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e3c8f4a5b6d7"
down_revision = ("b7c8d9e0f1a2", "d2b7af503c81")
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {
column["name"]
for column in inspector.get_columns("campaign_versions")
}
with op.batch_alter_table("campaign_versions") as batch_op:
if "archived_at" not in columns:
batch_op.add_column(
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True)
)
if "archived_by_user_id" not in columns:
batch_op.add_column(
sa.Column("archived_by_user_id", sa.String(length=36), nullable=True)
)
batch_op.create_foreign_key(
"fk_campaign_versions_archived_by_user_id_access_users",
"access_users",
["archived_by_user_id"],
["id"],
ondelete="SET NULL",
)
indexes = {
index["name"]
for index in sa.inspect(op.get_bind()).get_indexes("campaign_versions")
}
if "ix_campaign_versions_archived_at" not in indexes:
op.create_index(
"ix_campaign_versions_archived_at",
"campaign_versions",
["archived_at"],
unique=False,
)
if "ix_campaign_versions_archived_by_user_id" not in indexes:
op.create_index(
"ix_campaign_versions_archived_by_user_id",
"campaign_versions",
["archived_by_user_id"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
indexes = {
index["name"]
for index in inspector.get_indexes("campaign_versions")
}
for index_name in (
"ix_campaign_versions_archived_by_user_id",
"ix_campaign_versions_archived_at",
):
if index_name in indexes:
op.drop_index(index_name, table_name="campaign_versions")
columns = {
column["name"]
for column in sa.inspect(op.get_bind()).get_columns("campaign_versions")
}
with op.batch_alter_table("campaign_versions") as batch_op:
if "archived_by_user_id" in columns:
batch_op.drop_constraint(
"fk_campaign_versions_archived_by_user_id_access_users",
type_="foreignkey",
)
batch_op.drop_column("archived_by_user_id")
if "archived_at" in columns:
batch_op.drop_column("archived_at")
@@ -0,0 +1,32 @@
"""repair a missing IMAP append attempt claim token
Revision ID: e9f0a1b2c3d4
Revises: d8b3e2c1f4a5
Create Date: 2026-07-28 23:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f0a1b2c3d4"
down_revision = "d8b3e2c1f4a5"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
columns = {column["name"] for column in sa.inspect(bind).get_columns("imap_append_attempts")}
if "claim_token" not in columns:
op.add_column(
"imap_append_attempts",
sa.Column("claim_token", sa.String(length=36), nullable=True),
)
def downgrade() -> None:
# The column belongs to revision 3c4d5e6f8192. This repair revision only
# restores drift, so downgrading to d8b3e2c1f4a5 must retain it.
pass
@@ -0,0 +1,153 @@
"""add governed campaign Postbox delivery
Revision ID: f0a1b2c3d4e5
Revises: e9f0a1b2c3d4
Create Date: 2026-07-29 02:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f0a1b2c3d4e5"
down_revision = "e9f0a1b2c3d4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaign_jobs",
sa.Column(
"delivery_channel_policy",
sa.String(length=30),
nullable=False,
server_default="mail",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"postbox_status",
sa.String(length=50),
nullable=False,
server_default="not_requested",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"postbox_attempt_count",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"resolved_postbox_targets",
sa.JSON(),
nullable=False,
server_default="[]",
),
)
op.create_index(
op.f("ix_campaign_jobs_delivery_channel_policy"),
"campaign_jobs",
["delivery_channel_policy"],
unique=False,
)
op.create_index(
op.f("ix_campaign_jobs_postbox_status"),
"campaign_jobs",
["postbox_status"],
unique=False,
)
op.create_table(
"campaign_postbox_delivery_attempts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("job_id", sa.String(length=36), nullable=False),
sa.Column("target_key", sa.String(length=64), nullable=False),
sa.Column("target_index", sa.Integer(), nullable=False),
sa.Column("attempt_number", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("target_snapshot", sa.JSON(), nullable=False),
sa.Column("provider_delivery_id", sa.String(length=36), nullable=True),
sa.Column("provider_message_id", sa.String(length=36), nullable=True),
sa.Column("postbox_id", sa.String(length=36), nullable=True),
sa.Column("address", sa.String(length=500), nullable=True),
sa.Column("holder_count", sa.Integer(), nullable=True),
sa.Column("vacant", sa.Boolean(), nullable=True),
sa.Column(
"duplicate",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("error_type", sa.String(length=255), nullable=True),
sa.Column("error_code", sa.String(length=100), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["job_id"],
["campaign_jobs.id"],
name=op.f(
"fk_campaign_postbox_delivery_attempts_job_id_campaign_jobs"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_campaign_postbox_delivery_attempts"),
),
sa.UniqueConstraint(
"job_id",
"target_key",
"attempt_number",
name="uq_campaign_postbox_attempt_target_number",
),
)
for column in ("tenant_id", "job_id", "status", "postbox_id"):
op.create_index(
op.f(f"ix_campaign_postbox_delivery_attempts_{column}"),
"campaign_postbox_delivery_attempts",
[column],
unique=False,
)
op.create_index(
"ix_campaign_postbox_attempt_job_status",
"campaign_postbox_delivery_attempts",
["job_id", "status"],
unique=False,
)
op.create_index(
"ix_campaign_postbox_attempt_idempotency",
"campaign_postbox_delivery_attempts",
["tenant_id", "idempotency_key"],
unique=False,
)
def downgrade() -> None:
op.drop_table("campaign_postbox_delivery_attempts")
op.drop_index(
op.f("ix_campaign_jobs_postbox_status"),
table_name="campaign_jobs",
)
op.drop_index(
op.f("ix_campaign_jobs_delivery_channel_policy"),
table_name="campaign_jobs",
)
op.drop_column("campaign_jobs", "resolved_postbox_targets")
op.drop_column("campaign_jobs", "postbox_attempt_count")
op.drop_column("campaign_jobs", "postbox_status")
op.drop_column("campaign_jobs", "delivery_channel_policy")
@@ -0,0 +1,106 @@
"""add durable campaign schedules and occurrence evidence
Revision ID: f4a5b6c7d8e9
Revises: e3c8f4a5b6d7
Create Date: 2026-08-07 10:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f4a5b6c7d8e9"
down_revision = "e3c8f4a5b6d7"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("campaign_schedules"):
op.create_table(
"campaign_schedules",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("campaign_id", sa.String(length=36), nullable=False),
sa.Column("source_version_id", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("recurrence_kind", sa.String(length=20), nullable=False),
sa.Column("interval_count", sa.Integer(), nullable=False),
sa.Column("timezone", sa.String(length=100), nullable=False),
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("next_fire_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("max_occurrences", sa.Integer(), nullable=False),
sa.Column("occurrence_count", sa.Integer(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("resource_revision", sa.Integer(), nullable=False),
sa.Column("copy_options", sa.JSON(), nullable=False),
sa.Column("source_snapshot", sa.JSON(), nullable=False),
sa.Column("source_snapshot_hash", sa.String(length=64), nullable=False),
sa.Column("source_base_path", sa.String(length=1000), nullable=True),
sa.Column("last_fired_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_campaign_id", sa.String(length=36), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["last_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_campaign_schedules_tenant_id", ["tenant_id"]),
("ix_campaign_schedules_campaign_id", ["campaign_id"]),
("ix_campaign_schedules_source_version_id", ["source_version_id"]),
("ix_campaign_schedules_created_by_user_id", ["created_by_user_id"]),
("ix_campaign_schedules_recurrence_kind", ["recurrence_kind"]),
("ix_campaign_schedules_next_fire_at", ["next_fire_at"]),
("ix_campaign_schedules_active", ["active"]),
("ix_campaign_schedules_source_snapshot_hash", ["source_snapshot_hash"]),
("ix_campaign_schedules_last_campaign_id", ["last_campaign_id"]),
("ix_campaign_schedules_due", ["tenant_id", "active", "next_fire_at"]),
):
op.create_index(name, "campaign_schedules", columns, unique=False)
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("campaign_schedule_occurrences"):
op.create_table(
"campaign_schedule_occurrences",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("schedule_id", sa.String(length=36), nullable=False),
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("generated_campaign_id", sa.String(length=36), nullable=True),
sa.Column("generated_version_id", sa.String(length=36), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["schedule_id"], ["campaign_schedules.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["generated_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["generated_version_id"], ["campaign_versions.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("schedule_id", "scheduled_for", name="uq_campaign_schedule_occurrence"),
)
for name, columns in (
("ix_campaign_schedule_occurrences_tenant_id", ["tenant_id"]),
("ix_campaign_schedule_occurrences_schedule_id", ["schedule_id"]),
("ix_campaign_schedule_occurrences_status", ["status"]),
("ix_campaign_schedule_occurrences_generated_campaign_id", ["generated_campaign_id"]),
("ix_campaign_schedule_occurrences_generated_version_id", ["generated_version_id"]),
("ix_campaign_schedule_occurrences_schedule", ["schedule_id", "scheduled_for"]),
):
op.create_index(name, "campaign_schedule_occurrences", columns, unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_schedule_occurrences"):
op.drop_table("campaign_schedule_occurrences")
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_schedules"):
op.drop_table("campaign_schedules")
@@ -0,0 +1,56 @@
from __future__ import annotations
import secrets
from govoplan_core.core.object_storage import (
StorageBackendError,
configured_storage_backend,
)
from govoplan_core.core.operations import OperationalCheck
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.runtime import get_settings
def generated_eml_storage_check() -> OperationalCheck:
"""Verify Campaign evidence against the deployment object-store boundary."""
storage = configured_storage_backend(get_settings() or core_settings)
probe = f"campaign-artifacts/.health/{secrets.token_hex(16)}.probe"
payload = secrets.token_bytes(64)
try:
storage.put_bytes(probe, payload, content_type="application/octet-stream")
if storage.get_bytes(probe) != payload:
raise OSError("generated EML persistence returned different bytes")
except (OSError, StorageBackendError) as exc:
return OperationalCheck(
id="campaign.generated_eml_storage",
label="Generated Campaign EML evidence",
state="error",
detail=(
"The generated EML object store failed a bounded write/read probe "
f"({type(exc).__name__})."
),
readiness_critical=True,
metrics={"backend": storage.name},
)
finally:
try:
storage.delete(probe)
except StorageBackendError:
pass
node_local = storage.name == "local"
return OperationalCheck(
id="campaign.generated_eml_storage",
label="Generated Campaign EML evidence",
state="warning" if node_local else "ok",
detail=(
"Generated EML passed the object-store write/read/delete probe. "
+ (
"The configured backend is node-local and is suitable only for a single-node profile."
if node_local
else "The configured backend is shared across application and worker nodes."
)
),
metrics={"backend": storage.name},
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -58,6 +58,10 @@ class AggregateOutcomeCounts(BaseModel):
model_config = ConfigDict(extra="forbid")
smtp_accepted: AggregateCount
postbox_accepted: AggregateCount
print_accepted: AggregateCount
delivered: AggregateCount
partially_accepted: AggregateCount
failed: AggregateCount
outcome_unknown: AggregateCount
queued_or_active: AggregateCount
@@ -105,6 +109,10 @@ class AggregateCampaignReport(BaseModel):
_OUTCOME_KEYS = (
"smtp_accepted",
"postbox_accepted",
"print_accepted",
"delivered",
"partially_accepted",
"failed",
"outcome_unknown",
"queued_or_active",
@@ -175,7 +183,19 @@ def _query_aggregate_facts(
if version is None:
return _AggregateFacts.empty()
accepted = CampaignJob.send_status.in_({"smtp_accepted", "sent"})
smtp_accepted = CampaignJob.send_status.in_(
{"smtp_accepted", "sent"}
)
accepted = CampaignJob.send_status.in_(
{
"smtp_accepted",
"postbox_accepted",
"print_accepted",
"delivered",
"partially_accepted",
"sent",
}
)
failed = CampaignJob.send_status.in_({"failed_temporary", "failed_permanent"})
unknown = CampaignJob.send_status == "outcome_unknown"
active = CampaignJob.send_status.in_({"queued", "claimed", "sending"})
@@ -188,7 +208,39 @@ def _query_aggregate_facts(
row = (
session.query(
func.count(CampaignJob.id).label("denominator"),
func.sum(case((accepted, 1), else_=0)).label("smtp_accepted"),
func.sum(case((smtp_accepted, 1), else_=0)).label(
"smtp_accepted"
),
func.sum(
case(
(
CampaignJob.send_status == "postbox_accepted",
1,
),
else_=0,
)
).label("postbox_accepted"),
func.sum(
case(
(CampaignJob.send_status == "print_accepted", 1),
else_=0,
)
).label("print_accepted"),
func.sum(
case(
(CampaignJob.send_status == "delivered", 1),
else_=0,
)
).label("delivered"),
func.sum(
case(
(
CampaignJob.send_status == "partially_accepted",
1,
),
else_=0,
)
).label("partially_accepted"),
func.sum(case((failed, 1), else_=0)).label("failed"),
func.sum(case((unknown, 1), else_=0)).label("outcome_unknown"),
func.sum(case((active, 1), else_=0)).label("queued_or_active"),
@@ -353,6 +405,14 @@ def _outcome_counts(jobs: list[CampaignJob]) -> dict[str, int]:
status = job.send_status
if status in {"smtp_accepted", "sent"}:
counts["smtp_accepted"] += 1
elif status == "postbox_accepted":
counts["postbox_accepted"] += 1
elif status == "print_accepted":
counts["print_accepted"] += 1
elif status == "delivered":
counts["delivered"] += 1
elif status == "partially_accepted":
counts["partially_accepted"] += 1
elif status in {"failed_temporary", "failed_permanent"}:
counts["failed"] += 1
elif status == "outcome_unknown":
@@ -460,10 +520,16 @@ def _completion_state(
return "outcome_unknown"
if counts["queued_or_active"]:
return "in_progress"
accepted = counts["smtp_accepted"]
if accepted == total:
fully_accepted = (
counts["smtp_accepted"]
+ counts["postbox_accepted"]
+ counts["print_accepted"]
+ counts["delivered"]
)
partially_accepted = counts["partially_accepted"]
if fully_accepted == total:
return "completed"
if accepted:
if fully_accepted or partially_accepted:
return "partially_completed"
return "incomplete"
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,11 @@ from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
from govoplan_campaign.backend.persistence.campaigns import load_version_config
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.integrations import SmtpConfigurationError
from govoplan_campaign.backend.integrations import (
MailDeliveryCommandError,
SmtpConfigurationError,
mail_integration,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
@@ -32,6 +36,9 @@ class CampaignReportEmailResult:
attached_jobs_csv: bool
attached_report_json: bool
accepted_count: int | None = None
command_id: str | None = None
delivery_status: str | None = None
duplicate: bool = False
def as_dict(self) -> dict[str, Any]:
return {
@@ -44,6 +51,22 @@ class CampaignReportEmailResult:
"attached_jobs_csv": self.attached_jobs_csv,
"attached_report_json": self.attached_report_json,
"accepted_count": self.accepted_count,
"command_id": self.command_id,
"delivery_status": self.delivery_status,
"duplicate": self.duplicate,
}
def audit_dict(self) -> dict[str, Any]:
return {
"campaign_id": self.campaign_id,
"version_id": self.version_id,
"recipient_count": len(self.to),
"dry_run": self.dry_run,
"attached_jobs_csv": self.attached_jobs_csv,
"attached_report_json": self.attached_report_json,
"command_id": self.command_id,
"delivery_status": self.delivery_status,
"duplicate": self.duplicate,
}
@@ -149,6 +172,8 @@ def send_campaign_report_email(
attach_jobs_csv: bool = False,
attach_report_json: bool = False,
dry_run: bool = False,
idempotency_key: str | None = None,
created_by_user_id: str | None = None,
) -> CampaignReportEmailResult:
campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
@@ -175,11 +200,16 @@ def send_campaign_report_email(
) from exc
if not snapshot.smtp_transport_revision:
raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision")
mail = mail_integration()
if not dry_run:
raise CampaignReportEmailError(
"Report email delivery is disabled until it uses a durable, idempotent Mail-owned outbox with unknown-outcome reconciliation."
)
if not mail.durable_delivery_available:
raise CampaignReportEmailError(
"Report email delivery requires the durable, idempotent Mail-owned outbox."
)
if not str(idempotency_key or "").strip():
raise CampaignReportEmailError(
"Live report email delivery requires an idempotency key"
)
report = generate_campaign_report(
session,
tenant_id=tenant_id,
@@ -202,6 +232,48 @@ def send_campaign_report_email(
jobs_csv=jobs_csv,
report_json=report_json,
)
if not dry_run:
clean_idempotency_key = str(idempotency_key or "").strip()
from_email, _from_name = _effective_from(config)
if not snapshot.mail_profile_id:
raise CampaignReportEmailError(
"Campaign build evidence has no Mail profile reference"
)
try:
command = mail.submit_delivery_command(
session,
tenant_id=tenant_id,
command_type="campaign_report",
source_module="campaigns",
source_resource_type="campaign",
source_resource_id=campaign.id,
source_version_id=version.id,
idempotency_key=clean_idempotency_key,
profile_id=snapshot.mail_profile_id,
message_bytes=message.as_bytes(),
envelope_from=from_email,
envelope_recipients=to,
from_header=str(message["From"]),
expected_smtp_transport_revision=snapshot.smtp_transport_revision,
smtp_server_id=snapshot.smtp_server_id,
smtp_credential_id=snapshot.smtp_credential_id,
created_by_user_id=created_by_user_id,
)
except MailDeliveryCommandError as exc:
raise CampaignReportEmailError(str(exc)) from exc
return CampaignReportEmailResult(
campaign_id=campaign.id,
version_id=version.id,
to=to,
subject=str(message["Subject"]),
dry_run=False,
sent=False,
attached_jobs_csv=jobs_csv is not None,
attached_report_json=report_json is not None,
command_id=str(command["id"]),
delivery_status=str(command["status"]),
duplicate=bool(command.get("duplicate")),
)
return CampaignReportEmailResult(
campaign_id=campaign.id,
version_id=version.id,
@@ -0,0 +1,308 @@
"""Cross-module provider for Campaign's recipient-free aggregate report."""
from __future__ import annotations
from collections.abc import Mapping
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign
from govoplan_campaign.backend.report_privacy_policy import (
effective_campaign_report_privacy_policy,
)
from govoplan_campaign.backend.reports.aggregate import (
generate_aggregate_campaign_report,
)
from govoplan_campaign.backend.route_support import (
_campaign_query_for_principal,
_get_campaign_for_principal,
)
from govoplan_core.auth import has_scope
from govoplan_core.core.reporting import (
REPORT_PROVIDER_CONTRACT_VERSION,
ReportDescriptor,
ReportParameterDescriptor,
ReportParameterOption,
ReportPrivacyTransform,
ReportProviderRequest,
ReportProviderResult,
ReportResultField,
)
CAMPAIGN_AGGREGATE_REPORT_ID = "delivery-outcomes"
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS = (
"server_side_aggregation",
"small_cell_suppression",
"complementary_suppression",
"explicit_denominator",
"recipient_payload_exclusion",
)
class CampaignAggregateReportProvider:
provider_id = "campaigns"
contract_version = REPORT_PROVIDER_CONTRACT_VERSION
def list_reports(
self,
session: object,
principal: object,
) -> tuple[ReportDescriptor, ...]:
del session
if not has_scope(principal, "campaigns:report:read"):
return ()
return (_descriptor(),)
def parameter_options(
self,
session: object,
principal: object,
*,
report_id: str,
parameter_key: str,
query: str,
limit: int,
) -> tuple[ReportParameterOption, ...]:
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or parameter_key != "campaign_id":
return ()
if not has_scope(principal, "campaigns:report:read"):
return ()
sql_session = _session(session)
rows = _campaign_query_for_principal(sql_session, principal)
clean_query = query.strip().casefold()
campaigns = (
rows.order_by(Campaign.updated_at.desc(), Campaign.id.asc())
.limit(max(1, min(limit, 200)) if not clean_query else 500)
.all()
)
if clean_query:
campaigns = [
campaign
for campaign in campaigns
if clean_query in campaign.name.casefold()
][: max(1, min(limit, 200))]
return tuple(
ReportParameterOption(
value=campaign.id,
label=campaign.name,
description=campaign.status,
)
for campaign in campaigns
)
def execute_report(
self,
session: object,
principal: object,
*,
request: ReportProviderRequest,
) -> ReportProviderResult:
if request.report_id != CAMPAIGN_AGGREGATE_REPORT_ID:
raise LookupError("Campaign report provider does not know this report")
if not has_scope(principal, "campaigns:report:read"):
raise PermissionError("Missing scope: campaigns:report:read")
campaign_id = str(request.parameters.get("campaign_id") or "").strip()
if not campaign_id:
raise ValueError("campaign_id is required")
version_id = str(request.parameters.get("version_id") or "").strip() or None
sql_session = _session(session)
campaign = _get_campaign_for_principal(sql_session, campaign_id, principal)
report = generate_aggregate_campaign_report(
sql_session,
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
version_id=version_id,
)
policy = effective_campaign_report_privacy_policy(
sql_session,
tenant_id=principal.tenant_id,
)
selected_version_id = version_id or campaign.current_version_id
return ReportProviderResult(
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
generated_at=report.generated_at,
payload=report.model_dump(mode="json"),
source_revisions=(
{
"module_id": "campaigns",
"resource_type": "campaign",
"resource_id": campaign.id,
"revision_type": "campaign_version",
"revision_id": selected_version_id,
"version_number": report.version_number,
"updated_at": campaign.updated_at.isoformat(),
},
),
effective_scope={
"tenant_id": principal.tenant_id,
"campaign_id": campaign.id,
"campaign_version_id": selected_version_id,
"audience": dict(request.audience_scope),
},
applied_privacy_transforms=CAMPAIGN_REPORT_PRIVACY_TRANSFORMS,
provenance={
"provider": "campaigns",
"projection": "recipient-free-delivery-outcomes-v1",
"privacy_policy": policy.as_dict(),
"purpose": request.purpose,
},
)
def authorize_result(
self,
session: object,
principal: object,
*,
report_id: str,
source_revisions: tuple[Mapping[str, object], ...],
effective_scope: Mapping[str, object],
) -> bool:
del source_revisions
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or not has_scope(
principal, "campaigns:report:read"
):
return False
campaign_id = str(effective_scope.get("campaign_id") or "").strip()
tenant_id = str(effective_scope.get("tenant_id") or "").strip()
if not campaign_id or tenant_id != str(getattr(principal, "tenant_id", "")):
return False
return (
_campaign_query_for_principal(_session(session), principal)
.filter(Campaign.id == campaign_id)
.first()
is not None
)
def _descriptor() -> ReportDescriptor:
fields = (
("generated_at", "Generated", "datetime", "Campaign"),
("campaign.id", "Campaign ID", "string", "Campaign"),
("campaign.name", "Campaign", "string", "Campaign"),
("campaign.status", "Status", "string", "Campaign"),
("version_number", "Version", "integer", "Campaign"),
("completion_state", "Completion", "string", "Campaign"),
(
"population.denominator",
"Report denominator",
"suppressed_count",
"Population",
),
(
"population.denominator_definition",
"Denominator definition",
"string",
"Population",
),
(
"population.inactive_source_entries",
"Inactive source entries",
"suppressed_count",
"Population",
),
(
"population.excluded_or_blocked_jobs",
"Excluded or blocked jobs",
"suppressed_count",
"Population",
),
("outcomes.smtp_accepted", "SMTP accepted", "suppressed_count", "Outcomes"),
(
"outcomes.postbox_accepted",
"Postbox accepted",
"suppressed_count",
"Outcomes",
),
(
"outcomes.print_accepted",
"Printable output accepted",
"suppressed_count",
"Outcomes",
),
(
"outcomes.delivered",
"Both channels accepted",
"suppressed_count",
"Outcomes",
),
(
"outcomes.partially_accepted",
"Partially accepted",
"suppressed_count",
"Outcomes",
),
("outcomes.failed", "Failed", "suppressed_count", "Outcomes"),
("outcomes.outcome_unknown", "Outcome unknown", "suppressed_count", "Outcomes"),
(
"outcomes.queued_or_active",
"Queued or active",
"suppressed_count",
"Outcomes",
),
("outcomes.not_attempted", "Not attempted", "suppressed_count", "Outcomes"),
("outcomes.cancelled", "Cancelled", "suppressed_count", "Outcomes"),
("outcomes.excluded", "Excluded", "suppressed_count", "Outcomes"),
("time_range.first_activity_at", "First activity", "datetime", "Activity"),
("time_range.last_activity_at", "Last activity", "datetime", "Activity"),
("time_range.suppressed", "Activity range suppressed", "boolean", "Activity"),
("privacy.small_cell_threshold", "Small-cell threshold", "integer", "Privacy"),
("privacy.suppression_applied", "Suppression applied", "boolean", "Privacy"),
("privacy.rule", "Privacy rule", "string", "Privacy"),
)
return ReportDescriptor(
provider_id="campaigns",
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
revision="campaign.aggregate.v1",
title="Campaign delivery outcomes",
summary=(
"Privacy-protected delivery outcomes without recipient-level records."
),
parameters=(
ReportParameterDescriptor(
key="campaign_id",
label="Campaign",
type="reference",
required=True,
options_from_provider=True,
),
ReportParameterDescriptor(
key="version_id",
label="Campaign version",
type="string",
required=False,
description="Leave empty to use the current campaign version.",
),
),
result_schema=tuple(
ReportResultField(
path=path,
label=label,
type=field_type, # type: ignore[arg-type]
group=group,
nullable=path.startswith("time_range.") or path == "version_number",
)
for path, label, field_type, group in fields
),
privacy_transforms=tuple(
ReportPrivacyTransform(id=item, label=item.replace("_", " ").title())
for item in CAMPAIGN_REPORT_PRIVACY_TRANSFORMS
),
retention_class="stored_report_detail",
export_formats=("json",),
reidentification_risk="low",
presentation={"kind": "metric_summary"},
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Campaign report provider requires a SQLAlchemy Session")
return value
__all__ = [
"CAMPAIGN_AGGREGATE_REPORT_ID",
"CAMPAIGN_REPORT_PRIVACY_TRANSFORMS",
"CampaignAggregateReportProvider",
]
@@ -48,7 +48,12 @@ _SEND_NOW_RESULT_KEYS = (
"failed_count",
"outcome_unknown_count",
"skipped_count",
"paused_count",
"preflight_count",
"batch_state",
"batch_pause_reason_code",
"smtp_connection_count",
"smtp_reconnect_count",
"delivery_mode",
"dry_run",
)
@@ -110,6 +115,7 @@ def public_delivery_result_message(
last_error: Any,
send_status: Any,
imap_status: Any,
postbox_status: Any = None,
) -> str | None:
"""Map persisted provider text to a stable business-safe explanation."""
@@ -117,10 +123,22 @@ def public_delivery_result_message(
return None
clean_send_status = str(send_status or "")
clean_imap_status = str(imap_status or "")
clean_postbox_status = str(postbox_status or "")
if clean_postbox_status == "outcome_unknown":
return "Postbox delivery outcome requires operator reconciliation."
if clean_send_status == "outcome_unknown":
return "SMTP delivery outcome requires operator reconciliation."
return "Delivery outcome requires operator reconciliation."
if clean_postbox_status in {
"rejected_temporary",
"rejected_permanent",
}:
return "Postbox delivery was rejected; an operator can inspect restricted diagnostics."
if clean_postbox_status == "partially_accepted":
return "Some Postbox targets accepted the message and others rejected it."
if clean_send_status in {"failed_temporary", "failed_permanent"}:
return "SMTP delivery failed; an operator can inspect restricted diagnostics."
if clean_postbox_status in {"", "not_requested"}:
return "SMTP delivery failed; an operator can inspect restricted diagnostics."
return "Delivery failed; an operator can inspect restricted diagnostics."
if clean_imap_status in {"outcome_unknown", "appending"}:
return "Sent-folder append outcome requires operator reconciliation."
if clean_imap_status in {"failed", "skipped"}:
+328 -7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
import hashlib
import json
from datetime import datetime, timedelta, timezone
@@ -9,7 +10,35 @@ from typing import Any, Callable
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
from govoplan_core.core.recovery import (
RecoveryGuaranteeError,
RecoveryMode,
RecoveryPlan,
RecoveryStatus,
)
from govoplan_core.core.recovery_runtime import (
DurableRecoveryOperation,
RecoveryOperationBusy,
RecoveryOperationStateConflict,
begin_durable_recovery_operation,
)
from govoplan_core.core.object_storage import (
StorageBackend,
StorageBackendError,
StorageObjectMissing,
configured_storage_backend,
)
from govoplan_core.core.runtime_coordination import process_runtime_identity
from govoplan_core.db.session import get_database
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import (
CampaignJob,
CampaignSchedule,
CampaignVersion,
JobImapStatus,
JobQueueStatus,
)
from govoplan_campaign.backend.runtime import get_settings
FINAL_VERSION_STATES = {
"completed",
@@ -28,6 +57,15 @@ FINAL_EML_SEND_STATUSES = {
}
@dataclass(frozen=True, slots=True)
class _GeneratedArtifactRecovery:
operation: DurableRecoveryOperation
job_id: str
storage_key: str | None
local_path: str | None
storage: StorageBackend | None
def _cutoff(days: int | None, *, now: datetime) -> datetime | None:
if days is None:
return None
@@ -105,14 +143,233 @@ def _apply_raw_json_retention(
return result
def _artifact_locator_sha256(
*,
storage_key: str | None,
local_path: str | None,
) -> str:
return _json_sha256(
{
"storage_key": storage_key,
"local_path": local_path,
}
)
def _begin_generated_artifact_recovery(
*,
job: CampaignJob,
storage: StorageBackend | None,
) -> _GeneratedArtifactRecovery | None:
storage_key = str(job.eml_storage_key) if job.eml_storage_key else None
local_path = str(job.eml_local_path) if job.eml_local_path else None
locator_sha256 = _artifact_locator_sha256(
storage_key=storage_key,
local_path=local_path,
)
try:
started = begin_durable_recovery_operation(
get_database().SessionLocal,
identity=process_runtime_identity(),
module_id="campaigns",
operation_type="generated-artifact-retention",
idempotency_key=(
f"campaign-retention:{job.id}:{locator_sha256[:32]}"
),
request={
"tenant_id": job.tenant_id,
"campaign_id": job.campaign_id,
"version_id": job.campaign_version_id,
"job_id": job.id,
"message_sha256": job.eml_sha256,
"artifact_locator_sha256": locator_sha256,
},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=(
"the Campaign job is terminal and outside its retention window",
"no IMAP append or delivery outcome remains unresolved",
),
forward_recovery_steps=(
"verify whether each recorded artifact still exists",
"clear the database locator only after absence is established",
),
verification_steps=(
"reload the Campaign job through an independent session",
"probe every original object or local-development path",
),
),
precondition_evidence={
"job_id": job.id,
"queue_status": job.queue_status,
"send_status": job.send_status,
"imap_status": job.imap_status,
"message_sha256": job.eml_sha256,
"artifact_locator_sha256": locator_sha256,
},
lease_resource_key=f"campaign:retention:{job.tenant_id}:{job.id}",
lease_ttl_seconds=15 * 60,
resource_type="campaign_job",
resource_id=job.id,
metadata={
"resources": [
"postgresql",
"object-storage" if storage_key else "local-development-storage",
],
},
)
except (RecoveryOperationBusy, RecoveryOperationStateConflict):
return None
if started.replayed or started.operation is None:
return None
return _GeneratedArtifactRecovery(
operation=started.operation,
job_id=job.id,
storage_key=storage_key,
local_path=local_path,
storage=storage,
)
def _generated_artifact_recovery_evidence(
recovery: _GeneratedArtifactRecovery,
) -> tuple[str, dict[str, Any]]:
probes: dict[str, bool | None] = {}
if recovery.storage_key:
try:
if recovery.storage is None:
raise StorageBackendError("Artifact storage is unavailable")
probes["object_missing"] = not recovery.storage.exists(
recovery.storage_key
)
except (StorageBackendError, OSError):
probes["object_missing"] = None
if recovery.local_path:
try:
probes["local_path_missing"] = not Path(recovery.local_path).exists()
except OSError:
probes["local_path_missing"] = None
with get_database().SessionLocal() as evidence_session:
job = evidence_session.get(CampaignJob, recovery.job_id)
job_present = job is not None
metadata_cleared = bool(
job is None
or (
(
not recovery.storage_key
or job.eml_storage_key != recovery.storage_key
)
and (
not recovery.local_path
or job.eml_local_path != recovery.local_path
)
)
)
metadata_intact = bool(
job is not None
and job.eml_storage_key == recovery.storage_key
and job.eml_local_path == recovery.local_path
)
probe_values = tuple(probes.values())
probe_verified = bool(probe_values) and all(
value is not None for value in probe_values
)
artifacts_absent = probe_verified and all(value is True for value in probe_values)
artifacts_intact = probe_verified and all(value is False for value in probe_values)
evidence = {
"verified": probe_verified,
"checks": {
"job_state_reloaded": True,
"artifact_locations_probed": probe_verified,
},
"job_present": job_present,
"metadata_cleared": metadata_cleared,
"metadata_intact": metadata_intact,
"artifact_probes": probes,
}
if not probe_verified:
return "outcome_unknown", evidence
if artifacts_absent and metadata_cleared:
return "succeeded", evidence
if artifacts_intact and metadata_intact:
return "failed", evidence
return "recovery_required", evidence
def _finish_generated_artifact_recovery(
recovery: _GeneratedArtifactRecovery,
) -> None:
outcome, evidence = _generated_artifact_recovery_evidence(recovery)
if outcome == "succeeded":
recovery.operation.succeed(evidence=evidence)
elif outcome == "failed":
recovery.operation.reject(
summary="Generated Campaign artifacts were not deleted",
evidence=evidence,
)
elif outcome == "outcome_unknown":
recovery.operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="Generated artifact deletion could not be verified",
evidence=evidence,
failure_summary="Artifact storage availability prevented verification",
)
else:
recovery.operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="Generated artifact retention is only partially complete",
evidence=evidence,
failure_summary="Artifact and Campaign metadata state require reconciliation",
)
def _finish_generated_artifact_recoveries(
recoveries: list[_GeneratedArtifactRecovery],
) -> None:
failures: list[Exception] = []
for recovery in recoveries:
try:
_finish_generated_artifact_recovery(recovery)
except Exception as exc: # preserve every operation's chance to close
failures.append(exc)
if failures:
raise RecoveryGuaranteeError(
f"{len(failures)} Campaign retention recovery operation(s) could not be finalized"
) from failures[0]
def _apply_eml_retention(
session: Session,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
storage: StorageBackend | None = None,
recovery_operations: list[_GeneratedArtifactRecovery] | None = None,
) -> dict[str, int]:
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}
result = {
"eligible": 0,
"metadata_cleared": 0,
"files_deleted": 0,
"files_missing": 0,
"delete_failed": 0,
"recovery_blocked": 0,
"skipped_not_final": 0,
"skipped_schedule_source": 0,
}
protected_source_versions = {
str(version_id)
for (version_id,) in (
session.query(CampaignSchedule.source_version_id)
.filter(
CampaignSchedule.delivery_mode == "autonomous",
CampaignSchedule.next_fire_at.is_not(None),
)
.all()
)
}
jobs = (
session.query(CampaignJob)
.filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None)))
@@ -120,6 +377,9 @@ def _apply_eml_retention(
.all()
)
for job in jobs:
if getattr(job, "campaign_version_id", None) in protected_source_versions:
result["skipped_schedule_source"] += 1
continue
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):
@@ -137,6 +397,33 @@ def _apply_eml_retention(
result["eligible"] += 1
if dry_run:
continue
active_storage = storage
if job.eml_storage_key and active_storage is None:
active_storage = configured_storage_backend(
get_settings() or core_settings
)
if recovery_operations is not None:
recovery = _begin_generated_artifact_recovery(
job=job,
storage=active_storage,
)
if recovery is None:
result["recovery_blocked"] += 1
continue
recovery_operations.append(recovery)
if job.eml_storage_key:
assert active_storage is not None
try:
if active_storage.exists(job.eml_storage_key):
active_storage.delete(job.eml_storage_key)
result["files_deleted"] += 1
else:
result["files_missing"] += 1
except StorageObjectMissing:
result["files_missing"] += 1
except StorageBackendError:
result["delete_failed"] += 1
continue
if job.eml_local_path:
path = Path(job.eml_local_path)
if path.exists():
@@ -188,8 +475,42 @@ def apply_campaign_retention(
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),
}
recoveries: list[_GeneratedArtifactRecovery] = []
try:
# Start external-effect fences before queries for database-only
# redaction can autoflush unrelated changes in the caller session.
generated_eml = _apply_eml_retention(
session,
dry_run=dry_run,
now=now,
policy_for_campaign_id=policy_for_campaign_id,
recovery_operations=None if dry_run else recoveries,
)
result = {
"raw_campaign_json": _apply_raw_json_retention(
session,
dry_run=dry_run,
now=now,
policy_for_campaign_id=policy_for_campaign_id,
),
"generated_eml": generated_eml,
"stored_report_detail": _apply_report_detail_retention(
session,
dry_run=dry_run,
now=now,
policy_for_campaign_id=policy_for_campaign_id,
),
}
if not dry_run:
# External artifact deletion and its locator update form one
# module-owned recovery boundary. The outer Policy audit commits
# separately after Campaign has verified this boundary.
session.commit()
except Exception:
session.rollback()
if recoveries:
_finish_generated_artifact_recoveries(recoveries)
raise
if recoveries:
_finish_generated_artifact_recoveries(recoveries)
return result
@@ -0,0 +1,717 @@
from __future__ import annotations
import copy
import dataclasses
from collections.abc import Callable
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import and_, exists, or_
from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CAMPAIGN_MAIL_SERVER_KEYS,
campaign_mail_profile_id,
)
from govoplan_campaign.backend.archive_encryption import (
CampaignArchiveEncryptionError,
stamp_legacy_zipcrypto_acknowledgements,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignShare,
CampaignStatus,
CampaignVersion,
CampaignVersionWorkflowState,
RecipientImportMappingProfile,
)
from govoplan_campaign.backend.path_security import CampaignPathSecurityError
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError
from govoplan_campaign.backend.persistence.versions import (
LockedCampaignVersionError,
is_user_locked_version,
is_version_final_locked,
is_version_locked,
update_campaign_version,
)
from govoplan_campaign.backend.schemas import (
CampaignVersionDetailResponse,
CampaignVersionUpdateRequest,
RecipientImportMappingProfilePayload,
)
from govoplan_campaign.backend.sending.execution import (
clear_execution_snapshot,
)
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.runtime import get_registry
def _capability_payload(value: object) -> dict[str, Any]:
if dataclasses.is_dataclass(value):
return dataclasses.asdict(value)
if isinstance(value, dict):
return dict(value)
payload: dict[str, Any] = {}
for key in (
"contact_id",
"address_book_id",
"display_name",
"email",
"email_label",
"organization",
"role_title",
"tags",
"source_kind",
"source_ref",
"source_revision",
"source_id",
"source_label",
"recipient_count",
"generated_at",
"recipients",
"fields",
"provenance",
):
if hasattr(value, key):
payload[key] = getattr(value, key)
return payload
def _registry_capability(name: str) -> object | None:
registry = get_registry()
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _access_directory() -> AccessDirectory:
registry = get_registry()
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Access directory capability is not configured",
)
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
if not isinstance(capability, AccessDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Access directory capability is invalid",
)
return capability
def _get_campaign_for_tenant(
session: Session, campaign_id: str, tenant_id: str
) -> Campaign:
campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found"
)
return campaign
def _get_version_for_tenant(
session: Session, version_id: str, tenant_id: str
) -> CampaignVersion:
version = session.get(CampaignVersion, version_id)
if not version:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
)
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
)
return version
def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]:
del session
return {
group.id
for group in _access_directory().groups_for_user(
principal.user.id, tenant_id=principal.tenant_id
)
}
def _campaign_acl_filter(session: Session, principal: ApiPrincipal):
if has_scope(principal, "tenant:*"):
return None
group_ids = _principal_group_ids(session, principal)
clauses = [Campaign.owner_user_id == principal.user.id]
if group_ids:
clauses.append(Campaign.owner_group_id.in_(group_ids))
share_clauses = [
and_(
CampaignShare.tenant_id == Campaign.tenant_id,
CampaignShare.campaign_id == Campaign.id,
CampaignShare.revoked_at.is_(None),
CampaignShare.target_type == "user",
CampaignShare.target_id == principal.user.id,
)
]
if group_ids:
share_clauses.append(
and_(
CampaignShare.tenant_id == Campaign.tenant_id,
CampaignShare.campaign_id == Campaign.id,
CampaignShare.revoked_at.is_(None),
CampaignShare.target_type == "group",
CampaignShare.target_id.in_(group_ids),
)
)
clauses.append(exists().where(or_(*share_clauses)))
return or_(*clauses)
def _campaign_acl_allows(
session: Session,
campaign: Campaign,
principal: ApiPrincipal,
*,
write: bool = False,
) -> bool:
if has_scope(principal, "tenant:*"):
return True
if campaign.owner_user_id == principal.user.id:
return True
group_ids = _principal_group_ids(session, principal)
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
return True
target_ids = [principal.user.id, *group_ids]
if not target_ids:
return False
query = session.query(CampaignShare).filter(
CampaignShare.tenant_id == campaign.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
or_(
CampaignShare.target_type == "user",
CampaignShare.target_type == "group",
),
CampaignShare.target_id.in_(target_ids),
)
shares = query.all()
if not shares:
return False
if not write:
return True
return any(item.permission == "write" for item in shares)
def _require_campaign_acl(
session: Session,
campaign: Campaign,
principal: ApiPrincipal,
*,
write: bool = False,
) -> None:
if not _campaign_acl_allows(session, campaign, principal, write=write):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Campaign is not shared with this principal",
)
def _get_campaign_for_principal(
session: Session, campaign_id: str, principal: ApiPrincipal, *, write: bool = False
) -> Campaign:
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
_require_campaign_acl(session, campaign, principal, write=write)
return campaign
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}"
)
def _campaign_query_for_principal(session: Session, principal: ApiPrincipal):
query = session.query(Campaign).filter(
Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted"
)
acl_filter = _campaign_acl_filter(session, principal)
if acl_filter is not None:
query = query.filter(acl_filter)
return query
def _get_recipient_import_profile_for_principal(
session: Session, profile_id: str, principal: ApiPrincipal
) -> RecipientImportMappingProfile:
profile = session.get(RecipientImportMappingProfile, profile_id)
if (
not profile
or profile.tenant_id != principal.tenant_id
or profile.owner_user_id != principal.user.id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Recipient import mapping profile not found",
)
return profile
def _apply_recipient_import_profile_payload(
profile: RecipientImportMappingProfile,
payload: RecipientImportMappingProfilePayload,
) -> None:
profile.name = payload.name.strip()
profile.column_count = payload.column_count
profile.headers = list(payload.headers)
profile.normalized_headers = list(payload.normalized_headers)
profile.ordered_header_fingerprint = payload.ordered_header_fingerprint
profile.unordered_header_fingerprint = payload.unordered_header_fingerprint
profile.delimiter = payload.delimiter
profile.header_rows = payload.header_rows
profile.quoted = payload.quoted
profile.value_separators = payload.value_separators
profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings]
def _recipient_sections_changed(
current: dict[str, object] | None, proposed: dict[str, object] | None
) -> bool:
if proposed is None:
return False
current = current or {}
return any(
current.get(key) != proposed.get(key) for key in ("recipients", "entries")
)
def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None:
return campaign_mail_profile_id(raw_json)
def _require_mail_profile_use_if_needed(
principal: ApiPrincipal, raw_json: dict[str, object] | None
) -> None:
if _campaign_mail_profile_id(raw_json) and not has_scope(
principal, "mail:profile:use"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing scope: mail:profile:use",
)
def _campaign_response_context(principal: ApiPrincipal) -> dict[str, bool]:
return {"include_diagnostics": has_scope(principal, "campaigns:diagnostic:read")}
def _campaign_version_detail_response(
session: Session,
principal: ApiPrincipal,
campaign_id: str,
mutation: Callable[[], CampaignVersion],
*,
audit_action: str,
details: dict[str, Any] | Callable[[CampaignVersion], dict[str, Any]] | None = None,
validation_error_status: int | None = None,
) -> CampaignVersionDetailResponse:
try:
version = mutation()
audit_details = (
details(version)
if callable(details)
else dict(details or {"campaign_id": campaign_id})
)
audit_from_principal(
session,
principal,
action=audit_action,
object_type="campaign_version",
object_id=version.id,
details=audit_details,
commit=True,
)
_write_current_version_snapshot_if_available(version)
return CampaignVersionDetailResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
except LockedCampaignVersionError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except CampaignPathSecurityError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except CampaignPersistenceError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except RevisionConflictError:
session.rollback()
raise
except Exception as exc:
session.rollback()
if validation_error_status is None:
raise
raise HTTPException(
status_code=validation_error_status, detail=str(exc)
) from exc
def _update_campaign_version_detail_response(
session: Session,
principal: ApiPrincipal,
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
*,
if_match: str | None,
autosave: bool,
audit_action: str,
) -> CampaignVersionDetailResponse:
campaign = _get_campaign_for_principal(
session, campaign_id, principal, write=True
)
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if payload.base_revision is None:
error = MissingPreconditionError(
resource_type="campaign_version",
resource_id=version_id,
)
raise HTTPException(
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
detail=error.as_dict(),
)
try:
assert_revision_precondition(
if_match,
resource_type="campaign_version",
resource_id=version_id,
submitted_base_revision=payload.base_revision,
)
except MissingPreconditionError as exc:
raise HTTPException(
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
detail=exc.as_dict(),
) from exc
except ConcurrencyError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "invalid_precondition",
"message": str(exc),
},
) from exc
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
_require_permission(principal, "campaigns:recipient:write")
acknowledgements: list[dict[str, Any]] = []
try:
payload.campaign_json, acknowledgements = (
stamp_legacy_zipcrypto_acknowledgements(
session,
campaign,
current_version.raw_json
if isinstance(current_version.raw_json, dict)
else {},
payload.campaign_json,
principal=principal,
)
)
except CampaignArchiveEncryptionError as exc:
audit_from_principal(
session,
principal,
action="campaign.archive_encryption_denied",
object_type="campaign_version",
object_id=version_id,
details={"campaign_id": campaign_id, "reason": str(exc)},
commit=True,
)
raise HTTPException(
status_code=(
status.HTTP_403_FORBIDDEN
if "Missing scope:" in str(exc)
else status.HTTP_422_UNPROCESSABLE_CONTENT
),
detail=str(exc),
) from exc
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
try:
result = _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=payload.campaign_json,
current_flow=payload.current_flow,
current_step=payload.current_step,
workflow_state=payload.workflow_state,
is_complete=payload.is_complete,
editor_state=payload.editor_state,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=autosave,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
expected_revision=payload.base_revision,
commit=False,
),
audit_action=audit_action,
details=lambda version: {
"campaign_id": campaign_id,
"current_flow": version.current_flow,
"current_step": version.current_step,
"base_revision": payload.base_revision,
"result_revision": version.edit_revision,
"reconciliation_kind": payload.reconciliation_kind,
"resolved_conflict_path_count": len(payload.resolved_conflict_paths),
"resolved_conflict_sections": sorted(
{
path.strip("/").split("/", 1)[0][:80]
for path in payload.resolved_conflict_paths[:100]
if path.strip("/")
}
),
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
"legacy_zipcrypto_acknowledgements": acknowledgements,
},
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
)
for acknowledgement in acknowledgements:
audit_from_principal(
session,
principal,
action="campaign.legacy_zipcrypto_acknowledged",
object_type="campaign_version",
object_id=version_id,
details={"campaign_id": campaign_id, **acknowledgement},
commit=False,
)
if acknowledgements:
session.commit()
return result
except RevisionConflictError as exc:
session.rollback()
audit_from_principal(
session,
principal,
action="campaign.version_conflict_detected",
object_type="campaign_version",
object_id=version_id,
details={
"campaign_id": campaign_id,
"submitted_base_revision": exc.submitted_base_revision,
"current_revision": exc.current_revision,
"retryable": True,
},
commit=True,
)
raise HTTPException(
status_code=status.HTTP_412_PRECONDITION_FAILED,
detail=exc.as_dict(),
) from exc
def _require_campaign_profile_use_if_needed(
session: Session,
principal: ApiPrincipal,
campaign_id: str,
version_id: str | None = None,
) -> None:
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
target_version_id = version_id or campaign.current_version_id
if not target_version_id:
return
version = _get_version_for_tenant(session, target_version_id, principal.tenant_id)
if version.campaign_id != campaign.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
)
_require_mail_profile_use_if_needed(
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
)
def _require_campaign_versions_profile_use(
session: Session,
principal: ApiPrincipal,
campaign_id: str,
version_ids: set[str],
) -> None:
"""Authorize every historical version affected by a campaign-wide action."""
for version_id in sorted(version_ids):
_require_campaign_profile_use_if_needed(
session,
principal,
campaign_id,
version_id,
)
def _get_version_for_principal(
session: Session,
version_id: str,
principal: ApiPrincipal,
*,
write: bool = False,
) -> CampaignVersion:
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
campaign = _get_campaign_for_tenant(
session, version.campaign_id, principal.tenant_id
)
_require_campaign_acl(session, campaign, principal, write=write)
return version
def _sync_campaign_metadata_to_current_version(
session: Session, campaign: Campaign
) -> None:
"""Keep editable version JSON aligned with version-independent campaign metadata.
Campaign metadata can be edited from the overview while individual campaign
sections save the current version JSON later. Without this sync, a later
version save can re-apply stale `campaign.name` / `campaign.id` values from
raw_json and make the old overview metadata appear to come back. Audit-safe
or validation-locked versions are left untouched.
"""
if not campaign.current_version_id:
return
version = session.get(CampaignVersion, campaign.current_version_id)
if not version or version.campaign_id != campaign.id or is_version_locked(version):
return
raw_json = copy.deepcopy(
version.raw_json if isinstance(version.raw_json, dict) else {}
)
campaign_section = (
raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
)
raw_json["campaign"] = {
**campaign_section,
"id": campaign.external_id,
"name": campaign.name,
"description": campaign.description or "",
}
version.raw_json = raw_json
session.add(version)
def _clear_current_version_mail_profile_for_owner_transfer(
session: Session, campaign: Campaign
) -> bool:
"""Force explicit profile reselection after campaign ownership changes.
User/group-scoped reusable mail profiles are evaluated against the current
owner. Instead of trying to keep a stale selection across an ownership
transfer, clear the profile from the editable current version and invalidate
validation/build state so the operator has to reselect and revalidate.
"""
if not campaign.current_version_id:
return False
version = session.get(CampaignVersion, campaign.current_version_id)
if not version or version.campaign_id != campaign.id:
return False
raw_json = copy.deepcopy(
version.raw_json if isinstance(version.raw_json, dict) else {}
)
server = (
raw_json.get("server") if isinstance(raw_json.get("server"), dict) else None
)
if not isinstance(server, dict):
return False
profile_id = _campaign_mail_profile_id(raw_json)
if not profile_id:
return False
if is_version_final_locked(version) or is_user_locked_version(version):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Change owner only after creating an editable campaign version; the current version has a selected mail profile and is locked.",
)
next_server = dict(server)
for key in CAMPAIGN_MAIL_SERVER_KEYS:
next_server.pop(key, None)
next_server.pop("profile_id", None)
raw_json["server"] = next_server
version.raw_json = raw_json
version.validation_summary = None
version.build_summary = None
clear_execution_snapshot(version)
version.locked_at = None
version.locked_by_user_id = None
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
version.is_complete = False
editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None)
editor_state.pop("approval_gate", None)
version.editor_state = editor_state
session.query(CampaignIssue).filter(
CampaignIssue.campaign_version_id == version.id
).delete(synchronize_session=False)
session.query(CampaignJob).filter(
CampaignJob.campaign_version_id == version.id
).delete(synchronize_session=False)
campaign.status = CampaignStatus.DRAFT.value
session.add(version)
_write_current_version_snapshot_if_available(version)
return True
def _write_current_version_snapshot_if_available(version: CampaignVersion) -> None:
# Kept as a compatibility no-op for callers outside this package. Campaign
# JSON is database-authoritative and no longer mirrored onto an API node.
del version
def bounded_query_rows(query, *, limit: int, label: str):
rows = query.limit(limit + 1).all()
if len(rows) > limit:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
f"{label} exceeds the maximum response size of {limit} rows. "
"Narrow the request or use a paginated/delta endpoint."
),
)
return rows
def job_attempt_rows(query, *, label: str):
return bounded_query_rows(query, limit=1000, label=label)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"""Focused HTTP route modules for the campaign API."""
@@ -0,0 +1,988 @@
from __future__ import annotations
import base64
import binascii
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignShare,
CampaignWorkAssignment,
CampaignWorkAssignmentEvent,
)
from govoplan_campaign.backend.route_support import (
_access_directory,
_get_campaign_for_principal,
_require_permission,
)
from govoplan_campaign.backend.routes.collaboration import _validated_reference
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.schemas import (
CampaignWorkAssigneeInput,
CampaignWorkAssignmentCreateRequest,
CampaignWorkAssignmentEventResponse,
CampaignWorkAssignmentHistoryResponse,
CampaignWorkAssignmentListResponse,
CampaignWorkAssignmentReassignRequest,
CampaignWorkAssignmentReconcileResponse,
CampaignWorkAssignmentReferenceResponse,
CampaignWorkAssignmentResponse,
CampaignWorkAssignmentTransitionRequest,
)
from govoplan_core.api.v1.schemas import ReferenceOptionListResponse, ReferenceOptionResponse
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.core.idm import (
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
IdmFunctionAssignmentDirectory,
)
from govoplan_core.core.notifications import (
NotificationDispatchRequest,
notification_dispatch_provider,
)
from govoplan_core.core.organizations import organization_directory
from govoplan_core.core.references import ReferenceOption
from govoplan_core.core.tasks import (
TaskCreateCommand,
WorkAssignmentRef,
WorkSourceRef,
task_command_provider,
)
from govoplan_core.db.session import get_session
from govoplan_core.security.time import utc_now
router = APIRouter(prefix="/campaigns", tags=["campaign work assignments"])
AssigneeType = Literal["account", "group", "organization_function"]
@dataclass(frozen=True, slots=True)
class _AssigneeResolution:
state: Literal["resolved", "unavailable", "provider_unavailable"]
label: str | None
recipient_type: str
recipient_id: str
account_ids: tuple[str, ...]
provenance: dict[str, object]
@router.get(
"/{campaign_id}/assignments/options",
response_model=ReferenceOptionListResponse,
)
def search_campaign_assignment_options(
campaign_id: str,
assignee_type: AssigneeType,
q: str = "",
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=100),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
) -> ReferenceOptionListResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
options = _assignment_options(
session,
campaign=campaign,
assignee_type=assignee_type,
query=q,
selected_values=selected,
limit=limit,
)
provider_available = (
assignee_type != "organization_function"
or (
organization_directory(get_registry()) is not None
and _idm_function_directory() is not None
)
)
return ReferenceOptionListResponse(
options=[ReferenceOptionResponse(**item.to_dict()) for item in options],
provider_available=provider_available,
next_cursor=None,
has_more=False,
)
@router.get(
"/{campaign_id}/assignments",
response_model=CampaignWorkAssignmentListResponse,
)
def list_campaign_work_assignments(
campaign_id: str,
assignment_status: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:read")),
) -> CampaignWorkAssignmentListResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
statuses = tuple(dict.fromkeys(item.strip() for item in assignment_status if item.strip()))
if any(item not in {"open", "in_progress", "completed", "cancelled"} for item in statuses):
raise HTTPException(status_code=422, detail="Unsupported assignment status filter.")
query = session.query(CampaignWorkAssignment).filter(
CampaignWorkAssignment.tenant_id == principal.tenant_id,
CampaignWorkAssignment.campaign_id == campaign.id,
)
if statuses:
query = query.filter(CampaignWorkAssignment.status.in_(statuses))
if cursor:
created_at, assignment_id = _decode_cursor(cursor)
query = query.filter(
or_(
CampaignWorkAssignment.created_at < created_at,
and_(
CampaignWorkAssignment.created_at == created_at,
CampaignWorkAssignment.id < assignment_id,
),
)
)
rows = (
query.order_by(CampaignWorkAssignment.created_at.desc(), CampaignWorkAssignment.id.desc())
.limit(limit + 1)
.all()
)
has_more = len(rows) > limit
items = rows[:limit]
return CampaignWorkAssignmentListResponse(
items=[_assignment_response(item) for item in items],
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
has_more=has_more,
)
@router.post(
"/{campaign_id}/assignments",
response_model=CampaignWorkAssignmentResponse,
status_code=status.HTTP_201_CREATED,
)
def create_campaign_work_assignment(
campaign_id: str,
payload: CampaignWorkAssignmentCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
) -> CampaignWorkAssignmentResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
reference = _validated_reference(session, campaign=campaign, reference=payload.reference)
resolution = _resolve_assignee(session, campaign=campaign, assignee=payload.assignee)
_require_resolved_assignee(resolution)
now = utc_now()
actor_label = _actor_label(principal)
assignment = CampaignWorkAssignment(
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
campaign_version_id=reference[0] if reference else None,
reference_kind=reference[1] if reference else None,
reference_id=reference[2] if reference else None,
reference_label=reference[3] if reference else None,
purpose=payload.purpose,
status="open",
due_at=payload.due_at,
assignee_type=payload.assignee.type,
assignee_id=payload.assignee.id,
assignee_label_snapshot=(resolution.label or payload.assignee.id)[:500],
assignee_current_label=resolution.label,
assignee_resolution_state=resolution.state,
resolution_provenance=resolution.provenance,
resolution_checked_at=now,
assigned_by_user_id=principal.user.id,
assigned_by_label_snapshot=actor_label,
)
session.add(assignment)
session.flush()
_record_event(session, assignment=assignment, principal=principal, event_kind="assigned")
if payload.mirror_to_tasks:
_mirror_assignment_to_tasks(session, campaign=campaign, assignment=assignment, principal=principal)
else:
assignment.task_mirror_status = "skipped"
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind="assigned")
_audit_assignment(session, principal, assignment=assignment, action="campaign.assignment.created")
session.refresh(assignment)
return _assignment_response(assignment)
@router.post(
"/{campaign_id}/assignments/{assignment_id}/reassign",
response_model=CampaignWorkAssignmentResponse,
)
def reassign_campaign_work_assignment(
campaign_id: str,
assignment_id: str,
payload: CampaignWorkAssignmentReassignRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
) -> CampaignWorkAssignmentResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
assignment = _assignment_for_campaign(session, campaign=campaign, assignment_id=assignment_id)
_require_revision(assignment, payload.expected_revision)
if assignment.status in {"completed", "cancelled"}:
raise HTTPException(status_code=409, detail="Closed assignments cannot be reassigned.")
resolution = _resolve_assignee(session, campaign=campaign, assignee=payload.assignee)
_require_resolved_assignee(resolution)
if assignment.assignee_type == payload.assignee.type and assignment.assignee_id == payload.assignee.id:
return _assignment_response(assignment)
previous = {
"assignee_type": assignment.assignee_type,
"assignee_id": assignment.assignee_id,
"assignee_label": assignment.assignee_label_snapshot,
"reason": payload.reason,
}
assignment.assignee_type = payload.assignee.type
assignment.assignee_id = payload.assignee.id
assignment.assignee_label_snapshot = (resolution.label or payload.assignee.id)[:500]
assignment.assignee_current_label = resolution.label
assignment.assignee_resolution_state = resolution.state
assignment.resolution_provenance = resolution.provenance
assignment.resolution_checked_at = utc_now()
assignment.resource_revision += 1
assignment.task_mirror_id = None
assignment.task_mirror_error = None
assignment.task_mirrored_at = None
assignment.task_mirror_status = "not_configured"
_record_event(
session,
assignment=assignment,
principal=principal,
event_kind="reassigned",
details=previous,
)
if payload.mirror_to_tasks:
_mirror_assignment_to_tasks(session, campaign=campaign, assignment=assignment, principal=principal)
else:
assignment.task_mirror_status = "skipped"
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind="reassigned")
_audit_assignment(session, principal, assignment=assignment, action="campaign.assignment.reassigned")
session.refresh(assignment)
return _assignment_response(assignment)
@router.post(
"/{campaign_id}/assignments/{assignment_id}/transition",
response_model=CampaignWorkAssignmentResponse,
)
def transition_campaign_work_assignment(
campaign_id: str,
assignment_id: str,
payload: CampaignWorkAssignmentTransitionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:read")),
) -> CampaignWorkAssignmentResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
assignment = _assignment_for_campaign(session, campaign=campaign, assignment_id=assignment_id)
if not has_scope(principal, "campaigns:assignment:manage"):
_require_permission(principal, "campaigns:assignment:complete")
if not _principal_matches_assignment(session, principal=principal, assignment=assignment):
raise HTTPException(status_code=403, detail="Only the assignee or an assignment manager may update this work.")
if payload.action == "cancel":
raise HTTPException(status_code=403, detail="Only an assignment manager may cancel work.")
_require_revision(assignment, payload.expected_revision)
target_status = {"start": "in_progress", "complete": "completed", "cancel": "cancelled"}[payload.action]
if assignment.status == target_status:
return _assignment_response(assignment)
allowed = {
"start": {"open"},
"complete": {"open", "in_progress"},
"cancel": {"open", "in_progress"},
}
if assignment.status not in allowed[payload.action]:
raise HTTPException(status_code=409, detail=f"Assignment status {assignment.status!r} cannot be changed using {payload.action!r}.")
assignment.status = target_status
assignment.completed_at = utc_now() if target_status == "completed" else None
assignment.cancelled_at = utc_now() if target_status == "cancelled" else None
assignment.resource_revision += 1
_record_event(
session,
assignment=assignment,
principal=principal,
event_kind={"start": "started", "complete": "completed", "cancel": "cancelled"}[payload.action],
details={"reason": payload.reason},
)
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind=payload.action)
_audit_assignment(session, principal, assignment=assignment, action=f"campaign.assignment.{target_status}")
session.refresh(assignment)
return _assignment_response(assignment)
@router.get(
"/{campaign_id}/assignments/{assignment_id}/history",
response_model=CampaignWorkAssignmentHistoryResponse,
)
def list_campaign_work_assignment_history(
campaign_id: str,
assignment_id: str,
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:read")),
) -> CampaignWorkAssignmentHistoryResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
assignment = _assignment_for_campaign(session, campaign=campaign, assignment_id=assignment_id)
query = session.query(CampaignWorkAssignmentEvent).filter(
CampaignWorkAssignmentEvent.tenant_id == principal.tenant_id,
CampaignWorkAssignmentEvent.assignment_id == assignment.id,
)
if cursor:
created_at, event_id = _decode_cursor(cursor)
query = query.filter(
or_(
CampaignWorkAssignmentEvent.created_at < created_at,
and_(
CampaignWorkAssignmentEvent.created_at == created_at,
CampaignWorkAssignmentEvent.id < event_id,
),
)
)
rows = query.order_by(CampaignWorkAssignmentEvent.created_at.desc(), CampaignWorkAssignmentEvent.id.desc()).limit(limit + 1).all()
has_more = len(rows) > limit
items = rows[:limit]
return CampaignWorkAssignmentHistoryResponse(
items=[_event_response(item) for item in items],
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
has_more=has_more,
)
@router.post(
"/{campaign_id}/assignments/reconcile",
response_model=CampaignWorkAssignmentReconcileResponse,
)
def reconcile_campaign_work_assignments(
campaign_id: str,
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
) -> CampaignWorkAssignmentReconcileResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
rows = (
session.query(CampaignWorkAssignment)
.filter(
CampaignWorkAssignment.tenant_id == campaign.tenant_id,
CampaignWorkAssignment.campaign_id == campaign.id,
CampaignWorkAssignment.status.in_(("open", "in_progress")),
)
.order_by(CampaignWorkAssignment.updated_at, CampaignWorkAssignment.id)
.limit(limit)
.all()
)
changed: list[CampaignWorkAssignment] = []
for assignment in rows:
resolution = _resolve_assignee(
session,
campaign=campaign,
assignee=CampaignWorkAssigneeInput(type=assignment.assignee_type, id=assignment.assignee_id),
)
if (
assignment.assignee_resolution_state == resolution.state
and assignment.assignee_current_label == resolution.label
):
assignment.resolution_checked_at = utc_now()
assignment.resolution_provenance = resolution.provenance
continue
previous_state = assignment.assignee_resolution_state
assignment.assignee_resolution_state = resolution.state
assignment.assignee_current_label = resolution.label
assignment.resolution_provenance = resolution.provenance
assignment.resolution_checked_at = utc_now()
assignment.resource_revision += 1
_record_event(
session,
assignment=assignment,
principal=principal,
event_kind="assignee_resolved" if resolution.state == "resolved" else "assignee_unavailable",
details={"previous_resolution_state": previous_state},
)
changed.append(assignment)
audit_from_principal(
session,
principal,
action="campaign.assignment.reconciled",
object_type="campaign",
object_id=campaign.id,
details={"checked": len(rows), "changed": len(changed), "content_disclosed": False},
commit=True,
)
return CampaignWorkAssignmentReconcileResponse(
checked=len(rows),
changed=len(changed),
assignments=[_assignment_response(item) for item in changed],
)
def _assignment_options(
session: Session,
*,
campaign: Campaign,
assignee_type: AssigneeType,
query: str,
selected_values: list[str],
limit: int,
) -> tuple[ReferenceOption, ...]:
clean_query = query.strip().casefold()
selected = set(item.strip() for item in selected_values if item.strip())
candidates: list[ReferenceOption] = []
directory = _access_directory()
if assignee_type == "account":
for user in directory.users_for_tenant(campaign.tenant_id):
if user.status != "active" or not _user_has_campaign_access(session, campaign=campaign, user_id=user.id):
continue
label = user.display_name or user.email or user.account_id
candidates.append(ReferenceOption(value=user.account_id, label=label, description=user.email, kind="account"))
elif assignee_type == "group":
for group in directory.groups_for_tenant(campaign.tenant_id):
if group.status == "active" and _group_has_campaign_access(session, campaign=campaign, group_id=group.id):
candidates.append(ReferenceOption(value=group.id, label=group.name, kind="group"))
else:
provider = organization_directory(get_registry())
if provider is not None:
seen: set[str] = set()
for unit in provider.organization_units_for_tenant(campaign.tenant_id):
for function in provider.functions_for_organization_unit(unit.id):
if function.id in seen:
continue
seen.add(function.id)
resolution = _resolve_assignee(
session,
campaign=campaign,
assignee=CampaignWorkAssigneeInput(type="organization_function", id=function.id),
)
candidates.append(
ReferenceOption(
value=function.id,
label=function.name,
description=unit.name,
kind="organization_function",
availability="available" if resolution.state == "resolved" else "unavailable",
disabled=resolution.state != "resolved",
source_module="organizations",
provenance=resolution.provenance,
)
)
filtered = [
item for item in candidates
if item.value in selected or not clean_query or clean_query in f"{item.label} {item.description or ''} {item.value}".casefold()
]
filtered.sort(key=lambda item: (item.disabled, item.label.casefold(), item.value))
return tuple(filtered[:limit])
def _resolve_assignee(
session: Session,
*,
campaign: Campaign,
assignee: CampaignWorkAssigneeInput,
) -> _AssigneeResolution:
provenance: dict[str, object] = {
"policy_code": "assignment_does_not_grant_access",
"campaign_access_checked": True,
"assignee_type": assignee.type,
}
directory = _access_directory()
if assignee.type == "account":
user = next(
(item for item in directory.users_for_tenant(campaign.tenant_id) if item.account_id == assignee.id),
None,
)
available = user is not None and user.status == "active" and _user_has_campaign_access(session, campaign=campaign, user_id=user.id)
provenance.update({"resolved_members": 1 if available else 0, "provider": "access.directory"})
return _AssigneeResolution(
state="resolved" if available else "unavailable",
label=(user.display_name or user.email or user.account_id) if user is not None else None,
recipient_type="account",
recipient_id=assignee.id,
account_ids=(assignee.id,) if available else (),
provenance=provenance,
)
if assignee.type == "group":
group = next((item for item in directory.groups_for_tenant(campaign.tenant_id) if item.id == assignee.id), None)
available = group is not None and group.status == "active" and _group_has_campaign_access(session, campaign=campaign, group_id=assignee.id)
provenance.update({"resolved_members": 1 if available else 0, "provider": "access.directory"})
return _AssigneeResolution(
state="resolved" if available else "unavailable",
label=group.name if group is not None else None,
recipient_type="group",
recipient_id=assignee.id,
account_ids=(),
provenance=provenance,
)
organizations = organization_directory(get_registry())
idm = _idm_function_directory()
if organizations is None or idm is None:
provenance.update({"provider": "organizations.directory+idm.function_assignments", "provider_available": False})
return _AssigneeResolution("provider_unavailable", None, "function", assignee.id, (), provenance)
function = organizations.get_function(assignee.id)
if function is None or function.tenant_id != campaign.tenant_id or function.status != "active":
provenance.update({"provider": "organizations.directory", "resolved_members": 0})
return _AssigneeResolution("unavailable", function.name if function else None, "function", assignee.id, (), provenance)
now = utc_now()
incumbencies = idm.organization_function_assignments_for_function(
assignee.id,
tenant_id=campaign.tenant_id,
effective_at=now,
)
account_ids = tuple(
dict.fromkeys(
item.account_id
for item in incumbencies
if item.account_id
and _incumbency_is_effective(item, now=now)
)
)
user_by_account = {
user.account_id: user for user in directory.users_for_tenant(campaign.tenant_id)
}
authorized = bool(account_ids) and all(
account_id in user_by_account
and user_by_account[account_id].status == "active"
and _user_has_campaign_access(session, campaign=campaign, user_id=user_by_account[account_id].id)
for account_id in account_ids
)
provenance.update(
{
"provider": "organizations.directory+idm.function_assignments",
"provider_available": True,
"resolved_members": len(account_ids),
"all_current_incumbents_authorized": authorized,
}
)
return _AssigneeResolution(
"resolved" if authorized else "unavailable",
function.name,
"function",
assignee.id,
account_ids if authorized else (),
provenance,
)
def _idm_function_directory() -> IdmFunctionAssignmentDirectory | None:
registry = get_registry()
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_IDM_FUNCTION_ASSIGNMENTS)
):
return None
capability = registry.capability(CAPABILITY_IDM_FUNCTION_ASSIGNMENTS)
return capability if isinstance(capability, IdmFunctionAssignmentDirectory) else None
def _require_resolved_assignee(resolution: _AssigneeResolution) -> None:
if resolution.state == "resolved":
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"code": "campaign_assignment_assignee_inaccessible",
"explanation": "The assignment target must already resolve to an active principal with access to this campaign. Assignment never grants access.",
"resolution_state": resolution.state,
"provenance": resolution.provenance,
},
)
def _user_has_campaign_access(session: Session, *, campaign: Campaign, user_id: str) -> bool:
if campaign.owner_user_id == user_id:
return True
directory = _access_directory()
group_ids = {item.id for item in directory.groups_for_user(user_id, tenant_id=campaign.tenant_id)}
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
return True
clauses = [and_(CampaignShare.target_type == "user", CampaignShare.target_id == user_id)]
if group_ids:
clauses.append(and_(CampaignShare.target_type == "group", CampaignShare.target_id.in_(sorted(group_ids))))
return session.query(CampaignShare.id).filter(
CampaignShare.tenant_id == campaign.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
or_(*clauses),
).first() is not None
def _group_has_campaign_access(session: Session, *, campaign: Campaign, group_id: str) -> bool:
if campaign.owner_group_id == group_id:
return True
return session.query(CampaignShare.id).filter(
CampaignShare.tenant_id == campaign.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.target_type == "group",
CampaignShare.target_id == group_id,
CampaignShare.revoked_at.is_(None),
).first() is not None
def _principal_matches_assignment(
session: Session,
*,
principal: ApiPrincipal,
assignment: CampaignWorkAssignment,
) -> bool:
if assignment.assignee_type == "account":
return principal.account_id == assignment.assignee_id
if assignment.assignee_type == "group":
return assignment.assignee_id in {
item.id for item in _access_directory().groups_for_user(principal.user.id, tenant_id=principal.tenant_id)
}
idm = _idm_function_directory()
if idm is None:
return False
return any(
item.account_id == principal.account_id and item.status == "active"
for item in idm.organization_function_assignments_for_function(
assignment.assignee_id,
tenant_id=principal.tenant_id,
effective_at=utc_now(),
)
)
def _assignment_for_campaign(
session: Session,
*,
campaign: Campaign,
assignment_id: str,
) -> CampaignWorkAssignment:
assignment = session.get(CampaignWorkAssignment, assignment_id)
if assignment is None or assignment.tenant_id != campaign.tenant_id or assignment.campaign_id != campaign.id:
raise HTTPException(status_code=404, detail="Campaign work assignment not found.")
return assignment
def _require_revision(assignment: CampaignWorkAssignment, expected: int) -> None:
if assignment.resource_revision != expected:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "campaign_assignment_revision_conflict",
"expected_revision": expected,
"current_revision": assignment.resource_revision,
},
)
def _record_event(
session: Session,
*,
assignment: CampaignWorkAssignment,
principal: ApiPrincipal,
event_kind: str,
details: dict[str, object] | None = None,
) -> CampaignWorkAssignmentEvent:
event = CampaignWorkAssignmentEvent(
tenant_id=assignment.tenant_id,
campaign_id=assignment.campaign_id,
assignment_id=assignment.id,
event_kind=event_kind,
actor_user_id=principal.user.id,
actor_label_snapshot=_actor_label(principal),
status_snapshot=assignment.status,
assignee_type_snapshot=assignment.assignee_type,
assignee_id_snapshot=assignment.assignee_id,
assignee_label_snapshot=assignment.assignee_label_snapshot,
resolution_state_snapshot=assignment.assignee_resolution_state,
details={key: value for key, value in (details or {}).items() if value is not None},
)
session.add(event)
session.flush()
return event
def _mirror_assignment_to_tasks(
session: Session,
*,
campaign: Campaign,
assignment: CampaignWorkAssignment,
principal: ApiPrincipal,
) -> None:
provider = task_command_provider(get_registry())
if provider is None:
assignment.task_mirror_status = "not_configured"
return
task_kind = "function" if assignment.assignee_type == "organization_function" else assignment.assignee_type
sources = [
WorkSourceRef(
module_id="campaigns",
resource_type="campaign_work_assignment",
resource_id=assignment.id,
revision=str(assignment.resource_revision),
url=f"/campaigns/{campaign.id}/work",
label=campaign.name,
)
]
if assignment.reference_kind and assignment.reference_id:
sources.append(
WorkSourceRef(
module_id="campaigns",
resource_type=assignment.reference_kind,
resource_id=assignment.reference_id,
label=assignment.reference_label,
)
)
try:
with session.begin_nested():
task = provider.create_task(
session,
principal,
command=TaskCreateCommand(
tenant_id=campaign.tenant_id,
title=assignment.purpose,
idempotency_key=f"campaign-assignment:{assignment.id}:r{assignment.resource_revision}",
summary=f"Accountable work assignment for campaign {campaign.name}.",
due_at=assignment.due_at,
required_action=assignment.purpose,
action_url=f"/campaigns/{campaign.id}/work",
assignments=(
WorkAssignmentRef(
kind=task_kind,
id=assignment.assignee_id,
label=assignment.assignee_label_snapshot,
),
),
sources=tuple(sources),
provenance={
"authorization_neutral": True,
"campaign_access_checked": True,
},
metadata={"campaign_id": campaign.id, "assignment_id": assignment.id},
),
)
assignment.task_mirror_id = task.id
assignment.task_mirror_status = "mirrored"
assignment.task_mirror_error = None
assignment.task_mirrored_at = utc_now()
except Exception as exc:
assignment.task_mirror_status = "failed"
assignment.task_mirror_error = f"{type(exc).__name__}: optional Tasks mirroring failed"[:500]
def _notify_assignment(
session: Session,
*,
campaign: Campaign,
assignment: CampaignWorkAssignment,
event_kind: str,
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
recipient_account_ids = _notification_account_ids(
campaign=campaign,
assignment=assignment,
)
if not recipient_account_ids:
return
try:
with session.begin_nested():
for account_id in recipient_account_ids:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=campaign.tenant_id,
source_module="campaigns",
source_resource_type="campaign_work_assignment",
source_resource_id=assignment.id,
event_kind=f"campaign.assignment.{event_kind}",
channel="inbox",
recipient_type="account",
recipient_id=account_id,
recipient_label=assignment.assignee_label_snapshot,
subject=f"Campaign work {event_kind}: {campaign.name}",
body_text="Open the campaign work area to review the assignment. Campaign access is managed separately.",
action_url=f"/campaigns/{campaign.id}/work",
payload={
"campaign_id": campaign.id,
"assignment_id": assignment.id,
"assignment_status": assignment.status,
"purpose_disclosed": False,
},
),
enqueue_delivery=False,
)
except Exception:
return
def _notification_account_ids(
*,
campaign: Campaign,
assignment: CampaignWorkAssignment,
) -> tuple[str, ...]:
if assignment.assignee_type == "account":
return (assignment.assignee_id,)
if assignment.assignee_type == "group":
directory = _access_directory()
return tuple(
dict.fromkeys(
user.account_id
for user in directory.users_for_tenant(campaign.tenant_id)
if user.status == "active"
and assignment.assignee_id
in {
group.id
for group in directory.groups_for_user(
user.id,
tenant_id=campaign.tenant_id,
)
}
)
)[:100]
idm = _idm_function_directory()
if idm is None:
return ()
return tuple(
dict.fromkeys(
item.account_id
for item in idm.organization_function_assignments_for_function(
assignment.assignee_id,
tenant_id=campaign.tenant_id,
effective_at=utc_now(),
)
if item.account_id and _incumbency_is_effective(item, now=utc_now())
)
)[:100]
def _incumbency_is_effective(item: object, *, now: datetime) -> bool:
if getattr(item, "status", None) != "active":
return False
valid_from = getattr(item, "valid_from", None)
valid_until = getattr(item, "valid_until", None)
comparison_now = now
for boundary in (valid_from, valid_until):
if isinstance(boundary, datetime) and boundary.tzinfo is None:
comparison_now = now.replace(tzinfo=None)
break
return (
(valid_from is None or valid_from <= comparison_now)
and (valid_until is None or valid_until > comparison_now)
)
def _audit_assignment(
session: Session,
principal: ApiPrincipal,
*,
assignment: CampaignWorkAssignment,
action: str,
) -> None:
audit_from_principal(
session,
principal,
action=action,
object_type="campaign_work_assignment",
object_id=assignment.id,
details={
"campaign_id": assignment.campaign_id,
"status": assignment.status,
"assignee_type": assignment.assignee_type,
"assignee_id": assignment.assignee_id,
"assignee_resolution_state": assignment.assignee_resolution_state,
"reference_kind": assignment.reference_kind,
"reference_id": assignment.reference_id,
"resource_revision": assignment.resource_revision,
"task_mirror_status": assignment.task_mirror_status,
"purpose_disclosed": False,
},
commit=True,
)
def _actor_label(principal: ApiPrincipal) -> str:
return str(
getattr(principal.user, "display_name", None)
or getattr(principal.user, "email", None)
or principal.user.id
)[:255]
def _assignment_response(assignment: CampaignWorkAssignment) -> CampaignWorkAssignmentResponse:
reference = None
if assignment.reference_kind and assignment.reference_id:
reference = CampaignWorkAssignmentReferenceResponse(
kind=assignment.reference_kind,
id=assignment.reference_id,
label=assignment.reference_label,
)
return CampaignWorkAssignmentResponse(
id=assignment.id,
campaign_id=assignment.campaign_id,
purpose=assignment.purpose,
status=assignment.status,
due_at=assignment.due_at,
assignee_type=assignment.assignee_type,
assignee_id=assignment.assignee_id,
assignee_label_snapshot=assignment.assignee_label_snapshot,
assignee_current_label=assignment.assignee_current_label,
assignee_resolution_state=assignment.assignee_resolution_state,
resolution_provenance=dict(assignment.resolution_provenance or {}),
resolution_checked_at=assignment.resolution_checked_at,
assigned_by_user_id=assignment.assigned_by_user_id,
assigned_by_label=assignment.assigned_by_label_snapshot,
reference=reference,
completed_at=assignment.completed_at,
cancelled_at=assignment.cancelled_at,
task_mirror_id=assignment.task_mirror_id,
task_mirror_status=assignment.task_mirror_status,
task_mirror_error=assignment.task_mirror_error,
resource_revision=assignment.resource_revision,
created_at=assignment.created_at,
updated_at=assignment.updated_at,
)
def _event_response(event: CampaignWorkAssignmentEvent) -> CampaignWorkAssignmentEventResponse:
return CampaignWorkAssignmentEventResponse(
id=event.id,
assignment_id=event.assignment_id,
event_kind=event.event_kind,
actor_user_id=event.actor_user_id,
actor_label=event.actor_label_snapshot,
status=event.status_snapshot,
assignee_type=event.assignee_type_snapshot,
assignee_id=event.assignee_id_snapshot,
assignee_label=event.assignee_label_snapshot,
resolution_state=event.resolution_state_snapshot,
details=dict(event.details or {}),
created_at=event.created_at,
)
def _encode_cursor(item: CampaignWorkAssignment | CampaignWorkAssignmentEvent) -> str:
created_at = item.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=UTC)
payload = json.dumps(
{"created_at": created_at.astimezone(UTC).isoformat(), "id": item.id},
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def _decode_cursor(value: str) -> tuple[datetime, str]:
try:
padded = value + "=" * (-len(value) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
created_at = datetime.fromisoformat(str(payload["created_at"]))
item_id = str(payload["id"])
if created_at.tzinfo is None or not item_id or len(item_id) > 36:
raise ValueError
return created_at, item_id
except (KeyError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error) as exc:
raise HTTPException(status_code=422, detail="Invalid assignment cursor.") from exc
__all__ = ["router"]
@@ -0,0 +1,401 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignVersion,
)
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.persistence.campaigns import (
load_campaign_config_from_json,
)
from govoplan_campaign.backend.integrations import (
files_integration,
)
from govoplan_campaign.backend.path_security import (
CampaignPathSecurityError,
assert_server_safe_campaign_paths,
)
from govoplan_campaign.backend.campaign.loader import load_campaign_json
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
)
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
from govoplan_campaign.backend.persistence.versions import (
is_version_final_locked,
is_user_locked_version,
)
from govoplan_campaign.backend.route_support import (
_get_campaign_for_principal,
_get_campaign_for_tenant,
_get_version_for_tenant,
_require_mail_profile_use_if_needed,
_require_permission,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
class CampaignAttachmentPreviewRequest(BaseModel):
include_unmatched: bool = True
include_unlinked_candidates: bool = False
campaign_json: dict[str, object] | None = None
class CampaignAttachmentPreviewResponse(BaseModel):
campaign_id: str
version_id: str
shared_file_count: int
candidate_file_count: int = 0
matched_file_count: int = 0
linked_file_count: int = 0
unlinked_file_count: int = 0
rules: list[dict[str, object]] = Field(default_factory=list)
linkable_files: list[dict[str, object]] = Field(default_factory=list)
unused_shared_files: list[dict[str, object]] = Field(default_factory=list)
class CampaignAttachmentLinkMatchesRequest(BaseModel):
campaign_json: dict[str, object] | None = None
dry_run: bool = False
class CampaignAttachmentLinkMatchesResponse(BaseModel):
campaign_id: str
version_id: str
matched_file_count: int
already_linked_file_count: int
linked_file_count: int
dry_run: bool = False
linked_files: list[dict[str, object]] = Field(default_factory=list)
linkable_files: list[dict[str, object]] = Field(default_factory=list)
def _file_preview(session: Session, asset) -> dict[str, object]:
version, blob = files_integration().current_version_and_blob(session, asset)
return {
"id": asset.id,
"version_id": version.id,
"blob_id": blob.id,
"display_path": asset.display_path,
"filename": asset.filename,
"owner_type": asset.owner_type,
"owner_id": asset.owner_user_id
if asset.owner_type == "user"
else asset.owner_group_id,
"checksum_sha256": blob.checksum_sha256,
"size_bytes": blob.size_bytes,
"content_type": blob.content_type,
"linked_to_campaign": True,
}
def _managed_preview_file(item: dict[str, object]) -> dict[str, object]:
return {
"id": item["asset_id"],
"version_id": item["version_id"],
"blob_id": item["blob_id"],
"display_path": item["display_path"],
"filename": item["filename"],
"owner_type": item["owner_type"],
"owner_id": item["owner_id"],
"checksum_sha256": item["checksum_sha256"],
"size_bytes": item["size_bytes"],
"content_type": item["content_type"],
"linked_to_campaign": bool(item.get("linked_to_campaign", True)),
}
def _attachment_preview_for_version(
session: Session,
principal: ApiPrincipal,
*,
campaign: Campaign,
version: CampaignVersion,
raw: dict[str, object],
include_unmatched: bool,
include_unlinked_candidates: bool,
) -> CampaignAttachmentPreviewResponse:
files = files_integration()
assert_server_safe_campaign_paths(raw, managed_files_available=files.available)
with files.prepared_campaign_snapshot(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
raw_json=raw,
include_bytes=False,
prefix="govoplan-managed-preview-",
include_unlinked_candidates=include_unlinked_candidates,
user_id=principal.user.id,
is_admin=has_scope(principal, "files:file:admin"),
) as prepared:
prepared_raw = load_campaign_json(prepared.path)
config = load_campaign_config_from_json(
session,
tenant_id=principal.tenant_id,
raw_json=prepared_raw,
campaign_id=campaign.id,
)
report = resolve_campaign_attachments(config, campaign_file=prepared.path)
rules: list[dict[str, object]] = []
matched_asset_ids: set[str] = set()
linked_asset_ids: set[str] = set()
linkable_by_id: dict[str, dict[str, object]] = {}
for entry in report.entries:
for attachment in entry.attachments:
managed_matches = files.managed_match_payloads(
attachment.matches, prepared.managed_files_by_local_path
)
matches: list[dict[str, object]] = []
for item in managed_matches:
asset_id = str(item["asset_id"])
matched_asset_ids.add(asset_id)
if bool(item.get("linked_to_campaign", True)):
linked_asset_ids.add(asset_id)
preview = _managed_preview_file(item)
matches.append(preview)
if not preview["linked_to_campaign"]:
linkable_by_id.setdefault(asset_id, preview)
if not matches:
matches = [
{
"id": "",
"display_path": match,
"filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1],
"owner_type": "legacy",
"owner_id": "",
"linked_to_campaign": True,
}
for match in attachment.matches
]
rules.append(
{
"source": attachment.scope.value,
"entry_index": entry.entry_index,
"entry_id": entry.entry_id,
"index": attachment.index,
"attachment_id": attachment.attachment_id,
"label": attachment.label,
"required": attachment.required,
"pattern": attachment.file_filter,
"base_path_name": attachment.base_path_name,
"base_path": attachment.base_path,
"status": attachment.status.value,
"behavior": attachment.behavior.value
if attachment.behavior
else None,
"zip_included": attachment.zip_enabled,
"zip_mode": attachment.zip_mode.value,
"zip_archive_id": attachment.zip_archive_id,
"zip_filename": attachment.zip_filename,
"matches": matches,
"match_count": len(matches),
"linked_match_count": sum(
1
for match in matches
if bool(match.get("linked_to_campaign", True))
),
"unlinked_match_count": sum(
1
for match in matches
if not bool(match.get("linked_to_campaign", True))
),
"issues": [
issue.model_dump(mode="json") for issue in attachment.issues
],
}
)
unused = [
asset
for asset in prepared.shared_assets
if asset.id not in matched_asset_ids
]
return CampaignAttachmentPreviewResponse(
campaign_id=campaign.id,
version_id=version.id,
shared_file_count=len(prepared.shared_assets),
candidate_file_count=len(
getattr(prepared, "candidate_assets", prepared.shared_assets)
),
matched_file_count=len(matched_asset_ids),
linked_file_count=len(linked_asset_ids),
unlinked_file_count=len(linkable_by_id),
rules=rules,
linkable_files=list(linkable_by_id.values()),
unused_shared_files=[_file_preview(session, asset) for asset in unused]
if include_unmatched
else [],
)
def _link_campaign_attachment_matches(
session: Session,
principal: ApiPrincipal,
*,
campaign: Campaign,
version: CampaignVersion,
raw: dict[str, object],
dry_run: bool = False,
) -> CampaignAttachmentLinkMatchesResponse:
preview = _attachment_preview_for_version(
session,
principal,
campaign=campaign,
version=version,
raw=raw,
include_unmatched=False,
include_unlinked_candidates=True,
)
file_ids = [
str(item.get("id") or "") for item in preview.linkable_files if item.get("id")
]
linked_files: list[dict[str, object]] = []
if file_ids and not dry_run:
files = files_integration()
shares = files.share_assets_with_campaign(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
file_ids=file_ids,
user_id=principal.user.id,
is_admin=has_scope(principal, "files:file:admin"),
)
share_by_asset_id = {
str(item.get("file_asset_id") or ""): item for item in shares
}
linked_files = [
{**item, "share": share_by_asset_id.get(str(item.get("id") or ""))}
for item in preview.linkable_files
]
return CampaignAttachmentLinkMatchesResponse(
campaign_id=campaign.id,
version_id=version.id,
matched_file_count=preview.matched_file_count,
already_linked_file_count=preview.linked_file_count,
linked_file_count=0 if dry_run else len(file_ids),
dry_run=dry_run,
linked_files=linked_files,
linkable_files=preview.linkable_files,
)
@router.post(
"/{campaign_id}/versions/{version_id}/attachments/preview",
response_model=CampaignAttachmentPreviewResponse,
)
def preview_campaign_attachments(
campaign_id: str,
version_id: str,
payload: CampaignAttachmentPreviewRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:read")
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if version.campaign_id != campaign.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
)
payload = payload or CampaignAttachmentPreviewRequest()
raw = (
payload.campaign_json
if isinstance(payload.campaign_json, dict)
else version.raw_json
)
raw = raw if isinstance(raw, dict) else {}
_require_mail_profile_use_if_needed(principal, raw)
try:
return _attachment_preview_for_version(
session,
principal,
campaign=campaign,
version=version,
raw=raw,
include_unmatched=payload.include_unmatched,
include_unlinked_candidates=payload.include_unlinked_candidates,
)
except (CampaignPathSecurityError, CampaignMailProfileBoundaryError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post(
"/{campaign_id}/versions/{version_id}/attachments/link-matches",
response_model=CampaignAttachmentLinkMatchesResponse,
)
def link_campaign_attachment_matches(
campaign_id: str,
version_id: str,
payload: CampaignAttachmentLinkMatchesRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
):
_require_permission(principal, "files:file:share")
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if version.campaign_id != campaign.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
)
if is_user_locked_version(version) or is_version_final_locked(version):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Locked campaign versions cannot link new attachment files",
)
payload = payload or CampaignAttachmentLinkMatchesRequest()
raw = (
payload.campaign_json
if isinstance(payload.campaign_json, dict)
else version.raw_json
)
raw = raw if isinstance(raw, dict) else {}
_require_mail_profile_use_if_needed(principal, raw)
try:
result = _link_campaign_attachment_matches(
session,
principal,
campaign=campaign,
version=version,
raw=raw,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="campaign.attachment_matches_linked"
if not payload.dry_run
else "campaign.attachment_matches_link_previewed",
object_type="campaign_version",
object_id=version_id,
details={
"matched_file_count": result.matched_file_count,
"already_linked_file_count": result.already_linked_file_count,
"linked_file_count": result.linked_file_count,
"dry_run": result.dry_run,
},
commit=True,
)
return result
except HTTPException:
raise
except Exception as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,571 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import json
from datetime import UTC, datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignCollaborationEntry,
CampaignJob,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.path_security import _attachment_rules
from govoplan_campaign.backend.route_support import (
_access_directory,
_get_campaign_for_principal,
_require_permission,
)
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.schemas import (
CampaignCollaborationCreateRequest,
CampaignCollaborationEntryResponse,
CampaignCollaborationListResponse,
CampaignCollaborationModerationRequest,
CampaignCollaborationReferenceInput,
CampaignCollaborationReferenceResponse,
)
from govoplan_core.api.v1.schemas import (
ReferenceOptionListResponse,
ReferenceOptionResponse,
)
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.core.notifications import (
NotificationDispatchRequest,
notification_dispatch_provider,
)
from govoplan_core.core.references import (
access_scope_reference_page,
access_scope_reference_provider_available,
)
from govoplan_core.db.session import get_session
from govoplan_core.security.time import utc_now
router = APIRouter(prefix="/campaigns", tags=["campaign collaboration"])
@router.get(
"/{campaign_id}/collaboration/mention-options",
response_model=ReferenceOptionListResponse,
)
def search_campaign_collaboration_mentions(
campaign_id: str,
q: str = "",
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
) -> ReferenceOptionListResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
try:
page = access_scope_reference_page(
get_registry(),
principal,
scope_type="user",
reference_kind="membership",
query=q,
selected_values=selected,
limit=limit,
cursor=cursor,
administrative=True,
session=session,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
allowed = [
option
for option in page.options
if _mentioned_user_has_campaign_access(
session,
campaign=campaign,
user_id=option.value,
)
]
return ReferenceOptionListResponse(
options=[ReferenceOptionResponse(**option.to_dict()) for option in allowed],
provider_available=access_scope_reference_provider_available(get_registry()),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
@router.get(
"/{campaign_id}/collaboration",
response_model=CampaignCollaborationListResponse,
)
def list_campaign_collaboration(
campaign_id: str,
limit: int = Query(default=25, ge=1, le=50),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:read")),
) -> CampaignCollaborationListResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
query = session.query(CampaignCollaborationEntry).filter(
CampaignCollaborationEntry.tenant_id == principal.tenant_id,
CampaignCollaborationEntry.campaign_id == campaign.id,
)
if not has_scope(principal, "campaigns:discussion:moderate"):
query = query.filter(CampaignCollaborationEntry.visibility == "collaborators")
if cursor:
created_at, entry_id = _decode_cursor(cursor)
query = query.filter(
or_(
CampaignCollaborationEntry.created_at < created_at,
and_(
CampaignCollaborationEntry.created_at == created_at,
CampaignCollaborationEntry.id < entry_id,
),
)
)
rows = (
query.order_by(
CampaignCollaborationEntry.created_at.desc(),
CampaignCollaborationEntry.id.desc(),
)
.limit(limit + 1)
.all()
)
has_more = len(rows) > limit
items = rows[:limit]
return CampaignCollaborationListResponse(
items=[_entry_response(item) for item in items],
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
has_more=has_more,
)
@router.post(
"/{campaign_id}/collaboration",
response_model=CampaignCollaborationEntryResponse,
status_code=status.HTTP_201_CREATED,
)
def create_campaign_collaboration_entry(
campaign_id: str,
payload: CampaignCollaborationCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
) -> CampaignCollaborationEntryResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
if payload.visibility == "moderators":
_require_permission(principal, "campaigns:discussion:moderate")
reference = _validated_reference(
session,
campaign=campaign,
reference=payload.reference,
)
mentions = _validated_mentions(
session,
campaign=campaign,
user_ids=payload.mention_user_ids,
actor_user_id=principal.user.id,
)
actor_label = (
getattr(principal.user, "display_name", None)
or getattr(principal.user, "email", None)
or principal.user.id
)
entry = CampaignCollaborationEntry(
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
campaign_version_id=reference[0] if reference else None,
reference_kind=reference[1] if reference else None,
reference_id=reference[2] if reference else None,
reference_label=reference[3] if reference else None,
actor_user_id=principal.user.id,
actor_label_snapshot=str(actor_label)[:255],
visibility=payload.visibility,
content=payload.content,
content_sha256=hashlib.sha256(payload.content.encode("utf-8")).hexdigest(),
mention_user_ids=mentions,
)
session.add(entry)
session.flush()
_enqueue_mention_notifications(
session,
campaign=campaign,
entry=entry,
mention_user_ids=mentions,
)
audit_from_principal(
session,
principal,
action="campaign.collaboration.posted",
object_type="campaign_collaboration_entry",
object_id=entry.id,
details={
"campaign_id": campaign.id,
"campaign_version_id": entry.campaign_version_id,
"visibility": entry.visibility,
"reference_kind": entry.reference_kind,
"reference_id": entry.reference_id,
"mention_count": len(mentions),
"content_sha256": entry.content_sha256,
"content_disclosed": False,
},
commit=True,
)
session.refresh(entry)
return _entry_response(entry)
@router.post(
"/{campaign_id}/collaboration/{entry_id}/withdraw",
response_model=CampaignCollaborationEntryResponse,
)
def withdraw_campaign_collaboration_entry(
campaign_id: str,
entry_id: str,
payload: CampaignCollaborationModerationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
) -> CampaignCollaborationEntryResponse:
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
if entry.actor_user_id != principal.user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the author can withdraw this collaboration entry.",
)
if entry.redacted_at is not None or entry.withdrawn_at is not None:
return _entry_response(entry)
entry.content = None
entry.withdrawn_at = utc_now()
entry.withdrawn_by_user_id = principal.user.id
entry.tombstone_reason = payload.reason
audit_from_principal(
session,
principal,
action="campaign.collaboration.withdrawn",
object_type="campaign_collaboration_entry",
object_id=entry.id,
details={
"campaign_id": campaign_id,
"content_sha256": entry.content_sha256,
"reason_recorded": bool(payload.reason),
"content_disclosed": False,
},
commit=True,
)
session.refresh(entry)
return _entry_response(entry)
@router.post(
"/{campaign_id}/collaboration/{entry_id}/redact",
response_model=CampaignCollaborationEntryResponse,
)
def redact_campaign_collaboration_entry(
campaign_id: str,
entry_id: str,
payload: CampaignCollaborationModerationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:moderate")),
) -> CampaignCollaborationEntryResponse:
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
if entry.redacted_at is not None:
return _entry_response(entry)
entry.content = None
entry.redacted_at = utc_now()
entry.redacted_by_user_id = principal.user.id
entry.tombstone_reason = payload.reason
audit_from_principal(
session,
principal,
action="campaign.collaboration.redacted",
object_type="campaign_collaboration_entry",
object_id=entry.id,
details={
"campaign_id": campaign_id,
"content_sha256": entry.content_sha256,
"reason_recorded": bool(payload.reason),
"previously_withdrawn": entry.withdrawn_at is not None,
"content_disclosed": False,
},
commit=True,
)
session.refresh(entry)
return _entry_response(entry)
def _entry_for_campaign(
session: Session,
*,
campaign_id: str,
entry_id: str,
principal: ApiPrincipal,
) -> CampaignCollaborationEntry:
entry = session.get(CampaignCollaborationEntry, entry_id)
if (
entry is None
or entry.tenant_id != principal.tenant_id
or entry.campaign_id != campaign_id
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collaboration entry not found")
return entry
def _validated_reference(
session: Session,
*,
campaign: Campaign,
reference: CampaignCollaborationReferenceInput | None,
) -> tuple[str | None, str, str, str] | None:
if reference is None:
return None
kind = reference.kind
reference_id = reference.id
version: CampaignVersion | None = None
default_label = kind.replace("_", " ").title()
if kind == "campaign_version":
version = session.get(CampaignVersion, reference_id)
if version is not None:
default_label = f"Version {version.version_number}"
elif kind == "delivery_job":
job = session.get(CampaignJob, reference_id)
if job is None or job.campaign_id != campaign.id or job.tenant_id != campaign.tenant_id:
raise _invalid_reference()
version = session.get(CampaignVersion, job.campaign_version_id)
default_label = f"Delivery job {job.id[:8]}"
elif kind in {"recipient_import_batch", "attachment_rule"}:
version_id, separator, child_id = reference_id.partition(":")
if not separator or not version_id or not child_id:
raise _invalid_reference()
version = session.get(CampaignVersion, version_id)
if version is not None and kind == "recipient_import_batch":
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {}
imports = entries.get("imports") if isinstance(entries, dict) else []
if not any(isinstance(item, dict) and str(item.get("id") or "") == child_id for item in imports or []):
raise _invalid_reference()
default_label = "Recipient import batch"
elif version is not None:
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
if child_id not in {path for path, _rule in _attachment_rules(raw)}:
raise _invalid_reference()
default_label = "Attachment rule"
else:
referenced_campaign_id, separator, remainder = reference_id.partition(":")
version_id, separator_two, report_kind = remainder.partition(":")
if (
not separator
or not separator_two
or referenced_campaign_id != campaign.id
or not version_id
or not report_kind
):
raise _invalid_reference()
version = session.get(CampaignVersion, version_id)
default_label = report_kind.replace("_", " ").title()
if version is None or version.campaign_id != campaign.id:
raise _invalid_reference()
return version.id, kind, reference_id, default_label[:255]
def _invalid_reference() -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="The collaboration reference is not stable evidence owned by this campaign.",
)
def _validated_mentions(
session: Session,
*,
campaign: Campaign,
user_ids: list[str],
actor_user_id: str,
) -> list[str]:
mentions = [user_id for user_id in user_ids if user_id != actor_user_id]
invalid = [
user_id
for user_id in mentions
if not _mentioned_user_has_campaign_access(
session,
campaign=campaign,
user_id=user_id,
)
]
if invalid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Mentioned users must be active and already have access to this campaign.",
)
return mentions
def _mentioned_user_has_campaign_access(
session: Session,
*,
campaign: Campaign,
user_id: str,
) -> bool:
directory = _access_directory()
user = next(
(candidate for candidate in directory.users_for_tenant(campaign.tenant_id) if candidate.id == user_id),
None,
)
if user is None or user.status != "active":
return False
if campaign.owner_user_id == user_id:
return True
group_ids = {
group.id
for group in directory.groups_for_user(user_id, tenant_id=campaign.tenant_id)
}
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
return True
clauses = [
and_(
CampaignShare.target_type == "user",
CampaignShare.target_id == user_id,
)
]
if group_ids:
clauses.append(
and_(
CampaignShare.target_type == "group",
CampaignShare.target_id.in_(sorted(group_ids)),
)
)
return (
session.query(CampaignShare.id)
.filter(
CampaignShare.tenant_id == campaign.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
or_(*clauses),
)
.first()
is not None
)
def _enqueue_mention_notifications(
session: Session,
*,
campaign: Campaign,
entry: CampaignCollaborationEntry,
mention_user_ids: list[str],
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None or not mention_user_ids:
return
try:
with session.begin_nested():
for user_id in mention_user_ids:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=campaign.tenant_id,
source_module="campaigns",
source_resource_type="campaign_collaboration_entry",
source_resource_id=entry.id,
event_kind="campaign.collaboration.mentioned",
channel="inbox",
recipient_type="user",
recipient_id=user_id,
subject=f"Mentioned in campaign: {campaign.name}",
body_text=(
f"{entry.actor_label_snapshot} mentioned you in the campaign collaboration thread."
),
action_url=f"/campaigns/{campaign.id}/activity",
payload={
"campaign_id": campaign.id,
"entry_id": entry.id,
"content_disclosed": False,
},
),
enqueue_delivery=False,
)
except Exception:
# Collaboration remains available when the optional Notifications
# provider is absent or temporarily unhealthy.
return
def _entry_response(entry: CampaignCollaborationEntry) -> CampaignCollaborationEntryResponse:
tombstone: Literal["withdrawn", "redacted"] | None = None
if entry.redacted_at is not None:
tombstone = "redacted"
elif entry.withdrawn_at is not None:
tombstone = "withdrawn"
reference = None
if entry.reference_kind and entry.reference_id:
reference = CampaignCollaborationReferenceResponse(
kind=entry.reference_kind, # type: ignore[arg-type]
id=entry.reference_id,
label=entry.reference_label,
)
return CampaignCollaborationEntryResponse(
id=entry.id,
campaign_id=entry.campaign_id,
actor_user_id=entry.actor_user_id,
actor_label=entry.actor_label_snapshot,
visibility=entry.visibility, # type: ignore[arg-type]
content=entry.content if tombstone is None else None,
content_sha256=entry.content_sha256,
mention_user_ids=list(entry.mention_user_ids or []),
reference=reference,
tombstone=tombstone,
tombstone_reason=entry.tombstone_reason,
withdrawn_at=entry.withdrawn_at,
redacted_at=entry.redacted_at,
created_at=entry.created_at,
)
def _encode_cursor(entry: CampaignCollaborationEntry) -> str:
created_at = entry.created_at
if created_at.tzinfo is None:
# SQLite returns timezone-aware columns as naive UTC values.
created_at = created_at.replace(tzinfo=UTC)
payload = json.dumps(
{"created_at": created_at.astimezone(UTC).isoformat(), "id": entry.id},
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def _decode_cursor(value: str) -> tuple[datetime, str]:
try:
padded = value + "=" * (-len(value) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
created_at = datetime.fromisoformat(str(payload["created_at"]))
entry_id = str(payload["id"])
if created_at.tzinfo is None or not entry_id or len(entry_id) > 36:
raise ValueError
return created_at, entry_id
except (
KeyError,
TypeError,
ValueError,
UnicodeDecodeError,
json.JSONDecodeError,
binascii.Error,
) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Invalid collaboration cursor.",
) from exc
@@ -0,0 +1,749 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import (
AppendSentRequest,
CampaignActionResponse,
CampaignRetryJobsRequest,
CampaignSendJobRequest,
CampaignSendUnattemptedRequest,
CampaignResolveOutcomeRequest,
CampaignDeliveryOptionsResponse,
MockCampaignSendRequest,
MockCampaignSendResponse,
QueueCampaignRequest,
QueueCampaignResponse,
SendCampaignNowRequest,
SendCampaignNowResponse,
)
from govoplan_campaign.backend.approval_gate import (
CampaignApprovalGateError,
campaign_approval_status,
request_campaign_approval,
)
from govoplan_campaign.backend.approval_schemas import CampaignApprovalRequestInput
from govoplan_core.auth import ApiPrincipal, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_campaign.backend.db.models import (
CampaignJob,
JobImapStatus,
JobQueueStatus,
JobSendStatus,
)
from govoplan_campaign.backend.integrations import (
postbox_integration,
)
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.response_security import (
public_send_campaign_now_result,
send_campaign_now_audit_details,
)
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
)
from govoplan_campaign.backend.persistence.versions import (
is_user_locked_version,
)
from govoplan_campaign.backend.dev.mock_campaign import (
MockCampaignSendError,
run_mock_campaign_send,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError
from govoplan_campaign.backend.sending.jobs import (
QueueingError,
SynchronousSendRejected,
cancel_campaign_jobs,
enqueue_pending_imap_appends,
pause_campaign_jobs,
queue_campaign_jobs,
queue_failed_jobs_for_retry,
queue_unattempted_jobs,
reconcile_job_outcome,
resume_campaign_jobs,
send_campaign_now,
send_single_campaign_job,
synchronous_send_options,
)
from govoplan_campaign.backend.route_support import (
_get_campaign_for_principal,
_get_campaign_for_tenant,
_get_version_for_tenant,
_require_campaign_profile_use_if_needed,
_require_campaign_versions_profile_use,
_require_mail_profile_use_if_needed,
_require_permission,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
logger = logging.getLogger(__name__)
@router.get(
"/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse
)
def campaign_delivery_options(
campaign_id: str,
version_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("campaigns:campaign:send", "campaigns:campaign:queue")
),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
try:
options = synchronous_send_options(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
version = _get_version_for_tenant(
session, str(options["version_id"]), principal.tenant_id
)
return CampaignDeliveryOptionsResponse(
**options,
postbox_available=postbox_integration().available,
approval_gate=campaign_approval_status(
session, tenant_id=principal.tenant_id, version=version
),
)
except QueueingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post(
"/{campaign_id}/versions/{version_id}/approval-request",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def create_campaign_approval_request(
campaign_id: str,
version_id: str,
payload: CampaignApprovalRequestInput,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")),
) -> dict[str, object]:
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
if campaign.current_version_id != version_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Approval can only be requested for the current Campaign version.",
)
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if not version.build_summary:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Build Campaign messages before requesting approval.",
)
try:
request = request_campaign_approval(
session,
principal,
campaign=campaign,
version=version,
title=payload.title,
description=payload.description,
steps=tuple(step.to_definition() for step in payload.steps),
idempotency_key=payload.idempotency_key,
template_id=payload.template_id,
template_revision=payload.template_revision,
unique_actors_across_steps=payload.unique_actors_across_steps,
expires_at=payload.expires_at,
policy_refs=tuple(payload.policy_refs),
)
audit_from_principal(
session,
principal,
action="campaign.approval_requested",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign.id,
"approval_request_id": request.id,
"approval_request_revision": request.revision,
"execution_snapshot_hash": version.execution_snapshot_hash,
},
commit=True,
)
return {
"request_id": request.id,
"request_revision": request.revision,
"request_state": request.state,
"approval_gate": campaign_approval_status(
session, tenant_id=principal.tenant_id, version=version
),
}
except (CampaignApprovalGateError, ExecutionSnapshotError) as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
def queue_campaign(
campaign_id: str,
payload: QueueCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or QueueCampaignRequest()
_require_campaign_profile_use_if_needed(
session, principal, campaign_id, payload.version_id
)
try:
result = queue_campaign_jobs(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=payload.version_id,
include_warnings=payload.include_warnings,
enqueue_celery=payload.enqueue_celery,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="campaign.queued"
if not payload.dry_run
else "campaign.queue_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result.as_dict(),
commit=True,
)
return QueueCampaignResponse(**result.as_dict())
except QueueingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post("/{campaign_id}/jobs/retry", response_model=CampaignActionResponse)
def retry_campaign_jobs(
campaign_id: str,
payload: CampaignRetryJobsRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:retry")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or CampaignRetryJobsRequest()
_require_campaign_profile_use_if_needed(
session, principal, campaign_id, payload.version_id
)
try:
result = queue_failed_jobs_for_retry(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=payload.version_id,
job_ids=payload.job_ids or None,
include_permanent=payload.include_permanent,
force_max_attempts=payload.force_max_attempts,
enqueue_celery=payload.enqueue_celery,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="campaign.jobs_retry_queued"
if not payload.dry_run
else "campaign.jobs_retry_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
commit=True,
)
return CampaignActionResponse(result=result)
except (QueueingError, ExecutionSnapshotError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post(
"/{campaign_id}/jobs/send-unattempted", response_model=CampaignActionResponse
)
def send_unattempted_campaign_jobs(
campaign_id: str,
payload: CampaignSendUnattemptedRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or CampaignSendUnattemptedRequest()
_require_campaign_profile_use_if_needed(
session, principal, campaign_id, payload.version_id
)
try:
result = queue_unattempted_jobs(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=payload.version_id,
job_ids=payload.job_ids or None,
enqueue_celery=payload.enqueue_celery,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="campaign.unattempted_jobs_queued"
if not payload.dry_run
else "campaign.unattempted_jobs_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
commit=True,
)
return CampaignActionResponse(result=result)
except (QueueingError, ExecutionSnapshotError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post("/{campaign_id}/jobs/{job_id}/send", response_model=CampaignActionResponse)
def send_single_campaign_job_endpoint(
campaign_id: str,
job_id: str,
payload: CampaignSendJobRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(
"campaigns:campaign:send",
"campaigns:campaign:send_test",
)
),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
_require_permission(
principal,
(
"campaigns:campaign:send_test"
if payload.kind == "test"
else "campaigns:campaign:send"
),
)
_require_campaign_profile_use_if_needed(session, principal, campaign_id, None)
try:
result = send_single_campaign_job(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
job_id=job_id,
kind=payload.kind,
idempotency_key=payload.idempotency_key,
actor_user_id=principal.user.id,
actor_api_key_id=getattr(principal, "api_key_id", None),
reason=payload.reason,
action_context=payload.context,
include_warnings=payload.include_warnings,
use_rate_limit=payload.use_rate_limit,
enqueue_imap_task=payload.enqueue_imap_task,
)
audit_from_principal(
session,
principal,
action=f"campaign.message_{payload.kind}",
object_type="campaign_job",
object_id=job_id,
details=result,
commit=True,
)
return CampaignActionResponse(result=result)
except (QueueingError, ExecutionSnapshotError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except Exception as exc:
logger.exception(
"Unexpected single-message campaign action failure",
extra={
"campaign_id": campaign_id,
"job_id": job_id,
"action_kind": payload.kind,
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="The message action failed because of an internal error.",
) from exc
@router.post(
"/{campaign_id}/jobs/{job_id}/resolve-outcome",
response_model=CampaignActionResponse,
)
def resolve_campaign_job_outcome(
campaign_id: str,
job_id: str,
payload: CampaignResolveOutcomeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
try:
result = reconcile_job_outcome(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
job_id=job_id,
decision=payload.decision,
note=payload.note,
attempt_id=payload.attempt_id,
commit=False,
)
audit_from_principal(
session,
principal,
action="campaign.job_outcome_reconciled",
object_type="campaign_job",
object_id=job_id,
details=result,
commit=True,
)
return CampaignActionResponse(result=result)
except (QueueingError, ExecutionSnapshotError) as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except Exception:
session.rollback()
raise
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)
def mock_send_campaign(
campaign_id: str,
payload: MockCampaignSendRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
):
"""Run a fully visible mock delivery flow without mutating campaign state.
The route validates and builds the selected version, then optionally records
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
real SMTP/IMAP servers and it does not mark the version sent/final.
"""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or MockCampaignSendRequest()
_require_campaign_profile_use_if_needed(
session, principal, campaign_id, payload.version_id
)
try:
result = run_mock_campaign_send(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=payload.version_id,
send=payload.send,
include_warnings=payload.include_warnings,
include_needs_review=payload.include_needs_review,
append_sent=payload.append_sent,
clear_mailbox=payload.clear_mailbox,
check_files=payload.check_files,
)
audit_from_principal(
session,
principal,
action="campaign.mock_send"
if payload.send
else "campaign.mock_send_review",
object_type="campaign",
object_id=campaign_id,
details={
"version_id": result.get("version_id"),
"send_requested": payload.send,
"sent_count": result.get("send", {}).get("sent_count"),
"failed_count": result.get("send", {}).get("failed_count"),
},
commit=True,
)
return MockCampaignSendResponse(result=result)
except MockCampaignSendError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse)
def send_campaign_now_endpoint(
campaign_id: str,
payload: SendCampaignNowRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
):
"""Preflight and synchronously send a policy-bounded built execution."""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or SendCampaignNowRequest()
try:
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
version_id = payload.version_id or campaign.current_version_id
if not version_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Campaign has no current version",
)
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
_require_mail_profile_use_if_needed(
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
)
validation_result: dict[str, object] | None = (
version.validation_summary
if isinstance(version.validation_summary, dict)
else None
)
build_result: dict[str, object] | None = (
version.build_summary if isinstance(version.build_summary, dict) else None
)
if is_user_locked_version(version):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.",
)
if (
not version.locked_at
or not validation_result
or validation_result.get("ok") is not True
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Campaign version must be validated and locked before dry-run or sending.",
)
if not build_result:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Campaign version must be built before dry-run or sending.",
)
delivery_result = send_campaign_now(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
include_warnings=payload.include_warnings,
dry_run=payload.dry_run,
use_rate_limit=payload.use_rate_limit,
enqueue_imap_task=payload.enqueue_imap_task,
).as_dict()
response_result = public_send_campaign_now_result(
delivery_result,
validation_summary=validation_result,
build_summary=build_result,
)
audit_from_principal(
session,
principal,
action="campaign.sent_now"
if not payload.dry_run
else "campaign.send_now_dry_run",
object_type="campaign",
object_id=campaign_id,
details=send_campaign_now_audit_details(delivery_result),
commit=True,
)
return SendCampaignNowResponse(result=response_result)
except SynchronousSendRejected as exc:
# A synchronous request stages queue state before the all-message
# preflight can run. Rejecting that preflight must not leave work
# eligible for a background worker when no provider effect occurred.
session.rollback()
audit_from_principal(
session,
principal,
action="campaign.send_now_rejected",
object_type="campaign",
object_id=campaign_id,
details={
**exc.audit_details(),
"version_id": payload.version_id,
},
commit=True,
)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except HTTPException:
raise
except (CampaignPersistenceError, QueueingError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse)
def pause_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
try:
result = pause_campaign_jobs(
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
)
audit_from_principal(
session,
principal,
action="campaign.paused",
object_type="campaign",
object_id=campaign_id,
details=result,
commit=True,
)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse)
def resume_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
version_ids = {
row[0]
for row in session.query(CampaignJob.campaign_version_id)
.filter(
CampaignJob.tenant_id == principal.tenant_id,
CampaignJob.campaign_id == campaign_id,
CampaignJob.queue_status == JobQueueStatus.PAUSED.value,
)
.distinct()
.all()
}
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
try:
result = resume_campaign_jobs(
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
)
audit_from_principal(
session,
principal,
action="campaign.resumed",
object_type="campaign",
object_id=campaign_id,
details=result,
commit=True,
)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse)
def cancel_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
try:
result = cancel_campaign_jobs(
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
)
audit_from_principal(
session,
principal,
action="campaign.cancelled",
object_type="campaign",
object_id=campaign_id,
details=result,
commit=True,
)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse)
def append_sent(
campaign_id: str,
payload: AppendSentRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
payload = payload or AppendSentRequest()
version_ids = {
row[0]
for row in session.query(CampaignJob.campaign_version_id)
.filter(
CampaignJob.tenant_id == principal.tenant_id,
CampaignJob.campaign_id == campaign_id,
CampaignJob.send_status.in_(
[JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value]
),
CampaignJob.imap_status.in_(
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
),
)
.distinct()
.all()
}
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
try:
result = enqueue_pending_imap_appends(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
enqueue_celery=payload.enqueue_celery,
run_inline=payload.run_inline,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="campaign.append_sent_enqueued"
if not payload.dry_run
else "campaign.append_sent_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
commit=True,
)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@@ -0,0 +1,530 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import (
CampaignJobsResponse,
CampaignJobsDeltaResponse,
CampaignJobDetailResponse,
CampaignJobDiagnosticsResponse,
)
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.core.change_sequence import (
decode_sequence_watermark,
encode_sequence_watermark,
sequence_entries_since,
sequence_watermark_is_expired,
)
from govoplan_campaign.backend.change_tracking import (
CAMPAIGNS_MODULE_ID,
CAMPAIGN_JOBS_COLLECTION,
)
from govoplan_campaign.backend.db.models import (
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.route_support import (
_get_campaign_for_principal,
_get_campaign_for_tenant,
_require_permission,
job_attempt_rows as _job_attempt_rows,
)
from govoplan_campaign.backend.services.job_queries import (
CampaignJobsQuery,
_campaign_jobs_delta_watermark,
_campaign_jobs_page_response,
_campaign_jobs_query_context,
_job_attempts_payload,
_calendar_invitations_for_jobs,
_job_detail_payload,
_job_diagnostics_payload,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _postbox_receipts_for_attempts(
session: Session,
*,
tenant_id: str,
attempts: list[PostboxDeliveryAttempt],
):
integration = postbox_integration()
if not integration.receipt_evidence_available:
return None
delivery_ids = [
attempt.provider_delivery_id
for attempt in attempts
if attempt.provider_delivery_id
]
return integration.delivery_receipt_summaries(
session,
tenant_id=tenant_id,
delivery_ids=delivery_ids,
)
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
def list_jobs(
campaign_id: str,
filters: CampaignJobsQuery = Depends(CampaignJobsQuery),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
"""Return a lightweight, paginated job list with server-side filters.
Complete recipients, attachment metadata, issues and attempt history are
available from the separate job-detail endpoint.
"""
_campaign, base_filters, filtered, review_metadata, reviewed_keys = (
_campaign_jobs_query_context(
session,
principal,
campaign_id=campaign_id,
version_id=filters.version_id,
send_status=filters.send_status,
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
)
)
return _campaign_jobs_page_response(
session,
campaign_id=campaign_id,
version_id=filters.version_id,
base_filters=base_filters,
filtered=filtered,
reviewed_keys=reviewed_keys,
review_metadata=review_metadata,
page=filters.page,
page_size=filters.page_size,
send_status=filters.send_status,
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
)
def _campaign_jobs_full_delta_response(
session: Session,
*,
principal: ApiPrincipal,
campaign_id: str,
version_id: str | None,
page: int,
page_size: int,
send_status: list[str] | None,
validation_status: list[str] | None,
imap_status: list[str] | None,
query_text: str | None,
grid_filters: dict[str, str] | None,
sort_by: str,
sort_direction: str,
cursor: str | None = None,
) -> CampaignJobsDeltaResponse:
_campaign, base_filters, filtered, review_metadata, reviewed_keys = (
_campaign_jobs_query_context(
session,
principal,
campaign_id=campaign_id,
version_id=version_id,
send_status=send_status,
validation_status=validation_status,
imap_status=imap_status,
query_text=query_text,
grid_filters=grid_filters,
)
)
payload = _campaign_jobs_page_response(
session,
campaign_id=campaign_id,
version_id=version_id,
base_filters=base_filters,
filtered=filtered,
reviewed_keys=reviewed_keys,
review_metadata=review_metadata,
page=page,
page_size=page_size,
send_status=send_status,
validation_status=validation_status,
imap_status=imap_status,
query_text=query_text,
grid_filters=grid_filters,
sort_by=sort_by,
sort_direction=sort_direction,
cursor=cursor,
)
return CampaignJobsDeltaResponse(
**payload.model_dump(),
deleted=[],
watermark=_campaign_jobs_delta_watermark(session, principal.tenant_id),
has_more=False,
full=True,
)
def _job_filter_membership_can_shift(
*,
send_status: list[str] | None,
validation_status: list[str] | None,
imap_status: list[str] | None,
query_text: str | None,
grid_filters: dict[str, str] | None,
sort_by: str,
sort_direction: str,
) -> bool:
return bool(
send_status
or validation_status
or imap_status
or (query_text and query_text.strip())
or grid_filters
or sort_by != "number"
or sort_direction != "asc"
)
@router.get("/{campaign_id}/jobs/delta", response_model=CampaignJobsDeltaResponse)
def list_jobs_delta(
campaign_id: str,
filters: CampaignJobsQuery = Depends(CampaignJobsQuery),
since: str | None = None,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
if since is None:
return _campaign_jobs_full_delta_response(
session,
principal=principal,
campaign_id=campaign_id,
version_id=filters.version_id,
page=filters.page,
page_size=filters.page_size,
send_status=filters.send_status,
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
)
campaign, base_filters, filtered, review_metadata, reviewed_keys = (
_campaign_jobs_query_context(
session,
principal,
campaign_id=campaign_id,
version_id=filters.version_id,
send_status=filters.send_status,
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
)
)
try:
since_sequence = decode_sequence_watermark(since)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
if sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=principal.tenant_id,
module_id=CAMPAIGNS_MODULE_ID,
collections=(CAMPAIGN_JOBS_COLLECTION,),
):
return _campaign_jobs_full_delta_response(
session,
principal=principal,
campaign_id=campaign_id,
version_id=filters.version_id,
page=filters.page,
page_size=filters.page_size,
send_status=filters.send_status,
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
)
entries_plus_one = sequence_entries_since(
session,
since=since_sequence,
tenant_id=principal.tenant_id,
module_id=CAMPAIGNS_MODULE_ID,
collections=(CAMPAIGN_JOBS_COLLECTION,),
limit=limit + 1,
)
has_more = len(entries_plus_one) > limit
entries = entries_plus_one[:limit]
relevant_entries = [
entry
for entry in entries
if (entry.payload or {}).get("campaign_id") == campaign.id
and (
not filters.version_id
or (entry.payload or {}).get("version_id") == filters.version_id
)
]
if relevant_entries and (
_job_filter_membership_can_shift(
send_status=filters.send_status,
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
)
or any(entry.operation in {"created", "deleted"} for entry in relevant_entries)
):
return _campaign_jobs_full_delta_response(
session,
principal=principal,
campaign_id=campaign_id,
version_id=filters.version_id,
page=filters.page,
page_size=filters.page_size,
send_status=filters.send_status,
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
)
changed_job_ids = {
entry.resource_id
for entry in relevant_entries
if entry.resource_type == "campaign_job" and entry.operation != "deleted"
}
payload = _campaign_jobs_page_response(
session,
campaign_id=campaign_id,
version_id=filters.version_id,
base_filters=base_filters,
filtered=filtered,
reviewed_keys=reviewed_keys,
review_metadata=review_metadata,
page=filters.page,
page_size=filters.page_size,
send_status=filters.send_status,
validation_status=filters.validation_status,
imap_status=filters.imap_status,
query_text=filters.query_text,
grid_filters=filters.grid_filters,
sort_by=filters.sort_by,
sort_direction=filters.sort_direction,
cursor=filters.cursor,
changed_job_ids=changed_job_ids,
)
watermark = (
encode_sequence_watermark(entries[-1].id)
if has_more and entries
else _campaign_jobs_delta_watermark(session, principal.tenant_id)
)
return CampaignJobsDeltaResponse(
**payload.model_dump(),
deleted=[],
watermark=watermark,
has_more=has_more,
full=False,
)
@router.get("/{campaign_id}/jobs/{job_id}", response_model=CampaignJobDetailResponse)
def get_job_detail(
campaign_id: str,
job_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:read")
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
job = session.get(CampaignJob, job_id)
if (
not job
or job.campaign_id != campaign.id
or job.tenant_id != principal.tenant_id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found"
)
send_attempts = _job_attempt_rows(
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.asc()),
label="SMTP attempts for this campaign job",
)
imap_attempts = _job_attempt_rows(
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.asc()),
label="IMAP attempts for this campaign job",
)
postbox_attempts = _job_attempt_rows(
session.query(PostboxDeliveryAttempt)
.filter(PostboxDeliveryAttempt.job_id == job.id)
.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.asc(),
),
label="Postbox attempts for this campaign job",
)
print_attempts = _job_attempt_rows(
session.query(PrintOutputAttempt)
.filter(PrintOutputAttempt.job_id == job.id)
.order_by(PrintOutputAttempt.attempt_number.asc()),
label="Printable output attempts for this campaign job",
)
message_actions = _job_attempt_rows(
session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id == job.id)
.order_by(CampaignMessageAction.created_at.asc()),
label="Single-message actions for this campaign job",
)
action_ids = [action.id for action in message_actions]
message_action_attempts = (
_job_attempt_rows(
session.query(CampaignMessageActionAttempt)
.filter(CampaignMessageActionAttempt.action_id.in_(action_ids))
.order_by(CampaignMessageActionAttempt.started_at.asc()),
label="Single-message action attempts for this campaign job",
)
if action_ids
else []
)
return CampaignJobDetailResponse(
job=_job_detail_payload(
job,
calendar_invitation=_calendar_invitations_for_jobs(
session,
[job],
).get(job.id),
),
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
postbox_attempts=postbox_attempts,
print_attempts=print_attempts,
message_actions=message_actions,
message_action_attempts=message_action_attempts,
postbox_receipts=_postbox_receipts_for_attempts(
session,
tenant_id=principal.tenant_id,
attempts=postbox_attempts,
),
),
)
@router.get(
"/{campaign_id}/jobs/{job_id}/diagnostics",
response_model=CampaignJobDiagnosticsResponse,
)
def get_job_diagnostics(
campaign_id: str,
job_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:diagnostic:read")),
):
"""Return infrastructure details only to campaign operators/admins."""
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:read")
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
job = session.get(CampaignJob, job_id)
if (
not job
or job.campaign_id != campaign.id
or job.tenant_id != principal.tenant_id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found"
)
send_attempts = _job_attempt_rows(
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.asc()),
label="SMTP diagnostics for this campaign job",
)
imap_attempts = _job_attempt_rows(
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.asc()),
label="IMAP diagnostics for this campaign job",
)
postbox_attempts = _job_attempt_rows(
session.query(PostboxDeliveryAttempt)
.filter(PostboxDeliveryAttempt.job_id == job.id)
.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.asc(),
),
label="Postbox diagnostics for this campaign job",
)
print_attempts = _job_attempt_rows(
session.query(PrintOutputAttempt)
.filter(PrintOutputAttempt.job_id == job.id)
.order_by(PrintOutputAttempt.attempt_number.asc()),
label="Printable output diagnostics for this campaign job",
)
message_actions = _job_attempt_rows(
session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id == job.id)
.order_by(CampaignMessageAction.created_at.asc()),
label="Single-message action diagnostics for this campaign job",
)
action_ids = [action.id for action in message_actions]
message_action_attempts = (
_job_attempt_rows(
session.query(CampaignMessageActionAttempt)
.filter(CampaignMessageActionAttempt.action_id.in_(action_ids))
.order_by(CampaignMessageActionAttempt.started_at.asc()),
label="Single-message action-attempt diagnostics for this campaign job",
)
if action_ids
else []
)
return _job_diagnostics_payload(
job,
send_attempts,
imap_attempts,
postbox_attempts=postbox_attempts,
print_attempts=print_attempts,
message_actions=message_actions,
message_action_attempts=message_action_attempts,
postbox_receipts=_postbox_receipts_for_attempts(
session,
tenant_id=principal.tenant_id,
attempts=postbox_attempts,
),
)
@@ -0,0 +1,102 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from govoplan_campaign.backend.artifact_reconciliation import (
CampaignArtifactReconciliationError,
reconcile_campaign_artifacts,
)
from govoplan_campaign.backend.persistence.campaigns import _object_storage
from govoplan_campaign.backend.schemas import (
CampaignArtifactReconcileRequest,
CampaignArtifactReconcileResponse,
)
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.object_storage import StorageBackendError
from govoplan_core.core.recovery import RecoveryGuaranteeError
from govoplan_core.core.recovery_runtime import (
RecoveryOperationBusy,
RecoveryOperationStateConflict,
)
from govoplan_core.db.session import get_database, get_session
from govoplan_core.server.runtime_agent import application_runtime_identity
router = APIRouter(prefix="/campaigns/operations", tags=["campaigns"])
@router.post(
"/artifacts/reconcile",
response_model=CampaignArtifactReconcileResponse,
)
def reconcile_artifacts(
payload: CampaignArtifactReconcileRequest,
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
) -> CampaignArtifactReconcileResponse:
"""Inventory or remove old, unreferenced Campaign-owned build objects."""
try:
result = reconcile_campaign_artifacts(
get_database().SessionLocal,
storage=_object_storage(),
identity=application_runtime_identity(request.app),
tenant_id=principal.tenant_id,
apply=payload.apply,
idempotency_key=payload.idempotency_key,
grace_period_hours=payload.grace_period_hours,
cursor=payload.cursor,
page_size=payload.page_size,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (
CampaignArtifactReconciliationError,
RecoveryGuaranteeError,
StorageBackendError,
SQLAlchemyError,
OSError,
) as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Campaign artifact reconciliation is temporarily unavailable "
f"({type(exc).__name__})."
),
) from exc
audit_from_principal(
session,
principal,
action=(
"campaign.artifact_orphans_reconciled"
if payload.apply
else "campaign.artifact_inventory_scanned"
),
object_type="campaign_artifact_namespace",
object_id=principal.tenant_id,
details={
"apply": payload.apply,
"status": result["status"],
"scanned_count": result["scanned_count"],
"candidate_count": result["candidate_count"],
"deleted_count": result["deleted_count"],
"failure_count": result["failure_count"],
"manifest_sha256": result["manifest_sha256"],
"recovery_operation_id": result["recovery_operation_id"],
},
commit=True,
)
return CampaignArtifactReconcileResponse.model_validate(result)
@@ -0,0 +1,257 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import (
ReportEmailRequest,
ReportEmailResponse,
)
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_campaign.backend.db.models import (
CampaignVersion,
)
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import (
CampaignReportError,
generate_campaign_report,
generate_jobs_csv,
)
from govoplan_campaign.backend.reports.emailing import (
CampaignReportEmailError,
send_campaign_report_email,
)
from govoplan_campaign.backend.integrations import (
MailDeliveryCommandError,
MailProfileError,
SmtpConfigurationError,
SmtpSendError,
mail_integration,
)
from govoplan_campaign.backend.route_support import (
_get_campaign_for_principal,
_require_mail_profile_use_if_needed,
_require_permission,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
logger = logging.getLogger(__name__)
def _enqueue_mail_command() -> None:
try:
from govoplan_core.celery_app import celery
from govoplan_core.settings import settings
if settings.celery_enabled:
celery.send_task(
"govoplan.mail.dispatch_outbox",
args=[None, 25],
queue="mail",
)
except Exception:
logger.warning(
"Mail delivery command is durable but immediate worker wake-up failed",
exc_info=True,
)
@router.get("/{campaign_id}/summary")
def campaign_summary(
campaign_id: str,
version_id: str | None = None,
include_jobs: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
"""Return dashboard-friendly campaign status counters and summaries."""
_get_campaign_for_principal(session, campaign_id, principal)
if include_jobs:
_require_permission(principal, "campaigns:recipient:read")
try:
return generate_campaign_report(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
include_jobs=include_jobs,
include_recent_failures=include_jobs,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignReportError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
@router.get("/{campaign_id}/report")
def campaign_report(
campaign_id: str,
version_id: str | None = None,
include_jobs: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
):
"""Return the recipient-level JSON report for one campaign."""
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:read")
try:
return generate_campaign_report(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
include_jobs=include_jobs,
include_recent_failures=include_jobs,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignReportError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
@router.get("/{campaign_id}/report/jobs.csv")
def campaign_jobs_csv(
campaign_id: str,
version_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:export")),
):
"""Export per-job campaign status as CSV."""
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:export")
try:
csv_text = generate_jobs_csv(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignReportError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
return Response(
content=csv_text,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"'
},
)
@router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse)
def email_campaign_report(
campaign_id: str,
payload: ReportEmailRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")),
):
"""Generate a campaign report and send it to one or more email addresses."""
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:export")
selected_version_id = payload.version_id or campaign.current_version_id
selected_version = (
session.get(CampaignVersion, selected_version_id)
if selected_version_id
else None
)
if selected_version is not None and selected_version.campaign_id == campaign.id:
_require_mail_profile_use_if_needed(
principal,
selected_version.raw_json
if isinstance(selected_version.raw_json, dict)
else {},
)
try:
result = send_campaign_report_email(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=payload.version_id,
to=payload.to,
include_jobs=payload.include_jobs,
attach_jobs_csv=payload.attach_jobs_csv,
attach_report_json=payload.attach_report_json,
dry_run=payload.dry_run,
idempotency_key=payload.idempotency_key,
created_by_user_id=principal.user.id,
)
audit_from_principal(
session,
principal,
action="report.email_requested"
if not payload.dry_run
else "report.email_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result.audit_dict(),
commit=True,
)
if not payload.dry_run:
_enqueue_mail_command()
return ReportEmailResponse(result=result.as_dict())
except CampaignReportError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except (
CampaignReportEmailError,
MailProfileError,
MailDeliveryCommandError,
SmtpConfigurationError,
SmtpSendError,
) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except Exception as exc:
logger.error("Campaign report email failed with an unexpected internal error")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Campaign report email could not be completed.",
) from exc
@router.get(
"/{campaign_id}/report/email/{command_id}",
response_model=ReportEmailResponse,
)
def campaign_report_email_status(
campaign_id: str,
command_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
try:
result = mail_integration().delivery_command_summary(
session,
tenant_id=principal.tenant_id,
command_id=command_id,
)
except MailDeliveryCommandError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
if (
result.get("source_module") != "campaigns"
or result.get("source_resource_type") != "campaign"
or result.get("source_resource_id") != campaign_id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign report delivery not found",
)
return ReportEmailResponse(result=result)
@@ -0,0 +1,344 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.scheduling import (
campaign_schedule_source_snapshot,
canonical_configuration_hash,
validate_autonomous_schedule_source,
)
from govoplan_campaign.backend.db.models import (
CampaignSchedule,
CampaignScheduleOccurrence,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.route_support import (
_get_campaign_for_principal,
_require_permission,
)
from govoplan_campaign.backend.schemas import (
CampaignScheduleCreateRequest,
CampaignScheduleListResponse,
CampaignScheduleOccurrenceResponse,
CampaignScheduleResponse,
CampaignScheduleStateRequest,
)
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.db.session import get_session
router = APIRouter(prefix="/campaigns", tags=["campaign-schedules"])
@router.get(
"/{campaign_id}/schedules",
response_model=CampaignScheduleListResponse,
)
def list_campaign_schedules(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
schedules = (
session.query(CampaignSchedule)
.filter(
CampaignSchedule.tenant_id == principal.tenant_id,
CampaignSchedule.campaign_id == campaign_id,
)
.order_by(CampaignSchedule.created_at.desc(), CampaignSchedule.id.asc())
.all()
)
occurrences = _occurrences_by_schedule(session, schedules)
return CampaignScheduleListResponse(
items=[
_schedule_response(item, occurrences.get(item.id, []))
for item in schedules
]
)
@router.post(
"/{campaign_id}/schedules",
response_model=CampaignScheduleResponse,
status_code=status.HTTP_201_CREATED,
)
def create_campaign_schedule(
campaign_id: str,
payload: CampaignScheduleCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:copy")
if payload.include_recipients:
_require_permission(principal, "campaigns:recipient:read")
if payload.include_shares:
_require_permission(principal, "campaigns:campaign:share")
if payload.delivery_mode == "autonomous":
_require_permission(principal, "campaigns:campaign:queue")
_require_permission(principal, "campaigns:campaign:send")
_require_permission(principal, "mail:profile:use")
source_version = (
session.query(CampaignVersion)
.filter(
CampaignVersion.id == payload.source_version_id,
CampaignVersion.campaign_id == campaign.id,
)
.one_or_none()
)
if source_version is None:
raise HTTPException(status_code=404, detail="Campaign version not found")
starts_at = payload.starts_at.astimezone(UTC)
if starts_at < datetime.now(UTC) - timedelta(minutes=5):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Campaign schedules cannot start in the past.",
)
try:
ZoneInfo(payload.timezone)
except ZoneInfoNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Unknown campaign schedule timezone.",
) from exc
source_shares = (
session.query(CampaignShare)
.filter(
CampaignShare.tenant_id == principal.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
)
.order_by(CampaignShare.id.asc())
.all()
if payload.include_shares
else []
)
snapshot = campaign_schedule_source_snapshot(
configuration=source_version.raw_json,
campaign_settings=campaign.settings or {},
mail_profile_policy=campaign.mail_profile_policy or {},
shares=[
{
"target_type": item.target_type,
"target_id": item.target_id,
"permission": item.permission,
}
for item in source_shares
],
)
autonomous_evidence: dict[str, object] | None = None
if payload.delivery_mode == "autonomous":
try:
autonomous_evidence = validate_autonomous_schedule_source(
session,
campaign=campaign,
version=source_version,
)
except (RuntimeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
snapshot["autonomous_delivery"] = autonomous_evidence
schedule = CampaignSchedule(
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
source_version_id=source_version.id,
created_by_user_id=principal.user.id,
name=payload.name.strip(),
delivery_mode=payload.delivery_mode,
recurrence_kind=payload.recurrence_kind,
interval_count=payload.interval_count,
timezone=payload.timezone,
starts_at=starts_at,
next_fire_at=starts_at,
ends_at=payload.ends_at.astimezone(UTC) if payload.ends_at else None,
max_occurrences=payload.max_occurrences,
copy_options={
"include_recipients": payload.include_recipients,
"include_files": payload.include_files,
"include_shares": payload.include_shares,
"include_policies": payload.include_policies,
"include_mail_profile": payload.include_mail_profile,
},
source_snapshot=snapshot,
source_snapshot_hash=canonical_configuration_hash(snapshot),
approved_execution_snapshot_hash=(
str(autonomous_evidence["execution_snapshot_hash"])
if autonomous_evidence is not None
else None
),
source_base_path=source_version.source_base_path,
)
session.add(schedule)
session.flush()
audit_from_principal(
session,
principal,
action="campaign.schedule.created",
object_type="campaign_schedule",
object_id=schedule.id,
details={
"campaign_id": campaign.id,
"source_version_id": source_version.id,
"recurrence_kind": schedule.recurrence_kind,
"interval_count": schedule.interval_count,
"starts_at": schedule.starts_at.isoformat(),
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
"max_occurrences": schedule.max_occurrences,
"delivery_mode": schedule.delivery_mode,
"delivery_started": False,
"autonomous_delivery_opted_in": (
schedule.delivery_mode == "autonomous"
),
"approved_execution_snapshot_hash": (
schedule.approved_execution_snapshot_hash
),
"approval_request_id": (
autonomous_evidence.get("approval_request_id")
if autonomous_evidence is not None
else None
),
},
commit=True,
)
session.refresh(schedule)
return _schedule_response(schedule, [])
@router.patch(
"/{campaign_id}/schedules/{schedule_id}",
response_model=CampaignScheduleResponse,
)
def set_campaign_schedule_state(
campaign_id: str,
schedule_id: str,
payload: CampaignScheduleStateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
):
_get_campaign_for_principal(session, campaign_id, principal)
schedule = _schedule_for_campaign(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
schedule_id=schedule_id,
for_update=True,
)
if schedule.resource_revision != payload.base_revision:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Campaign schedule changed. Reload it before changing its state.",
)
if payload.active and schedule.next_fire_at is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A completed campaign schedule cannot be resumed.",
)
if payload.active:
unresolved = (
session.query(CampaignScheduleOccurrence.id)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule.id,
CampaignScheduleOccurrence.status == "uncertain",
)
.first()
)
if unresolved is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"Reconcile the autonomous delivery outcome in Mail before "
"resuming this schedule."
),
)
schedule.active = payload.active
schedule.last_error = None if payload.active else schedule.last_error
schedule.resource_revision += 1
session.add(schedule)
audit_from_principal(
session,
principal,
action=(
"campaign.schedule.resumed"
if payload.active
else "campaign.schedule.paused"
),
object_type="campaign_schedule",
object_id=schedule.id,
details={"campaign_id": campaign_id},
commit=True,
)
session.refresh(schedule)
occurrences = _occurrences_by_schedule(session, [schedule]).get(schedule.id, [])
return _schedule_response(schedule, occurrences)
def _schedule_for_campaign(
session: Session,
*,
tenant_id: str,
campaign_id: str,
schedule_id: str,
for_update: bool = False,
) -> CampaignSchedule:
query = session.query(CampaignSchedule)
if for_update:
query = query.with_for_update()
schedule = (
query
.filter(
CampaignSchedule.id == schedule_id,
CampaignSchedule.tenant_id == tenant_id,
CampaignSchedule.campaign_id == campaign_id,
)
.one_or_none()
)
if schedule is None:
raise HTTPException(status_code=404, detail="Campaign schedule not found")
return schedule
def _occurrences_by_schedule(
session: Session,
schedules: list[CampaignSchedule],
) -> dict[str, list[CampaignScheduleOccurrence]]:
ids = [item.id for item in schedules]
if not ids:
return {}
rows = (
session.query(CampaignScheduleOccurrence)
.filter(CampaignScheduleOccurrence.schedule_id.in_(ids))
.order_by(
CampaignScheduleOccurrence.scheduled_for.desc(),
CampaignScheduleOccurrence.id.asc(),
)
.all()
)
grouped: dict[str, list[CampaignScheduleOccurrence]] = {}
for row in rows:
grouped.setdefault(row.schedule_id, []).append(row)
return grouped
def _schedule_response(
schedule: CampaignSchedule,
occurrences: list[CampaignScheduleOccurrence],
) -> CampaignScheduleResponse:
response = CampaignScheduleResponse.model_validate(schedule)
return response.model_copy(
update={
"occurrences": [
CampaignScheduleOccurrenceResponse.model_validate(item)
for item in occurrences
]
}
)
@@ -0,0 +1,288 @@
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
ReferenceOptionListResponse,
ReferenceOptionResponse,
)
from govoplan_campaign.backend.schemas import (
CampaignShareItem,
CampaignShareListResponse,
CampaignShareTargetItem,
CampaignShareTargetsResponse,
CampaignShareUpsertRequest,
CampaignOwnerUpdateRequest,
CampaignResponse,
)
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_campaign.backend.db.models import (
CampaignShare,
)
from govoplan_core.db.session import get_session
from govoplan_core.core.references import (
access_scope_reference_page,
access_scope_reference_provider_available,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.security.time import utc_now
from govoplan_campaign.backend.route_support import (
_access_directory,
_get_campaign_for_principal,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
@router.get(
"/{campaign_id}/share-target-options",
response_model=ReferenceOptionListResponse,
)
def search_campaign_share_targets(
campaign_id: str,
target_type: Literal["user", "group"],
q: str = "",
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=200),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
) -> ReferenceOptionListResponse:
_get_campaign_for_principal(session, campaign_id, principal, write=True)
registry = get_registry()
try:
page = access_scope_reference_page(
registry,
principal,
scope_type=target_type,
reference_kind="membership" if target_type == "user" else "group",
query=q,
selected_values=selected,
limit=limit,
cursor=cursor,
administrative=True,
session=session,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return ReferenceOptionListResponse(
options=[
ReferenceOptionResponse(**option.to_dict())
for option in page.options
],
provider_available=access_scope_reference_provider_available(registry),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
@router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse)
def list_campaign_share_targets(
campaign_id: str,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
directory = _access_directory()
users = [
user
for user in directory.users_for_tenant(principal.tenant_id)
if user.status == "active"
]
groups = [
group
for group in directory.groups_for_tenant(principal.tenant_id)
if group.status == "active"
]
if len(users) > limit or len(groups) > limit:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
f"Campaign share targets exceed the maximum response size of {limit} "
"users or groups. Use a searchable directory selector."
),
)
return CampaignShareTargetsResponse(
users=[
CampaignShareTargetItem(
id=item.id, name=item.display_name or item.email, secondary=item.email
)
for item in users
],
groups=[
CampaignShareTargetItem(id=item.id, name=item.name, secondary=None)
for item in groups
],
)
@router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse)
def list_campaign_shares(
campaign_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
query = session.query(CampaignShare).filter(
CampaignShare.tenant_id == principal.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
)
total = query.order_by(None).count()
pages = max(1, (total + page_size - 1) // page_size)
shares = (
query.order_by(
CampaignShare.target_type.asc(),
CampaignShare.target_id.asc(),
CampaignShare.id.asc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return CampaignShareListResponse(
shares=[CampaignShareItem.model_validate(item) for item in shares],
total=total,
page=page,
page_size=page_size,
pages=pages,
)
@router.put("/{campaign_id}/owner", response_model=CampaignResponse)
def update_campaign_owner(
campaign_id: str,
payload: CampaignOwnerUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
):
del payload
_get_campaign_for_principal(
session,
campaign_id,
principal,
write=True,
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"Direct campaign owner mutation has been retired. Use the "
"ownership transfer workflow so the target can accept."
),
)
@router.post(
"/{campaign_id}/shares",
response_model=CampaignShareItem,
status_code=status.HTTP_201_CREATED,
)
def upsert_campaign_share(
campaign_id: str,
payload: CampaignShareUpsertRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
directory = _access_directory()
if payload.target_type == "user":
target = directory.get_user(payload.target_id)
if target is not None and (
target.tenant_id != principal.tenant_id or target.status != "active"
):
target = None
else:
target = directory.get_group(payload.target_id)
if target is not None and (
target.tenant_id != principal.tenant_id or target.status != "active"
):
target = None
if target is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found"
)
share = (
session.query(CampaignShare)
.filter(
CampaignShare.campaign_id == campaign.id,
CampaignShare.target_type == payload.target_type,
CampaignShare.target_id == payload.target_id,
)
.one_or_none()
)
if share is None:
share = CampaignShare(
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
target_type=payload.target_type,
target_id=payload.target_id,
permission=payload.permission,
created_by_user_id=principal.user.id,
)
else:
share.permission = payload.permission
share.revoked_at = None
session.add(share)
audit_from_principal(
session,
principal,
action="campaign.share_upserted",
object_type="campaign",
object_id=campaign.id,
details=payload.model_dump(),
commit=True,
)
return CampaignShareItem.model_validate(share)
@router.delete(
"/{campaign_id}/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT
)
def revoke_campaign_share(
campaign_id: str,
share_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
share = (
session.query(CampaignShare)
.filter(
CampaignShare.id == share_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.tenant_id == principal.tenant_id,
)
.one_or_none()
)
if share is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign share not found"
)
share.revoked_at = utc_now()
session.add(share)
audit_from_principal(
session,
principal,
action="campaign.share_revoked",
object_type="campaign",
object_id=campaign.id,
details={"share_id": share_id},
commit=True,
)
return None
# Queue / delivery control -------------------------------------------------
File diff suppressed because it is too large Load Diff
@@ -60,7 +60,9 @@
"integer",
"double",
"date",
"password"
"password",
"organization_unit",
"organization_function"
],
"default": "string"
},
@@ -92,7 +94,27 @@
"mail_profile_id": {
"type": "string",
"minLength": 1,
"description": "Stable reference to an authorized profile owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials."
"description": "Stable reference to an authorized server envelope owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials."
},
"smtp_server_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit Mail-owned SMTP server selection."
},
"smtp_credential_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit core credential envelope bound to the selected SMTP server."
},
"imap_server_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit Mail-owned IMAP server selection."
},
"imap_credential_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit core credential envelope bound to the selected IMAP server."
}
},
"additionalProperties": false
@@ -337,7 +359,7 @@
"continue",
"warn"
],
"default": "ask"
"default": "warn"
},
"ambiguous_behavior": {
"type": "string",
@@ -349,6 +371,13 @@
"warn"
],
"default": "ask"
},
"reuse_policy": {
"$ref": "#/$defs/attachment_reuse_policy",
"description": "Controls whether resolving the same source file more than once is allowed, warned, reviewed, or blocked, with an optional same-recipient or same-message allowance."
},
"residual_files": {
"$ref": "#/$defs/residual_file_disposition"
}
},
"additionalProperties": false,
@@ -433,7 +462,7 @@
"continue",
"warn"
],
"default": "ask"
"default": "block"
},
"missing_optional_attachment": {
"type": "string",
@@ -509,6 +538,15 @@
"delivery": {
"type": "object",
"properties": {
"channel_policy": {
"$ref": "#/$defs/delivery_channel_policy"
},
"postbox": {
"$ref": "#/$defs/postbox_delivery"
},
"print": {
"$ref": "#/$defs/print_delivery"
},
"rate_limit": {
"type": "object",
"properties": {
@@ -626,6 +664,249 @@
},
"additionalProperties": false
},
"delivery_channel_policy": {
"type": "string",
"enum": [
"mail",
"postbox",
"print",
"mail_and_postbox",
"mail_then_postbox",
"postbox_then_mail",
"mail_then_print",
"postbox_then_print"
],
"default": "mail"
},
"postbox_target": {
"type": "object",
"required": [
"id",
"mode"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"mode": {
"type": "string",
"enum": [
"direct",
"derived"
]
},
"label": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"postbox_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"address_key": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"template_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"organization_unit_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"organization_unit_field": {
"type": [
"string",
"null"
],
"maxLength": 255
},
"organization_unit_match": {
"type": "string",
"enum": [
"id",
"slug"
],
"default": "id"
},
"function_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"function_field": {
"type": [
"string",
"null"
],
"maxLength": 255
},
"function_match": {
"type": "string",
"enum": [
"id",
"slug"
],
"default": "id"
},
"context_key": {
"type": [
"string",
"null"
],
"maxLength": 255
},
"context_field": {
"type": [
"string",
"null"
],
"maxLength": 255
}
},
"additionalProperties": false
},
"postbox_delivery": {
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {
"$ref": "#/$defs/postbox_target"
},
"maxItems": 50,
"default": []
},
"classification": {
"type": "string",
"minLength": 1,
"maxLength": 50,
"default": "internal"
},
"unresolved_target": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "block"
},
"vacant_target": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "warn"
},
"duplicate_target": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "warn"
}
},
"additionalProperties": false,
"default": {}
},
"print_target": {
"type": "object",
"required": ["channel", "target", "target_key"],
"properties": {
"channel": {
"type": "string",
"enum": ["postal", "internal_mail"]
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"target_key": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"contact_point_id": {
"type": ["string", "null"],
"maxLength": 36
},
"locale": {
"type": ["string", "null"],
"maxLength": 35
},
"decision_provenance": {
"type": "object",
"default": {}
}
},
"additionalProperties": false
},
"print_delivery": {
"type": "object",
"properties": {
"template_id": {
"type": ["string", "null"],
"maxLength": 36
},
"template_revision": {
"type": ["integer", "null"],
"minimum": 1
},
"usage": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"default": "campaign_print"
},
"output_format": {
"type": "string",
"enum": ["html", "text"],
"default": "html"
},
"profile_id": {
"type": ["string", "null"],
"maxLength": 120
},
"persist_to_files": {
"type": "boolean",
"default": true
}
},
"additionalProperties": false,
"default": {}
},
"attachment_config": {
"type": "object",
"required": [
@@ -869,6 +1150,36 @@
"default": true,
"description": "Deprecated compatibility alias for merge_disposition_notification_to. New campaign JSON should use merge_*."
},
"channel_policy": {
"oneOf": [
{
"$ref": "#/$defs/delivery_channel_policy"
},
{
"type": "null"
}
],
"default": null
},
"postbox_targets": {
"type": "array",
"items": {
"$ref": "#/$defs/postbox_target"
},
"maxItems": 50,
"default": []
},
"merge_postbox_targets": {
"type": "boolean",
"default": true
},
"print_target": {
"oneOf": [
{ "$ref": "#/$defs/print_target" },
{ "type": "null" }
],
"default": null
},
"attachments": {
"type": "array",
"items": {
@@ -885,6 +1196,11 @@
"additionalProperties": true,
"default": {}
},
"distribution_source": {
"type": "object",
"description": "Immutable Distribution List recipient, route-decision, and source evidence captured for this Campaign version.",
"additionalProperties": true
},
"last_sent": {
"type": "string",
"format": "date-time"
@@ -971,7 +1287,8 @@
"csv",
"xlsx",
"text",
"addresses"
"addresses",
"distribution_list"
]
},
"source_id": {
@@ -1145,6 +1462,53 @@
},
"additionalProperties": false
},
"attachment_reuse_policy": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["allow", "warn", "review", "block"],
"default": "allow",
"description": "Action when one resolved source file is used more than once outside the configured allowance. Review creates an explicit, reasoned review decision."
},
"allow_within": {
"type": "string",
"enum": ["none", "same_recipient", "same_message"],
"default": "none",
"description": "Optional exception that permits reuse confined to one recipient or one built message."
}
},
"additionalProperties": false,
"default": {"action": "allow", "allow_within": "none"}
},
"residual_file_disposition": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["none", "report", "attach"],
"default": "none",
"description": "Keep normal warning policy, prepare a reviewed report message, or prepare a reviewed message with the residual files attached."
},
"recipient": {
"oneOf": [
{ "$ref": "#/$defs/recipient" },
{ "type": "null" }
],
"default": null
},
"subject": {
"type": "string",
"default": "Unassigned files in campaign {{local:campaign_name}}"
},
"text": {
"type": "string",
"default": "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}"
}
},
"additionalProperties": false,
"default": { "mode": "none", "recipient": null }
},
"zip_config": {
"type": "object",
"properties": {
@@ -1261,6 +1625,44 @@
],
"default": "aes"
},
"password_delivery_channel": {
"type": "string",
"enum": [
"separate_mail",
"sms",
"letter",
"phone",
"in_person"
],
"default": "separate_mail"
},
"legacy_zipcrypto_acknowledged": {
"type": "boolean",
"default": false
},
"legacy_zipcrypto_reason": {
"type": [
"string",
"null"
],
"maxLength": 1000
},
"legacy_zipcrypto_acknowledged_by": {
"type": [
"string",
"null"
],
"maxLength": 255,
"readOnly": true
},
"legacy_zipcrypto_acknowledged_at": {
"type": [
"string",
"null"
],
"maxLength": 80,
"readOnly": true
},
"password_mode": {
"type": [
"string",
@@ -61,6 +61,16 @@
}
],
"fields": {
"/attachments/reuse_policy/action": {
"label": "Duplicate-file action",
"control": "select",
"description": "Allow and record, warn, require a reasoned review decision, or block repeated use of the same resolved file."
},
"/attachments/reuse_policy/allow_within": {
"label": "Allowed reuse boundary",
"control": "select",
"description": "Optionally exempt reuse confined to one recipient or one built message."
},
"/attachments/global[]/message_filename_template": {
"label": "Direct attachment filename",
"control": "text",
+720 -59
View File
@@ -1,9 +1,17 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from pydantic import (
BaseModel,
BeforeValidator,
ConfigDict,
Field,
ValidationInfo,
field_validator,
model_validator,
)
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
@@ -25,8 +33,6 @@ class CampaignCreateRequest(BaseModel):
source_base_path: str | None = None
class CampaignUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -36,6 +42,375 @@ class CampaignUpdateRequest(BaseModel):
description: str | None = None
CampaignCollaborationReferenceKind = Literal[
"campaign_version",
"recipient_import_batch",
"attachment_rule",
"delivery_job",
"report",
]
class CampaignCollaborationReferenceInput(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: CampaignCollaborationReferenceKind
id: str = Field(min_length=1, max_length=500)
@field_validator("id")
@classmethod
def strip_reference_text(cls, value: str) -> str:
clean = value.strip()
if not clean:
raise ValueError("A collaboration reference ID cannot be empty.")
return clean
class CampaignCollaborationCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
content: str = Field(min_length=1, max_length=8_000)
visibility: Literal["collaborators", "moderators"] = "collaborators"
reference: CampaignCollaborationReferenceInput | None = None
mention_user_ids: list[str] = Field(default_factory=list, max_length=20)
@field_validator("content")
@classmethod
def strip_content(cls, value: str) -> str:
if not value.strip():
raise ValueError("A collaboration entry cannot be empty.")
return value.strip()
@field_validator("mention_user_ids")
@classmethod
def normalize_mentions(cls, value: list[str]) -> list[str]:
normalized = list(dict.fromkeys(item.strip() for item in value if item.strip()))
if len(normalized) > 20:
raise ValueError("A collaboration entry can mention at most 20 users.")
if any(len(item) > 64 for item in normalized):
raise ValueError("A collaboration mention contains an invalid user ID.")
return normalized
class CampaignCollaborationModerationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str | None = Field(default=None, max_length=500)
@field_validator("reason")
@classmethod
def strip_reason(cls, value: str | None) -> str | None:
clean = value.strip() if value is not None else None
return clean or None
class CampaignCollaborationReferenceResponse(BaseModel):
kind: CampaignCollaborationReferenceKind
id: str
label: str | None = None
class CampaignCollaborationEntryResponse(BaseModel):
id: str
campaign_id: str
actor_user_id: str | None = None
actor_label: str
visibility: Literal["collaborators", "moderators"]
content: str | None = None
content_sha256: str
mention_user_ids: list[str] = Field(default_factory=list)
reference: CampaignCollaborationReferenceResponse | None = None
tombstone: Literal["withdrawn", "redacted"] | None = None
tombstone_reason: str | None = None
withdrawn_at: datetime | None = None
redacted_at: datetime | None = None
created_at: datetime
class CampaignCollaborationListResponse(BaseModel):
items: list[CampaignCollaborationEntryResponse]
next_cursor: str | None = None
has_more: bool = False
CampaignWorkAssigneeType = Literal["account", "group", "organization_function"]
CampaignWorkAssignmentStatus = Literal["open", "in_progress", "completed", "cancelled"]
CampaignWorkAssigneeResolutionState = Literal[
"resolved",
"unavailable",
"provider_unavailable",
]
class CampaignWorkAssigneeInput(BaseModel):
model_config = ConfigDict(extra="forbid")
type: CampaignWorkAssigneeType
id: str = Field(min_length=1, max_length=255)
@field_validator("id")
@classmethod
def strip_assignee_id(cls, value: str) -> str:
return value.strip()
class CampaignWorkAssignmentCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
purpose: str = Field(min_length=1, max_length=500)
assignee: CampaignWorkAssigneeInput
due_at: datetime | None = None
reference: CampaignCollaborationReferenceInput | None = None
mirror_to_tasks: bool = True
@field_validator("purpose")
@classmethod
def strip_assignment_purpose(cls, value: str) -> str:
return value.strip()
@field_validator("due_at")
@classmethod
def require_due_timezone(cls, value: datetime | None) -> datetime | None:
if value is not None and value.tzinfo is None:
raise ValueError("Assignment due dates must include a timezone.")
return value
class CampaignWorkAssignmentReassignRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
assignee: CampaignWorkAssigneeInput
reason: str | None = Field(default=None, max_length=500)
mirror_to_tasks: bool = True
@field_validator("reason")
@classmethod
def strip_reassignment_reason(cls, value: str | None) -> str | None:
clean = value.strip() if value is not None else None
return clean or None
class CampaignWorkAssignmentTransitionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
action: Literal["start", "complete", "cancel"]
reason: str | None = Field(default=None, max_length=500)
@field_validator("reason")
@classmethod
def strip_transition_reason(cls, value: str | None) -> str | None:
clean = value.strip() if value is not None else None
return clean or None
class CampaignWorkAssignmentReferenceResponse(BaseModel):
kind: CampaignCollaborationReferenceKind
id: str
label: str | None = None
class CampaignWorkAssignmentResponse(BaseModel):
id: str
campaign_id: str
purpose: str
status: CampaignWorkAssignmentStatus
due_at: datetime | None = None
assignee_type: CampaignWorkAssigneeType
assignee_id: str
assignee_label_snapshot: str
assignee_current_label: str | None = None
assignee_resolution_state: CampaignWorkAssigneeResolutionState
resolution_provenance: dict[str, Any] = Field(default_factory=dict)
resolution_checked_at: datetime
assigned_by_user_id: str | None = None
assigned_by_label: str
reference: CampaignWorkAssignmentReferenceResponse | None = None
completed_at: datetime | None = None
cancelled_at: datetime | None = None
task_mirror_id: str | None = None
task_mirror_status: Literal["not_configured", "mirrored", "failed", "skipped"]
task_mirror_error: str | None = None
resource_revision: int
created_at: datetime
updated_at: datetime
class CampaignWorkAssignmentListResponse(BaseModel):
items: list[CampaignWorkAssignmentResponse]
next_cursor: str | None = None
has_more: bool = False
class CampaignWorkAssignmentEventResponse(BaseModel):
id: str
assignment_id: str
event_kind: str
actor_user_id: str | None = None
actor_label: str
status: CampaignWorkAssignmentStatus
assignee_type: CampaignWorkAssigneeType
assignee_id: str
assignee_label: str
resolution_state: CampaignWorkAssigneeResolutionState
details: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class CampaignWorkAssignmentHistoryResponse(BaseModel):
items: list[CampaignWorkAssignmentEventResponse]
next_cursor: str | None = None
has_more: bool = False
class CampaignWorkAssignmentReconcileResponse(BaseModel):
checked: int
changed: int
assignments: list[CampaignWorkAssignmentResponse]
class CampaignLifecycleMutationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_state_token: str = Field(min_length=64, max_length=64)
class CampaignCopyRequest(CampaignLifecycleMutationRequest):
source_version_id: str = Field(min_length=1, max_length=36)
external_id: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
include_recipients: bool = True
include_files: bool = True
include_shares: bool = False
include_policies: bool = True
include_mail_profile: bool = True
class CampaignScheduleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_version_id: str = Field(min_length=1, max_length=36)
name: str = Field(min_length=1, max_length=255)
delivery_mode: Literal["manual", "autonomous"] = "manual"
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
interval_count: int = Field(default=1, ge=1, le=365)
timezone: str = Field(default="UTC", min_length=1, max_length=100)
starts_at: datetime
ends_at: datetime | None = None
max_occurrences: int = Field(default=1, ge=1, le=1000)
include_recipients: bool = True
include_files: bool = True
include_shares: bool = False
include_policies: bool = True
include_mail_profile: bool = True
@model_validator(mode="after")
def validate_schedule(self) -> "CampaignScheduleCreateRequest":
if self.starts_at.tzinfo is None:
raise ValueError("Campaign schedule start must include a timezone.")
if self.ends_at is not None:
if self.ends_at.tzinfo is None:
raise ValueError("Campaign schedule end must include a timezone.")
if self.ends_at <= self.starts_at:
raise ValueError("Campaign schedule end must be after its start.")
if self.recurrence_kind == "once":
self.max_occurrences = 1
self.interval_count = 1
elif self.max_occurrences < 2:
raise ValueError("A recurring campaign schedule needs at least two occurrences.")
return self
class CampaignScheduleStateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
active: bool
base_revision: int = Field(ge=1)
class CampaignScheduleOccurrenceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
schedule_id: str
scheduled_for: datetime
status: str
idempotency_key: str | None = None
generated_campaign_id: str | None = None
generated_version_id: str | None = None
error: str | None = None
delivery_command_ids: list[str] = Field(default_factory=list)
recovery_state: str = "none"
evidence: dict[str, object] = Field(default_factory=dict)
last_checked_at: datetime | None = None
created_at: datetime
class CampaignScheduleResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
campaign_id: str
source_version_id: str
name: str
delivery_mode: str
recurrence_kind: str
interval_count: int
timezone: str
starts_at: datetime
next_fire_at: datetime | None = None
ends_at: datetime | None = None
max_occurrences: int
occurrence_count: int
active: bool
resource_revision: int
last_fired_at: datetime | None = None
last_campaign_id: str | None = None
last_error: str | None = None
last_outcome: str | None = None
last_recovery_state: str | None = None
created_at: datetime
updated_at: datetime
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
class CampaignScheduleListResponse(BaseModel):
items: list[CampaignScheduleResponse]
class CampaignContentLibrarySaveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4000)
kind: Literal["fragment", "campaign_part"]
target: Literal["subject", "text", "html"] | None = None
subject: str | None = Field(default=None, max_length=1000)
text: str | None = Field(default=None, max_length=1_000_000)
html: str | None = Field(default=None, max_length=2_000_000)
body_mode: Literal["text", "html", "both"] = "both"
locale: str = Field(default="de", min_length=2, max_length=35)
visibility: Literal["personal", "tenant"] = "personal"
@model_validator(mode="after")
def validate_content(self) -> "CampaignContentLibrarySaveRequest":
if self.kind == "fragment" and self.target is None:
raise ValueError("A content fragment requires a target field.")
values = {
"subject": self.subject,
"text": self.text,
"html": self.html,
}
if self.kind == "fragment":
selected = values[self.target or "text"]
if not selected or not selected.strip():
raise ValueError("The selected fragment field is empty.")
elif not any(value and value.strip() for value in (self.text, self.html)):
raise ValueError("A campaign part requires text or HTML body content.")
return self
class CampaignCreateMinimalRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -58,10 +433,15 @@ class CampaignVersionUpdateRequest(BaseModel):
source_filename: str | None = None
source_base_path: str | None = None
migrate_legacy_mail_settings: bool = False
base_revision: int | None = Field(default=None, ge=1)
reconciliation_kind: Literal["none", "auto_merge", "manual"] = "none"
resolved_conflict_paths: list[str] = Field(default_factory=list, max_length=100)
@field_validator("editor_state")
@classmethod
def validate_editor_state(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
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
@@ -72,11 +452,23 @@ class CampaignVersionSetStepRequest(BaseModel):
current_step: str
class CampaignReviewDecisionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
job_id: str = Field(min_length=1, max_length=36)
decision: Literal["accept"] = "accept"
reason: str | None = Field(default=None, max_length=4_000)
class CampaignReviewStateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
inspection_complete: bool = False
reviewed_message_keys: list[str] = Field(default_factory=list)
issue_decisions: list[CampaignReviewDecisionRequest] = Field(
default_factory=list,
max_length=100_000,
)
class CampaignPartialValidationRequest(BaseModel):
@@ -92,6 +484,8 @@ class CampaignVersionResponse(BaseModel):
id: str
campaign_id: str
version_number: int
edit_revision: int = 1
strong_etag: str = ""
schema_version: str
source_filename: str | None = None
workflow_state: str = "editing"
@@ -112,12 +506,18 @@ class CampaignVersionResponse(BaseModel):
build_summary: dict[str, Any] | None = None
execution_snapshot_hash: str | None = None
execution_snapshot_at: datetime | None = None
delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = None
delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = (
None
)
delivery_mode_selected_at: datetime | None = None
archived_at: datetime | None = None
archived_by_user_id: str | None = None
@field_validator("editor_state", mode="before")
@classmethod
def remove_unsupported_editor_state(cls, value: Any, info: ValidationInfo) -> dict[str, Any]:
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")),
@@ -171,6 +571,19 @@ class CampaignResponse(BaseModel):
updated_at: datetime
class CampaignLifecycleActionResponse(BaseModel):
allowed: bool
reason: str | None = None
class CampaignLifecyclePolicyResponse(BaseModel):
policy_id: str
policy_version: str
state_token: str
actions: dict[str, CampaignLifecycleActionResponse]
provenance: dict[str, Any]
class CampaignCreateResponse(BaseModel):
campaign: CampaignResponse
version: CampaignVersionResponse
@@ -194,6 +607,10 @@ class CampaignDeltaResponse(BaseModel):
watermark: str | None = None
has_more: bool = False
full: bool = False
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse):
@@ -216,6 +633,10 @@ class CampaignShareItem(BaseModel):
class CampaignShareListResponse(BaseModel):
shares: list[CampaignShareItem]
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class CampaignShareTargetItem(BaseModel):
@@ -266,7 +687,9 @@ class RecipientImportColumnMappingPayload(BaseModel):
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")
new_field_name: str | None = Field(
default=None, max_length=255, alias="newFieldName"
)
class RecipientImportMappingProfilePayload(BaseModel):
@@ -275,14 +698,22 @@ class RecipientImportMappingProfilePayload(BaseModel):
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")
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)
mappings: list[RecipientImportColumnMappingPayload] = Field(
default_factory=list, max_length=500
)
@model_validator(mode="after")
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
@@ -342,10 +773,24 @@ class CampaignRecipientAddressSourcesResponse(BaseModel):
sources: list[CampaignRecipientAddressSource] = Field(default_factory=list)
class CampaignPostboxCatalogResponse(BaseModel):
available: bool = False
postboxes: list[dict[str, Any]] = Field(default_factory=list)
templates: list[dict[str, Any]] = Field(default_factory=list)
organization_units: list[dict[str, Any]] = Field(default_factory=list)
class CampaignCalendarCatalogResponse(BaseModel):
available: bool = False
reason: str | None = None
calendars: list[dict[str, Any]] = Field(default_factory=list)
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: str = Field(min_length=1)
purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120)
class CampaignRecipientSnapshotItem(BaseModel):
@@ -357,6 +802,18 @@ class CampaignRecipientSnapshotItem(BaseModel):
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientSnapshotExcludedItem(BaseModel):
contact_id: str
display_name: str
channel: str
target: str
contact_point_id: str | None = None
status: str
reason_code: str | None = None
explanation: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_id: str
source_label: str
@@ -364,9 +821,139 @@ class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_revision: str
generated_at: str
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list)
excluded: list[CampaignRecipientSnapshotExcludedItem] = Field(default_factory=list)
included_count: int = 0
excluded_count: int = 0
purpose: str = "campaign_delivery"
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionListParameter(BaseModel):
key: str
value_type: str
label: str | None = None
required: bool = False
default: Any = None
allowed_values: list[Any] = Field(default_factory=list)
minimum: float | None = None
maximum: float | None = None
pattern: str | None = None
description: str | None = None
class CampaignDistributionListSource(BaseModel):
id: str
tenant_id: str
name: str
revision_id: str
revision: int
definition_hash: str
definition_kind: str = "static"
description: str | None = None
status: str = "active"
entry_count: int = 0
read_only: bool = False
stale: bool = False
parameters: list[CampaignDistributionListParameter] = Field(default_factory=list)
updated_at: datetime | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionListSourcesResponse(BaseModel):
available: bool = False
expand_available: bool = False
sources: list[CampaignDistributionListSource] = Field(default_factory=list)
class CampaignDistributionListExpansionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
list_id: str = Field(min_length=1, max_length=36)
revision: int | None = Field(default=None, ge=1)
effective_at: datetime | None = None
purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120)
requested_channels: list[Literal["email", "postal", "internal_mail", "portal"]] = Field(
default_factory=list,
max_length=4,
)
parameters: dict[str, Any] = Field(default_factory=dict)
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
class CampaignDistributionSourceReference(BaseModel):
provider: str
resource_type: str
resource_id: str
revision: str | None = None
fingerprint: str | None = None
label: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionExplanation(BaseModel):
code: str
message: str
severity: str
provider: str | None = None
source: CampaignDistributionSourceReference | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionChannelCandidate(BaseModel):
channel: str
target: str
target_key: str
status: str
contact_point_id: str | None = None
locale: str | None = None
preferred: bool = False
reason_code: str | None = None
explanation: str | None = None
source: CampaignDistributionSourceReference | None = None
decision_provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionRecipient(BaseModel):
recipient_key: str
display_name: str
status: str
channels: list[CampaignDistributionChannelCandidate] = Field(default_factory=list)
identity_id: str | None = None
account_id: str | None = None
contact_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
source_entry_ids: list[str] = Field(default_factory=list)
explanations: list[CampaignDistributionExplanation] = Field(default_factory=list)
attributes: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionProviderEvidence(BaseModel):
provider: str
source: CampaignDistributionSourceReference
actual_revision: str | None = None
actual_fingerprint: str | None = None
stale: bool = False
generated_at: datetime | None = None
details: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionListExpansionResponse(BaseModel):
source: CampaignDistributionListSource
request: dict[str, Any] = Field(default_factory=dict)
recipients: list[CampaignDistributionRecipient] = Field(default_factory=list)
excluded: list[CampaignDistributionRecipient] = Field(default_factory=list)
diagnostics: list[CampaignDistributionExplanation] = Field(default_factory=list)
provider_evidence: list[CampaignDistributionProviderEvidence] = Field(default_factory=list)
expansion_hash: str
generated_at: datetime | None = None
snapshot_id: str | None = None
stale: bool = False
truncated: bool = False
class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]]
page: int = 1
@@ -425,11 +1012,21 @@ class CampaignSendUnattemptedRequest(BaseModel):
class CampaignSendJobRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["test", "single_send", "single_resend"]
idempotency_key: str = Field(min_length=1, max_length=200)
reason: str | None = Field(default=None, max_length=2000)
context: dict[str, Any] = Field(default_factory=dict)
include_warnings: bool = True
dry_run: bool = False
use_rate_limit: bool = True
enqueue_imap_task: bool = False
@model_validator(mode="after")
def require_resend_reason(self):
self.reason = (self.reason or "").strip() or None
if self.kind == "single_resend" and not self.reason:
raise ValueError("single_resend requires a reason")
return self
class CampaignResolveOutcomeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -439,8 +1036,11 @@ class CampaignResolveOutcomeRequest(BaseModel):
"not_sent",
"imap_appended",
"imap_not_appended",
"postbox_accepted",
"postbox_not_accepted",
]
note: str | None = Field(default=None, max_length=2000)
attempt_id: str | None = Field(default=None, max_length=36)
@model_validator(mode="after")
def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest":
@@ -461,6 +1061,56 @@ class BuildCampaignRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
write_eml: bool = True
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
class CampaignArtifactReconcileRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
apply: bool = False
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
grace_period_hours: int = Field(default=24, ge=24, le=24 * 90)
cursor: str | None = Field(default=None, min_length=1, max_length=1000)
page_size: int = Field(default=250, ge=1, le=1000)
@model_validator(mode="after")
def require_apply_idempotency_key(self) -> "CampaignArtifactReconcileRequest":
if self.apply and not self.idempotency_key:
raise ValueError("Applied artifact cleanup requires an idempotency key")
return self
class CampaignArtifactCandidateResponse(BaseModel):
key: str
size_bytes: int
modified_at: datetime
age_seconds: int
reason: str
disposition: str
failure_type: str | None = None
class CampaignArtifactReconcileResponse(BaseModel):
apply: bool
status: str
recovery_operation_id: str | None = None
tenant_prefix: str
cursor: str | None = None
next_cursor: str | None = None
scanned_count: int
scanned_bytes: int
referenced_count: int
active_build_count: int
young_count: int
unknown_age_count: int
invalid_shape_count: int
candidate_count: int
candidate_bytes: int
deleted_count: int
deleted_bytes: int
failure_count: int
manifest_sha256: str | None = None
candidates: list[CampaignArtifactCandidateResponse] = Field(default_factory=list)
class ApiKeyCreateRequest(BaseModel):
@@ -520,6 +1170,8 @@ class CampaignDeliveryOptionsResponse(BaseModel):
campaign_id: str
version_id: str
worker_queue_available: bool
postbox_available: bool = False
approval_gate: dict[str, Any] = Field(default_factory=dict)
synchronous_send: dict[str, Any] = Field(default_factory=dict)
@@ -551,62 +1203,71 @@ class CampaignActionResponse(BaseModel):
result: dict[str, Any]
def _valid_report_email_domain(domain: str) -> bool:
if not domain or domain.startswith(".") or domain.endswith(".") or ".." in domain:
return False
return all(
label
and not label.startswith("-")
and not label.endswith("-")
and all(character.isalnum() or character == "-" for character in label)
for label in domain.split(".")
)
def _normalize_report_recipient(value: Any) -> str:
if not isinstance(value, str):
raise ValueError("report recipients must be email-address strings")
recipient = value.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)
invalid_local = (
not local or local.startswith(".") or local.endswith(".") or ".." in local
)
invalid_address = any(character.isspace() for character in recipient) or any(
character in ',;:<>[]()\\"' for character in recipient
)
if invalid_local or invalid_address or not _valid_report_email_domain(domain):
raise ValueError("report recipients must be email addresses")
return recipient
ReportEmailAddress = Annotated[str, BeforeValidator(_normalize_report_recipient)]
def _deduplicate_report_recipients(value: list[str]) -> list[str]:
recipients: list[str] = []
seen: set[str] = set()
for recipient in value:
key = recipient.casefold()
if key not in seen:
seen.add(key)
recipients.append(recipient)
return recipients
class ReportEmailRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
to: list[str] = Field(min_length=1, max_length=50)
to: list[ReportEmailAddress] = Field(min_length=1, max_length=50)
version_id: str | None = None
include_jobs: bool = False
attach_jobs_csv: bool = False
attach_report_json: bool = False
dry_run: bool = False
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
@field_validator("to", mode="before")
@field_validator("to")
@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
def normalize_and_validate_recipients(cls, value: list[str]) -> list[str]:
return _deduplicate_report_recipients(value)
class ReportEmailResponse(BaseModel):
@@ -0,0 +1,251 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_campaign.backend.capabilities import CampaignAccessService
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
PROVIDER_ID = "campaigns.campaigns"
RESOURCE_TYPE = "campaign"
READ_SCOPE = "campaigns:campaign:read"
class CampaignSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="campaigns",
resource_type=RESOURCE_TYPE,
label="Campaigns",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
statement = select(Campaign).where(
Campaign.tenant_id == request.tenant_id,
Campaign.status != "deleted",
)
if request.cursor:
statement = statement.where(Campaign.id > request.cursor)
rows = list(
db.scalars(
statement.order_by(Campaign.id).limit(request.limit + 1)
)
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
shares = _shares_by_campaign(db, selected)
high_watermark = db.scalar(
select(func.max(Campaign.updated_at)).where(
Campaign.tenant_id == request.tenant_id,
Campaign.status != "deleted",
)
)
return SearchBackfillPage(
documents=tuple(
_document(row, shares=shares.get(row.id, ()))
for row in selected
),
next_cursor=selected[-1].id if has_more and selected else None,
complete=not has_more,
high_watermark=(
high_watermark.isoformat()
if high_watermark is not None
else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {item.reference.key: False for item in requests}
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
return decisions
db = _session(session)
access = CampaignAccessService()
user_id = str(getattr(principal.user, "id", "") or principal.membership_id or "")
for request in requests:
reference = request.reference
if (
reference.tenant_id != principal.tenant_id
or reference.module_id != "campaigns"
or reference.resource_type != RESOURCE_TYPE
):
continue
decisions[reference.key] = access.can_read_campaign(
db,
tenant_id=principal.tenant_id,
campaign_id=reference.resource_id,
user_id=user_id,
group_ids=principal.group_ids,
tenant_admin=principal.has("tenant:*"),
)
return decisions
def index_changes_for_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "campaigns"
or event.tenant is None
or event.resource is None
or event.resource.type != RESOURCE_TYPE
or event.resource.id is None
):
return ()
db = _session(session)
row = db.get(Campaign, event.resource.id)
deleted = row is None or row.tenant_id != event.tenant.id or row.status == "deleted"
cursor = event.event_id
document = None
if not deleted:
document = _document(
row,
shares=tuple(
db.scalars(
select(CampaignShare).where(
CampaignShare.campaign_id == row.id,
CampaignShare.revoked_at.is_(None),
)
)
),
change_cursor=cursor,
)
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="campaigns",
resource_type=RESOURCE_TYPE,
resource_id=event.resource.id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}",
provider_id=PROVIDER_ID,
kind="delete" if deleted else "upsert",
reference=reference,
source_revision=(
document.source_revision if document is not None else cursor
),
cursor=cursor,
document=document,
occurred_at=event.occurred_at,
),
)
def create_campaign_search_source(_context: ModuleContext) -> CampaignSearchSource:
return CampaignSearchSource()
def _document(
row: Campaign,
*,
shares: Sequence[CampaignShare],
change_cursor: str | None = None,
) -> SearchDocument:
tokens = [f"scope:{READ_SCOPE}"]
if row.owner_user_id:
tokens.append(f"membership:{row.owner_user_id}")
if row.owner_group_id:
tokens.append(f"group:{row.owner_group_id}")
for share in shares:
prefix = "membership" if share.target_type == "user" else share.target_type
if prefix in {"membership", "group"}:
tokens.append(f"{prefix}:{share.target_id}")
updated_at = row.updated_at or row.created_at
return SearchDocument(
tenant_id=row.tenant_id,
module_id="campaigns",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=row.id,
title=row.name,
url=f"/campaigns/{quote(row.id, safe='')}",
summary=row.description[:4000] if row.description else None,
body=" ".join(
value for value in (row.external_id, row.description) if value
)[:200_000],
keywords=(row.external_id[:200], row.status[:200]),
visibility="restricted",
acl_tokens=tuple(dict.fromkeys(tokens)),
metadata={
"external_id": row.external_id,
"status": row.status,
"current_version_id": row.current_version_id,
},
source_revision=f"{row.current_version_id or 'none'}:{updated_at.isoformat()}",
change_cursor=change_cursor,
source_updated_at=updated_at,
requires_authorization_recheck=True,
)
def _shares_by_campaign(
session: Session,
rows: Sequence[Campaign],
) -> dict[str, tuple[CampaignShare, ...]]:
ids = [row.id for row in rows]
grouped: dict[str, list[CampaignShare]] = {item: [] for item in ids}
if not ids:
return {}
for share in session.scalars(
select(CampaignShare).where(
CampaignShare.campaign_id.in_(ids),
CampaignShare.revoked_at.is_(None),
)
):
grouped.setdefault(share.campaign_id, []).append(share)
return {key: tuple(value) for key, value in grouped.items()}
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Campaign search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Campaign search requires a SQLAlchemy session.")
return value
__all__ = [
"CampaignSearchSource",
"PROVIDER_ID",
"RESOURCE_TYPE",
"create_campaign_search_source",
]
@@ -9,16 +9,25 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.archive_encryption import (
CampaignArchiveEncryptionError,
assert_archive_encryption_allowed,
)
from govoplan_campaign.backend.campaign.models import (
DeliveryChannelPolicy,
DeliveryConfig,
)
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_id,
campaign_mail_resource_ids,
)
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 = "5"
SNAPSHOT_VERSION = "9"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", "8", SNAPSHOT_VERSION}
class ExecutionSnapshotError(RuntimeError):
@@ -40,7 +49,11 @@ class ExecutionSnapshot(BaseModel):
snapshot_version: str = SNAPSHOT_VERSION
campaign_version_id: str
campaign_json_sha256: str
mail_profile_id: str
mail_profile_id: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
imap_server_id: str | None = None
imap_credential_id: str | None = None
created_at: str
build_token: str | None = None
built_at: str | None = None
@@ -48,8 +61,12 @@ class ExecutionSnapshot(BaseModel):
queueable_job_count: int = 0
job_manifest_sha256: str | None = None
effective_policy_sha256: str | None = None
archive_encryption: dict[str, Any] | None = None
smtp_transport_revision: str | None = None
imap_transport_revision: str | None = None
uses_mail: bool = True
uses_postbox: bool = False
uses_print: bool = False
delivery: DeliveryConfig
@@ -67,7 +84,7 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json)
_assert_version_mail_profile_boundary(raw_json, require_profile=True)
mail = mail_integration()
profile_id = campaign_mail_profile_id(raw_json)
if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
@@ -75,12 +92,17 @@ def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
references = campaign_mail_resource_ids(raw_json)
try:
return mail.campaign_profile_delivery_summary(
session,
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
profile_id=profile_id,
smtp_server_id=references["smtp_server_id"],
smtp_credential_id=references["smtp_credential_id"],
imap_server_id=references["imap_server_id"],
imap_credential_id=references["imap_credential_id"],
)
except MailProfileError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
@@ -96,27 +118,63 @@ def profile_transport_revisions(session: Session, version: CampaignVersion) -> d
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json)
_assert_version_mail_profile_boundary(
raw_json,
require_profile=snapshot.uses_mail,
)
if not snapshot.uses_mail:
return
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
raise ExecutionSnapshotError(
"The campaign's Mail profile reference differs from the built execution snapshot. "
"Revalidate and rebuild the campaign before delivery."
)
references = campaign_mail_resource_ids(raw_json)
for key in (
"smtp_server_id",
"smtp_credential_id",
"imap_server_id",
"imap_credential_id",
):
configured = references[key]
if configured and configured != getattr(snapshot, key):
raise ExecutionSnapshotError(
"The campaign's Mail server or credential selection differs from the built "
"execution snapshot. Revalidate and rebuild before delivery."
)
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None:
def _assert_version_mail_profile_boundary(
raw_json: dict[str, Any],
*,
require_profile: bool,
) -> None:
try:
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True)
assert_campaign_uses_mail_profile_reference(
raw_json,
require_profile=require_profile,
)
except CampaignMailProfileBoundaryError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
def _policy_fingerprint(
raw_json: dict[str, Any],
delivery: DeliveryConfig,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
delivery_payload = delivery.model_dump(mode="json")
if snapshot_version == "6":
delivery_payload.pop("channel_policy", None)
delivery_payload.pop("postbox", None)
if snapshot_version in {"6", "7"}:
delivery_payload.pop("print", None)
return _sha256(
{
"validation_policy": raw_json.get("validation_policy"),
"policy": raw_json.get("policy"),
"delivery": delivery.model_dump(mode="json"),
"delivery": delivery_payload,
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
if isinstance(raw_json.get("attachments"), dict)
else None,
@@ -124,8 +182,12 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
)
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
return {
def _job_execution_input_payload(
job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> dict[str, Any]:
payload = {
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
@@ -140,17 +202,54 @@ def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []),
}
if snapshot_version != "6":
payload.update(
{
"delivery_channel_policy": getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
),
"resolved_postbox_targets_sha256": _sha256(
getattr(job, "resolved_postbox_targets", None) or []
),
}
)
if snapshot_version not in {"6", "7"}:
payload["delivery_provenance_sha256"] = _sha256(
getattr(job, "delivery_provenance", None) or {}
)
payload["resolved_print_output_sha256"] = _sha256(
getattr(job, "resolved_print_output", None) or {}
)
return payload
def job_execution_input_hash(job: CampaignJob) -> str:
return _sha256(_job_execution_input_payload(job))
def job_execution_input_hash(
job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
return _sha256(
_job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
)
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
def job_manifest_hash(
jobs: Iterable[CampaignJob],
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
"""Hash the immutable per-message execution records in stable order."""
payload = [
_job_execution_input_payload(job)
_job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
]
return _sha256(payload)
@@ -159,31 +258,71 @@ def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
def create_execution_snapshot(
version: CampaignVersion,
*,
mail_profile_id: str,
smtp_transport_revision: str,
mail_profile_id: str | None,
smtp_transport_revision: str | None,
imap_transport_revision: str | None,
delivery: DeliveryConfig,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
jobs: Iterable[CampaignJob] = (),
build_summary: dict[str, Any] | None = None,
archive_encryption: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs)
channel_policies = {
DeliveryChannelPolicy(
getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
)
)
for job in job_list
}
uses_mail = any(policy.uses_mail for policy in channel_policies)
uses_postbox = any(policy.uses_postbox for policy in channel_policies)
uses_print = any(policy.uses_print for policy in channel_policies)
for job in job_list:
job.execution_input_sha256 = job_execution_input_hash(job)
job.execution_input_sha256 = job_execution_input_hash(
job,
snapshot_version=SNAPSHOT_VERSION,
)
summary = build_summary if isinstance(build_summary, dict) else {}
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
payload = ExecutionSnapshot(
campaign_version_id=version.id,
campaign_json_sha256=_sha256(raw_json),
mail_profile_id=mail_profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
imap_server_id=imap_server_id,
imap_credential_id=imap_credential_id,
build_token=str(summary.get("build_token") or "") or None,
built_at=str(summary.get("built_at") or "") or None,
job_count=len(job_list),
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
job_manifest_sha256=(
job_manifest_hash(
job_list,
snapshot_version=SNAPSHOT_VERSION,
)
if job_list
else None
),
effective_policy_sha256=_policy_fingerprint(
raw_json,
delivery,
snapshot_version=SNAPSHOT_VERSION,
),
archive_encryption=archive_encryption,
smtp_transport_revision=smtp_transport_revision,
imap_transport_revision=imap_transport_revision,
uses_mail=uses_mail,
uses_postbox=uses_postbox,
uses_print=uses_print,
created_at=datetime.now(timezone.utc).isoformat(),
delivery=delivery,
).model_dump(mode="json")
@@ -207,24 +346,64 @@ def _assert_snapshot_matches_persisted_inputs(
"Campaign inputs changed after this execution snapshot was built. "
"Revalidate and rebuild the campaign before delivery."
)
if not snapshot.smtp_transport_revision:
if snapshot.uses_mail and not snapshot.smtp_transport_revision:
raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision")
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):
if snapshot.effective_policy_sha256 != _policy_fingerprint(
raw_json,
snapshot.delivery,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError(
"Campaign delivery policy changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
)
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Execution snapshot Campaign no longer exists")
try:
current_archive_policy = assert_archive_encryption_allowed(
session,
campaign,
raw_json,
)
except CampaignArchiveEncryptionError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
archive_snapshot = snapshot.archive_encryption
configured_archives = (
((raw_json.get("attachments") or {}).get("zip") or {}).get("archives")
if isinstance(raw_json.get("attachments"), dict)
else None
)
if configured_archives and not isinstance(archive_snapshot, dict):
raise ExecutionSnapshotError(
"Execution snapshot has no governed archive-encryption evidence; rebuild before delivery."
)
if isinstance(archive_snapshot, dict):
frozen_policy = archive_snapshot.get("policy")
frozen_hash = (
frozen_policy.get("policy_hash")
if isinstance(frozen_policy, dict)
else None
)
if frozen_hash != current_archive_policy.policy_hash:
raise ExecutionSnapshotError(
"The effective archive-encryption policy changed after build. Revalidate and rebuild 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):
if effect_job.execution_input_sha256 != job_execution_input_hash(
effect_job,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
@@ -246,8 +425,19 @@ def _assert_snapshot_matches_persisted_inputs(
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)
or snapshot.job_manifest_sha256
!= job_manifest_hash(
jobs,
snapshot_version=snapshot.snapshot_version,
)
or any(
getattr(job, "execution_input_sha256", None)
!= job_execution_input_hash(
job,
snapshot_version=snapshot.snapshot_version,
)
for job in jobs
)
):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
@@ -276,17 +466,20 @@ def ensure_execution_snapshot(
)
except CampaignPathSecurityError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
_assert_version_mail_profile_boundary(raw_json)
_assert_version_mail_profile_boundary(raw_json, require_profile=False)
if isinstance(version.execution_snapshot, dict):
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION:
stored_version = str(
version.execution_snapshot.get("snapshot_version") or ""
)
if stored_version not in SUPPORTED_SNAPSHOT_VERSIONS:
raise ExecutionSnapshotError(
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. "
"It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
"and rebuild a new campaign version."
)
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
expected = snapshot_hash(snapshot.model_dump(mode="json"))
expected = snapshot_hash(version.execution_snapshot)
if not version.execution_snapshot_hash:
raise ExecutionSnapshotError("Execution snapshot checksum is missing")
if version.execution_snapshot_hash != expected:
@@ -303,11 +496,6 @@ def ensure_execution_snapshot(
from govoplan_campaign.backend.persistence.campaigns import load_version_config
_, _, config = load_version_config(session, version.id)
profile_id = campaign_mail_profile_id(raw_json)
if not config.server.profile_capabilities.smtp_available:
raise ExecutionSnapshotError("The selected Mail profile has no SMTP configuration")
if profile_id is None:
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
@@ -316,17 +504,42 @@ def ensure_execution_snapshot(
)
if not jobs:
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
revisions = profile_transport_revisions(session, version)
if not revisions["smtp"]:
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
uses_mail = any(
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
for job in jobs
)
profile_id = campaign_mail_profile_id(raw_json)
summary: dict[str, Any] = {}
if uses_mail:
if not config.server.profile_capabilities.smtp_available:
raise ExecutionSnapshotError(
"The selected Mail profile has no SMTP configuration"
)
if profile_id is None:
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
summary = profile_delivery_summary(session, version)
if not summary.get("smtp_transport_revision"):
raise ExecutionSnapshotError(
"The selected Mail profile has no SMTP transport revision"
)
payload, digest = create_execution_snapshot(
version,
mail_profile_id=profile_id,
smtp_transport_revision=revisions["smtp"],
imap_transport_revision=revisions["imap"],
smtp_server_id=summary.get("smtp_server_id"),
smtp_credential_id=summary.get("smtp_credential_id"),
imap_server_id=summary.get("imap_server_id"),
imap_credential_id=summary.get("imap_credential_id"),
smtp_transport_revision=summary.get("smtp_transport_revision"),
imap_transport_revision=summary.get("imap_transport_revision"),
delivery=config.delivery,
jobs=jobs,
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
archive_encryption=(
version.build_summary.get("archive_encryption")
if isinstance(version.build_summary, dict)
and isinstance(version.build_summary.get("archive_encryption"), dict)
else None
),
)
version.execution_snapshot = payload
version.execution_snapshot_hash = digest
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,506 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from email import policy
from email.parser import BytesParser
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.postbox import (
PostboxAttachmentRef,
PostboxDeliveryOutcomeUnknown,
PostboxDeliveryRejected,
PostboxDeliveryRequest,
PostboxParticipantRef,
PostboxTargetRef,
)
from govoplan_campaign.backend.db.models import (
CampaignJob,
JobPostboxStatus,
PostboxDeliveryAttempt,
)
from govoplan_campaign.backend.integrations import (
PostboxDeliveryUnavailable,
postbox_integration,
)
from govoplan_core.security.time import utc_now
ACCEPTED_POSTBOX_ATTEMPT_STATUSES = {
JobPostboxStatus.ACCEPTED.value,
JobPostboxStatus.ACCEPTED_VACANT.value,
}
@dataclass(slots=True)
class PostboxChannelOutcome:
accepted: int = 0
accepted_vacant: int = 0
rejected_temporary: int = 0
rejected_permanent: int = 0
outcome_unknown: int = 0
messages: list[str] = field(default_factory=list)
@property
def accepted_count(self) -> int:
return self.accepted + self.accepted_vacant
@property
def rejected_count(self) -> int:
return self.rejected_temporary + self.rejected_permanent
@property
def all_rejected_before_acceptance(self) -> bool:
return (
self.accepted_count == 0
and self.outcome_unknown == 0
and self.rejected_count > 0
)
@property
def status(self) -> str:
if self.outcome_unknown:
return JobPostboxStatus.OUTCOME_UNKNOWN.value
if self.accepted_count and self.rejected_count:
return JobPostboxStatus.PARTIALLY_ACCEPTED.value
if self.accepted_vacant and not self.accepted:
return JobPostboxStatus.ACCEPTED_VACANT.value
if self.accepted_count:
return JobPostboxStatus.ACCEPTED.value
if self.rejected_temporary:
return JobPostboxStatus.REJECTED_TEMPORARY.value
return JobPostboxStatus.REJECTED_PERMANENT.value
def _target_key(target: dict[str, Any]) -> str:
payload = json.dumps(
target,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _attempt_number(
session: Session,
*,
job_id: str,
target_key: str,
) -> int:
current = (
session.query(func.max(PostboxDeliveryAttempt.attempt_number))
.filter(
PostboxDeliveryAttempt.job_id == job_id,
PostboxDeliveryAttempt.target_key == target_key,
)
.scalar()
)
return int(current or 0) + 1
def _accepted_attempt(
session: Session,
*,
job_id: str,
target_key: str,
) -> PostboxDeliveryAttempt | None:
return (
session.query(PostboxDeliveryAttempt)
.filter(
PostboxDeliveryAttempt.job_id == job_id,
PostboxDeliveryAttempt.target_key == target_key,
PostboxDeliveryAttempt.status.in_(
list(ACCEPTED_POSTBOX_ATTEMPT_STATUSES)
),
)
.order_by(PostboxDeliveryAttempt.attempt_number.desc())
.first()
)
def _message_body(message_bytes: bytes) -> str | None:
message = BytesParser(policy=policy.default).parsebytes(message_bytes)
body = message.get_body(preferencelist=("plain", "html"))
if body is None:
payload = message.get_payload(decode=True)
if not isinstance(payload, bytes):
return None
return payload.decode(message.get_content_charset() or "utf-8", "replace")
try:
content = body.get_content()
except (LookupError, UnicodeError):
payload = body.get_payload(decode=True)
if not isinstance(payload, bytes):
return None
return payload.decode(body.get_content_charset() or "utf-8", "replace")
return str(content)
def _participants(job: CampaignJob) -> tuple[PostboxParticipantRef, ...]:
recipients = job.resolved_recipients or {}
values: list[PostboxParticipantRef] = []
for key in (
"from_all",
"to",
"cc",
"bcc",
"reply_to",
"bounce_to",
"disposition_notification_to",
):
for item in recipients.get(key) or []:
if not isinstance(item, dict):
continue
address = str(item.get("email") or "").strip() or None
label = str(item.get("name") or "").strip() or None
if address is None and label is None:
continue
values.append(
PostboxParticipantRef(
kind="sender" if key == "from_all" else key,
reference_type="mail_address",
label=label,
address=address,
)
)
return tuple(values)
def _attachments(job: CampaignJob) -> tuple[PostboxAttachmentRef, ...]:
values = [_eml_attachment(job)]
for rule_index, rule in enumerate(job.resolved_attachments or []):
if not isinstance(rule, dict):
continue
managed_matches = rule.get("managed_matches")
if isinstance(managed_matches, list) and managed_matches:
values.extend(_managed_attachments(managed_matches))
continue
matches = rule.get("matches")
if isinstance(matches, list):
values.extend(_campaign_attachments(job, rule_index=rule_index, matches=matches))
return tuple(values)
def _eml_attachment(job: CampaignJob) -> PostboxAttachmentRef:
return PostboxAttachmentRef(
reference_type="campaign_eml",
reference_id=job.id,
name=f"{job.entry_id or job.entry_index}.eml",
media_type="message/rfc822",
size_bytes=job.eml_size_bytes,
digest=job.eml_sha256,
metadata={"campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id},
)
MANAGED_ATTACHMENT_METADATA_KEYS = {
"asset_id",
"version_id",
"blob_id",
"display_path",
"relative_path",
"owner_type",
"owner_id",
"source_revision",
}
def _managed_attachment(match: dict[str, Any]) -> PostboxAttachmentRef | None:
reference_id = str(match.get("version_id") or match.get("asset_id") or match.get("blob_id") or "").strip()
if not reference_id:
return None
return PostboxAttachmentRef(
reference_type="file_version" if match.get("version_id") else "file_asset",
reference_id=reference_id,
name=str(match.get("filename") or "").strip() or None,
media_type=str(match.get("content_type")) if match.get("content_type") else None,
size_bytes=int(match["size_bytes"]) if match.get("size_bytes") is not None else None,
digest=str(match.get("checksum_sha256")) if match.get("checksum_sha256") else None,
metadata={key: value for key, value in match.items() if key in MANAGED_ATTACHMENT_METADATA_KEYS},
)
def _managed_attachments(matches: list[Any]) -> list[PostboxAttachmentRef]:
attachments = (_managed_attachment(match) for match in matches if isinstance(match, dict))
return [attachment for attachment in attachments if attachment is not None]
def _campaign_attachments(
job: CampaignJob,
*,
rule_index: int,
matches: list[Any],
) -> list[PostboxAttachmentRef]:
metadata = {"campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id, "job_id": job.id}
return [
PostboxAttachmentRef(
reference_type="campaign_attachment",
reference_id=f"{job.id}:{rule_index}:{match_index}",
name=str(match).rsplit("/", 1)[-1] or None,
metadata=metadata,
)
for match_index, match in enumerate(matches)
]
def _sender_label(job: CampaignJob) -> str | None:
recipients = job.resolved_recipients or {}
sender = recipients.get("from")
if not isinstance(sender, dict):
return None
name = str(sender.get("name") or "").strip()
address = str(sender.get("email") or "").strip()
if name and address:
return f"{name} <{address}>"
return address or name or None
def _request(
job: CampaignJob,
target: dict[str, Any],
*,
target_key: str,
body_text: str | None,
classification: str,
) -> PostboxDeliveryRequest:
return PostboxDeliveryRequest(
tenant_id=job.tenant_id,
target=PostboxTargetRef(postbox_id=str(target["postbox_id"])),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id=job.id,
idempotency_key=(
f"campaign:{job.campaign_version_id}:{job.id}:postbox:"
f"{target_key}"
),
subject=(job.subject or "").strip() or "(No subject)",
body_text=body_text,
sender_label=_sender_label(job),
classification=classification,
participants=_participants(job),
attachments=_attachments(job),
metadata={
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"campaign_job_id": job.id,
"entry_id": job.entry_id,
"entry_index": job.entry_index,
"delivery_channel_policy": job.delivery_channel_policy,
"target_snapshot": target,
},
)
def _record_rejection(
session: Session,
*,
job_id: str,
attempt_id: str,
exc: Exception,
temporary: bool,
) -> None:
session.rollback()
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
job = session.get(CampaignJob, job_id)
if attempt is None or job is None:
raise RuntimeError(
"Postbox rejection could not be written to Campaign evidence."
) from exc
attempt.status = (
JobPostboxStatus.REJECTED_TEMPORARY.value
if temporary
else JobPostboxStatus.REJECTED_PERMANENT.value
)
attempt.error_type = exc.__class__.__name__
attempt.error_code = str(getattr(exc, "code", "") or "") or None
attempt.error_message = str(exc)
attempt.finished_at = utc_now()
session.add(attempt)
session.add(job)
session.commit()
def _record_unknown(
session: Session,
*,
job_id: str,
attempt_id: str,
exc: Exception,
) -> None:
session.rollback()
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
job = session.get(CampaignJob, job_id)
if attempt is None or job is None:
raise RuntimeError(
"Unknown Postbox outcome could not be written to Campaign evidence."
) from exc
attempt.status = JobPostboxStatus.OUTCOME_UNKNOWN.value
attempt.error_type = exc.__class__.__name__
attempt.error_code = str(getattr(exc, "code", "") or "") or None
attempt.error_message = str(exc)
attempt.finished_at = utc_now()
job.postbox_status = JobPostboxStatus.OUTCOME_UNKNOWN.value
session.add(attempt)
session.add(job)
session.commit()
def deliver_campaign_job_to_postboxes(
session: Session,
*,
job: CampaignJob,
message_bytes: bytes,
classification: str,
) -> PostboxChannelOutcome:
outcome = PostboxChannelOutcome()
targets = [
target
for target in (job.resolved_postbox_targets or [])
if isinstance(target, dict) and target.get("postbox_id")
]
if not targets:
outcome.rejected_permanent = 1
outcome.messages.append("No frozen Postbox target is available.")
job.postbox_status = JobPostboxStatus.REJECTED_PERMANENT.value
session.add(job)
session.commit()
return outcome
body_text = _message_body(message_bytes)
for target_index, target in enumerate(targets):
key = _target_key(target)
accepted = _accepted_attempt(
session,
job_id=job.id,
target_key=key,
)
if accepted is not None:
if accepted.vacant:
outcome.accepted_vacant += 1
else:
outcome.accepted += 1
continue
attempt_number = _attempt_number(
session,
job_id=job.id,
target_key=key,
)
request = _request(
job,
target,
target_key=key,
body_text=body_text,
classification=classification,
)
attempt = PostboxDeliveryAttempt(
tenant_id=job.tenant_id,
job_id=job.id,
target_key=key,
target_index=target_index,
attempt_number=attempt_number,
idempotency_key=request.idempotency_key,
status=JobPostboxStatus.DELIVERING.value,
target_snapshot=target,
evidence={},
started_at=utc_now(),
)
job.postbox_attempt_count += 1
job.postbox_status = JobPostboxStatus.DELIVERING.value
session.add(attempt)
session.add(job)
session.commit()
attempt_id = attempt.id
try:
result = postbox_integration().deliver(session, request)
current_attempt = session.get(PostboxDeliveryAttempt, attempt_id)
current_job = session.get(CampaignJob, job.id)
if current_attempt is None or current_job is None:
raise RuntimeError(
"Campaign Postbox attempt disappeared before acceptance."
)
current_attempt.status = (
JobPostboxStatus.ACCEPTED_VACANT.value
if result.vacant
else JobPostboxStatus.ACCEPTED.value
)
current_attempt.provider_delivery_id = result.delivery_id
current_attempt.provider_message_id = result.message_id
current_attempt.postbox_id = result.postbox_id
current_attempt.address = result.address
current_attempt.holder_count = result.holder_count
current_attempt.vacant = result.vacant
current_attempt.duplicate = result.duplicate
current_attempt.evidence = dict(result.evidence)
current_attempt.finished_at = utc_now()
session.add(current_attempt)
session.add(current_job)
session.commit()
if result.vacant:
outcome.accepted_vacant += 1
else:
outcome.accepted += 1
except PostboxDeliveryOutcomeUnknown as exc:
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
outcome.messages.append(str(exc))
except PostboxDeliveryRejected as exc:
if exc.code == "idempotency_conflict":
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
else:
_record_rejection(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
temporary=exc.temporary,
)
if exc.temporary:
outcome.rejected_temporary += 1
else:
outcome.rejected_permanent += 1
outcome.messages.append(str(exc))
except PostboxDeliveryUnavailable as exc:
_record_rejection(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
temporary=True,
)
outcome.rejected_temporary += 1
outcome.messages.append(str(exc))
except Exception as exc:
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
outcome.messages.append(str(exc))
current_job = session.get(CampaignJob, job.id)
if current_job is not None:
current_job.postbox_status = outcome.status
session.add(current_job)
session.commit()
return outcome
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,8 @@ from __future__ import annotations
import binascii
from datetime import datetime
import hashlib
from importlib import metadata
import secrets
import stat
import struct
@@ -49,6 +51,8 @@ def create_zip_archive(
output_path.parent.mkdir(parents=True, exist_ok=True)
members = _normalized_members(files)
if password:
if method not in {ZIP_METHOD_AES, ZIP_METHOD_STANDARD}:
raise ValueError(f"Unsupported password-encryption method: {method}")
if method == ZIP_METHOD_STANDARD:
_create_zipcrypto_archive(output_path, members, password)
return output_path
@@ -61,6 +65,51 @@ def create_zip_archive(
return output_path
def zip_archive_evidence(
output_path: Path,
members: Iterable[Path | ArchiveMember],
*,
password_protected: bool,
method: str,
) -> dict[str, object]:
"""Return password-free, content-addressed evidence for one built archive."""
normalized = _normalized_members(members)
archive_bytes = output_path.read_bytes()
if password_protected and method == ZIP_METHOD_AES:
try:
implementation_version = metadata.version("pyzipper")
except metadata.PackageNotFoundError: # pragma: no cover - guarded by writer
implementation_version = "unknown"
implementation = "pyzipper"
archive_format = "WinZip AES"
elif password_protected and method == ZIP_METHOD_STANDARD:
implementation = "govoplan-campaign.zipcrypto"
implementation_version = "1"
archive_format = "Legacy ZipCrypto"
else:
implementation = "python.zipfile"
implementation_version = "stdlib"
archive_format = "ZIP (unencrypted)"
return {
"format": archive_format,
"method": method if password_protected else "none",
"password_protected": password_protected,
"implementation": implementation,
"implementation_version": implementation_version,
"archive_sha256": hashlib.sha256(archive_bytes).hexdigest(),
"archive_size_bytes": len(archive_bytes),
"members": [
{
"name": archive_name,
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"size_bytes": path.stat().st_size,
}
for path, archive_name in normalized
],
}
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."""
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import pytest
from govoplan_core.core import runtime_coordination
from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
bind_process_runtime_identity,
)
@pytest.fixture(scope="session", autouse=True)
def _bind_test_runtime_identity():
previous = runtime_coordination._process_runtime_identity
bind_process_runtime_identity(
RuntimeIdentity(
installation_id="campaign-tests",
node_id="campaign-test-process",
incarnation="campaign-test-incarnation",
role="test",
software_version="test",
composition_hash="a" * 64,
)
)
try:
yield
finally:
bind_process_runtime_identity(previous)
File diff suppressed because it is too large Load Diff
+75 -30
View File
@@ -9,12 +9,20 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend import router
from govoplan_campaign.backend import router as campaign_api
from govoplan_campaign.backend.routes import campaigns as campaign_routes
from govoplan_campaign.backend.routes import jobs as job_routes
from govoplan_campaign.backend.routes import reports as report_routes
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.reports.aggregate import (
AggregateCampaignReportError,
generate_aggregate_campaign_report,
)
from govoplan_campaign.backend.reports.provider import (
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS,
CampaignAggregateReportProvider,
)
from govoplan_core.core.reporting import ReportProviderRequest
from govoplan_campaign.backend.schemas import ReportEmailRequest
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
@@ -37,10 +45,12 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(
report_routes, "_get_campaign_for_principal", return_value=campaign
),
pytest.raises(HTTPException) as full_report_denied,
):
router.campaign_report(
report_routes.campaign_report(
"campaign-1",
session=session,
principal=principal, # type: ignore[arg-type]
@@ -49,10 +59,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
assert "campaigns:recipient:read" in full_report_denied.value.detail
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as job_detail_denied,
):
router.get_job_detail(
job_routes.get_job_detail(
"campaign-1",
"job-1",
session=session,
@@ -61,10 +71,12 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
assert job_detail_denied.value.status_code == 403
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(
report_routes, "_get_campaign_for_principal", return_value=campaign
),
pytest.raises(HTTPException) as report_email_denied,
):
router.email_campaign_report(
report_routes.email_campaign_report(
"campaign-1",
ReportEmailRequest(to=["auditor@example.test"]),
session=session,
@@ -74,10 +86,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
assert "campaigns:recipient:export" in report_email_denied.value.detail
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as diagnostics_denied,
):
router.get_job_diagnostics(
job_routes.get_job_diagnostics(
"campaign-1",
"job-1",
session=session,
@@ -93,14 +105,14 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
safe_projection = Mock()
with (
patch.object(router, "_get_campaign_for_principal") as acl,
patch.object(campaign_routes, "_get_campaign_for_principal") as acl,
patch.object(
router,
campaign_routes,
"generate_aggregate_campaign_report",
return_value=safe_projection,
) as generate,
):
result = router.aggregate_campaign_report(
result = campaign_routes.aggregate_campaign_report(
"campaign-1",
session=session,
principal=principal, # type: ignore[arg-type]
@@ -116,10 +128,15 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
)
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
@pytest.mark.parametrize(
"path",
["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"],
)
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
route = next(item for item in router.router.routes if item.path == path)
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
route = next(item for item in campaign_api.router.routes if item.path == path)
dependency = next(
item for item in route.dependant.dependencies if item.name == "principal"
)
with pytest.raises(HTTPException) as denied:
dependency.call(_Principal())
@@ -145,7 +162,9 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
],
)
with Session(engine) as session:
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={}))
session.add(
Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={})
)
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
@@ -165,20 +184,22 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
campaign.current_version_id = version.id
session.add_all([campaign, version])
for index in range(5):
session.add(CampaignJob(
id=f"job-{index}",
tenant_id="tenant-1",
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=index,
recipient_email=f"private-{index}@example.test",
subject="Private",
build_status="built",
validation_status="ready",
queue_status="queued",
send_status="smtp_accepted",
imap_status="not_requested",
))
session.add(
CampaignJob(
id=f"job-{index}",
tenant_id="tenant-1",
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=index,
recipient_email=f"private-{index}@example.test",
subject="Private",
build_status="built",
validation_status="ready",
queue_status="queued",
send_status="smtp_accepted",
imap_status="not_requested",
)
)
session.commit()
report = generate_aggregate_campaign_report(
@@ -189,6 +210,30 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
assert report.population.denominator.value == 5
assert report.outcomes.smtp_accepted.value == 5
provider = CampaignAggregateReportProvider()
provider_principal = _Principal(
"campaigns:report:read",
"tenant:*",
)
descriptors = provider.list_reports(session, provider_principal)
assert descriptors[0].report_id == "delivery-outcomes"
assert descriptors[0].reidentification_risk == "low"
provided = provider.execute_report(
session,
provider_principal,
request=ReportProviderRequest(
report_id="delivery-outcomes",
parameters={"campaign_id": campaign.id},
purpose="Tenant delivery overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
),
)
assert set(provided.applied_privacy_transforms) == set(
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS
)
assert "recipient_email" not in repr(provided.payload)
assert provided.source_revisions[0]["revision_id"] == version.id
with pytest.raises(AggregateCampaignReportError):
generate_aggregate_campaign_report(
session,
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from govoplan_core.core.approvals import (
ApprovalActorSelector,
ApprovalCheck,
ApprovalRequestRef,
ApprovalStepDefinition,
)
from govoplan_campaign.backend.approval_gate import (
CampaignApprovalGateError,
assert_campaign_approval,
request_campaign_approval,
)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "requester"
class Session:
def add(self, _value: object) -> None:
return None
def flush(self) -> None:
return None
class ApprovalStub:
available = True
def __init__(self, *, state: str = "pending") -> None:
self.state = state
self.command = None
def create_request(self, _session, _principal, *, command, idempotency_key):
assert idempotency_key == "request-1"
self.command = command
return ApprovalRequestRef("approval-1", 1, "pending", "release")
def check_approved(self, _session, _principal, **kwargs):
return ApprovalCheck(
request_id=str(kwargs["request_id"]),
revision=2,
state=self.state,
approved=self.state == "approved",
subject_module=str(kwargs["subject_module"]),
subject_type=str(kwargs["subject_type"]),
subject_id=str(kwargs["subject_id"]),
subject_version=kwargs["subject_version"],
subject_digest=str(kwargs["subject_digest"]),
)
def _objects():
campaign = SimpleNamespace(
id="campaign-1",
tenant_id="tenant-1",
created_by_user_id="author",
owner_user_id="owner",
)
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
version_number=7,
execution_snapshot_hash="a" * 64,
editor_state={"review_send": {"updated_by_user_id": "reviewer"}},
validation_summary={"validated_by_user_id": "validator"},
build_summary={"built_by_user_id": "builder"},
locked_by_user_id="validator",
)
return campaign, version
def test_request_binds_snapshot_and_action_evidence() -> None:
campaign, version = _objects()
provider = ApprovalStub()
snapshot = SimpleNamespace(build_token="build-7", snapshot_version="7")
with (
patch(
"govoplan_campaign.backend.approval_gate.ensure_execution_snapshot",
return_value=snapshot,
),
patch(
"govoplan_campaign.backend.approval_gate.approvals_integration",
return_value=provider,
),
):
request_campaign_approval(
Session(),
Principal(),
campaign=campaign,
version=version,
title="Release",
description=None,
steps=(
ApprovalStepDefinition(
"release",
"Release",
(ApprovalActorSelector("role", "sender"),),
forbidden_evidence_roles=("builder", "reviewer"),
),
),
idempotency_key="request-1",
)
assert provider.command.subject_digest == "a" * 64
assert provider.command.evidence_actors["builder"] == ("builder",)
assert provider.command.evidence_actors["reviewer"] == ("reviewer",)
assert version.editor_state["approval_gate"]["request_id"] == "approval-1"
def test_gate_fails_closed_until_exact_request_is_approved() -> None:
campaign, version = _objects()
version.editor_state["approval_gate"] = {
"request_id": "approval-1",
"request_revision": 1,
"subject_version": "build-7",
"subject_digest": "a" * 64,
"requested_at": "2026-08-01T00:00:00+00:00",
"requested_by_user_id": "requester",
}
snapshot = SimpleNamespace(build_token="build-7", snapshot_version="7")
provider = ApprovalStub(state="pending")
with (
patch(
"govoplan_campaign.backend.approval_gate.ensure_execution_snapshot",
return_value=snapshot,
),
patch(
"govoplan_campaign.backend.approval_gate.approvals_integration",
return_value=provider,
),
):
with pytest.raises(CampaignApprovalGateError, match="pending"):
assert_campaign_approval(Session(), tenant_id="tenant-1", version=version)
provider.state = "approved"
assert_campaign_approval(Session(), tenant_id="tenant-1", version=version)
version.execution_snapshot_hash = "b" * 64
with pytest.raises(CampaignApprovalGateError, match="changed"):
assert_campaign_approval(Session(), tenant_id="tenant-1", version=version)
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_campaign.backend.archive_encryption import (
CampaignArchiveEncryptionError,
LEGACY_ZIPCRYPTO_SCOPE,
assert_archive_encryption_allowed,
effective_archive_encryption_policy,
stamp_legacy_zipcrypto_acknowledgements,
)
from govoplan_campaign.backend.db.models import Campaign
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.policy import (
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
CampaignArchiveEncryptionDecision,
PolicySourceStep,
)
class _PolicyProvider:
def __init__(self, methods: set[str]) -> None:
self.methods = methods
def resolve_campaign_archive_encryption(self, session=None, *, request):
del session, request
return CampaignArchiveEncryptionDecision(
allowed_password_encryption_methods=frozenset(self.methods),
allowed_password_delivery_channels=frozenset(
{"separate_mail", "sms", "letter", "phone", "in_person"}
),
policy_hash="f" * 64,
source_path=(
PolicySourceStep(
scope_type="system",
label="System archive-encryption policy",
applied_fields=("allowed_password_encryption_methods",),
policy={"allowed_password_encryption_methods": sorted(self.methods)},
),
),
)
class _Registry:
def __init__(self, provider) -> None:
self.provider = provider
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION
def capability(self, name: str):
return self.provider if self.has_capability(name) else None
class CampaignArchiveEncryptionGovernanceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
self.session = Session(self.engine)
self.campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
external_id="example",
name="Example",
owner_user_id="user-1",
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_unavailable_policy_keeps_aes_and_fails_closed_for_legacy(self) -> None:
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None):
policy = effective_archive_encryption_policy(self.session, self.campaign)
self.assertFalse(policy.available)
self.assertEqual(frozenset({"aes"}), policy.allowed_password_encryption_methods)
assert_archive_encryption_allowed(
self.session,
self.campaign,
_raw_archive("aes"),
)
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "blocked"):
assert_archive_encryption_allowed(
self.session,
self.campaign,
_raw_archive("zip_standard", stamped=True),
)
def test_existing_password_archive_inherits_separate_mail_channel(self) -> None:
raw = _raw_archive("aes")
raw["attachments"]["zip"]["archives"][0].pop("password_delivery_channel")
with patch(
"govoplan_campaign.backend.archive_encryption.get_registry",
return_value=None,
):
decision = assert_archive_encryption_allowed(
self.session,
self.campaign,
raw,
)
self.assertIn(
"separate_mail",
decision.allowed_password_delivery_channels,
)
def test_legacy_selection_requires_permission_and_gets_server_stamp(self) -> None:
registry = _Registry(_PolicyProvider({"aes", "zip_standard"}))
candidate = _raw_archive("zip_standard")
candidate["attachments"]["zip"]["archives"][0].update(
{
"legacy_zipcrypto_acknowledged": True,
"legacy_zipcrypto_reason": "Recipient requires built-in Windows extraction",
}
)
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=registry):
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "Missing scope"):
stamp_legacy_zipcrypto_acknowledgements(
self.session,
self.campaign,
{},
candidate,
principal=_principal(set()),
)
stamped, evidence = stamp_legacy_zipcrypto_acknowledgements(
self.session,
self.campaign,
{},
candidate,
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
)
archive = stamped["attachments"]["zip"]["archives"][0]
self.assertEqual("user-1", archive["legacy_zipcrypto_acknowledged_by"])
self.assertTrue(archive["legacy_zipcrypto_acknowledged_at"])
self.assertEqual("f" * 64, evidence[0]["policy_hash"])
assert_archive_encryption_allowed(
self.session,
self.campaign,
stamped,
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
)
def _raw_archive(method: str, *, stamped: bool = False) -> dict:
archive = {
"id": "archive-1",
"method": method,
"password_enabled": True,
"password_delivery_channel": "separate_mail",
"legacy_zipcrypto_acknowledged": method == "zip_standard",
"legacy_zipcrypto_reason": "Windows recipient compatibility required"
if method == "zip_standard"
else None,
}
if stamped:
archive.update(
{
"legacy_zipcrypto_acknowledged_by": "user-1",
"legacy_zipcrypto_acknowledged_at": "2026-08-20T10:00:00+00:00",
}
)
return {"attachments": {"zip": {"enabled": True, "archives": [archive]}}}
def _principal(scopes: set[str]) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
if __name__ == "__main__":
unittest.main()
+338
View File
@@ -0,0 +1,338 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_campaign.backend.artifact_reconciliation import (
campaign_artifact_inventory,
reconcile_campaign_artifacts,
)
from govoplan_core.core.object_storage import (
StorageBackendError,
StorageObjectInfo,
StorageObjectMissing,
StorageObjectPage,
)
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
from govoplan_core.core.recovery_runtime import RecoveryOperationBusy
from govoplan_core.core.runtime_coordination import (
DistributedLease,
RuntimeIdentity,
acquire_lease,
)
NOW = datetime(2026, 8, 3, 12, 0, tzinfo=timezone.utc)
PREFIX = "campaign-artifacts/tenant-1/campaign-1/version-1/"
class _MemoryStorage:
name = "memory"
def __init__(self) -> None:
self.objects: dict[str, bytes] = {}
self.modified_at: dict[str, datetime | None] = {}
self.delete_failures: set[str] = set()
self.exists_failures: set[str] = set()
def add(
self,
key: str,
*,
payload: bytes = b"artifact",
modified_at: datetime | None = NOW - timedelta(days=2),
) -> None:
self.objects[key] = payload
self.modified_at[key] = modified_at
def put_bytes(self, key: str, data: bytes, **_kwargs) -> None:
self.add(key, payload=data, modified_at=NOW)
def get_bytes(self, key: str) -> bytes:
try:
return self.objects[key]
except KeyError as exc:
raise StorageObjectMissing("missing") from exc
def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024):
del chunk_size
yield self.get_bytes(key)
def delete(self, key: str) -> None:
if key in self.delete_failures:
raise StorageBackendError("delete unavailable")
self.objects.pop(key, None)
self.modified_at.pop(key, None)
def exists(self, key: str) -> bool:
if key in self.exists_failures:
raise StorageBackendError("probe unavailable")
return key in self.objects
def stat(self, key: str) -> StorageObjectInfo:
if key not in self.objects:
raise StorageObjectMissing("missing")
return StorageObjectInfo(
key=key,
size_bytes=len(self.objects[key]),
modified_at=self.modified_at[key],
)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
keys = [
key
for key in sorted(self.objects)
if key.startswith(prefix) and (after is None or key > after)
]
selected = keys[:limit]
return StorageObjectPage(
objects=tuple(self.stat(key) for key in selected),
next_cursor=(selected[-1] if len(keys) > len(selected) else None),
)
@pytest.fixture
def recovery_session_factory():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
DistributedLease.__table__.create(engine)
RecoveryOperation.__table__.create(engine)
RecoveryCheckpoint.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
def _identity(*, node_id: str = "node-1", incarnation: str = "run-1"):
return RuntimeIdentity(
installation_id="campaign-artifact-tests",
node_id=node_id,
incarnation=incarnation,
role="worker",
software_version="test",
composition_hash="a" * 64,
)
def _without_domain_references():
return (
patch(
"govoplan_campaign.backend.artifact_reconciliation._referenced_artifact_keys",
return_value=set(),
),
patch(
"govoplan_campaign.backend.artifact_reconciliation._active_build_ids",
return_value=set(),
),
)
def test_inventory_classifies_reference_grace_active_build_and_unknown_age() -> None:
storage = _MemoryStorage()
orphan = f"{PREFIX}build-orphan/message.eml"
referenced = f"{PREFIX}build-referenced/message.eml"
active = f"{PREFIX}build-active/message.eml"
young = f"{PREFIX}build-young/message.eml"
unknown_age = f"{PREFIX}build-unknown/message.eml"
malformed = "campaign-artifacts/tenant-1/not-a-build-object"
storage.add(orphan)
storage.add(referenced)
storage.add(active)
storage.add(young, modified_at=NOW - timedelta(hours=1))
storage.add(unknown_age, modified_at=None)
storage.add(malformed)
with (
patch(
"govoplan_campaign.backend.artifact_reconciliation._referenced_artifact_keys",
return_value={referenced},
),
patch(
"govoplan_campaign.backend.artifact_reconciliation._active_build_ids",
return_value={"build-active"},
),
):
inventory = campaign_artifact_inventory(
object(), # type: ignore[arg-type]
storage=storage,
tenant_id="tenant-1",
grace_period=timedelta(hours=24),
page_size=20,
now=NOW,
)
assert [candidate.key for candidate in inventory.candidates] == [orphan]
assert inventory.referenced_count == 1
assert inventory.active_build_count == 1
assert inventory.young_count == 1
assert inventory.unknown_age_count == 1
assert inventory.invalid_shape_count == 1
def test_process_loss_orphan_is_deleted_once_and_same_request_replays(
recovery_session_factory,
) -> None:
storage = _MemoryStorage()
orphan = f"{PREFIX}lost-build/message.eml"
storage.add(orphan)
reference_patch, active_patch = _without_domain_references()
with reference_patch, active_patch:
dry_run = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
now=NOW,
)
applied = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="cleanup-lost-build",
now=NOW,
)
replayed = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="cleanup-lost-build",
now=NOW,
)
repeated = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="cleanup-empty-page",
now=NOW,
)
assert dry_run["candidate_count"] == 1
assert dry_run["deleted_count"] == 0
assert applied["status"] == "applied"
assert applied["deleted_count"] == 1
assert orphan not in storage.objects
assert replayed["status"] == "already_completed"
assert repeated["candidate_count"] == 0
def test_competing_node_cannot_acquire_cleanup_authority(
recovery_session_factory,
) -> None:
lease_observed_at = datetime.now(timezone.utc)
with recovery_session_factory() as session:
claim = acquire_lease(
session,
installation_id="campaign-artifact-tests",
resource_key="campaign:artifact-reconcile:tenant-1",
holder_node_id="node-other",
holder_incarnation="run-other",
ttl_seconds=900,
now=lease_observed_at,
)
assert claim is not None
session.commit()
storage = _MemoryStorage()
reference_patch, active_patch = _without_domain_references()
with reference_patch, active_patch, pytest.raises(RecoveryOperationBusy):
reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="competing-cleanup",
now=NOW,
)
def test_partial_storage_outage_remains_visible_and_retryable(
recovery_session_factory,
) -> None:
storage = _MemoryStorage()
removed = f"{PREFIX}build-a/message.eml"
retained = f"{PREFIX}build-b/message.eml"
storage.add(removed)
storage.add(retained)
storage.delete_failures.add(retained)
reference_patch, active_patch = _without_domain_references()
with reference_patch, active_patch:
partial = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="partial-cleanup",
now=NOW,
)
storage.delete_failures.clear()
retry = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="partial-cleanup-retry",
now=NOW,
)
assert partial["status"] == "recovery_required"
assert partial["deleted_count"] == 1
assert partial["failure_count"] == 1
assert removed not in storage.objects
assert retry["status"] == "applied"
assert retry["deleted_count"] == 1
assert storage.objects == {}
with recovery_session_factory() as session:
states = session.execute(
select(RecoveryOperation.status).order_by(RecoveryOperation.created_at)
).scalars().all()
assert "recovery_required" in states
assert states[-1] == "succeeded"
def test_unverifiable_delete_is_outcome_unknown(
recovery_session_factory,
) -> None:
storage = _MemoryStorage()
orphan = f"{PREFIX}build-unknown/message.eml"
storage.add(orphan)
storage.exists_failures.add(orphan)
reference_patch, active_patch = _without_domain_references()
with reference_patch, active_patch:
result = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="unknown-cleanup",
now=NOW,
)
assert result["status"] == "outcome_unknown"
assert result["failure_count"] == 1
+384 -2
View File
@@ -7,6 +7,7 @@ import zipfile
from pathlib import Path
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.loader import validate_against_schema
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.messages.builder import build_campaign_messages
@@ -56,6 +57,64 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
"delivery": {"imap_append_sent": {"enabled": False}},
})
def _attachment_reuse_config(
self,
*,
action: str,
allow_within: str = "none",
recipient_emails: tuple[str, ...] = (
"first@example.org",
"second@example.org",
),
duplicate_rules: bool = False,
) -> CampaignConfig:
rules = [{
"id": "shared-file",
"base_dir": "documents",
"file_filter": "shared.pdf",
"required": True,
}]
if duplicate_rules:
rules.append({**rules[0], "id": "shared-file-again"})
return CampaignConfig.model_validate({
"version": "1.0",
"campaign": {
"id": f"reuse-{action}-{allow_within}",
"name": "Attachment reuse",
"mode": "test",
},
"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": {
"global": rules,
"reuse_policy": {
"action": action,
"allow_within": allow_within,
},
},
"entries": {
"inline": [
{
"id": f"recipient-{index}",
"to": [{"email": email, "type": "to"}],
}
for index, email in enumerate(recipient_emails, start=1)
]
},
"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)
@@ -100,9 +159,9 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
cases = {
"block": ("build_failed", "blocked", 0, "block", False),
"ask": ("built", "needs_review", 0, "ask", True),
"drop": ("built", "excluded", 0, "drop", True),
"drop": ("built", "needs_review", 0, "ask", True),
"warn": ("built", "warning", 1, "warn", True),
"continue": ("built", "ready", 1, None, True),
"continue": ("built", "warning", 1, None, True),
}
for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
with self.subTest(behavior=behavior):
@@ -133,6 +192,71 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
self.assertEqual(coverage_issues[0].behavior, issue_behavior)
self.assertEqual(result.built_messages[0].mime is not None, has_mime)
def test_required_missing_policy_cannot_be_loosened_by_rule(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="continue",
configure_missing_rule=True,
)
rule = config.attachments.global_[0]
rule.required = True
rule.missing_behavior = "continue"
config.attachments.missing_behavior = "continue"
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=True,
)
message = result.report.messages[0]
self.assertEqual(message.validation_status.value, "blocked")
issue = next(
item
for item in message.issues
if item.code == "missing_required_attachment"
)
self.assertEqual(issue.behavior, "block")
self.assertEqual(
issue.details["effective_policy"]["requirement_policy"],
"block",
)
self.assertEqual(
message.attachments[0].missing_policy["effective_behavior"],
"block",
)
def test_optional_missing_policy_warns_by_default(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="continue",
configure_missing_rule=True,
)
config.attachments.global_[0].missing_behavior = None
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=True,
)
message = result.report.messages[0]
self.assertEqual(message.validation_status.value, "warning")
issue = next(
item
for item in message.issues
if item.code == "missing_optional_attachment"
)
self.assertEqual(issue.behavior, "warn")
def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -210,6 +334,264 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
self.assertEqual(archive.namelist(), ["matched.xlsx"])
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
def test_attachment_reuse_action_controls_message_validation(self) -> None:
expected = {
"allow": ("ready", None, 0, 1),
"warn": ("warning", "warn", 1, 0),
"review": ("needs_review", "ask", 1, 0),
"block": ("blocked", "block", 1, 0),
}
for action, (status, behavior, violation_count, allowed_count) in expected.items():
with self.subTest(action=action), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
documents = root / "documents"
documents.mkdir()
(documents / "shared.pdf").write_bytes(b"shared")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = self._attachment_reuse_config(action=action)
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(
[status, status],
[message.validation_status.value for message in result.report.messages],
)
report = result.report.attachment_reuse
self.assertEqual(1, report["duplicate_file_count"])
self.assertEqual(violation_count, report["violation_file_count"])
self.assertEqual(allowed_count, report["allowed_file_count"])
finding = report["findings"][0]
self.assertEqual("shared.pdf", finding["file_name"])
self.assertNotIn(str(root), str(report))
issues = [
issue
for message in result.report.messages
for issue in message.issues
if issue.code == "duplicate_attachment_reuse"
]
if behavior is None:
self.assertEqual([], issues)
else:
self.assertEqual([behavior, behavior], [issue.behavior for issue in issues])
self.assertEqual(
{"action": action, "allow_within": "none"},
issues[0].details["policy"],
)
def test_attachment_reuse_can_be_allowed_within_recipient_or_message(self) -> None:
cases = (
("same_recipient", ("same@example.org", "same@example.org"), False, 2),
("same_message", ("same@example.org",), True, 2),
)
for allow_within, recipients, duplicate_rules, expected_use_count in cases:
with self.subTest(allow_within=allow_within), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
documents = root / "documents"
documents.mkdir()
(documents / "shared.pdf").write_bytes(b"shared")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
result = build_campaign_messages(
self._attachment_reuse_config(
action="block",
allow_within=allow_within,
recipient_emails=recipients,
duplicate_rules=duplicate_rules,
),
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(
["ready"] * len(recipients),
[message.validation_status.value for message in result.report.messages],
)
report = result.report.attachment_reuse
self.assertEqual(1, report["allowed_file_count"])
self.assertEqual(0, report["violation_file_count"])
self.assertEqual(expected_use_count, report["findings"][0]["use_count"])
def test_attachment_reuse_policy_is_part_of_the_json_schema(self) -> None:
config = self._attachment_reuse_config(
action="review",
allow_within="same_recipient",
)
payload = config.model_dump(
mode="json",
by_alias=True,
exclude_none=True,
exclude_defaults=True,
)
payload["server"].pop("profile_capabilities", None)
validate_against_schema(payload)
def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None:
for mode, expected_attachment_count in (("report", 0), ("attach", 1)):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
watched = root / "watched"
watched.mkdir()
(watched / "assigned.txt").write_text("assigned", encoding="utf-8")
(watched / "residual.txt").write_text("residual", encoding="utf-8")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": f"residual-{mode}", "name": "Monthly import", "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": "Normal message", "text": "Normal body"},
"attachments": {
"base_paths": [{
"id": "watched",
"name": "Watched folder",
"path": "watched",
"unsent_warning": True,
}],
"global": [{
"id": "assigned",
"base_path_id": "watched",
"base_dir": "watched",
"file_filter": "assigned.txt",
"required": True,
}],
"residual_files": {
"mode": mode,
"recipient": {"email": "operator@example.org", "name": "Operator"},
"subject": "Residual files for {{local:campaign_name}}",
"text": "{{local:residual_file_count}} file(s):\n{{local:residual_file_list}}",
},
},
"entries": {"inline": [{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "type": "to"}],
}]},
"validation_policy": {
"missing_email": "block",
"template_error": "block",
"unsent_attachment_files": "block",
},
"delivery": {"imap_append_sent": {"enabled": False}},
})
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=True,
)
self.assertEqual(len(result.report.messages), 2)
self.assertEqual(
{
"contract_version": "1",
"action": "route_report" if mode == "report" else "route_with_files",
"routing_mode": mode,
"validation_behavior": "block",
"watched_source_count": 1,
"residual_file_count": 1,
"recipient": {
"email": "operator@example.org",
"name": "Operator",
"type": "to",
},
},
result.report.residual_file_disposition,
)
normal, residual = result.report.messages
self.assertEqual(normal.validation_status.value, "ready")
self.assertEqual(residual.entry_id, "__residual_files__")
self.assertEqual(residual.validation_status.value, "needs_review")
self.assertEqual(residual.to[0].email, "operator@example.org")
self.assertEqual(residual.subject, "Residual files for Monthly import")
self.assertEqual(residual.attachment_count, expected_attachment_count)
self.assertIn(
"residual_attachment_disposition",
{issue.code for issue in residual.issues},
)
self.assertNotIn(
"unsent_attachment_files",
{issue.code for message in result.report.messages for issue in message.issues},
)
mime = result.built_messages[1].mime
self.assertIsNotNone(mime)
self.assertIn("residual.txt", mime.get_body(preferencelist=("plain",)).get_content())
filenames = [part.get_filename() for part in mime.iter_attachments()]
self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else [])
def test_residual_file_policy_evidence_normalizes_block_and_ignore(self) -> None:
for behavior, action in (("block", "block"), ("continue", "ignore")):
with self.subTest(behavior=behavior), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
watched = root / "watched"
watched.mkdir()
(watched / "residual.txt").write_text("residual", encoding="utf-8")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": f"residual-{behavior}", "name": "Residual", "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": "Normal", "text": "Body"},
"attachments": {
"base_paths": [{
"id": "watched",
"name": "Watched folder",
"path": "watched",
"unsent_warning": True,
}],
},
"entries": {"inline": [{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "type": "to"}],
}]},
"validation_policy": {"unsent_attachment_files": behavior},
"delivery": {"imap_append_sent": {"enabled": False}},
})
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(action, result.report.residual_file_disposition["action"])
self.assertEqual(1, result.report.residual_file_disposition["residual_file_count"])
issue_codes = {
issue.code
for message in result.report.messages
for issue in message.issues
}
self.assertEqual(behavior == "block", "unsent_attachment_files" in issue_codes)
if __name__ == "__main__":
unittest.main()
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
import tempfile
import unittest
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_campaign.backend.campaign.models import (
BuildStatus,
CampaignConfig,
SendStatus,
)
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.messages.builder import BuiltMessage
from govoplan_campaign.backend.messages.models import (
ImapStatus,
MessageAddress,
MessageDraft,
MessageValidationStatus,
)
from govoplan_campaign.backend.persistence.campaigns import (
_prepare_built_calendar_invitations,
)
from govoplan_campaign.backend.sending.jobs import _mark_accepted_job_artifacts
from govoplan_core.core.calendar import CalendarInvitationRef
def _config() -> CampaignConfig:
return CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {
"id": "campaign-1",
"name": "Invitation campaign",
"mode": "send",
},
"template": {"subject": "Planning", "text": "Please reply."},
"entries": {
"inline": [
{
"id": "recipient-1",
"name": "Ada",
"to": [{"email": "ada@example.test", "name": "Ada"}],
"fields": {"appointment_start": "2026-08-05T09:00:00"},
}
]
},
"delivery": {
"channel_policy": "mail",
"calendar_invitation": {
"enabled": True,
"calendar_id": "calendar-1",
"summary_template": "Planning with {{name}}",
"start_at_template": "{{appointment_start}}",
"end_at_template": "2026-08-05T10:00:00",
"timezone": "Europe/Berlin",
},
},
}
)
class _CalendarIntegration:
available = True
def request_from_payload(self, payload):
return payload
def render_invitation(self, request):
assert request["correlation_id"].startswith("campaign:version-1:")
return "\r\n".join(
(
"BEGIN:VCALENDAR",
"VERSION:2.0",
"METHOD:REQUEST",
"BEGIN:VEVENT",
"UID:invitation-1@govoplan.local",
"SUMMARY:Planning with Ada",
"END:VEVENT",
"END:VCALENDAR",
"",
)
)
class _MirroringIntegration:
available = True
def __init__(self) -> None:
self.upsert_count = 0
def request_from_payload(self, payload):
return SimpleNamespace(
metadata=payload.get("metadata") or {},
payload=payload,
)
def upsert_invitation(self, _session, **_kwargs):
self.upsert_count += 1
return CalendarInvitationRef(
event_id="event-1",
calendar_id="calendar-1",
uid="invitation-1@govoplan.local",
correlation_id="campaign:version-1:entry-1",
source_module="campaigns",
source_resource_type="campaign_version",
source_resource_id="version-1",
)
class _Session:
def __init__(self) -> None:
self.added: list[object] = []
def add(self, value) -> None:
self.added.append(value)
class CampaignCalendarInvitationTests(unittest.TestCase):
def test_validation_requires_calendar_capability(self) -> None:
report = validate_campaign_config(_config(), calendar_available=False)
self.assertIn(
"calendar_invitation_unavailable",
{issue.code for issue in report.issues},
)
def test_build_freezes_individual_request_and_ics_attachment(self) -> None:
config = _config()
entry = config.entries.inline[0] # type: ignore[index]
message = EmailMessage()
message["From"] = "Organizer <organizer@example.test>"
message["To"] = "Ada <ada@example.test>"
message["Subject"] = "Planning"
message.set_content("Please reply.")
with tempfile.TemporaryDirectory() as temp_dir:
eml_path = Path(temp_dir) / "message.eml"
eml_path.write_bytes(bytes(message))
draft = MessageDraft(
entry_index=1,
entry_id="recipient-1",
active=True,
build_status=BuildStatus.BUILT,
validation_status=MessageValidationStatus.READY,
send_status=SendStatus.DRAFT,
imap_status=ImapStatus.NOT_REQUESTED,
subject="Planning",
**{
"from": MessageAddress(
email="organizer@example.test",
name="Organizer",
)
},
to=[MessageAddress(email="ada@example.test", name="Ada")],
eml_path=str(eml_path),
)
provenance: dict[int, dict[str, object]] = {1: {}}
with patch(
"govoplan_campaign.backend.persistence.campaigns.calendar_integration",
return_value=_CalendarIntegration(),
):
_prepare_built_calendar_invitations(
version=SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
),
config=config,
built_messages=[BuiltMessage(draft=draft, mime=message)],
entries_by_index={1: entry},
delivery_provenance_by_index=provenance,
user_id="user-1",
)
parsed = BytesParser(policy=policy.default).parsebytes(
eml_path.read_bytes()
)
attachments = list(parsed.iter_attachments())
self.assertEqual("text/calendar", attachments[0].get_content_type())
self.assertEqual("REQUEST", attachments[0].get_param("method"))
invitation = provenance[1]["calendar_invitation"]
self.assertEqual("prepared", invitation["state"])
self.assertEqual(
"2026-08-05T09:00:00+02:00",
invitation["request"]["start_at"],
)
def test_delivery_acceptance_mirrors_once_without_reopening_delivery(self) -> None:
integration = _MirroringIntegration()
job = SimpleNamespace(
tenant_id="tenant-1",
delivery_provenance={
"calendar_invitation": {
"state": "prepared",
"request": {
"metadata": {"prepared_by_user_id": "user-1"},
},
}
},
)
session = _Session()
with (
patch(
"govoplan_campaign.backend.sending.jobs.calendar_integration",
return_value=integration,
),
patch(
"govoplan_campaign.backend.sending.jobs.files_integration"
),
):
_mark_accepted_job_artifacts(session, job)
_mark_accepted_job_artifacts(session, job)
invitation = job.delivery_provenance["calendar_invitation"]
self.assertEqual("mirrored", invitation["state"])
self.assertEqual("event-1", invitation["event_id"])
self.assertEqual(1, integration.upsert_count)
self.assertEqual([job], session.added)
if __name__ == "__main__":
unittest.main()
+30 -18
View File
@@ -6,8 +6,11 @@ from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from govoplan_campaign.backend import router
from govoplan_campaign.backend import route_support
from govoplan_campaign.backend.routes import campaigns as campaign_routes
from govoplan_campaign.backend.routes import versions as version_routes
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
from govoplan_core.core.concurrency import strong_resource_etag
def _principal() -> SimpleNamespace:
@@ -26,6 +29,7 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
raw_json={},
current_flow="manual",
current_step="recipients",
edit_revision=1,
)
def mutate(*_args, **kwargs):
@@ -34,18 +38,26 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
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")),
patch.object(route_support, "_get_campaign_for_principal"),
patch.object(route_support, "_get_version_for_tenant", return_value=version),
patch.object(route_support, "update_campaign_version", side_effect=mutate),
patch.object(route_support, "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
route_support._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
session,
principal, # type: ignore[arg-type]
"campaign-1",
"version-1",
CampaignVersionUpdateRequest(current_step="recipients"),
CampaignVersionUpdateRequest(
current_step="recipients",
base_revision=1,
),
if_match=strong_resource_etag(
"campaign_version",
"version-1",
1,
),
autosave=True,
audit_action="campaign.version_autosaved",
)
@@ -68,15 +80,15 @@ def test_version_fork_rolls_back_when_its_audit_record_cannot_be_written() -> No
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")),
patch.object(version_routes, "_get_campaign_for_principal", return_value=campaign),
patch.object(version_routes, "_require_permission"),
patch.object(version_routes, "_get_version_for_tenant", return_value=source),
patch.object(version_routes, "_get_campaign_for_tenant", return_value=campaign),
patch.object(version_routes, "fork_campaign_version_for_edit", side_effect=mutate),
patch.object(version_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
):
with pytest.raises(RuntimeError, match="audit unavailable"):
router.fork_version_for_edit(
version_routes.fork_version_for_edit(
"campaign-1",
"version-1",
CampaignVersionUpdateRequest(),
@@ -102,12 +114,12 @@ def test_metadata_update_rolls_back_when_its_audit_record_cannot_be_written() ->
)
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")),
patch.object(campaign_routes, "_get_campaign_for_principal", return_value=campaign),
patch.object(campaign_routes, "_sync_campaign_metadata_to_current_version"),
patch.object(campaign_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
):
with pytest.raises(RuntimeError, match="audit unavailable"):
router.update_campaign_metadata_endpoint(
campaign_routes.update_campaign_metadata_endpoint(
"campaign-1",
CampaignUpdateRequest(name="New name"),
session=session,
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
from govoplan_core.core.object_storage import LocalFilesystemStorageBackend
from govoplan_core.core.recovery import RecoveryMode
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
_StoredEmlArtifact,
_build_storage_expectations,
_delete_storage_keys,
_verify_build_storage_manifest,
_verify_storage_keys_absent,
)
from govoplan_campaign.backend.routes.versions import (
_attachment_reuse_audit_evidence,
_campaign_build_recovery_plan,
_residual_file_audit_evidence,
_review_decision_audit_evidence,
)
def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None:
assert _campaign_build_recovery_plan({}).mode == RecoveryMode.COMPENSATION
assert (
_campaign_build_recovery_plan(
{"delivery": {"print": {"persist_to_files": True}}}
).mode
== RecoveryMode.FORWARD_RECOVERY
)
assert (
_campaign_build_recovery_plan(
{"delivery": {"print": {"persist_to_files": False}}}
).mode
== RecoveryMode.COMPENSATION
)
def test_residual_file_audit_evidence_omits_recipient_identity() -> None:
assert _residual_file_audit_evidence({
"contract_version": "1",
"action": "route_report",
"routing_mode": "report",
"validation_behavior": "warn",
"watched_source_count": 2,
"residual_file_count": 3,
"recipient": {"email": "operator@example.org"},
}) == {
"contract_version": "1",
"action": "route_report",
"routing_mode": "report",
"validation_behavior": "warn",
"watched_source_count": 2,
"residual_file_count": 3,
}
def test_attachment_reuse_audit_evidence_keeps_policy_but_not_file_names() -> None:
assert _attachment_reuse_audit_evidence({
"contract_version": "1",
"policy": {"action": "review", "allow_within": "same_recipient"},
"duplicate_file_count": 4,
"allowed_file_count": 1,
"violation_file_count": 3,
"affected_message_count": 5,
"findings": [{"file_name": "personal.pdf"}],
}) == {
"contract_version": "1",
"policy": {"action": "review", "allow_within": "same_recipient"},
"duplicate_file_count": 4,
"allowed_file_count": 1,
"violation_file_count": 3,
"affected_message_count": 5,
}
def test_review_decision_audit_evidence_is_aggregate_and_integrity_sealed() -> None:
version = type("Version", (), {
"editor_state": {
"review_send": {
"issue_decisions": [{
"decision": "accept",
"reason": "Approved exception",
"issue_codes": ["duplicate_attachment_reuse"],
"issue_fingerprint": "f" * 64,
"message_sha256": "m" * 64,
}]
}
}
})()
evidence = _review_decision_audit_evidence(version)
assert evidence["count"] == 1
assert evidence["with_reason_count"] == 1
assert evidence["issue_codes"] == ["duplicate_attachment_reuse"]
assert len(str(evidence["evidence_sha256"])) == 64
assert "Approved exception" not in str(evidence)
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
storage = LocalFilesystemStorageBackend(tmp_path)
payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
key = "campaign-artifacts/tenant/campaign/version/build/00000001.eml"
storage.put_bytes(key, payload)
artifact = _StoredEmlArtifact(
storage_key=key,
size_bytes=len(payload),
sha256=hashlib.sha256(payload).hexdigest(),
message_id_header="<build@example.test>",
)
evidence = _verify_build_storage_manifest(
storage,
_build_storage_expectations(
stored_eml_by_index={1: artifact},
print_outputs_by_index={},
),
)
assert evidence["object_count"] == 1
assert evidence["total_bytes"] == len(payload)
storage.put_bytes(key, b"tampered")
with pytest.raises(CampaignPersistenceError, match="does not match"):
_verify_build_storage_manifest(
storage,
_build_storage_expectations(
stored_eml_by_index={1: artifact},
print_outputs_by_index={},
),
)
def test_compensation_requires_verified_object_absence(tmp_path: Path) -> None:
storage = LocalFilesystemStorageBackend(tmp_path)
key = "campaign-artifacts/tenant/campaign/version/build/object"
storage.put_bytes(key, b"payload")
assert _delete_storage_keys(storage, [key]) == []
assert _verify_storage_keys_absent(storage, [key]) == (True, 0)
class _UnremovableStorage:
name = "unremovable"
def delete(self, _key: str) -> None:
from govoplan_core.core.object_storage import StorageBackendError
raise StorageBackendError("unavailable")
def exists(self, _key: str) -> bool:
return True
def test_failed_compensation_remains_observable() -> None:
storage = _UnremovableStorage()
key = "campaign-artifacts/tenant/campaign/version/build/object"
assert _delete_storage_keys(storage, [key]) == [key] # type: ignore[arg-type]
assert _verify_storage_keys_absent(storage, [key]) == (False, 1) # type: ignore[arg-type]
+382
View File
@@ -0,0 +1,382 @@
from __future__ import annotations
import importlib
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from alembic.migration import MigrationContext
from alembic.operations import Operations
from fastapi import HTTPException
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import Session, sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignCollaborationEntry,
CampaignJob,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.routes.collaboration import (
create_campaign_collaboration_entry,
list_campaign_collaboration,
redact_campaign_collaboration_entry,
withdraw_campaign_collaboration_entry,
)
from govoplan_campaign.backend.schemas import (
CampaignCollaborationCreateRequest,
CampaignCollaborationModerationRequest,
CampaignCollaborationReferenceInput,
)
from govoplan_core.core.access import GroupRef, UserRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
TENANT_ID = "tenant-1"
class _Principal:
tenant_id = TENANT_ID
api_key = None
def __init__(self, user_id: str, *scopes: str) -> None:
self.user = SimpleNamespace(
id=user_id,
display_name=f"User {user_id}",
email=f"{user_id}@example.test",
)
self.scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self.scopes or "tenant:*" in self.scopes
class _Directory:
users = (
UserRef(id="user-1", account_id="account-1", tenant_id=TENANT_ID, display_name="Author"),
UserRef(id="user-2", account_id="account-2", tenant_id=TENANT_ID, display_name="Collaborator"),
UserRef(id="user-3", account_id="account-3", tenant_id=TENANT_ID, display_name="Unrelated"),
)
def users_for_tenant(self, tenant_id: str):
return self.users if tenant_id == TENANT_ID else ()
def groups_for_user(self, user_id: str, *, tenant_id: str):
if tenant_id == TENANT_ID and user_id == "user-2":
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Collaborators"),)
return ()
class _Notifications:
def __init__(self) -> None:
self.requests = []
def enqueue_notification(self, _session, request, *, enqueue_delivery: bool = True):
self.requests.append((request, enqueue_delivery))
return {"id": f"notification-{len(self.requests)}"}
class _UnavailableNotifications:
def enqueue_notification(self, _session, _request, *, enqueue_delivery: bool = True):
raise RuntimeError("Notifications is temporarily unavailable")
@pytest.fixture()
def session() -> Session:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
ChangeSequenceEntry.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
CampaignShare.__table__,
CampaignCollaborationEntry.__table__,
],
)
session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
database = session_factory()
database.add_all(
[
Account(id=f"account-{number}", email=f"user-{number}@example.test", normalized_email=f"user-{number}@example.test")
for number in range(1, 4)
]
)
database.flush()
database.add_all(
[
User(id=f"user-{number}", tenant_id=TENANT_ID, account_id=f"account-{number}", email=f"user-{number}@example.test")
for number in range(1, 4)
]
)
database.add(Group(id="group-1", tenant_id=TENANT_ID, slug="collaborators", name="Collaborators"))
campaign = Campaign(
id="campaign-1",
tenant_id=TENANT_ID,
owner_user_id="user-1",
external_id="campaign-1",
name="Campaign One",
current_version_id="version-1",
)
version = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
raw_json={
"version": "1.0",
"entries": {"imports": [{"id": "import-1", "source_type": "csv"}]},
"attachments": {"global": [{"label": "Notice"}]},
},
)
database.add_all(
[
campaign,
version,
CampaignShare(
id="share-1",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
target_type="group",
target_id="group-1",
permission="read",
),
CampaignJob(
id="job-1",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=0,
),
]
)
database.commit()
try:
yield database
finally:
database.close()
engine.dispose()
def _commit_audit(session: Session, *_args, **_kwargs) -> None:
session.commit()
def _principal(user_id: str = "user-1", *, moderate: bool = False) -> _Principal:
scopes = [
"campaigns:campaign:read",
"campaigns:discussion:read",
"campaigns:discussion:post",
]
if moderate:
scopes.append("campaigns:discussion:moderate")
return _Principal(user_id, *scopes)
def test_post_is_append_only_references_a_version_and_notifies_authorized_mentions(session: Session) -> None:
notifications = _Notifications()
original_version = dict(session.get(CampaignVersion, "version-1").raw_json)
with (
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=notifications),
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit) as audit,
):
response = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(
content="Please review the frozen version.",
mention_user_ids=["user-2", "user-1", "user-2"],
reference=CampaignCollaborationReferenceInput(
kind="campaign_version",
id="version-1",
),
),
session,
_principal(),
)
assert response.content == "Please review the frozen version."
assert response.reference is not None
assert response.reference.label == "Version 1"
assert response.mention_user_ids == ["user-2"]
assert session.get(CampaignVersion, "version-1").raw_json == original_version
assert len(notifications.requests) == 1
notification, enqueue_delivery = notifications.requests[0]
assert notification.recipient_id == "user-2"
assert notification.payload["content_disclosed"] is False
assert enqueue_delivery is False
details = audit.call_args.kwargs["details"]
assert details["content_disclosed"] is False
assert "Please review" not in repr(details)
def test_mentions_reject_users_without_campaign_access(session: Session) -> None:
with patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()):
with pytest.raises(HTTPException) as raised:
create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(content="No leak", mention_user_ids=["user-3"]),
session,
_principal(),
)
assert raised.value.status_code == 422
assert session.query(CampaignCollaborationEntry).count() == 0
def test_notification_failure_does_not_block_or_roll_back_collaboration(session: Session) -> None:
with (
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
patch(
"govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider",
return_value=_UnavailableNotifications(),
),
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
):
response = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(
content="This discussion entry must survive an optional integration outage.",
mention_user_ids=["user-2"],
),
session,
_principal(),
)
stored = session.get(CampaignCollaborationEntry, response.id)
assert stored is not None
assert stored.content == response.content
assert stored.mention_user_ids == ["user-2"]
def test_collaboration_migration_is_repeatable_and_creates_thread_index() -> None:
migration = importlib.import_module(
"govoplan_campaign.backend.migrations.versions."
"c7d8e9f0a1b2_v0120_campaign_collaboration"
)
engine = create_engine("sqlite+pysqlite:///:memory:")
with engine.begin() as connection:
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
connection.execute(
text(
"CREATE TABLE campaign_versions ("
"id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"
)
)
context = MigrationContext.configure(connection)
with patch.object(migration, "op", Operations(context)):
migration.upgrade()
migration.upgrade()
inspector = inspect(connection)
columns = {
column["name"]
for column in inspector.get_columns("campaign_collaboration_entries")
}
indexes = {
index["name"]
for index in inspector.get_indexes("campaign_collaboration_entries")
}
assert {
"campaign_id",
"campaign_version_id",
"content_sha256",
"mention_user_ids",
"withdrawn_at",
"redacted_at",
}.issubset(columns)
assert "ix_campaign_collaboration_entries_thread" in indexes
with patch.object(migration, "op", Operations(context)):
migration.downgrade()
assert not inspect(connection).has_table("campaign_collaboration_entries")
def test_visibility_pagination_withdrawal_and_redaction_leave_tombstones(session: Session) -> None:
with (
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
):
public_entry = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(content="Visible discussion"),
session,
_principal(),
)
moderator_entry = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(content="Restricted discussion", visibility="moderators"),
session,
_principal(moderate=True),
)
ordinary = list_campaign_collaboration("campaign-1", 1, None, session, _principal())
moderated = list_campaign_collaboration("campaign-1", 1, None, session, _principal(moderate=True))
withdrawn = withdraw_campaign_collaboration_entry(
"campaign-1",
public_entry.id,
CampaignCollaborationModerationRequest(reason="Posted in error"),
session,
_principal(),
)
redacted = redact_campaign_collaboration_entry(
"campaign-1",
moderator_entry.id,
CampaignCollaborationModerationRequest(reason="Contains restricted material"),
session,
_principal(moderate=True),
)
assert [entry.id for entry in ordinary.items] == [public_entry.id]
assert ordinary.has_more is False
assert [entry.id for entry in moderated.items] == [moderator_entry.id]
assert moderated.has_more is True
assert moderated.next_cursor
second_page = list_campaign_collaboration(
"campaign-1",
1,
moderated.next_cursor,
session,
_principal(moderate=True),
)
assert [entry.id for entry in second_page.items] == [public_entry.id]
assert withdrawn.tombstone == "withdrawn"
assert withdrawn.content is None
assert redacted.tombstone == "redacted"
assert redacted.content is None
assert session.get(CampaignCollaborationEntry, public_entry.id).content is None
assert session.get(CampaignCollaborationEntry, redacted.id).content_sha256 == redacted.content_sha256
@pytest.mark.parametrize(
("kind", "reference_id"),
[
("recipient_import_batch", "version-1:import-1"),
("attachment_rule", "version-1:attachments.global[0]"),
("delivery_job", "job-1"),
("report", "campaign-1:version-1:delivery"),
],
)
def test_supported_reference_contexts_are_validated(session: Session, kind: str, reference_id: str) -> None:
with (
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
):
response = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(
content="Reference context",
reference=CampaignCollaborationReferenceInput(kind=kind, id=reference_id), # type: ignore[arg-type]
),
session,
_principal(),
)
assert response.reference is not None
assert response.reference.kind == kind
assert response.reference.id == reference_id
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from datetime import UTC, datetime
from govoplan_campaign.backend.integrations import TemplatesCampaignIntegration
from govoplan_campaign.backend.routes.campaigns import (
_campaign_content_library_item,
_campaign_content_required_fields,
)
from govoplan_campaign.backend.schemas import CampaignContentLibrarySaveRequest
from govoplan_core.core.templates import (
TemplateContentDraftRequest,
TemplateRef,
TemplateRevisionRef,
)
class _Catalog:
def __init__(self, template: TemplateRef) -> None:
self.template = template
self.calls: list[dict[str, object]] = []
def list_templates(self, session, principal, **kwargs):
del session, principal
self.calls.append(kwargs)
return (self.template,)
def get_template(self, session, principal, **kwargs):
del session, principal, kwargs
return self.template
def check_compatibility(self, session, principal, **kwargs):
del session, principal, kwargs
raise AssertionError("Compatibility is not needed for raw reusable content.")
class _ContentLibrary:
def __init__(self, template: TemplateRef) -> None:
self.template = template
self.request: TemplateContentDraftRequest | None = None
def create_content_draft(self, session, principal, *, request):
del session, principal
self.request = request
return self.template
def _template_ref() -> TemplateRef:
revision = TemplateRevisionRef(
id="revision-1",
template_id="template-1",
revision=3,
definition_hash="a" * 64,
template_type="content_fragment",
usages=("campaign.content",),
locale="de",
required_fields=(),
output_profiles=(),
content_text="Mit freundlichen Grussen",
metadata={
"campaign_kind": "fragment",
"campaign_targets": ["subject"],
},
published_at=datetime.now(tz=UTC),
)
return TemplateRef(
id="template-1",
tenant_id="tenant-1",
name="Closing",
slug="closing",
template_type="content_fragment",
status="published",
current_revision=3,
current_revision_id=revision.id,
published_revision_id=revision.id,
revision=revision,
)
def test_campaign_content_integration_filters_usage_and_delegates_draft_creation() -> None:
template = _template_ref()
catalog = _Catalog(template)
writer = _ContentLibrary(template)
integration = TemplatesCampaignIntegration(catalog, None, writer)
listed = integration.list_content_templates(object(), object(), query="close")
request = TemplateContentDraftRequest(
name="Closing",
template_type="content_fragment",
usages=("campaign.content",),
content_text="Regards",
)
created = integration.create_content_draft(
object(),
object(),
request=request,
)
assert listed == (template,)
assert catalog.calls == [
{"query": "close", "usage": "campaign.content", "limit": 100}
]
assert writer.request is request
assert created is template
def test_campaign_content_payload_keeps_revision_and_declared_target() -> None:
payload = _campaign_content_library_item(_template_ref())
assert payload["kind"] == "fragment"
assert payload["targets"] == ["subject"]
assert payload["text"] == "Mit freundlichen Grussen"
assert payload["published"] is True
assert payload["revision"] == 3
assert payload["required_fields"] == []
def test_campaign_content_required_fields_are_normalized_and_deduplicated() -> None:
payload = CampaignContentLibrarySaveRequest(
name="Greeting",
kind="campaign_part",
subject="Hello {{ local:display_name }}",
text="Reference ${global.case_reference}; hello {{local::display_name}}",
html="<p>{{fields.salutation}} {{local:to[2].email}} {{unsupported namespace}}</p>",
)
requirements = _campaign_content_required_fields(payload)
assert [item.path for item in requirements] == [
"global.case_reference",
"local.display_name",
"local.to.2.email",
"salutation",
]
def test_content_save_request_rejects_empty_selected_fragment() -> None:
try:
CampaignContentLibrarySaveRequest(
name="Empty",
kind="fragment",
target="html",
text="Only text",
)
except ValueError as exc:
assert "selected fragment field is empty" in str(exc)
else:
raise AssertionError("Expected empty target validation to fail")
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from unittest.mock import Mock
import pytest
from govoplan_core.core.recovery import RecoveryStatus
from govoplan_campaign.backend.sending import jobs
@pytest.mark.parametrize(
("outcome", "expected_method", "expected_status"),
[
("succeeded", "succeed", None),
("failed", "reject", None),
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
],
)
def test_delivery_recovery_maps_verified_job_state(
monkeypatch,
outcome: str,
expected_method: str,
expected_status: RecoveryStatus | None,
) -> None:
evidence = {
"verified": outcome != "recovery_required",
"checks": {"job_state_reloaded": True},
"send_status": outcome,
}
monkeypatch.setattr(
jobs,
"_job_delivery_recovery_evidence",
lambda _job_id: (outcome, evidence),
)
operation = Mock()
jobs._finish_job_delivery_recovery(operation, job_id="job-1")
method = getattr(operation, expected_method)
method.assert_called_once()
if expected_status is not None:
assert method.call_args.kwargs["status"] == expected_status
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
getattr(operation, other).assert_not_called()
@pytest.mark.parametrize(
("outcome", "expected_method", "expected_status"),
[
("succeeded", "succeed", None),
("failed", "reject", None),
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
],
)
def test_imap_recovery_maps_verified_append_state(
monkeypatch,
outcome: str,
expected_method: str,
expected_status: RecoveryStatus | None,
) -> None:
evidence = {
"verified": outcome != "recovery_required",
"checks": {"job_state_reloaded": True},
"imap_status": outcome,
}
monkeypatch.setattr(
jobs,
"_imap_recovery_evidence",
lambda _job_id: (outcome, evidence),
)
operation = Mock()
jobs._finish_imap_append_recovery(operation, job_id="job-1")
method = getattr(operation, expected_method)
method.assert_called_once()
if expected_status is not None:
assert method.call_args.kwargs["status"] == expected_status
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
getattr(operation, other).assert_not_called()
@pytest.mark.parametrize(
("outcome", "expected_method", "expected_status"),
[
("succeeded", "succeed", None),
("failed", "reject", None),
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
],
)
def test_single_action_recovery_maps_verified_action_state(
monkeypatch,
outcome: str,
expected_method: str,
expected_status: RecoveryStatus | None,
) -> None:
evidence = {
"verified": outcome != "recovery_required",
"checks": {"action_state_reloaded": True},
"action_status": outcome,
}
monkeypatch.setattr(
jobs,
"_single_action_recovery_evidence",
lambda _action_id: (outcome, evidence),
)
operation = Mock()
jobs._finish_single_action_delivery_recovery(operation, action_id="action-1")
method = getattr(operation, expected_method)
method.assert_called_once()
if expected_status is not None:
assert method.call_args.kwargs["status"] == expected_status
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
getattr(operation, other).assert_not_called()
+422
View File
@@ -0,0 +1,422 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_policy
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignSchedule,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.routes.campaigns import (
_campaign_copy_configuration,
archive_campaign_version,
copy_campaign,
delete_draft_campaign,
)
from govoplan_campaign.backend.schemas import (
CampaignCopyRequest,
CampaignLifecycleMutationRequest,
)
from govoplan_core.db.base import Base
from govoplan_core.core.change_sequence import ChangeSequenceEntry
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="user-1")
def __init__(self, *scopes: str) -> None:
self.scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self.scopes or "tenant:*" in self.scopes
class CampaignLifecycleTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
access_users = Base.metadata.tables.get("access_users")
if access_users is None:
access_users = Table(
"access_users",
Base.metadata,
Column("id", String(36), primary_key=True),
)
access_groups = Base.metadata.tables.get("access_groups")
if access_groups is None:
access_groups = Table(
"access_groups",
Base.metadata,
Column("id", String(36), primary_key=True),
)
Base.metadata.create_all(
self.engine,
tables=[
access_users,
access_groups,
Campaign.__table__,
CampaignVersion.__table__,
CampaignShare.__table__,
CampaignJob.__table__,
CampaignSchedule.__table__,
ChangeSequenceEntry.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
user_values = {"id": "user-1"}
if "tenant_id" in access_users.c:
user_values.update(
tenant_id="tenant-1",
account_id="account-1",
email="user-1@example.test",
)
session.execute(access_users.insert().values(**user_values))
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
created_by_user_id="user-1",
owner_user_id="user-1",
external_id="campaign-1",
name="Campaign",
status="draft",
current_version_id="version-2",
)
historical = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
workflow_state="completed",
raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}},
)
current = CampaignVersion(
id="version-2",
campaign_id=campaign.id,
version_number=2,
workflow_state="editing",
raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}},
)
session.add_all((campaign, historical, current))
session.commit()
self.principal = _Principal(
"campaigns:campaign:read",
"campaigns:campaign:copy",
"campaigns:campaign:archive",
"campaigns:campaign:delete",
"campaigns:recipient:read",
)
self.addCleanup(self.engine.dispose)
def _policy(self, session: Session, version_id: str | None = None):
campaign = session.get(Campaign, "campaign-1")
assert campaign is not None
return campaign_lifecycle_policy(
session,
campaign=campaign,
principal=self.principal,
version_id=version_id,
)
def test_policy_explains_retained_evidence_and_changes_token(self) -> None:
with self.SessionLocal() as session:
initial = self._policy(session, "version-1")
self.assertTrue(initial["actions"]["delete_campaign"]["allowed"])
self.assertTrue(initial["actions"]["archive_version"]["allowed"])
current = session.get(CampaignVersion, "version-2")
assert current is not None
current.published_at = datetime.now(UTC)
session.commit()
changed = self._policy(session, "version-1")
self.assertNotEqual(initial["state_token"], changed["state_token"])
self.assertFalse(changed["actions"]["delete_campaign"]["allowed"])
self.assertIn("Audit-relevant", changed["actions"]["delete_campaign"]["reason"])
def test_active_delivery_blocks_campaign_archival(self) -> None:
with self.SessionLocal() as session:
session.add(
CampaignJob(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-2",
entry_index=0,
queue_status="sending",
send_status="sending",
)
)
session.commit()
policy = self._policy(session)
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"])
def test_schedule_evidence_blocks_destructive_lifecycle_actions(self) -> None:
with self.SessionLocal() as session:
session.add(
CampaignSchedule(
id="schedule-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
source_version_id="version-2",
created_by_user_id="user-1",
name="Recurring draft",
recurrence_kind="daily",
interval_count=1,
timezone="UTC",
starts_at=datetime(2026, 8, 8, tzinfo=UTC),
next_fire_at=datetime(2026, 8, 8, tzinfo=UTC),
max_occurrences=2,
copy_options={},
source_snapshot={"schema": "test"},
source_snapshot_hash="a" * 64,
)
)
session.commit()
active_policy = self._policy(session)
self.assertFalse(active_policy["actions"]["archive_campaign"]["allowed"])
self.assertIn(
"Pause active",
active_policy["actions"]["archive_campaign"]["reason"],
)
self.assertFalse(active_policy["actions"]["delete_campaign"]["allowed"])
self.assertIn(
"schedule evidence",
active_policy["actions"]["delete_campaign"]["reason"],
)
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.active = False
schedule.resource_revision += 1
session.commit()
paused_policy = self._policy(session)
self.assertTrue(paused_policy["actions"]["archive_campaign"]["allowed"])
self.assertFalse(paused_policy["actions"]["delete_campaign"]["allowed"])
def test_stale_delete_token_is_rejected(self) -> None:
with self.SessionLocal() as session:
policy = self._policy(session)
campaign = session.get(Campaign, "campaign-1")
assert campaign is not None
campaign.name = "Changed elsewhere"
session.commit()
with self.assertRaises(HTTPException) as raised:
delete_draft_campaign(
"campaign-1",
CampaignLifecycleMutationRequest(
expected_state_token=policy["state_token"],
),
session=session,
principal=self.principal,
)
self.assertEqual(raised.exception.status_code, 409)
self.assertIn("state changed", str(raised.exception.detail))
def test_historical_archival_preserves_version_state_and_content(self) -> None:
with self.SessionLocal() as session:
policy = self._policy(session, "version-1")
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
active_session.commit()
with patch(
"govoplan_campaign.backend.routes.campaigns.audit_from_principal",
side_effect=commit_audit,
):
response = archive_campaign_version(
"campaign-1",
"version-1",
CampaignLifecycleMutationRequest(
expected_state_token=policy["state_token"],
),
session=session,
principal=self.principal,
)
self.assertIsNotNone(response.archived_at)
self.assertEqual(response.archived_by_user_id, "user-1")
version = session.get(CampaignVersion, "version-1")
assert version is not None
self.assertEqual(version.workflow_state, "completed")
self.assertEqual(version.raw_json["campaign"]["name"], "Campaign")
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
with self.SessionLocal() as session:
source_campaign = session.get(Campaign, "campaign-1")
assert source_campaign is not None
source_campaign.settings = {"retention": "source-policy"}
source_campaign.mail_profile_policy = {"profile_id": "mail-profile-1"}
session.add_all(
(
CampaignShare(
id="share-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
target_type="user",
target_id="user-1",
permission="read",
),
CampaignJob(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-2",
entry_index=0,
queue_status="cancelled",
send_status="cancelled",
),
)
)
session.commit()
policy = self._policy(session, "version-2")
captured: dict[str, object] = {}
def create_copy(active_session: Session, **kwargs):
raw_json = kwargs["raw_json"]
captured["raw_json"] = raw_json
destination = Campaign(
id="campaign-copy",
tenant_id="tenant-1",
created_by_user_id="user-1",
owner_user_id="user-1",
external_id=raw_json["campaign"]["id"],
name=raw_json["campaign"]["name"],
status="draft",
current_version_id="version-copy",
)
version = CampaignVersion(
id="version-copy",
campaign_id=destination.id,
version_number=1,
raw_json=raw_json,
)
active_session.add_all((destination, version))
active_session.flush()
return destination, version
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
active_session.commit()
with (
patch(
"govoplan_campaign.backend.routes.campaigns.create_campaign_version_from_json",
side_effect=create_copy,
),
patch(
"govoplan_campaign.backend.routes.campaigns.audit_from_principal",
side_effect=commit_audit,
),
):
response = copy_campaign(
"campaign-1",
CampaignCopyRequest(
source_version_id="version-2",
expected_state_token=policy["state_token"],
include_policies=False,
include_mail_profile=True,
),
session=session,
principal=self.principal,
)
self.assertEqual(response.campaign.external_id, "campaign-1-copy")
self.assertEqual(response.campaign.owner_user_id, "user-1")
self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft")
copied_campaign = session.get(Campaign, "campaign-copy")
assert copied_campaign is not None
self.assertEqual({}, copied_campaign.settings)
self.assertEqual(
{"profile_id": "mail-profile-1"},
copied_campaign.mail_profile_policy,
)
self.assertEqual(
session.query(CampaignJob)
.filter(CampaignJob.campaign_id == "campaign-copy")
.count(),
0,
)
self.assertEqual(
session.query(CampaignShare)
.filter(CampaignShare.campaign_id == "campaign-copy")
.count(),
0,
)
def test_copy_choices_reset_only_selected_configuration_domains(self) -> None:
source = {
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign"},
"fields": [{"name": "case_id"}],
"global_values": {"sender": "Office"},
"recipients": {"to": [{"email": "team@example.test"}]},
"entries": {
"inline": [
{
"id": "one",
"to": [{"email": "person@example.test"}],
"attachments": [{"base_dir": ".", "file_filter": "one.pdf"}],
}
]
},
"template": {"subject": "Hello", "text": "Body"},
"attachments": {
"global": [{"base_dir": ".", "file_filter": "global.pdf"}]
},
"validation_policy": {"missing_required_attachment": "warn"},
"server": {"mail_profile_id": "profile-1"},
"delivery": {"rate_limit": {"messages_per_minute": 20}},
}
copied = _campaign_copy_configuration(
source,
CampaignCopyRequest(
source_version_id="version-2",
expected_state_token="a" * 64,
include_recipients=True,
include_files=False,
include_policies=False,
include_mail_profile=False,
),
)
self.assertEqual(source["attachments"]["global"][0]["file_filter"], "global.pdf")
self.assertEqual({}, copied["attachments"])
self.assertEqual([], copied["entries"]["inline"][0]["attachments"])
self.assertEqual("person@example.test", copied["entries"]["inline"][0]["to"][0]["email"])
self.assertEqual({}, copied["validation_policy"])
self.assertEqual({}, copied["server"])
self.assertEqual(20, copied["delivery"]["rate_limit"]["messages_per_minute"])
def test_copy_without_recipient_permission_is_allowed_when_recipient_data_is_excluded(self) -> None:
principal = _Principal("campaigns:campaign:read", "campaigns:campaign:copy")
with self.SessionLocal() as session:
campaign = session.get(Campaign, "campaign-1")
assert campaign is not None
policy = campaign_lifecycle_policy(
session,
campaign=campaign,
principal=principal,
version_id="version-2",
)
self.assertTrue(policy["actions"]["copy_campaign"]["allowed"])
if __name__ == "__main__":
unittest.main()

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