Compare commits

...
27 Commits
Author SHA1 Message Date
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
162 changed files with 32414 additions and 8408 deletions
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Campaign Codex Guide # 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 ## 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`. 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`.
+24 -3
View File
@@ -15,12 +15,27 @@ This repository owns:
- campaign/version/job/issue/send-attempt/append-attempt models and migrations - campaign/version/job/issue/send-attempt/append-attempt models and migrations
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths - campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
- WebUI package `@govoplan/campaign-webui` - WebUI package `@govoplan/campaign-webui`
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, 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 Core owns the auth facade, RBAC/capability contracts, database/session
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
optional platform module for tenant administration and tenant resolver behavior. optional platform module for tenant administration and tenant resolver behavior.
Files and mail own their respective storage and transport capabilities. 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, and retention keeps database references when object
deletion fails so cleanup can be retried.
## Dependencies ## Dependencies
@@ -28,10 +43,16 @@ The module has one required runtime dependency:
- `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration - `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-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-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.
Public campaign, version, job, and report responses expose business data and Public campaign, version, job, and report responses expose business data and
delivery evidence, but never process-local paths, storage-backend keys, or delivery evidence, but never process-local paths, storage-backend keys, or
@@ -94,7 +115,7 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives. - [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist. - [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
- Immediate delivery is bounded to 25 exact eligible recipient jobs by default. Deployments may set `GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0500), and tenants may narrow that ceiling through `campaign_delivery_policy.synchronous_send_max_recipients` in tenant settings. - Immediate delivery is bounded to 25 exact eligible recipient jobs by default. Deployments may set `GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0500), and tenants may narrow that ceiling through `campaign_delivery_policy.synchronous_send_max_recipients` in tenant settings.
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested. - Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path. - [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence. - [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
- [Recipient and address boundary](docs/RECIPIENT_ADDRESS_BOUNDARY.md) defines the split between campaign-local recipients and future reusable address management. - [Recipient and address boundary](docs/RECIPIENT_ADDRESS_BOUNDARY.md) defines the split between campaign-local recipients and future reusable address management.
+39
View File
@@ -0,0 +1,39 @@
# Campaign Accessibility Review
The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form,
alert, and segmented-control components. Core owns focus trapping, focus return,
Escape handling, labels, disabled state, and keyboard behavior for those 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.
## 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.
+40
View File
@@ -0,0 +1,40 @@
# 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
## Planned Slices
1. Recipient rows and imported recipient-source snapshots
2. Attachment bindings and frozen attachment resolutions
3. Validation issues, review decisions, and attachment-policy overrides
4. Delivery attempts, IMAP append attempts, Postbox attempts, and
reconciliation decisions
5. Campaign shares and ownership-transfer records
6. Import mapping profiles and import executions
7. Reusable Campaign templates and template revisions when the template
library becomes persistent
8. Export packages and protocol/report artifacts
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.
+26
View File
@@ -0,0 +1,26 @@
# 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 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. The orphan inventory
reconciler tracked in Campaign issue 91 will automate that bounded inspection.
No recovery checkpoint contains message bodies, recipients, credentials, or
resolved provider secrets. Object keys remain restricted diagnostics rather
than Campaign business data.
+23
View File
@@ -134,6 +134,29 @@ production process manager. Repeat the worker-loss drill under the selected
systemd, container, Kubernetes, or other production supervisor and the target systemd, container, Kubernetes, or other production supervisor and the target
Redis/SMTP infrastructure before deployment approval. 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 clears metadata only after object deletion succeeds; an unavailable
backend leaves the reference in place for a later retry.
- A hard process loss between object creation and metadata commit can leave an
orphan object. Reconcile only within the Campaign build prefix and verify that
no job references the object before deleting it.
- 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 ## Reporting Checks
- Partial delivery must show accepted, failed, and unknown counts separately. - Partial delivery must show accepted, failed, and unknown counts separately.
+99 -12
View File
@@ -30,12 +30,13 @@ The shorter task documents remain useful companions:
## What Campaign is for ## What Campaign is for
Campaign turns governed source data into individually built messages and then Campaign turns governed source data into individually built messages or
controls their review, delivery, and evidence. It is intentionally a printable output and then controls their review, delivery, and evidence. It is
composition module: it demonstrates how one user journey can use optional Mail, intentionally a composition module: it demonstrates how one user journey can
Files, Addresses, and Notifications capabilities alongside Core access/audit use optional Mail, Files, Addresses, Distribution Lists, Templates, Postbox,
infrastructure without copying ownership from those modules. Policy may consume and Notifications capabilities alongside Core access/audit infrastructure
Campaign context through a narrow capability; Campaign does not import Policy. without copying ownership from those modules. Policy may consume Campaign
context through a narrow capability; Campaign does not import Policy.
Campaign owns: Campaign owns:
@@ -66,9 +67,9 @@ The supported process is a controlled progression, not a single "send" call:
create/edit create/edit
-> validate and resolve policy/integrations -> validate and resolve policy/integrations
-> review warnings and blockers -> review warnings and blockers
-> build exact recipient messages -> build exact recipient messages and/or printable artifacts
-> complete review and queue -> complete review and queue
-> SMTP attempt per job -> selected Mail, Postbox, or print effect per job
-> optional IMAP append per accepted job -> optional IMAP append per accepted job
-> report, retry, reconcile, or correct -> report, retry, reconcile, or correct
-> archive when no active/uncertain delivery remains -> archive when no active/uncertain delivery remains
@@ -135,9 +136,23 @@ just the authoring form:
5. Confirm attachment behavior when a rule matches no files, ZIP/password 5. Confirm attachment behavior when a rule matches no files, ZIP/password
behavior, and any recipient-specific files. behavior, and any recipient-specific files.
6. Record review completion and the inspected message keys through the review 6. Record review completion and the inspected message keys through the review
surface. The current baseline does not persist a distinct approve/reject surface. Validation, build, review, and exception evidence records the actor,
decision or review reason. If content, recipients, attachment inputs, owner timestamp, and immutable build token/message digest where applicable. If
context, or non-secret transport identity changes, revalidate and rebuild. content, recipients, attachment inputs, owner context, or non-secret
transport identity changes, revalidate and rebuild.
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 Normal readers and reviewers see business state and safe evidence. Process-local
paths, storage keys, worker claim tokens, and raw provider diagnostics require paths, storage keys, worker claim tokens, and raw provider diagnostics require
@@ -153,6 +168,24 @@ every action control that the actor lacks. The server authorizes each action,
but permission-aware action visibility on that detailed surface remains open but permission-aware action visibility on that detailed surface remains open
work; do not confuse it with the aggregate reader experience. 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 ### Deliver and resolve outcomes
Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed
@@ -380,6 +413,8 @@ Current principal contracts include:
| `files.campaign_attachments` 0.1.x | Files -> Campaign | Select/materialize governed file versions and preserve campaign usage/evidence | | `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.lookup` 0.1.x | Addresses -> Campaign | Optional address suggestions |
| `addresses.recipient_source` 0.1.x | Addresses -> Campaign | Optional versioned recipient-source snapshots | | `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.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.mail_policy_context` 0.1.x | Campaign -> Mail | Resolve campaign tenant/owner context for Mail policy |
| `campaigns.delivery_tasks` 0.1.x | Campaign -> workers | Execute narrow queued send/append tasks | | `campaigns.delivery_tasks` 0.1.x | Campaign -> workers | Execute narrow queued send/append tasks |
@@ -389,6 +424,58 @@ Breaking payload or ownership changes require an interface-version bump and a
release-composition alignment gate. Optional absence must be tested physically, release-composition alignment gate. Optional absence must be tested physically,
not only hidden in navigation. not only hidden in navigation.
### 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 ### External API expectations
- Tenant and campaign access are evaluated for every operation. - Tenant and campaign access are evaluated for every operation.
@@ -469,7 +556,7 @@ the current baseline:
- the final audited **test / single send / single resend** semantics; - the final audited **test / single send / single resend** semantics;
- reusable SMTP batch sessions and their measured throughput benefit; - reusable SMTP batch sessions and their measured throughput benefit;
- durable, idempotent Campaign report delivery through a Mail-owned outbox - durable, idempotent Campaign report delivery through a Mail-owned outbox
([`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17)); ([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17));
- a fully packaged one-command Campaign reference composition with production - a fully packaged one-command Campaign reference composition with production
policy presets and target-provider certification; policy presets and target-provider certification;
- function-bound Postbox delivery (stage 2 of the reference program); - function-bound Postbox delivery (stage 2 of the reference program);
+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 durable job/effect model through a direct SMTP call. Re-enabling it requires the
Mail-owned idempotent outbox, attempt, unknown-outcome, and reconciliation path Mail-owned idempotent outbox, attempt, unknown-outcome, and reconciliation path
tracked in tracked in
[`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17). [`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17).
Report generation and dry-run validation remain separate from an external Report generation and dry-run validation remain separate from an external
effect; recipient-level exports require recipient-export authorization. effect; recipient-level exports require recipient-export authorization.
+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 The user still chooses append or replace; campaign should not silently rewrite
recipient rows. 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 ## Non-Goals For Campaign
Campaign should not become the global address book. It should not own: Campaign should not become the global address book. It should not own:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.11", "version": "0.1.12",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-campaign" name = "govoplan-campaign"
version = "0.1.11" version = "0.1.12"
description = "GovOPlaN campaigns module with backend and WebUI integration." description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.14",
"jsonschema>=4,<5", "jsonschema>=4,<5",
"pydantic>=2,<3", "pydantic>=2,<3",
"SQLAlchemy>=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"]
@@ -53,6 +53,17 @@ class AttachmentIssue(BaseModel):
code: str code: str
message: str message: str
behavior: Behavior | None = None 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): class ResolvedAttachment(BaseModel):
@@ -82,6 +93,7 @@ class ResolvedAttachment(BaseModel):
zip_entry_names: list[str] = Field(default_factory=list) zip_entry_names: list[str] = Field(default_factory=list)
status: AttachmentMatchStatus status: AttachmentMatchStatus
behavior: Behavior | None = None behavior: Behavior | None = None
missing_policy: AttachmentPolicyDecision | None = None
matches: list[str] = Field(default_factory=list) matches: list[str] = Field(default_factory=list)
issues: list[AttachmentIssue] = 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 "*?[") 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: if config.missing_behavior is not None:
return config.missing_behavior candidates.append(config.missing_behavior)
if config.required: configured = max(
return campaign_config.validation_policy.missing_required_attachment candidates,
return campaign_config.validation_policy.missing_optional_attachment 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: 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 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" 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( return AttachmentIssue(
severity=severity, severity=severity,
code=code, code=code,
message=f"No file matched attachment filter {config.file_filter!r}", message=f"No file matched attachment filter {config.file_filter!r}",
behavior=behavior, 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: def effective_send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
return config.attachments.send_without_attachments_behavior or ( configured = config.attachments.send_without_attachments_behavior or (
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK 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: 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", code="missing_attachment_coverage",
message=messages.get(behavior, "No attachment file was resolved for this message."), message=messages.get(behavior, "No attachment file was resolved for this message."),
behavior=behavior, behavior=behavior,
details={"effective_behavior": behavior.value},
) )
@@ -422,24 +479,13 @@ def _resolve_one_config(
behavior: Behavior | None = None behavior: Behavior | None = None
managed_source = selected_base_path is not None and is_managed_source(selected_base_path.source) managed_source = selected_base_path is not None and is_managed_source(selected_base_path.source)
unsafe_managed_path = False unsafe_managed_path = False
if managed_source: resolution_failed = False
try: try:
if managed_source:
assert_logical_relative_path( assert_logical_relative_path(
rendered_file_filter, rendered_file_filter,
field="rendered managed attachment 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 = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
matches, rejected = _confine_managed_matches(directory, matches) matches, rejected = _confine_managed_matches(directory, matches)
if rejected: if rejected:
@@ -455,14 +501,42 @@ def _resolve_one_config(
) )
else: else:
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index) 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: if unsafe_managed_path:
status = AttachmentMatchStatus.MISSING status = AttachmentMatchStatus.MISSING
behavior = Behavior.BLOCK behavior = Behavior.BLOCK
elif resolution_failed:
status = AttachmentMatchStatus.MISSING
behavior = Behavior.BLOCK
elif not matches: elif not matches:
status = AttachmentMatchStatus.MISSING status = AttachmentMatchStatus.MISSING
behavior = _missing_behavior(campaign_config, config) missing_policy = _missing_policy_decision(campaign_config, config)
issues.append(_issue_for_missing(config, behavior)) behavior = missing_policy.effective_behavior
issues.append(_issue_for_missing(config, missing_policy))
elif len(matches) > 1 and not allow_multiple: elif len(matches) > 1 and not allow_multiple:
status = AttachmentMatchStatus.AMBIGUOUS status = AttachmentMatchStatus.AMBIGUOUS
behavior = _ambiguous_behavior(campaign_config, config) behavior = _ambiguous_behavior(campaign_config, config)
@@ -494,6 +568,7 @@ def _resolve_one_config(
zip_entry_name_template=config.zip_entry_name_template, zip_entry_name_template=config.zip_entry_name_template,
status=status, status=status,
behavior=behavior, behavior=behavior,
missing_policy=missing_policy,
matches=[str(path) for path in matches], matches=[str(path) for path in matches],
issues=issues, issues=issues,
) )
@@ -540,7 +615,7 @@ def resolve_entry_attachments(
) )
issues = [issue for item in resolved for issue in item.issues] issues = [issue for item in resolved for issue in item.issues]
missing_coverage_behavior = _send_without_attachments_behavior(config) missing_coverage_behavior = effective_send_without_attachments_behavior(config)
if ( if (
entry.active entry.active
and resolved and resolved
@@ -44,6 +44,7 @@ def _parse_scalar_for_target(target: str, value: Any) -> Any:
"merge_reply_to", "merge_reply_to",
"merge_bounce_to", "merge_bounce_to",
"merge_disposition_notification_to", "merge_disposition_notification_to",
"merge_postbox_targets",
"combine_to", "combine_to",
"combine_cc", "combine_cc",
"combine_bcc", "combine_bcc",
@@ -4,8 +4,18 @@ import copy
from typing import Any from typing import Any
CAMPAIGN_MAIL_SERVER_KEYS = frozenset({"mail_profile_id"}) CAMPAIGN_MAIL_SERVER_KEYS = frozenset(
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"}) {
"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_OPT_IN_KEYS = frozenset(
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"} {"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
) )
@@ -14,18 +24,29 @@ CAMPAIGN_REVIEW_STATE_KEYS = frozenset(
"build_token", "build_token",
"inspection_complete", "inspection_complete",
"reviewed_message_keys", "reviewed_message_keys",
"issue_decisions",
"updated_at", "updated_at",
"updated_by_user_id", "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): class CampaignMailProfileBoundaryError(ValueError):
"""Raised when campaign JSON owns mail transport configuration. """Raised when campaign JSON owns mail transport configuration.
SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON
may select one Mail-owned profile, but it must never copy or override that may select Mail-owned profile, server, and credential identifiers, but it
profile's transport configuration. must never copy or override transport configuration.
""" """
@@ -85,10 +106,13 @@ def validate_campaign_editor_state(
if value is None: if value is None:
return {} return {}
if not isinstance(value, dict): 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) allowed = set(CAMPAIGN_CLIENT_EDITOR_STATE_KEYS)
if allow_server_review_state: if allow_server_review_state:
allowed.add("review_send") allowed.add("review_send")
allowed.add("approval_gate")
if any(key not in allowed for key in value): if any(key not in allowed for key in value):
raise CampaignMailProfileBoundaryError( raise CampaignMailProfileBoundaryError(
"Campaign editor state contains unsupported or transport-owned fields" "Campaign editor state contains unsupported or transport-owned fields"
@@ -104,11 +128,13 @@ def validate_campaign_editor_state(
if "opt_ins" in value: if "opt_ins" in value:
result["opt_ins"] = _validated_opt_ins(value["opt_ins"]) result["opt_ins"] = _validated_opt_ins(value["opt_ins"])
if "field_overrides" in value: if "field_overrides" in value:
result["field_overrides"] = _validated_field_overrides( result["field_overrides"] = _validated_field_overrides(value["field_overrides"])
value["field_overrides"]
)
if "review_send" in value: if "review_send" in value:
result["review_send"] = _validated_server_review_state(value["review_send"]) 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 return result
@@ -134,9 +160,32 @@ def public_campaign_editor_state(
review_state = _validated_server_review_state(value["review_send"]) review_state = _validated_server_review_state(value["review_send"])
if not include_diagnostics: if not include_diagnostics:
review_state.pop("build_token", None) 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 result["review_send"] = review_state
except CampaignMailProfileBoundaryError: except CampaignMailProfileBoundaryError:
pass pass
if "approval_gate" in value:
try:
result["approval_gate"] = _validated_server_approval_gate(
value["approval_gate"]
)
except CampaignMailProfileBoundaryError:
pass
return result return result
@@ -145,9 +194,48 @@ def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
state = public_campaign_editor_state(value) state = public_campaign_editor_state(value)
state.pop("review_send", None) state.pop("review_send", None)
state.pop("approval_gate", None)
return state 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: def _is_valid_reviewed_message_key(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip()) and len(value) <= 512 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 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]: def _validated_server_review_state(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or any( if not isinstance(value, dict) or any(
key not in CAMPAIGN_REVIEW_STATE_KEYS for key in value 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") build_token = value.get("build_token")
inspected = value.get("inspection_complete") inspected = value.get("inspection_complete")
keys = value.get("reviewed_message_keys", []) keys = value.get("reviewed_message_keys", [])
issue_decisions = value.get("issue_decisions", [])
updated_at = value.get("updated_at") updated_at = value.get("updated_at")
updated_by = value.get("updated_by_user_id") updated_by = value.get("updated_by_user_id")
validated_build_token = _validated_required_string( 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" "Campaign review completion state is invalid"
) )
validated_keys = _validated_reviewed_message_keys(keys) validated_keys = _validated_reviewed_message_keys(keys)
validated_decisions = _validated_issue_decisions(issue_decisions)
validated_updated_at = _validated_required_string( validated_updated_at = _validated_required_string(
updated_at, updated_at,
max_length=128, max_length=128,
@@ -202,6 +384,7 @@ def _validated_server_review_state(value: Any) -> dict[str, Any]:
"build_token": validated_build_token, "build_token": validated_build_token,
"inspection_complete": inspected, "inspection_complete": inspected,
"reviewed_message_keys": validated_keys, "reviewed_message_keys": validated_keys,
"issue_decisions": validated_decisions,
"updated_at": validated_updated_at, "updated_at": validated_updated_at,
"updated_by_user_id": validated_updated_by, "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 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 server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict): if not isinstance(server, dict):
return () return ()
violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS] violations = [
if "mail_profile_id" in server: f"/server/{key}"
profile_id = server["mail_profile_id"] for key in sorted(server)
if not isinstance(profile_id, str) or not profile_id.strip(): 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") violations.append("/server/mail_profile_id")
for protocol in ("smtp", "imap"):
if (
references[f"{protocol}_credential_id"]
and not references[f"{protocol}_server_id"]
):
violations.append(f"/server/{protocol}_server_id")
return tuple(violations) return tuple(violations)
@@ -240,9 +464,9 @@ def assert_campaign_uses_mail_profile_reference(
if violations: if violations:
fields = ", ".join(violations) fields = ", ".join(violations)
raise CampaignMailProfileBoundaryError( raise CampaignMailProfileBoundaryError(
"Campaign JSON may only reference a Mail-module profile through " "Campaign JSON may only reference Mail-owned profiles, servers, and credentials; "
f"server.mail_profile_id; remove campaign-local SMTP/IMAP settings ({fields}), " f"remove campaign-local SMTP/IMAP settings or invalid references ({fields}), "
"select an authorized Mail profile, and save a new campaign version." "select authorized Mail resources, and save a new campaign version."
) )
if require_profile and campaign_mail_profile_id(raw_json) is None: if require_profile and campaign_mail_profile_id(raw_json) is None:
raise CampaignMailProfileBoundaryError( raise CampaignMailProfileBoundaryError(
@@ -254,5 +478,8 @@ def assert_campaign_uses_mail_profile_reference(
def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]: def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]:
"""Return the complete public/persisted Campaign-to-Mail contract.""" """Return the complete public/persisted Campaign-to-Mail contract."""
profile_id = campaign_mail_profile_id(raw_json) return {
return {"mail_profile_id": profile_id} if profile_id else {} key: value
for key, value in campaign_mail_resource_ids(raw_json).items()
if value
}
@@ -23,6 +23,8 @@ class FieldType(StrEnum):
DOUBLE = "double" DOUBLE = "double"
DATE = "date" DATE = "date"
PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary. PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary.
ORGANIZATION_UNIT = "organization_unit"
ORGANIZATION_FUNCTION = "organization_function"
class RecipientType(StrEnum): class RecipientType(StrEnum):
@@ -92,6 +94,131 @@ class SendStatus(StrEnum):
SKIPPED = "skipped" 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): class CampaignMeta(StrictModel):
id: str id: str
name: str name: str
@@ -114,6 +241,10 @@ class MailProfileCapabilities(StrictModel):
class ServerConfig(StrictModel): class ServerConfig(StrictModel):
mail_profile_id: str | None = None mail_profile_id: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
imap_server_id: str | None = None
imap_credential_id: str | None = None
profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities) profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities)
@@ -376,7 +507,7 @@ class AttachmentsConfig(StrictModel):
send_without_attachments_behavior: Behavior | None = None send_without_attachments_behavior: Behavior | None = None
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig) zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global") global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
missing_behavior: Behavior = Behavior.ASK missing_behavior: Behavior = Behavior.WARN
ambiguous_behavior: Behavior = Behavior.ASK ambiguous_behavior: Behavior = Behavior.ASK
@model_validator(mode="after") @model_validator(mode="after")
@@ -446,10 +577,22 @@ class EntryConfig(StrictModel):
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list) disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
merge_disposition_notification_to: bool = True merge_disposition_notification_to: bool = True
channel_policy: DeliveryChannelPolicy | None = None
postbox_targets: list[PostboxTargetConfig] = Field(
default_factory=list,
max_length=50,
)
merge_postbox_targets: bool = True
print_target: PrintTargetConfig | None = None
attachments: list[AttachmentConfig] = Field(default_factory=list) attachments: list[AttachmentConfig] = Field(default_factory=list)
combine_attachments: bool = True combine_attachments: bool = True
fields: dict[str, Any] = Field(default_factory=dict) 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 last_sent: str | None = None
@@ -465,7 +608,7 @@ class ImportProvenance(StrictModel):
id: str id: str
imported_at: str imported_at: str
mode: Literal["append", "replace"] 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_id: str | None = None
source_label: str | None = None source_label: str | None = None
source_revision: str | None = None source_revision: str | None = None
@@ -527,7 +670,7 @@ class EntriesConfig(StrictModel):
class ValidationPolicy(StrictModel): class ValidationPolicy(StrictModel):
missing_required_attachment: Behavior = Behavior.ASK missing_required_attachment: Behavior = Behavior.BLOCK
missing_optional_attachment: Behavior = Behavior.WARN missing_optional_attachment: Behavior = Behavior.WARN
ambiguous_attachment_match: Behavior = Behavior.ASK ambiguous_attachment_match: Behavior = Behavior.ASK
ignore_empty_fields: bool = False ignore_empty_fields: bool = False
@@ -559,7 +702,43 @@ class RetryConfig(StrictModel):
return values return values
class PostboxDeliveryConfig(StrictModel):
targets: list[PostboxTargetConfig] = Field(default_factory=list, max_length=50)
classification: str = Field(default="internal", min_length=1, max_length=50)
unresolved_target: Behavior = Behavior.BLOCK
vacant_target: Behavior = Behavior.WARN
duplicate_target: Behavior = Behavior.WARN
class 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): 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) rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig) imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
retry: RetryConfig = Field(default_factory=RetryConfig) retry: RetryConfig = Field(default_factory=RetryConfig)
@@ -602,3 +781,23 @@ class CampaignConfig(StrictModel):
if path.is_absolute(): if path.is_absolute():
return path return path
return (campaign_file.parent / path).resolve() return (campaign_file.parent / path).resolve()
def effective_delivery_channel_policy(
config: CampaignConfig,
entry: EntryConfig,
) -> DeliveryChannelPolicy:
return entry.channel_policy or config.delivery.channel_policy
def effective_postbox_targets(
config: CampaignConfig,
entry: EntryConfig,
) -> list[PostboxTargetConfig]:
global_targets = list(config.delivery.postbox.targets)
individual_targets = list(entry.postbox_targets)
if not individual_targets:
return global_targets
if entry.merge_postbox_targets:
return [*global_targets, *individual_targets]
return individual_targets
@@ -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)
@@ -2,15 +2,31 @@ from __future__ import annotations
import csv import csv
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from .addressing import effective_address_lists from .addressing import effective_address_lists
from .field_values import ignored_entry_field_overrides from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode from .models import (
AttachmentConfig,
CampaignConfig,
DeliveryChannelPolicy,
EntryConfig,
FieldType,
PostboxTargetConfig,
SourceType,
ZipArchiveConfig,
ZipPasswordMode,
ZipPasswordScope,
ZipRuleMode,
effective_delivery_channel_policy,
effective_postbox_targets,
)
from ..attachments.resolver import resolve_campaign_attachments from ..attachments.resolver import resolve_campaign_attachments
@@ -90,6 +106,8 @@ def _mapping_target_known(target: str, field_names: set[str]) -> bool:
"merge_reply_to", "merge_reply_to",
"merge_bounce_to", "merge_bounce_to",
"merge_disposition_notification_to", "merge_disposition_notification_to",
"merge_postbox_targets",
"channel_policy",
"combine_to", "combine_to",
"combine_cc", "combine_cc",
"combine_bcc", "combine_bcc",
@@ -337,10 +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] = [] 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() profile_id = (config.server.mail_profile_id or "").strip()
if (config.campaign.mode == "send" or config.delivery.imap_append_sent.enabled) and not profile_id: if (
(
config.campaign.mode == "send"
and uses_mail
or config.delivery.imap_append_sent.enabled
)
and not profile_id
):
issues.append( issues.append(
_issue( _issue(
Severity.ERROR, Severity.ERROR,
@@ -350,7 +508,12 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
) )
) )
capabilities = config.server.profile_capabilities capabilities = config.server.profile_capabilities
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available: if (
config.campaign.mode == "send"
and uses_mail
and profile_id
and not capabilities.smtp_available
):
issues.append( issues.append(
_issue( _issue(
Severity.ERROR, Severity.ERROR,
@@ -368,13 +531,163 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
"/server/mail_profile_id", "/server/mail_profile_id",
) )
) )
issues.extend(
_postbox_delivery_issues(
config,
postbox_available=postbox_available,
)
)
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 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]: def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
"""Require Campaign-owned sender data before a send-mode build.""" """Require Campaign-owned sender data before a send-mode build."""
if config.campaign.mode != "send": if (
config.campaign.mode != "send"
or not any(policy.uses_mail for policy in _delivery_policies(config))
):
return [] return []
if config.entries.is_inline: if config.entries.is_inline:
return [ return [
@@ -385,7 +698,11 @@ def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
f"/entries/inline/{index}/from", f"/entries/inline/{index}/from",
) )
for index, entry in enumerate(config.entries.inline or []) for index, entry in enumerate(config.entries.inline or [])
if entry.active and not effective_address_lists(config, entry)["from"] if (
entry.active
and effective_delivery_channel_policy(config, entry).uses_mail
and not effective_address_lists(config, entry)["from"]
)
] ]
if config.recipients.from_: if config.recipients.from_:
return [] return []
@@ -611,6 +928,9 @@ def validate_campaign_config(
*, *,
campaign_file: str | Path | None = None, campaign_file: str | Path | None = None,
check_files: bool = False, check_files: bool = False,
postbox_available: bool = False,
templates_available: bool = False,
calendar_available: bool = False,
) -> SemanticReport: ) -> SemanticReport:
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json" campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
issues: list[SemanticIssue] = [] issues: list[SemanticIssue] = []
@@ -622,7 +942,14 @@ def validate_campaign_config(
issues.extend(_global_value_issues(config, declared_names)) issues.extend(_global_value_issues(config, declared_names))
issues.extend(_attachment_path_issues(config)) issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config)) issues.extend(_zip_configuration_issues(config))
issues.extend(_delivery_issues(config)) issues.extend(
_delivery_issues(
config,
postbox_available=postbox_available,
templates_available=templates_available,
calendar_available=calendar_available,
)
)
issues.extend(_sender_issues(config)) issues.extend(_sender_issues(config))
entries = _entries_validation( entries = _entries_validation(
+435 -12
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from fastapi import HTTPException
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
@@ -14,12 +15,61 @@ from govoplan_core.core.campaigns import (
CampaignPolicyContextProvider, CampaignPolicyContextProvider,
CampaignRetentionProvider, CampaignRetentionProvider,
) )
from govoplan_core.core.ownership import (
OwnershipActionDecision,
OwnershipSubjectRef,
OwnershipTransferError,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_campaign.backend.db.models import Campaign, CampaignShare from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignShare,
CampaignVersion,
)
READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"} READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"}
CAMPAIGN_RESOURCE_TYPES = {
"campaign",
"campaign_object",
"campaigns:campaign",
}
CAMPAIGN_VERSION_RESOURCE_TYPES = {
"campaign_version",
"campaigns:version",
}
CAMPAIGN_DELIVERY_JOB_RESOURCE_TYPES = {
"campaign_delivery_job",
"campaign_job",
"campaigns:delivery_job",
}
CAMPAIGN_REPORT_RESOURCE_TYPES = {
"campaign_report",
"campaigns:report",
}
def campaign_report_resource_id(
*,
campaign_id: str,
version_id: str,
report_kind: str,
) -> str:
kind = report_kind.strip().lower()
if not campaign_id or not version_id or not kind or ":" in kind:
raise ValueError("Campaign report references require campaign, version, and a bounded kind")
return f"{campaign_id}:{version_id}:{kind}"
def _campaign_report_reference(
resource_id: str,
) -> tuple[str, str, str] | None:
parts = resource_id.split(":", 2)
if len(parts) != 3 or not all(part.strip() for part in parts):
return None
return parts[0], parts[1], parts[2].strip().lower()
class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider): class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider):
@@ -120,20 +170,112 @@ class CampaignAccessService(CampaignAccessProvider):
action: str, action: str,
) -> tuple[AccessDecisionProvenance, ...]: ) -> tuple[AccessDecisionProvenance, ...]:
normalized_type = resource_type.lower().strip() normalized_type = resource_type.lower().strip()
if normalized_type not in {"campaign", "campaign_object", "campaigns:campaign"}: child_item: AccessDecisionProvenance | None = None
return () campaign: Campaign | None
if normalized_type in CAMPAIGN_RESOURCE_TYPES:
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined] campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
if campaign is None or (principal.tenant_id and campaign.tenant_id != principal.tenant_id): elif normalized_type in CAMPAIGN_VERSION_RESOURCE_TYPES:
return ( version = session.get(CampaignVersion, resource_id) # type: ignore[attr-defined]
AccessDecisionProvenance( if version is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_version",
resource_id=resource_id,
)
campaign = session.get(Campaign, version.campaign_id) # type: ignore[attr-defined]
child_item = AccessDecisionProvenance(
kind="resource",
id=version.id,
label=f"Version {version.version_number}",
tenant_id=campaign.tenant_id if campaign else principal.tenant_id,
source="campaigns.version",
details={
"resource_type": "campaign_version",
"campaign_id": version.campaign_id,
"version_number": version.version_number,
"workflow_state": version.workflow_state,
"authorization_inherited_from": {
"resource_type": "campaign",
"resource_id": version.campaign_id,
},
},
)
elif normalized_type in CAMPAIGN_DELIVERY_JOB_RESOURCE_TYPES:
job = session.get(CampaignJob, resource_id) # type: ignore[attr-defined]
if job is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_delivery_job",
resource_id=resource_id,
)
campaign = session.get(Campaign, job.campaign_id) # type: ignore[attr-defined]
child_item = AccessDecisionProvenance(
kind="resource",
id=job.id,
label=job.recipient_email or f"Recipient {job.entry_index}",
tenant_id=job.tenant_id,
source="campaigns.delivery_job",
details={
"resource_type": "campaign_delivery_job",
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"queue_status": job.queue_status,
"send_status": job.send_status,
"authorization_inherited_from": {
"resource_type": "campaign",
"resource_id": job.campaign_id,
},
},
)
elif normalized_type in CAMPAIGN_REPORT_RESOURCE_TYPES:
reference = _campaign_report_reference(resource_id)
if reference is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_report",
resource_id=resource_id,
reason="invalid_reference",
)
campaign_id, version_id, report_kind = reference
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
version = session.get(CampaignVersion, version_id) # type: ignore[attr-defined]
if (
campaign is None
or version is None
or version.campaign_id != campaign.id
):
return _missing_resource_provenance(
principal,
resource_type="campaign_report",
resource_id=resource_id,
)
child_item = AccessDecisionProvenance(
kind="resource", kind="resource",
id=resource_id, id=resource_id,
tenant_id=principal.tenant_id, label=report_kind,
source="campaigns.not_found", tenant_id=campaign.tenant_id,
details={"resource_type": "campaign", "found": False}, source="campaigns.report",
), details={
"resource_type": "campaign_report",
"campaign_id": campaign.id,
"campaign_version_id": version.id,
"report_kind": report_kind,
"persisted": False,
"authorization_inherited_from": {
"resource_type": "campaign",
"resource_id": campaign.id,
},
},
) )
items = [ else:
return ()
if campaign is None or (principal.tenant_id and campaign.tenant_id != principal.tenant_id):
return _missing_resource_provenance(
principal,
resource_type=normalized_type,
resource_id=resource_id,
)
items = ([child_item] if child_item is not None else []) + [
AccessDecisionProvenance( AccessDecisionProvenance(
kind="resource", kind="resource",
id=campaign.id, id=campaign.id,
@@ -149,7 +291,11 @@ class CampaignAccessService(CampaignAccessProvider):
] ]
items.extend(_owner_provenance(campaign, principal)) items.extend(_owner_provenance(campaign, principal))
items.extend(_tenant_admin_provenance(principal)) items.extend(_tenant_admin_provenance(principal))
permission_values = {"read", "write"} if action in READ_ACTIONS else {"write"} permission_values = (
{"read", "write"}
if action in READ_ACTIONS or action.strip().lower().endswith(":read")
else {"write"}
)
shares = ( shares = (
session.query(CampaignShare) # type: ignore[attr-defined] session.query(CampaignShare) # type: ignore[attr-defined]
.filter( .filter(
@@ -187,6 +333,283 @@ def access_capability(context: object) -> CampaignAccessService:
return CampaignAccessService() return CampaignAccessService()
class CampaignOwnershipService:
def current_owner(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
) -> OwnershipSubjectRef | None:
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
return None
if campaign.owner_user_id:
return OwnershipSubjectRef(type="user", id=campaign.owner_user_id)
if campaign.owner_group_id:
return OwnershipSubjectRef(type="group", id=campaign.owner_group_id)
return None
def authorize_ownership_action(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
action: str,
actor: OwnershipSubjectRef,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
) -> OwnershipActionDecision:
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
return OwnershipActionDecision(False, "Campaign was not found")
if target_owner.type not in {"user", "group"}:
return OwnershipActionDecision(
False,
"Campaign ownership can be assigned only to a user or group",
)
actor_is_current_owner = _actor_controls_owner(actor, current_owner)
actor_is_target = _actor_controls_owner(actor, target_owner)
actor_can_share = scopes_grant_compatible(
actor.scopes,
"campaigns:campaign:share",
)
actor_can_accept_for_group = scopes_grant_compatible(
actor.scopes,
"campaigns:ownership:accept_group",
)
actor_can_recover = scopes_grant_compatible(
actor.scopes,
"campaigns:ownership:recover",
)
actor_can_read = (
scopes_grant_compatible(
actor.scopes,
"campaigns:campaign:read",
)
and CampaignAccessService().can_read_campaign(
session,
tenant_id=tenant_id,
campaign_id=resource_id,
user_id=actor.id,
group_ids=actor.group_ids,
tenant_admin=scopes_grant_compatible(
actor.scopes,
"tenant:*",
),
)
)
if action in {
"propose_transfer",
"request_ownership",
"request_recovery",
}:
target_decision = _valid_campaign_owner_target(
tenant_id=tenant_id,
target_owner=target_owner,
)
if target_decision is not None:
return target_decision
if action == "view_transfer":
allowed = (
_actor_is_owner_member(actor, current_owner)
or _actor_is_owner_member(actor, target_owner)
or actor_can_recover
)
elif action in {
"propose_transfer",
"approve_requested_transfer",
}:
allowed = actor_is_current_owner and actor_can_share
elif action == "cancel_transfer":
allowed = (
(actor_is_current_owner and actor_can_share)
or actor_is_target
)
elif action == "request_ownership":
allowed = actor_can_read and actor_is_target and (
target_owner.type != "group" or actor_can_accept_for_group
)
elif action == "accept_transfer":
allowed = target_owner.type == "user" and actor_is_target
elif action == "accept_group_transfer":
allowed = (
target_owner.type == "group"
and actor_is_target
and actor_can_accept_for_group
)
elif action == "decline_transfer":
allowed = (
actor_is_target
and (
target_owner.type != "group"
or actor_can_accept_for_group
)
) or (
actor_is_current_owner
and actor_can_share
)
elif action in {
"request_recovery",
"approve_recovery",
"execute_recovery",
}:
allowed = actor_can_recover
else:
allowed = False
return OwnershipActionDecision(
allowed=allowed,
reason=None if allowed else f"Campaign ownership action is not allowed: {action}",
)
def apply_owner(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
expected_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
reason: str | None,
) -> None:
del actor, reason
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
raise OwnershipTransferError("Campaign was not found")
if target_owner.type not in {"user", "group"}:
raise OwnershipTransferError(
"Campaign ownership can be assigned only to a user or group"
)
from govoplan_campaign.backend.route_support import (
_clear_current_version_mail_profile_for_owner_transfer,
)
try:
_clear_current_version_mail_profile_for_owner_transfer(
session, # type: ignore[arg-type]
campaign,
)
except HTTPException as exc:
raise OwnershipTransferError(
str(
exc.detail
or "Campaign owner cannot be changed in its current state"
)
) from exc
expected_filter = (
(
Campaign.owner_user_id == expected_owner.id,
Campaign.owner_group_id.is_(None),
)
if expected_owner.type == "user"
else (
Campaign.owner_user_id.is_(None),
Campaign.owner_group_id == expected_owner.id,
)
)
updated = (
session.query(Campaign) # type: ignore[attr-defined]
.filter(
Campaign.id == resource_id,
Campaign.tenant_id == tenant_id,
*expected_filter,
)
.update(
{
Campaign.owner_user_id: (
target_owner.id
if target_owner.type == "user"
else None
),
Campaign.owner_group_id: (
target_owner.id
if target_owner.type == "group"
else None
),
},
synchronize_session=False,
)
)
if updated != 1:
raise OwnershipTransferError(
"Campaign owner changed while the transfer was pending"
)
session.expire(campaign) # type: ignore[attr-defined]
def _valid_campaign_owner_target(
*,
tenant_id: str,
target_owner: OwnershipSubjectRef,
) -> OwnershipActionDecision | None:
from govoplan_campaign.backend.route_support import _access_directory
directory = _access_directory()
if target_owner.type == "user":
item = directory.get_user(target_owner.id)
elif target_owner.type == "group":
item = directory.get_group(target_owner.id)
else:
return OwnershipActionDecision(
False,
"Campaign ownership can be assigned only to a user or group",
)
if (
item is None
or item.tenant_id != tenant_id
or item.status != "active"
):
return OwnershipActionDecision(
False,
f"Target {target_owner.type} is not active in this tenant",
)
return None
def _actor_is_owner_member(
actor: OwnershipSubjectRef,
owner: OwnershipSubjectRef,
) -> bool:
if owner.type == "group":
return owner.id in actor.group_ids
return actor.type == owner.type and actor.id == owner.id
def _actor_controls_owner(
actor: OwnershipSubjectRef,
owner: OwnershipSubjectRef,
) -> bool:
return _actor_is_owner_member(actor, owner)
def _missing_resource_provenance(
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
reason: str = "not_found",
) -> tuple[AccessDecisionProvenance, ...]:
return (
AccessDecisionProvenance(
kind="resource",
id=resource_id,
tenant_id=principal.tenant_id,
source="campaigns.not_found",
details={
"resource_type": resource_type,
"found": False,
"reason": reason,
},
),
)
def _owner_provenance(campaign: Campaign, principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]: def _owner_provenance(campaign: Campaign, principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]:
if campaign.owner_user_id and campaign.owner_user_id == principal.membership_id: if campaign.owner_user_id and campaign.owner_user_id == principal.membership_id:
return ( return (
@@ -21,6 +21,8 @@ from govoplan_campaign.backend.db.models import (
CampaignShare, CampaignShare,
CampaignVersion, CampaignVersion,
ImapAppendAttempt, ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt, SendAttempt,
new_uuid, new_uuid,
) )
@@ -55,7 +57,10 @@ def _record_campaign_changes(session: OrmSession, _flush_context: object, _insta
_record_job_change(session, obj) _record_job_change(session, obj)
elif isinstance(obj, CampaignIssue): elif isinstance(obj, CampaignIssue):
_record_issue_change(session, obj) _record_issue_change(session, obj)
elif isinstance(obj, (SendAttempt, ImapAppendAttempt)): elif isinstance(
obj,
(SendAttempt, ImapAppendAttempt, PostboxDeliveryAttempt, PrintOutputAttempt),
):
_record_attempt_change(session, obj) _record_attempt_change(session, obj)
@@ -182,8 +187,13 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"validation_status", "validation_status",
"queue_status", "queue_status",
"send_status", "send_status",
"delivery_channel_policy",
"postbox_status",
"print_status",
"imap_status", "imap_status",
"attempt_count", "attempt_count",
"postbox_attempt_count",
"print_attempt_count",
"last_error", "last_error",
"queued_at", "queued_at",
"claimed_at", "claimed_at",
@@ -191,6 +201,9 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"outcome_unknown_at", "outcome_unknown_at",
"sent_at", "sent_at",
"resolved_recipients", "resolved_recipients",
"delivery_provenance",
"resolved_postbox_targets",
"resolved_print_output",
"resolved_attachments", "resolved_attachments",
"issues_snapshot", "issues_snapshot",
), ),
@@ -217,6 +230,9 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"validation_status": job.validation_status, "validation_status": job.validation_status,
"queue_status": job.queue_status, "queue_status": job.queue_status,
"send_status": job.send_status, "send_status": job.send_status,
"delivery_channel_policy": job.delivery_channel_policy,
"postbox_status": job.postbox_status,
"print_status": job.print_status,
"imap_status": job.imap_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( operation = _operation_for_object(
attempt, attempt,
changed_attrs=("status", "claim_token", "smtp_status_code", "smtp_response", "error_type", "error_message", "folder"), changed_attrs=(
"status",
"claim_token",
"smtp_status_code",
"smtp_response",
"error_type",
"error_message",
"folder",
"provider_delivery_id",
"provider_message_id",
"postbox_id",
"render_id",
"artifact_sha256",
"evidence",
),
) )
if operation is None: if operation is None:
return return
@@ -270,7 +303,15 @@ def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppen
payload={ payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}), **(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
"attempt_id": attempt_id, "attempt_id": attempt_id,
"attempt_kind": "imap" if isinstance(attempt, ImapAppendAttempt) else "smtp", "attempt_kind": (
"postbox"
if isinstance(attempt, PostboxDeliveryAttempt)
else "print"
if isinstance(attempt, PrintOutputAttempt)
else "imap"
if isinstance(attempt, ImapAppendAttempt)
else "smtp"
),
"job_id": job.id, "job_id": job.id,
"version_id": job.campaign_version_id, "version_id": job.campaign_version_id,
}, },
+329
View File
@@ -8,6 +8,7 @@ from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship 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 from govoplan_core.db.base import Base, TimestampMixin
@@ -83,6 +84,10 @@ class JobSendStatus(StrEnum):
CLAIMED = "claimed" CLAIMED = "claimed"
SENDING = "sending" SENDING = "sending"
SMTP_ACCEPTED = "smtp_accepted" 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 SENT = "sent" # legacy value retained for existing databases/reports
OUTCOME_UNKNOWN = "outcome_unknown" OUTCOME_UNKNOWN = "outcome_unknown"
FAILED_TEMPORARY = "failed_temporary" FAILED_TEMPORARY = "failed_temporary"
@@ -90,6 +95,28 @@ class JobSendStatus(StrEnum):
CANCELLED = "cancelled" CANCELLED = "cancelled"
class JobPostboxStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
DELIVERING = "delivering"
ACCEPTED = "accepted"
ACCEPTED_VACANT = "accepted_vacant"
PARTIALLY_ACCEPTED = "partially_accepted"
REJECTED_TEMPORARY = "rejected_temporary"
REJECTED_PERMANENT = "rejected_permanent"
OUTCOME_UNKNOWN = "outcome_unknown"
SKIPPED = "skipped"
class JobPrintStatus(StrEnum):
NOT_REQUESTED = "not_requested"
READY = "ready"
ACCEPTING = "accepting"
ACCEPTED = "accepted"
FAILED = "failed"
SKIPPED = "skipped"
class JobImapStatus(StrEnum): class JobImapStatus(StrEnum):
NOT_REQUESTED = "not_requested" NOT_REQUESTED = "not_requested"
PENDING = "pending" PENDING = "pending"
@@ -172,6 +199,11 @@ class CampaignVersion(Base, TimestampMixin):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
version_number: Mapped[int] = mapped_column(Integer, nullable=False) 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) raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False) schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False)
source_filename: Mapped[str | None] = mapped_column(String(500)) source_filename: Mapped[str | None] = mapped_column(String(500))
@@ -218,6 +250,18 @@ class CampaignVersion(Base, TimestampMixin):
campaign: Mapped[Campaign] = relationship(back_populates="versions") 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 @property
def mail_profile_migration_required(self) -> bool: def mail_profile_migration_required(self) -> bool:
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_boundary_violations from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_boundary_violations
@@ -249,9 +293,37 @@ class CampaignJob(Base, TimestampMixin):
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True) validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True) queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True)
send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True) send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True)
delivery_channel_policy: Mapped[str] = mapped_column(
String(30),
default="mail",
nullable=False,
index=True,
)
postbox_status: Mapped[str] = mapped_column(
String(50),
default=JobPostboxStatus.NOT_REQUESTED.value,
nullable=False,
index=True,
)
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) imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
postbox_attempt_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
)
print_attempt_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
)
last_error: Mapped[str | None] = mapped_column(Text) last_error: Mapped[str | None] = mapped_column(Text)
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -263,6 +335,20 @@ class CampaignJob(Base, TimestampMixin):
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
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) resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
@@ -327,6 +413,139 @@ class SendAttempt(Base, TimestampMixin):
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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): class ImapAppendAttempt(Base, TimestampMixin):
__tablename__ = "imap_append_attempts" __tablename__ = "imap_append_attempts"
__table_args__ = ( __table_args__ = (
@@ -342,6 +561,112 @@ class ImapAppendAttempt(Base, TimestampMixin):
error_message: Mapped[str | None] = mapped_column(Text) error_message: Mapped[str | None] = mapped_column(Text)
class PostboxDeliveryAttempt(Base, TimestampMixin):
__tablename__ = "campaign_postbox_delivery_attempts"
__table_args__ = (
UniqueConstraint(
"job_id",
"target_key",
"attempt_number",
name="uq_campaign_postbox_attempt_target_number",
),
Index(
"ix_campaign_postbox_attempt_idempotency",
"tenant_id",
"idempotency_key",
),
Index(
"ix_campaign_postbox_attempt_job_status",
"job_id",
"status",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=new_uuid,
)
tenant_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
index=True,
)
job_id: Mapped[str] = mapped_column(
ForeignKey("campaign_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
target_key: Mapped[str] = mapped_column(String(64), nullable=False)
target_index: Mapped[int] = mapped_column(Integer, nullable=False)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True,
)
target_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
provider_delivery_id: Mapped[str | None] = mapped_column(String(36))
provider_message_id: Mapped[str | None] = mapped_column(String(36))
postbox_id: Mapped[str | None] = mapped_column(String(36), index=True)
address: Mapped[str | None] = mapped_column(String(500))
holder_count: Mapped[int | None] = mapped_column(Integer)
vacant: Mapped[bool | None] = mapped_column(Boolean)
duplicate: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
error_type: Mapped[str | None] = mapped_column(String(255))
error_code: Mapped[str | None] = mapped_column(String(100))
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
)
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__ = [ __all__ = [
@@ -359,8 +684,12 @@ __all__ = [
"IssueSeverity", "IssueSeverity",
"JobBuildStatus", "JobBuildStatus",
"JobImapStatus", "JobImapStatus",
"JobPostboxStatus",
"JobPrintStatus",
"JobQueueStatus", "JobQueueStatus",
"JobSendStatus", "JobSendStatus",
"JobValidationStatus", "JobValidationStatus",
"SendAttempt", "SendAttempt",
"PostboxDeliveryAttempt",
"PrintOutputAttempt",
] ]
+140 -3
View File
@@ -43,6 +43,11 @@ _FILES_INTEGRATION = "files.campaign_attachments"
_MAIL_INTEGRATION = "mail.campaign_delivery" _MAIL_INTEGRATION = "mail.campaign_delivery"
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup" _ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source" _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_RENDERER_INTEGRATION = "templates.renderer"
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch" _NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
@@ -219,6 +224,84 @@ CAMPAIGN_USER_DOCUMENTATION = (
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"), related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
related_modules=("addresses",), 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( _workflow_topic(
topic_id="campaigns.workflow.use-managed-attachments", topic_id="campaigns.workflow.use-managed-attachments",
title="Use managed files as campaign attachments", title="Use managed files as campaign attachments",
@@ -258,6 +341,43 @@ CAMPAIGN_USER_DOCUMENTATION = (
), ),
related_modules=("files",), related_modules=("files",),
), ),
_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( _workflow_topic(
topic_id="campaigns.workflow.queue-delivery", topic_id="campaigns.workflow.queue-delivery",
title="Queue a campaign for controlled delivery", title="Queue a campaign for controlled delivery",
@@ -292,7 +412,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"), related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
links=( links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"), 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"), related_modules=("mail", "notifications"),
), ),
@@ -338,7 +458,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
order=37, order=37,
audience=("campaign_aggregate_reader", "campaign_reader", "campaign_manager"), audience=("campaign_aggregate_reader", "campaign_reader", "campaign_manager"),
required_scopes=("campaigns:report:read",), required_scopes=("campaigns:report:read",),
route="/reports", route="/campaigns/reports",
screen="Reports", screen="Reports",
help_contexts=("campaign.report",), help_contexts=("campaign.report",),
prerequisites=("The campaign is owned by or explicitly shared with you, or you hold tenant-wide authority.",), prerequisites=("The campaign is owned by or explicitly shared with you, or you hold tenant-wide authority.",),
@@ -351,7 +471,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
outcome="A business-level Campaign outcome view with small-group and recipient privacy preserved.", 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.", 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",), 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( _workflow_topic(
topic_id="campaigns.workflow.view-delivery-report", topic_id="campaigns.workflow.view-delivery-report",
@@ -669,6 +789,23 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
else: else:
limitations.append("Automatic in-app Campaign status notifications are not configured.") limitations.append("Automatic in-app Campaign status notifications are not configured.")
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) return tuple(configured), tuple(limitations)
+610 -21
View File
@@ -7,11 +7,57 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any, Iterator
from govoplan_core.core.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_RENDERER,
TemplateCatalogProvider,
TemplateCompatibility,
TemplateRef,
TemplateRenderRequest,
TemplateRenderResult,
TemplateRendererProvider,
)
from govoplan_campaign.backend.runtime import capability from govoplan_campaign.backend.runtime import capability
FILES_CAPABILITY = "files.campaign_attachments" FILES_CAPABILITY = "files.campaign_attachments"
MAIL_CAPABILITY = "mail.campaign_delivery" MAIL_CAPABILITY = "mail.campaign_delivery"
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS
class OptionalModuleUnavailable(RuntimeError): class OptionalModuleUnavailable(RuntimeError):
@@ -23,7 +69,9 @@ class SmtpConfigurationError(RuntimeError):
class SmtpSendError(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
) -> None:
super().__init__(message) super().__init__(message)
self.temporary = temporary self.temporary = temporary
self.outcome_unknown = outcome_unknown self.outcome_unknown = outcome_unknown
@@ -50,6 +98,26 @@ class MailProfileError(OptionalModuleUnavailable):
pass pass
class MailDeliveryCommandError(RuntimeError):
pass
class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
pass
class ApprovalGateUnavailable(OptionalModuleUnavailable):
pass
class TemplateOutputUnavailable(OptionalModuleUnavailable):
pass
class CalendarInvitationUnavailable(OptionalModuleUnavailable):
pass
class _PreparedCampaignSnapshot: class _PreparedCampaignSnapshot:
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None: def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
self._directory = directory self._directory = directory
@@ -78,20 +146,30 @@ class FilesCampaignIntegration:
yield prepared yield prepared
return 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-") prefix = str(kwargs.get("prefix") or "govoplan-campaign-")
directory = Path(tempfile.mkdtemp(prefix=prefix)) directory = Path(tempfile.mkdtemp(prefix=prefix))
snapshot = _PreparedCampaignSnapshot(directory, directory / "campaign.json", raw_json) snapshot = _PreparedCampaignSnapshot(
snapshot.path.write_text(json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8") directory, directory / "campaign.json", raw_json
)
snapshot.path.write_text(
json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8"
)
try: try:
yield snapshot yield snapshot
finally: finally:
snapshot.cleanup() 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: if self._delegate is None:
return [] 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]: def public_attachment_summary_payload(self, attachment: Any) -> dict[str, Any]:
if self._delegate is not None: if self._delegate is not None:
@@ -102,20 +180,30 @@ class FilesCampaignIntegration:
return dict(attachment) return dict(attachment)
return {"path": str(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: 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: 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]: def current_version_and_blob(self, session: Any, asset: Any) -> tuple[Any, Any]:
if self._delegate is None: if self._delegate is None:
raise OptionalModuleUnavailable("Files module is not available") raise OptionalModuleUnavailable("Files module is not available")
return self._delegate.current_version_and_blob(session, asset) 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: if self._delegate is None:
raise OptionalModuleUnavailable("Files module is not available") raise OptionalModuleUnavailable("Files module is not available")
return self._delegate.share_assets_with_campaign(session, **kwargs) return self._delegate.share_assets_with_campaign(session, **kwargs)
@@ -129,7 +217,9 @@ class MailCampaignIntegration:
def __init__(self, delegate: Any | None = None) -> None: def __init__(self, delegate: Any | None = None) -> None:
self._delegate = delegate self._delegate = delegate
if delegate is not None: if delegate is not None:
self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError) self.MailProfileError = getattr(
delegate, "MailProfileError", MailProfileError
)
MailProfileError = MailProfileError MailProfileError = MailProfileError
SmtpConfigurationError = SmtpConfigurationError SmtpConfigurationError = SmtpConfigurationError
@@ -141,31 +231,49 @@ class MailCampaignIntegration:
def available(self) -> bool: def available(self) -> bool:
return self._delegate is not None 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: def _require(self) -> Any:
if self._delegate is None: if self._delegate is None:
raise MailProfileError("Mail module is not available") raise MailProfileError("Mail module is not available")
return self._delegate 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: if self._delegate is None:
raw_json = kwargs.get("raw_json") 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: 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 return None
try: 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: except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from 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: if self._delegate is not None:
return self._delegate.mail_profile_id_from_campaign_json(raw_json) return self._delegate.mail_profile_id_from_campaign_json(raw_json)
server = raw_json.get("server") if isinstance(raw_json, dict) else None 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 profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
return str(profile_id).strip() if profile_id 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() delegate = self._require()
try: try:
return delegate.campaign_profile_delivery_summary(session, **kwargs) return delegate.campaign_profile_delivery_summary(session, **kwargs)
@@ -182,8 +290,14 @@ class MailCampaignIntegration:
try: try:
return delegate.send_campaign_email_bytes(*args, **kwargs) return delegate.send_campaign_email_bytes(*args, **kwargs)
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc: 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(
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc: str(exc),
temporary=bool(getattr(exc, "temporary", False)),
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
) from exc
except getattr(
delegate, "SmtpConfigurationError", SmtpConfigurationError
) as exc:
raise SmtpConfigurationError(str(exc)) from exc raise SmtpConfigurationError(str(exc)) from exc
except getattr(delegate, "MailProfileError", MailProfileError) as exc: except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc raise MailProfileError(str(exc)) from exc
@@ -198,20 +312,495 @@ class MailCampaignIntegration:
temporary=getattr(exc, "temporary", None), temporary=getattr(exc, "temporary", None),
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
) from exc ) from exc
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc: except getattr(
delegate, "ImapConfigurationError", ImapConfigurationError
) as exc:
raise ImapConfigurationError(str(exc)) from exc raise ImapConfigurationError(str(exc)) from exc
except getattr(delegate, "MailProfileError", MailProfileError) as exc: except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from 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: def mock_mailbox(self) -> Any | None:
if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"): if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"):
return None return None
return self._delegate.mock_mailbox() return self._delegate.mock_mailbox()
class PostboxCampaignIntegration:
def __init__(
self,
delivery_delegate: object | None = None,
directory_delegate: object | None = None,
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,
) -> None:
self._catalog = (
catalog_delegate
if isinstance(catalog_delegate, TemplateCatalogProvider)
else None
)
self._renderer = (
renderer_delegate
if isinstance(renderer_delegate, TemplateRendererProvider)
else None
)
@property
def available(self) -> bool:
return self._catalog is not None and self._renderer 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 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: def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY)) return FilesCampaignIntegration(capability(FILES_CAPABILITY))
def mail_integration() -> MailCampaignIntegration: def mail_integration() -> MailCampaignIntegration:
return MailCampaignIntegration(capability(MAIL_CAPABILITY)) return MailCampaignIntegration(capability(MAIL_CAPABILITY))
def postbox_integration() -> PostboxCampaignIntegration:
return PostboxCampaignIntegration(
capability(POSTBOX_CAPABILITY),
capability(POSTBOX_DIRECTORY_CAPABILITY),
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),
)
def calendar_integration() -> CalendarCampaignIntegration:
return CalendarCampaignIntegration(capability(CALENDAR_INVITATIONS_CAPABILITY))
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,7 @@ from govoplan_campaign.backend.attachments.resolver import (
EntryAttachmentResolution, EntryAttachmentResolution,
MessageAttachmentStatus, MessageAttachmentStatus,
ResolvedAttachment, ResolvedAttachment,
effective_send_without_attachments_behavior,
resolve_entry_attachments, resolve_entry_attachments,
) )
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
@@ -33,6 +34,7 @@ from govoplan_campaign.backend.campaign.models import (
ZipArchiveConfig, ZipArchiveConfig,
ZipPasswordMode, ZipPasswordMode,
ZipPasswordScope, ZipPasswordScope,
effective_delivery_channel_policy,
) )
from govoplan_campaign.backend.campaign.template_values import build_template_values from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.services.zip_service import create_zip_archive from govoplan_campaign.backend.services.zip_service import create_zip_archive
@@ -171,6 +173,11 @@ def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[Message
label=attachment.label, label=attachment.label,
status=attachment.status.value, status=attachment.status.value,
behavior=attachment.behavior.value if attachment.behavior else None, 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, required=attachment.required,
allow_multiple=attachment.allow_multiple, allow_multiple=attachment.allow_multiple,
zip_enabled=attachment.zip_enabled, zip_enabled=attachment.zip_enabled,
@@ -199,6 +206,7 @@ def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolu
message=issue.message, message=issue.message,
behavior=issue.behavior.value if issue.behavior else None, behavior=issue.behavior.value if issue.behavior else None,
source="attachments", source="attachments",
details=issue.details,
) )
for issue in resolution.issues for issue in resolution.issues
] ]
@@ -218,12 +226,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: def _safe_filename(value: str | None, fallback: str) -> str:
raw = value or fallback raw = value or fallback
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._") safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
@@ -425,8 +427,14 @@ def _attach_files(
return attached_count return attached_count
def _imap_initial_status(config: CampaignConfig) -> ImapStatus: def _imap_initial_status(
if config.delivery.imap_append_sent.enabled: config: CampaignConfig,
entry: EntryConfig,
) -> ImapStatus:
if (
effective_delivery_channel_policy(config, entry).uses_mail
and config.delivery.imap_append_sent.enabled
):
return ImapStatus.PENDING return ImapStatus.PENDING
return ImapStatus.NOT_REQUESTED return ImapStatus.NOT_REQUESTED
@@ -520,7 +528,11 @@ def _message_draft(
send_status = SendStatus.SKIPPED send_status = SendStatus.SKIPPED
imap_status = ImapStatus.SKIPPED imap_status = ImapStatus.SKIPPED
if imap_status is None: if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED imap_status = (
_imap_initial_status(config, entry)
if build_status == BuildStatus.BUILT
else ImapStatus.SKIPPED
)
return MessageDraft( return MessageDraft(
entry_index=entry_index, entry_index=entry_index,
entry_id=entry.id, entry_id=entry.id,
@@ -529,6 +541,10 @@ def _message_draft(
validation_status=validation_status, validation_status=validation_status,
send_status=send_status, send_status=send_status,
imap_status=imap_status, imap_status=imap_status,
delivery_channel_policy=effective_delivery_channel_policy(
config,
entry,
).value,
subject=subject, subject=subject,
from_=_message_address(context.sender), from_=_message_address(context.sender),
from_all=_message_addresses(context.senders), from_all=_message_addresses(context.senders),
@@ -746,7 +762,11 @@ def _build_mime_message(
values=rendered.values, values=rendered.values,
work_dir=work_dir, 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) _append_no_attachment_coverage_issue(context.issues)
return _MimeBuildResult( return _MimeBuildResult(
message=None, message=None,
@@ -770,6 +790,16 @@ def _build_mime_message(
source="attachments", 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: except Exception as exc:
context.issues.append( context.issues.append(
MessageIssue( MessageIssue(
@@ -809,6 +839,8 @@ def build_entry_message(
if not entry.active: if not entry.active:
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context) return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
channel_policy = effective_delivery_channel_policy(config, entry)
if channel_policy.uses_mail:
context.validation_status = _validate_required_sender( context.validation_status = _validate_required_sender(
context.senders, context.senders,
context.issues, context.issues,
@@ -33,6 +33,7 @@ class MessageIssue(BaseModel):
message: str message: str
behavior: str | None = None behavior: str | None = None
source: str | None = None source: str | None = None
details: dict[str, object] = Field(default_factory=dict)
class MessageAddress(BaseModel): class MessageAddress(BaseModel):
@@ -49,6 +50,7 @@ class MessageAttachmentSummary(BaseModel):
label: str | None = None label: str | None = None
status: str status: str
behavior: str | None = None behavior: str | None = None
missing_policy: dict[str, object] | None = None
required: bool required: bool
allow_multiple: bool allow_multiple: bool
zip_enabled: bool zip_enabled: bool
@@ -78,6 +80,7 @@ class MessageDraft(BaseModel):
validation_status: MessageValidationStatus validation_status: MessageValidationStatus
send_status: SendStatus send_status: SendStatus
imap_status: ImapStatus imap_status: ImapStatus
delivery_channel_policy: str = "mail"
subject: str | None = None subject: str | None = None
from_: MessageAddress | None = Field(default=None, alias="from") from_: MessageAddress | None = Field(default=None, alias="from")
@@ -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,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,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,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,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,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") model_config = ConfigDict(extra="forbid")
smtp_accepted: AggregateCount smtp_accepted: AggregateCount
postbox_accepted: AggregateCount
print_accepted: AggregateCount
delivered: AggregateCount
partially_accepted: AggregateCount
failed: AggregateCount failed: AggregateCount
outcome_unknown: AggregateCount outcome_unknown: AggregateCount
queued_or_active: AggregateCount queued_or_active: AggregateCount
@@ -105,6 +109,10 @@ class AggregateCampaignReport(BaseModel):
_OUTCOME_KEYS = ( _OUTCOME_KEYS = (
"smtp_accepted", "smtp_accepted",
"postbox_accepted",
"print_accepted",
"delivered",
"partially_accepted",
"failed", "failed",
"outcome_unknown", "outcome_unknown",
"queued_or_active", "queued_or_active",
@@ -175,7 +183,19 @@ def _query_aggregate_facts(
if version is None: if version is None:
return _AggregateFacts.empty() return _AggregateFacts.empty()
accepted = CampaignJob.send_status.in_({"smtp_accepted", "sent"}) smtp_accepted = CampaignJob.send_status.in_(
{"smtp_accepted", "sent"}
)
accepted = CampaignJob.send_status.in_(
{
"smtp_accepted",
"postbox_accepted",
"print_accepted",
"delivered",
"partially_accepted",
"sent",
}
)
failed = CampaignJob.send_status.in_({"failed_temporary", "failed_permanent"}) failed = CampaignJob.send_status.in_({"failed_temporary", "failed_permanent"})
unknown = CampaignJob.send_status == "outcome_unknown" unknown = CampaignJob.send_status == "outcome_unknown"
active = CampaignJob.send_status.in_({"queued", "claimed", "sending"}) active = CampaignJob.send_status.in_({"queued", "claimed", "sending"})
@@ -188,7 +208,39 @@ def _query_aggregate_facts(
row = ( row = (
session.query( session.query(
func.count(CampaignJob.id).label("denominator"), func.count(CampaignJob.id).label("denominator"),
func.sum(case((accepted, 1), else_=0)).label("smtp_accepted"), func.sum(case((smtp_accepted, 1), else_=0)).label(
"smtp_accepted"
),
func.sum(
case(
(
CampaignJob.send_status == "postbox_accepted",
1,
),
else_=0,
)
).label("postbox_accepted"),
func.sum(
case(
(CampaignJob.send_status == "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((failed, 1), else_=0)).label("failed"),
func.sum(case((unknown, 1), else_=0)).label("outcome_unknown"), func.sum(case((unknown, 1), else_=0)).label("outcome_unknown"),
func.sum(case((active, 1), else_=0)).label("queued_or_active"), func.sum(case((active, 1), else_=0)).label("queued_or_active"),
@@ -353,6 +405,14 @@ def _outcome_counts(jobs: list[CampaignJob]) -> dict[str, int]:
status = job.send_status status = job.send_status
if status in {"smtp_accepted", "sent"}: if status in {"smtp_accepted", "sent"}:
counts["smtp_accepted"] += 1 counts["smtp_accepted"] += 1
elif status == "postbox_accepted":
counts["postbox_accepted"] += 1
elif status == "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"}: elif status in {"failed_temporary", "failed_permanent"}:
counts["failed"] += 1 counts["failed"] += 1
elif status == "outcome_unknown": elif status == "outcome_unknown":
@@ -460,10 +520,16 @@ def _completion_state(
return "outcome_unknown" return "outcome_unknown"
if counts["queued_or_active"]: if counts["queued_or_active"]:
return "in_progress" return "in_progress"
accepted = counts["smtp_accepted"] fully_accepted = (
if accepted == total: counts["smtp_accepted"]
+ counts["postbox_accepted"]
+ counts["print_accepted"]
+ counts["delivered"]
)
partially_accepted = counts["partially_accepted"]
if fully_accepted == total:
return "completed" return "completed"
if accepted: if fully_accepted or partially_accepted:
return "partially_completed" return "partially_completed"
return "incomplete" return "incomplete"
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.campaign.mail_profile_boundary import campaign_mail_profile_id
from govoplan_campaign.backend.persistence.campaigns import load_version_config from govoplan_campaign.backend.persistence.campaigns import load_version_config
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_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 from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
@@ -32,6 +36,9 @@ class CampaignReportEmailResult:
attached_jobs_csv: bool attached_jobs_csv: bool
attached_report_json: bool attached_report_json: bool
accepted_count: int | None = None 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]: def as_dict(self) -> dict[str, Any]:
return { return {
@@ -44,6 +51,22 @@ class CampaignReportEmailResult:
"attached_jobs_csv": self.attached_jobs_csv, "attached_jobs_csv": self.attached_jobs_csv,
"attached_report_json": self.attached_report_json, "attached_report_json": self.attached_report_json,
"accepted_count": self.accepted_count, "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_jobs_csv: bool = False,
attach_report_json: bool = False, attach_report_json: bool = False,
dry_run: bool = False, dry_run: bool = False,
idempotency_key: str | None = None,
created_by_user_id: str | None = None,
) -> CampaignReportEmailResult: ) -> CampaignReportEmailResult:
campaign = session.get(Campaign, campaign_id) campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != tenant_id: if not campaign or campaign.tenant_id != tenant_id:
@@ -175,11 +200,16 @@ def send_campaign_report_email(
) from exc ) from exc
if not snapshot.smtp_transport_revision: if not snapshot.smtp_transport_revision:
raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision") raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision")
mail = mail_integration()
if not dry_run: if not dry_run:
if not mail.durable_delivery_available:
raise CampaignReportEmailError( raise CampaignReportEmailError(
"Report email delivery is disabled until it uses a durable, idempotent Mail-owned outbox with unknown-outcome reconciliation." "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( report = generate_campaign_report(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -202,6 +232,48 @@ def send_campaign_report_email(
jobs_csv=jobs_csv, jobs_csv=jobs_csv,
report_json=report_json, 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( return CampaignReportEmailResult(
campaign_id=campaign.id, campaign_id=campaign.id,
version_id=version.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",
]
@@ -110,6 +110,7 @@ def public_delivery_result_message(
last_error: Any, last_error: Any,
send_status: Any, send_status: Any,
imap_status: Any, imap_status: Any,
postbox_status: Any = None,
) -> str | None: ) -> str | None:
"""Map persisted provider text to a stable business-safe explanation.""" """Map persisted provider text to a stable business-safe explanation."""
@@ -117,10 +118,22 @@ def public_delivery_result_message(
return None return None
clean_send_status = str(send_status or "") clean_send_status = str(send_status or "")
clean_imap_status = str(imap_status or "") clean_imap_status = str(imap_status or "")
clean_postbox_status = str(postbox_status or "")
if clean_postbox_status == "outcome_unknown":
return "Postbox delivery outcome requires operator reconciliation."
if clean_send_status == "outcome_unknown": if clean_send_status == "outcome_unknown":
return "SMTP delivery outcome requires operator reconciliation." return "Delivery outcome requires operator reconciliation."
if clean_postbox_status in {
"rejected_temporary",
"rejected_permanent",
}:
return "Postbox delivery was rejected; an operator can inspect restricted diagnostics."
if clean_postbox_status == "partially_accepted":
return "Some Postbox targets accepted the message and others rejected it."
if clean_send_status in {"failed_temporary", "failed_permanent"}: if clean_send_status in {"failed_temporary", "failed_permanent"}:
if clean_postbox_status in {"", "not_requested"}:
return "SMTP delivery failed; an operator can inspect restricted diagnostics." return "SMTP delivery failed; an operator can inspect restricted diagnostics."
return "Delivery failed; an operator can inspect restricted diagnostics."
if clean_imap_status in {"outcome_unknown", "appending"}: if clean_imap_status in {"outcome_unknown", "appending"}:
return "Sent-folder append outcome requires operator reconciliation." return "Sent-folder append outcome requires operator reconciliation."
if clean_imap_status in {"failed", "skipped"}: if clean_imap_status in {"failed", "skipped"}:
+32 -1
View File
@@ -9,7 +9,15 @@ from typing import Any, Callable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.object_storage import (
StorageBackend,
StorageBackendError,
StorageObjectMissing,
configured_storage_backend,
)
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
from govoplan_campaign.backend.runtime import get_settings
FINAL_VERSION_STATES = { FINAL_VERSION_STATES = {
"completed", "completed",
@@ -111,8 +119,16 @@ def _apply_eml_retention(
dry_run: bool, dry_run: bool,
now: datetime, now: datetime,
policy_for_campaign_id: Callable[[str | None], object], policy_for_campaign_id: Callable[[str | None], object],
storage: StorageBackend | None = None,
) -> dict[str, int]: ) -> 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,
"skipped_not_final": 0,
}
jobs = ( jobs = (
session.query(CampaignJob) session.query(CampaignJob)
.filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None))) .filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None)))
@@ -137,6 +153,21 @@ def _apply_eml_retention(
result["eligible"] += 1 result["eligible"] += 1
if dry_run: if dry_run:
continue continue
if job.eml_storage_key:
active_storage = storage or configured_storage_backend(
get_settings() or core_settings
)
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: if job.eml_local_path:
path = Path(job.eml_local_path) path = Path(job.eml_local_path)
if path.exists(): if path.exists():
@@ -0,0 +1,666 @@
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.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:
_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")
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
try:
return _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,
},
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
)
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,398 @@
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.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 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,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,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,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 -------------------------------------------------
@@ -0,0 +1,908 @@
from __future__ import annotations
import hashlib
import json
from urllib.parse import quote
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import (
BuildCampaignRequest,
CampaignCreateResponse,
CampaignResponse,
CampaignVersionDetailResponse,
CampaignVersionResponse,
CampaignVersionSetStepRequest,
CampaignReviewStateRequest,
CampaignVersionUpdateRequest,
CampaignPartialValidationRequest,
CampaignPartialValidationResponse,
ValidateCampaignRequest,
)
from govoplan_core.auth import ApiPrincipal, has_scope, 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,
RecoveryMode,
RecoveryPlan,
)
from govoplan_core.core.recovery_runtime import (
RecoveryOperationBusy,
RecoveryOperationStateConflict,
begin_durable_recovery_operation,
)
from govoplan_campaign.backend.db.models import (
CampaignVersion,
)
from govoplan_core.db.session import get_database, get_session
from govoplan_core.server.runtime_agent import application_runtime_identity
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
)
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
_object_storage,
build_campaign_version,
validate_campaign_version,
)
from govoplan_campaign.backend.path_security import CampaignPathSecurityError
from govoplan_campaign.backend.persistence.versions import (
LockedCampaignVersionError,
fork_campaign_version_for_edit,
is_version_final_locked,
is_user_locked_version,
get_campaign_version_for_tenant,
lock_campaign_version_temporarily,
permanently_lock_campaign_version,
publish_campaign_version,
unlock_user_locked_campaign_version,
unlock_validated_campaign_version,
update_campaign_version,
update_campaign_review_state,
validate_campaign_partial,
)
from govoplan_campaign.backend.route_support import (
_campaign_response_context,
_campaign_version_detail_response,
_get_campaign_for_principal,
_get_campaign_for_tenant,
_get_version_for_principal,
_get_version_for_tenant,
_require_mail_profile_use_if_needed,
_require_permission,
_update_campaign_version_detail_response,
_write_current_version_snapshot_if_available,
bounded_query_rows as _bounded_query_rows,
)
from govoplan_campaign.backend.routes.attachments import (
CampaignAttachmentLinkMatchesResponse,
_link_campaign_attachment_matches,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _canonical_sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
).hexdigest()
def _campaign_build_recovery_plan(raw_json: dict[str, object]) -> RecoveryPlan:
delivery = raw_json.get("delivery")
print_config = delivery.get("print") if isinstance(delivery, dict) else None
persists_managed_output = bool(
isinstance(print_config, dict)
and print_config.get("persist_to_files", True)
)
if persists_managed_output:
return RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=("validated Campaign version is locked",),
forward_recovery_steps=(
"reuse the Templates render idempotency key",
"reconcile the managed output and Campaign build manifests",
),
verification_steps=(
"compare committed Campaign jobs with generated object evidence",
),
)
return RecoveryPlan(
mode=RecoveryMode.COMPENSATION,
preconditions=("validated Campaign version is locked",),
compensation_steps=("delete every object under the reserved build prefix",),
verification_steps=(
"compare committed Campaign jobs with generated object evidence",
),
)
@router.get("/{campaign_id}/versions/{version_id}/print-output/download")
def download_print_output(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
) -> Response:
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:read")
try:
version = get_campaign_version_for_tenant(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
build_summary = (
version.build_summary if isinstance(version.build_summary, dict) else {}
)
print_output = build_summary.get("print_output")
artifact = (
print_output.get("artifact")
if isinstance(print_output, dict)
and isinstance(print_output.get("artifact"), dict)
else {}
)
storage_key = artifact.get("storage_key")
if artifact.get("kind") != "bounded_download" or not storage_key:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This printable output is not stored as a Campaign download.",
)
try:
payload = _object_storage().get_bytes(str(storage_key))
except StorageBackendError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The printable output is temporarily unavailable.",
) from exc
expected_sha256 = str(print_output.get("output_sha256") or "")
actual_sha256 = hashlib.sha256(payload).hexdigest()
if not expected_sha256 or actual_sha256 != expected_sha256:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The printable output failed its integrity check.",
)
audit_from_principal(
session,
principal,
action="campaign.print_output_downloaded",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign_id,
"output_sha256": actual_sha256,
"template_id": print_output.get("template_id"),
"template_revision_id": print_output.get("template_revision_id"),
},
commit=True,
)
filename = quote(
str(artifact.get("filename") or "campaign-print-output.html"),
safe="._-",
)
return Response(
content=payload,
media_type=str(artifact.get("content_type") or "application/octet-stream"),
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}",
"X-Content-SHA256": actual_sha256,
"X-Content-Type-Options": "nosniff",
},
)
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
def list_versions(
campaign_id: str,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
versions = _bounded_query_rows(
session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.desc()),
limit=limit,
label="Campaign version history",
)
return [
CampaignVersionResponse.model_validate(
item,
context=_campaign_response_context(principal),
)
for item in versions
]
@router.get(
"/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse
)
def get_version_detail(
campaign_id: str,
version_id: str,
response: Response,
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")
try:
version = get_campaign_version_for_tenant(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
result = CampaignVersionDetailResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
response.headers["ETag"] = version.strong_etag
return result
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
@router.post(
"/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse
)
def fork_version_for_edit(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")),
):
"""Create the campaign's next and only editable working version.
A new working copy may be created only after the current version is
permanently user-locked or delivery-final. Validation and temporary user
locks must be removed in place instead of creating parallel drafts.
"""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or CampaignVersionUpdateRequest()
source_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if source_version.campaign_id != campaign_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
)
effective_json = (
payload.campaign_json
if isinstance(payload.campaign_json, dict)
else source_version.raw_json
)
_require_mail_profile_use_if_needed(
principal,
effective_json if isinstance(effective_json, dict) else {},
)
try:
version = fork_campaign_version_for_edit(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=payload.campaign_json,
current_flow=payload.current_flow or "manual",
current_step=payload.current_step,
editor_state=payload.editor_state,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=True,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
commit=False,
)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
audit_from_principal(
session,
principal,
action="campaign.version_forked_for_edit",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign_id,
"source_version_id": version_id,
"version_number": version.version_number,
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
},
commit=True,
)
_write_current_version_snapshot_if_available(version)
return CampaignCreateResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.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 Exception:
session.rollback()
raise
@router.post(
"/{campaign_id}/versions/{version_id}/unlock-validation",
response_model=CampaignVersionDetailResponse,
)
def unlock_version_validation(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
"""Unlock a successfully validated version before delivery starts.
Unlocking invalidates validation/build state and removes generated jobs for
that version. Sent/final versions cannot be unlocked and must be copied.
"""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: unlock_validated_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
commit=False,
),
audit_action="campaign.version_validation_unlocked",
)
@router.post(
"/{campaign_id}/versions/{version_id}/lock-temporarily",
response_model=CampaignVersionDetailResponse,
)
def lock_version_temporarily(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: lock_campaign_version_temporarily(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
user_id=principal.user.id,
commit=False,
),
audit_action="campaign.version_user_locked_temporarily",
)
@router.post(
"/{campaign_id}/versions/{version_id}/unlock-user-lock",
response_model=CampaignVersionDetailResponse,
)
def unlock_version_user_lock(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: unlock_user_locked_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
commit=False,
),
audit_action="campaign.version_user_lock_removed",
)
@router.post(
"/{campaign_id}/versions/{version_id}/lock-permanently",
response_model=CampaignVersionDetailResponse,
)
def lock_version_permanently(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: permanently_lock_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
user_id=principal.user.id,
commit=False,
),
audit_action="campaign.version_user_locked_permanently",
)
@router.put(
"/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse
)
def update_version_detail(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
result = _update_campaign_version_detail_response(
session,
principal,
campaign_id,
version_id,
payload,
if_match=if_match,
autosave=False,
audit_action="campaign.version_updated",
)
response.headers["ETag"] = result.strong_etag
return result
@router.post(
"/{campaign_id}/versions/{version_id}/autosave",
response_model=CampaignVersionDetailResponse,
)
def autosave_version(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
result = _update_campaign_version_detail_response(
session,
principal,
campaign_id,
version_id,
payload,
if_match=if_match,
autosave=True,
audit_action="campaign.version_autosaved",
)
response.headers["ETag"] = result.strong_etag
return result
@router.post(
"/{campaign_id}/versions/{version_id}/set-step",
response_model=CampaignVersionDetailResponse,
)
def set_version_step(
campaign_id: str,
version_id: str,
payload: CampaignVersionSetStepRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _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,
current_flow=payload.current_flow,
current_step=payload.current_step,
autosave=True,
commit=False,
),
audit_action="campaign.version_step_updated",
details={
"campaign_id": campaign_id,
"current_flow": payload.current_flow,
"current_step": payload.current_step,
},
)
@router.post(
"/{campaign_id}/versions/{version_id}/review-state",
response_model=CampaignVersionDetailResponse,
)
def set_version_review_state(
campaign_id: str,
version_id: str,
payload: CampaignReviewStateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
try:
version = update_campaign_review_state(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
inspection_complete=payload.inspection_complete,
reviewed_message_keys=payload.reviewed_message_keys,
issue_decisions=[
item.model_dump()
for item in payload.issue_decisions
],
user_id=principal.user.id,
commit=False,
)
audit_from_principal(
session,
principal,
action="campaign.message_review_updated",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign_id,
"inspection_complete": payload.inspection_complete,
"reviewed_message_count": len(payload.reviewed_message_keys),
"issue_decision_count": len(payload.issue_decisions),
},
commit=True,
)
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 CampaignPersistenceError 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}/versions/{version_id}/validate-partial",
response_model=CampaignPartialValidationResponse,
)
def validate_version_partial(
campaign_id: str,
version_id: str,
payload: CampaignPartialValidationRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
try:
version = get_campaign_version_for_tenant(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
campaign_json = (
payload.campaign_json
if payload and payload.campaign_json is not None
else version.raw_json
)
result = validate_campaign_partial(
campaign_json, section=payload.section if payload else None
)
audit_from_principal(
session,
principal,
action="campaign.version_partially_validated",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign_id,
"section": result.get("section"),
"ok": result.get("ok"),
},
commit=True,
)
return CampaignPartialValidationResponse(**result)
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
@router.post(
"/{campaign_id}/versions/{version_id}/publish",
response_model=CampaignVersionDetailResponse,
)
def publish_version(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: publish_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
user_id=principal.user.id,
commit=False,
),
audit_action="campaign.version_user_locked_permanently",
)
@router.post("/versions/{version_id}/validate")
def validate_version(
version_id: str,
payload: ValidateCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
):
_get_version_for_principal(session, version_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or ValidateCampaignRequest()
try:
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 {}
)
if is_user_locked_version(version) or is_version_final_locked(version):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.",
)
link_result: CampaignAttachmentLinkMatchesResponse | None = None
if payload.check_files and payload.link_unshared_matches:
_require_permission(principal, "files:file:share")
campaign = _get_campaign_for_tenant(
session, version.campaign_id, principal.tenant_id
)
link_result = _link_campaign_attachment_matches(
session,
principal,
campaign=campaign,
version=version,
raw=version.raw_json if isinstance(version.raw_json, dict) else {},
dry_run=False,
)
audit_from_principal(
session,
principal,
action="campaign.attachment_matches_linked",
object_type="campaign_version",
object_id=version_id,
details={
"matched_file_count": link_result.matched_file_count,
"already_linked_file_count": link_result.already_linked_file_count,
"linked_file_count": link_result.linked_file_count,
"during_validation": True,
},
commit=True,
)
result = validate_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
check_files=payload.check_files,
user_id=principal.user.id,
principal=principal,
)
audit_from_principal(
session,
principal,
action="campaign.validated",
object_type="campaign_version",
object_id=version_id,
details={
"check_files": payload.check_files,
"link_unshared_matches": payload.link_unshared_matches,
"linked_file_count": link_result.linked_file_count
if link_result
else 0,
"ok": result.get("ok"),
},
commit=True,
)
return public_campaign_payload(
result,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except HTTPException:
raise
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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("/versions/{version_id}/build")
def build_version(
version_id: str,
request: Request,
payload: BuildCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")),
):
version = _get_version_for_principal(session, version_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
_require_mail_profile_use_if_needed(
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
)
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
write_eml = payload.write_eml if payload else True
source_sha256 = _canonical_sha256(raw_json)
validation_sha256 = _canonical_sha256(
version.validation_summary
if isinstance(version.validation_summary, dict)
else {}
)
idempotency_key = (
payload.idempotency_key
if payload and payload.idempotency_key
else (
f"campaign-build:{version.id}:{source_sha256}:"
f"{validation_sha256}:{int(write_eml)}"
)
)
try:
identity = application_runtime_identity(request.app)
recovery_start = begin_durable_recovery_operation(
get_database().SessionLocal,
identity=identity,
module_id="campaigns",
operation_type="build-artifacts",
idempotency_key=idempotency_key,
request={
"tenant_id": principal.tenant_id,
"campaign_id": version.campaign_id,
"version_id": version.id,
"source_sha256": source_sha256,
"validation_sha256": validation_sha256,
"write_eml": write_eml,
},
recovery_plan=_campaign_build_recovery_plan(raw_json),
precondition_evidence={
"campaign_version_id": version.id,
"source_sha256": source_sha256,
"validation_sha256": validation_sha256,
"locked_at": version.locked_at.isoformat()
if version.locked_at
else None,
"workflow_state": version.workflow_state,
},
lease_resource_key=(
f"campaign:build:{principal.tenant_id}:{version.id}"
),
lease_ttl_seconds=30 * 60,
resource_type="campaign_version",
resource_id=version.id,
metadata={"actor_account_id": principal.account_id},
)
if recovery_start.replayed:
session.refresh(version)
if not isinstance(version.build_summary, dict):
raise RecoveryGuaranteeError(
"A completed build operation has no committed Campaign summary"
)
return public_campaign_payload(
version.build_summary,
include_diagnostics=has_scope(
principal, "campaigns:diagnostic:read"
),
)
recovery_operation = recovery_start.operation
if recovery_operation is None: # pragma: no cover - guarded by replay branch
raise RecoveryGuaranteeError("Campaign build authority was not created")
result = build_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
write_eml=write_eml,
user_id=principal.user.id,
principal=principal,
recovery_operation=recovery_operation,
build_id=recovery_start.operation_id,
)
audit_from_principal(
session,
principal,
action="campaign.messages_built",
object_type="campaign_version",
object_id=version_id,
details={
"write_eml": write_eml,
"built_count": result.get("built_count"),
"recovery_operation_id": recovery_start.operation_id,
},
commit=True,
)
return public_campaign_payload(
result,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except RecoveryGuaranteeError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Campaign build coordination is unavailable",
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@@ -60,7 +60,9 @@
"integer", "integer",
"double", "double",
"date", "date",
"password" "password",
"organization_unit",
"organization_function"
], ],
"default": "string" "default": "string"
}, },
@@ -92,7 +94,27 @@
"mail_profile_id": { "mail_profile_id": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"description": "Stable reference to an authorized profile owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials." "description": "Stable reference to an authorized server envelope owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials."
},
"smtp_server_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit Mail-owned SMTP server selection."
},
"smtp_credential_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit core credential envelope bound to the selected SMTP server."
},
"imap_server_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit Mail-owned IMAP server selection."
},
"imap_credential_id": {
"type": "string",
"minLength": 1,
"description": "Optional explicit core credential envelope bound to the selected IMAP server."
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -337,7 +359,7 @@
"continue", "continue",
"warn" "warn"
], ],
"default": "ask" "default": "warn"
}, },
"ambiguous_behavior": { "ambiguous_behavior": {
"type": "string", "type": "string",
@@ -433,7 +455,7 @@
"continue", "continue",
"warn" "warn"
], ],
"default": "ask" "default": "block"
}, },
"missing_optional_attachment": { "missing_optional_attachment": {
"type": "string", "type": "string",
@@ -509,6 +531,15 @@
"delivery": { "delivery": {
"type": "object", "type": "object",
"properties": { "properties": {
"channel_policy": {
"$ref": "#/$defs/delivery_channel_policy"
},
"postbox": {
"$ref": "#/$defs/postbox_delivery"
},
"print": {
"$ref": "#/$defs/print_delivery"
},
"rate_limit": { "rate_limit": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -626,6 +657,249 @@
}, },
"additionalProperties": false "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": { "attachment_config": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -869,6 +1143,36 @@
"default": true, "default": true,
"description": "Deprecated compatibility alias for merge_disposition_notification_to. New campaign JSON should use merge_*." "description": "Deprecated compatibility alias for merge_disposition_notification_to. New campaign JSON should use merge_*."
}, },
"channel_policy": {
"oneOf": [
{
"$ref": "#/$defs/delivery_channel_policy"
},
{
"type": "null"
}
],
"default": null
},
"postbox_targets": {
"type": "array",
"items": {
"$ref": "#/$defs/postbox_target"
},
"maxItems": 50,
"default": []
},
"merge_postbox_targets": {
"type": "boolean",
"default": true
},
"print_target": {
"oneOf": [
{ "$ref": "#/$defs/print_target" },
{ "type": "null" }
],
"default": null
},
"attachments": { "attachments": {
"type": "array", "type": "array",
"items": { "items": {
@@ -885,6 +1189,11 @@
"additionalProperties": true, "additionalProperties": true,
"default": {} "default": {}
}, },
"distribution_source": {
"type": "object",
"description": "Immutable Distribution List recipient, route-decision, and source evidence captured for this Campaign version.",
"additionalProperties": true
},
"last_sent": { "last_sent": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
@@ -971,7 +1280,8 @@
"csv", "csv",
"xlsx", "xlsx",
"text", "text",
"addresses" "addresses",
"distribution_list"
] ]
}, },
"source_id": { "source_id": {
+282 -54
View File
@@ -1,9 +1,17 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator from pydantic import (
BaseModel,
BeforeValidator,
ConfigDict,
Field,
ValidationInfo,
field_validator,
model_validator,
)
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_campaign.backend.campaign.mail_profile_boundary import ( from govoplan_campaign.backend.campaign.mail_profile_boundary import (
@@ -25,8 +33,6 @@ class CampaignCreateRequest(BaseModel):
source_base_path: str | None = None source_base_path: str | None = None
class CampaignUpdateRequest(BaseModel): class CampaignUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -58,10 +64,15 @@ class CampaignVersionUpdateRequest(BaseModel):
source_filename: str | None = None source_filename: str | None = None
source_base_path: str | None = None source_base_path: str | None = None
migrate_legacy_mail_settings: bool = False 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") @field_validator("editor_state")
@classmethod @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 return validate_campaign_editor_state(value) if value is not None else None
@@ -72,11 +83,23 @@ class CampaignVersionSetStepRequest(BaseModel):
current_step: str 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): class CampaignReviewStateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
inspection_complete: bool = False inspection_complete: bool = False
reviewed_message_keys: list[str] = Field(default_factory=list) reviewed_message_keys: list[str] = Field(default_factory=list)
issue_decisions: list[CampaignReviewDecisionRequest] = Field(
default_factory=list,
max_length=100_000,
)
class CampaignPartialValidationRequest(BaseModel): class CampaignPartialValidationRequest(BaseModel):
@@ -92,6 +115,8 @@ class CampaignVersionResponse(BaseModel):
id: str id: str
campaign_id: str campaign_id: str
version_number: int version_number: int
edit_revision: int = 1
strong_etag: str = ""
schema_version: str schema_version: str
source_filename: str | None = None source_filename: str | None = None
workflow_state: str = "editing" workflow_state: str = "editing"
@@ -112,12 +137,16 @@ class CampaignVersionResponse(BaseModel):
build_summary: dict[str, Any] | None = None build_summary: dict[str, Any] | None = None
execution_snapshot_hash: str | None = None execution_snapshot_hash: str | None = None
execution_snapshot_at: datetime | 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 delivery_mode_selected_at: datetime | None = None
@field_validator("editor_state", mode="before") @field_validator("editor_state", mode="before")
@classmethod @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( return public_campaign_editor_state(
value, value,
include_diagnostics=bool((info.context or {}).get("include_diagnostics")), include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
@@ -194,6 +223,10 @@ class CampaignDeltaResponse(BaseModel):
watermark: str | None = None watermark: str | None = None
has_more: bool = False has_more: bool = False
full: bool = False full: bool = False
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse): class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse):
@@ -216,6 +249,10 @@ class CampaignShareItem(BaseModel):
class CampaignShareListResponse(BaseModel): class CampaignShareListResponse(BaseModel):
shares: list[CampaignShareItem] shares: list[CampaignShareItem]
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class CampaignShareTargetItem(BaseModel): class CampaignShareTargetItem(BaseModel):
@@ -266,7 +303,9 @@ class RecipientImportColumnMappingPayload(BaseModel):
column_index: int = Field(ge=0, alias="columnIndex") column_index: int = Field(ge=0, alias="columnIndex")
kind: RecipientImportColumnKind kind: RecipientImportColumnKind
field_name: str | None = Field(default=None, max_length=255, alias="fieldName") 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): class RecipientImportMappingProfilePayload(BaseModel):
@@ -275,14 +314,22 @@ class RecipientImportMappingProfilePayload(BaseModel):
name: str = Field(min_length=1, max_length=255) name: str = Field(min_length=1, max_length=255)
column_count: int = Field(ge=0, le=500, alias="columnCount") column_count: int = Field(ge=0, le=500, alias="columnCount")
headers: list[str] = Field(default_factory=list, max_length=500) headers: list[str] = Field(default_factory=list, max_length=500)
normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders") normalized_headers: list[str] = Field(
ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint") default_factory=list, max_length=500, alias="normalizedHeaders"
unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint") )
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"] delimiter: Literal[",", ";", "\t"]
header_rows: int = Field(ge=0, le=10, alias="headerRows") header_rows: int = Field(ge=0, le=10, alias="headerRows")
quoted: bool = True quoted: bool = True
value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators") 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") @model_validator(mode="after")
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload": def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
@@ -342,10 +389,24 @@ class CampaignRecipientAddressSourcesResponse(BaseModel):
sources: list[CampaignRecipientAddressSource] = Field(default_factory=list) sources: list[CampaignRecipientAddressSource] = Field(default_factory=list)
class CampaignPostboxCatalogResponse(BaseModel):
available: bool = False
postboxes: list[dict[str, Any]] = Field(default_factory=list)
templates: list[dict[str, Any]] = Field(default_factory=list)
organization_units: list[dict[str, Any]] = Field(default_factory=list)
class CampaignCalendarCatalogResponse(BaseModel):
available: bool = False
reason: str | None = None
calendars: list[dict[str, Any]] = Field(default_factory=list)
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel): class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
source_id: str = Field(min_length=1) source_id: str = Field(min_length=1)
purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120)
class CampaignRecipientSnapshotItem(BaseModel): class CampaignRecipientSnapshotItem(BaseModel):
@@ -357,6 +418,18 @@ class CampaignRecipientSnapshotItem(BaseModel):
provenance: dict[str, Any] = Field(default_factory=dict) 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): class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_id: str source_id: str
source_label: str source_label: str
@@ -364,9 +437,139 @@ class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_revision: str source_revision: str
generated_at: str generated_at: str
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list) 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) 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): class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]] jobs: list[dict[str, Any]]
page: int = 1 page: int = 1
@@ -425,11 +628,21 @@ class CampaignSendUnattemptedRequest(BaseModel):
class CampaignSendJobRequest(BaseModel): class CampaignSendJobRequest(BaseModel):
model_config = ConfigDict(extra="forbid") 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 include_warnings: bool = True
dry_run: bool = False
use_rate_limit: bool = True use_rate_limit: bool = True
enqueue_imap_task: bool = False 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): class CampaignResolveOutcomeRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -439,8 +652,11 @@ class CampaignResolveOutcomeRequest(BaseModel):
"not_sent", "not_sent",
"imap_appended", "imap_appended",
"imap_not_appended", "imap_not_appended",
"postbox_accepted",
"postbox_not_accepted",
] ]
note: str | None = Field(default=None, max_length=2000) note: str | None = Field(default=None, max_length=2000)
attempt_id: str | None = Field(default=None, max_length=36)
@model_validator(mode="after") @model_validator(mode="after")
def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest": def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest":
@@ -461,6 +677,7 @@ class BuildCampaignRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
write_eml: bool = True write_eml: bool = True
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
class ApiKeyCreateRequest(BaseModel): class ApiKeyCreateRequest(BaseModel):
@@ -520,6 +737,8 @@ class CampaignDeliveryOptionsResponse(BaseModel):
campaign_id: str campaign_id: str
version_id: str version_id: str
worker_queue_available: bool worker_queue_available: bool
postbox_available: bool = False
approval_gate: dict[str, Any] = Field(default_factory=dict)
synchronous_send: dict[str, Any] = Field(default_factory=dict) synchronous_send: dict[str, Any] = Field(default_factory=dict)
@@ -551,63 +770,72 @@ class CampaignActionResponse(BaseModel):
result: dict[str, Any] result: dict[str, Any]
class ReportEmailRequest(BaseModel): def _valid_report_email_domain(domain: str) -> bool:
model_config = ConfigDict(extra="forbid") if not domain or domain.startswith(".") or domain.endswith(".") or ".." in domain:
return False
return all(
label
and not label.startswith("-")
and not label.endswith("-")
and all(character.isalnum() or character == "-" for character in label)
for label in domain.split(".")
)
to: list[str] = Field(min_length=1, max_length=50)
version_id: str | None = None
include_jobs: bool = False
attach_jobs_csv: bool = False
attach_report_json: bool = False
dry_run: bool = False
@field_validator("to", mode="before") def _normalize_report_recipient(value: Any) -> str:
@classmethod if not isinstance(value, str):
def normalize_and_validate_recipients(cls, value: Any) -> Any:
if not isinstance(value, list):
return value
if not 1 <= len(value) <= 50:
raise ValueError("report email requires between 1 and 50 recipients")
recipients: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
raise ValueError("report recipients must be email-address strings") raise ValueError("report recipients must be email-address strings")
recipient = item.strip() recipient = value.strip()
if len(recipient) > 320: if len(recipient) > 320:
raise ValueError("report recipient addresses must be at most 320 characters") raise ValueError("report recipient addresses must be at most 320 characters")
if any(ord(character) < 32 or ord(character) == 127 for character in recipient): if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
raise ValueError("report recipient addresses must not contain control characters") raise ValueError(
"report recipient addresses must not contain control characters"
)
if recipient.count("@") != 1: if recipient.count("@") != 1:
raise ValueError("report recipients must be email addresses") raise ValueError("report recipients must be email addresses")
local, domain = recipient.split("@", 1) local, domain = recipient.split("@", 1)
if ( invalid_local = (
not local not local or local.startswith(".") or local.endswith(".") or ".." in 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(".")
) )
): 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") raise ValueError("report recipients must be email addresses")
return recipient
ReportEmailAddress = Annotated[str, BeforeValidator(_normalize_report_recipient)]
def _deduplicate_report_recipients(value: list[str]) -> list[str]:
recipients: list[str] = []
seen: set[str] = set()
for recipient in value:
key = recipient.casefold() key = recipient.casefold()
if key in seen: if key not in seen:
continue
seen.add(key) seen.add(key)
recipients.append(recipient) recipients.append(recipient)
return recipients return recipients
class ReportEmailRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
to: list[ReportEmailAddress] = Field(min_length=1, max_length=50)
version_id: str | None = None
include_jobs: bool = False
attach_jobs_csv: bool = False
attach_report_json: bool = False
dry_run: bool = False
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
@field_validator("to")
@classmethod
def normalize_and_validate_recipients(cls, value: list[str]) -> list[str]:
return _deduplicate_report_recipients(value)
class ReportEmailResponse(BaseModel): class ReportEmailResponse(BaseModel):
result: dict[str, Any] result: dict[str, Any]
@@ -9,16 +9,21 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
from govoplan_campaign.backend.campaign.models import DeliveryConfig from govoplan_campaign.backend.campaign.models import (
DeliveryChannelPolicy,
DeliveryConfig,
)
from govoplan_campaign.backend.campaign.mail_profile_boundary import ( from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError, CampaignMailProfileBoundaryError,
assert_campaign_uses_mail_profile_reference, assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_id, campaign_mail_profile_id,
campaign_mail_resource_ids,
) )
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
SNAPSHOT_VERSION = "5" SNAPSHOT_VERSION = "8"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION}
class ExecutionSnapshotError(RuntimeError): class ExecutionSnapshotError(RuntimeError):
@@ -40,7 +45,11 @@ class ExecutionSnapshot(BaseModel):
snapshot_version: str = SNAPSHOT_VERSION snapshot_version: str = SNAPSHOT_VERSION
campaign_version_id: str campaign_version_id: str
campaign_json_sha256: str campaign_json_sha256: str
mail_profile_id: str mail_profile_id: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
imap_server_id: str | None = None
imap_credential_id: str | None = None
created_at: str created_at: str
build_token: str | None = None build_token: str | None = None
built_at: str | None = None built_at: str | None = None
@@ -50,6 +59,9 @@ class ExecutionSnapshot(BaseModel):
effective_policy_sha256: str | None = None effective_policy_sha256: str | None = None
smtp_transport_revision: str | None = None smtp_transport_revision: str | None = None
imap_transport_revision: str | None = None imap_transport_revision: str | None = None
uses_mail: bool = True
uses_postbox: bool = False
uses_print: bool = False
delivery: DeliveryConfig delivery: DeliveryConfig
@@ -67,7 +79,7 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]: def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json) _assert_version_mail_profile_boundary(raw_json, require_profile=True)
mail = mail_integration() mail = mail_integration()
profile_id = campaign_mail_profile_id(raw_json) profile_id = campaign_mail_profile_id(raw_json)
if profile_id is None: # Kept explicit for static typing; the assertion above requires it. if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
@@ -75,12 +87,17 @@ def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict
campaign = session.get(Campaign, version.campaign_id) campaign = session.get(Campaign, version.campaign_id)
if campaign is None: if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution") raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
references = campaign_mail_resource_ids(raw_json)
try: try:
return mail.campaign_profile_delivery_summary( return mail.campaign_profile_delivery_summary(
session, session,
tenant_id=campaign.tenant_id, tenant_id=campaign.tenant_id,
campaign_id=campaign.id, campaign_id=campaign.id,
profile_id=profile_id, profile_id=profile_id,
smtp_server_id=references["smtp_server_id"],
smtp_credential_id=references["smtp_credential_id"],
imap_server_id=references["imap_server_id"],
imap_credential_id=references["imap_credential_id"],
) )
except MailProfileError as exc: except MailProfileError as exc:
raise ExecutionSnapshotError(str(exc)) from exc raise ExecutionSnapshotError(str(exc)) from exc
@@ -96,27 +113,63 @@ def profile_transport_revisions(session: Session, version: CampaignVersion) -> d
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None: def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json) _assert_version_mail_profile_boundary(
raw_json,
require_profile=snapshot.uses_mail,
)
if not snapshot.uses_mail:
return
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id: if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"The campaign's Mail profile reference differs from the built execution snapshot. " "The campaign's Mail profile reference differs from the built execution snapshot. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
) )
references = campaign_mail_resource_ids(raw_json)
for key in (
"smtp_server_id",
"smtp_credential_id",
"imap_server_id",
"imap_credential_id",
):
configured = references[key]
if configured and configured != getattr(snapshot, key):
raise ExecutionSnapshotError(
"The campaign's Mail server or credential selection differs from the built "
"execution snapshot. Revalidate and rebuild before delivery."
)
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None: def _assert_version_mail_profile_boundary(
raw_json: dict[str, Any],
*,
require_profile: bool,
) -> None:
try: try:
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True) assert_campaign_uses_mail_profile_reference(
raw_json,
require_profile=require_profile,
)
except CampaignMailProfileBoundaryError as exc: except CampaignMailProfileBoundaryError as exc:
raise ExecutionSnapshotError(str(exc)) from exc raise ExecutionSnapshotError(str(exc)) from exc
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str: def _policy_fingerprint(
raw_json: dict[str, Any],
delivery: DeliveryConfig,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
delivery_payload = delivery.model_dump(mode="json")
if snapshot_version == "6":
delivery_payload.pop("channel_policy", None)
delivery_payload.pop("postbox", None)
if snapshot_version in {"6", "7"}:
delivery_payload.pop("print", None)
return _sha256( return _sha256(
{ {
"validation_policy": raw_json.get("validation_policy"), "validation_policy": raw_json.get("validation_policy"),
"policy": raw_json.get("policy"), "policy": raw_json.get("policy"),
"delivery": delivery.model_dump(mode="json"), "delivery": delivery_payload,
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults") "attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
if isinstance(raw_json.get("attachments"), dict) if isinstance(raw_json.get("attachments"), dict)
else None, else None,
@@ -124,8 +177,12 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
) )
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]: def _job_execution_input_payload(
return { job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> dict[str, Any]:
payload = {
"job_id": job.id, "job_id": job.id,
"entry_index": job.entry_index, "entry_index": job.entry_index,
"entry_id": job.entry_id, "entry_id": job.entry_id,
@@ -140,17 +197,54 @@ def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []), "resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []), "issues_sha256": _sha256(job.issues_snapshot or []),
} }
if snapshot_version != "6":
payload.update(
{
"delivery_channel_policy": getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
),
"resolved_postbox_targets_sha256": _sha256(
getattr(job, "resolved_postbox_targets", None) or []
),
}
)
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: def job_execution_input_hash(
return _sha256(_job_execution_input_payload(job)) job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
return _sha256(
_job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
)
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str: def job_manifest_hash(
jobs: Iterable[CampaignJob],
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
"""Hash the immutable per-message execution records in stable order.""" """Hash the immutable per-message execution records in stable order."""
payload = [ payload = [
_job_execution_input_payload(job) _job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)) for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
] ]
return _sha256(payload) return _sha256(payload)
@@ -159,31 +253,69 @@ def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
def create_execution_snapshot( def create_execution_snapshot(
version: CampaignVersion, version: CampaignVersion,
*, *,
mail_profile_id: str, mail_profile_id: str | None,
smtp_transport_revision: str, smtp_transport_revision: str | None,
imap_transport_revision: str | None, imap_transport_revision: str | None,
delivery: DeliveryConfig, delivery: DeliveryConfig,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
jobs: Iterable[CampaignJob] = (), jobs: Iterable[CampaignJob] = (),
build_summary: dict[str, Any] | None = None, build_summary: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]: ) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs) job_list = list(jobs)
channel_policies = {
DeliveryChannelPolicy(
getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
)
)
for job in job_list
}
uses_mail = any(policy.uses_mail for policy in channel_policies)
uses_postbox = any(policy.uses_postbox for policy in channel_policies)
uses_print = any(policy.uses_print for policy in channel_policies)
for job in job_list: for job in job_list:
job.execution_input_sha256 = job_execution_input_hash(job) job.execution_input_sha256 = job_execution_input_hash(
job,
snapshot_version=SNAPSHOT_VERSION,
)
summary = build_summary if isinstance(build_summary, dict) else {} summary = build_summary if isinstance(build_summary, dict) else {}
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value} queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
payload = ExecutionSnapshot( payload = ExecutionSnapshot(
campaign_version_id=version.id, campaign_version_id=version.id,
campaign_json_sha256=_sha256(raw_json), campaign_json_sha256=_sha256(raw_json),
mail_profile_id=mail_profile_id, mail_profile_id=mail_profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
imap_server_id=imap_server_id,
imap_credential_id=imap_credential_id,
build_token=str(summary.get("build_token") or "") or None, build_token=str(summary.get("build_token") or "") or None,
built_at=str(summary.get("built_at") or "") or None, built_at=str(summary.get("built_at") or "") or None,
job_count=len(job_list), job_count=len(job_list),
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses), queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None, job_manifest_sha256=(
effective_policy_sha256=_policy_fingerprint(raw_json, delivery), job_manifest_hash(
job_list,
snapshot_version=SNAPSHOT_VERSION,
)
if job_list
else None
),
effective_policy_sha256=_policy_fingerprint(
raw_json,
delivery,
snapshot_version=SNAPSHOT_VERSION,
),
smtp_transport_revision=smtp_transport_revision, smtp_transport_revision=smtp_transport_revision,
imap_transport_revision=imap_transport_revision, imap_transport_revision=imap_transport_revision,
uses_mail=uses_mail,
uses_postbox=uses_postbox,
uses_print=uses_print,
created_at=datetime.now(timezone.utc).isoformat(), created_at=datetime.now(timezone.utc).isoformat(),
delivery=delivery, delivery=delivery,
).model_dump(mode="json") ).model_dump(mode="json")
@@ -207,13 +339,17 @@ def _assert_snapshot_matches_persisted_inputs(
"Campaign inputs changed after this execution snapshot was built. " "Campaign inputs changed after this execution snapshot was built. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
) )
if not snapshot.smtp_transport_revision: if snapshot.uses_mail and not snapshot.smtp_transport_revision:
raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision") raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision")
if not snapshot.job_manifest_sha256: if not snapshot.job_manifest_sha256:
raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum") raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum")
if not snapshot.effective_policy_sha256: if not snapshot.effective_policy_sha256:
raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum") raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum")
if snapshot.effective_policy_sha256 != _policy_fingerprint(raw_json, snapshot.delivery): if snapshot.effective_policy_sha256 != _policy_fingerprint(
raw_json,
snapshot.delivery,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"Campaign delivery policy changed after the execution snapshot was created. " "Campaign delivery policy changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
@@ -224,7 +360,10 @@ def _assert_snapshot_matches_persisted_inputs(
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version") raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
if not getattr(effect_job, "execution_input_sha256", None): if not getattr(effect_job, "execution_input_sha256", None):
raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery") raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery")
if effect_job.execution_input_sha256 != job_execution_input_hash(effect_job): if effect_job.execution_input_sha256 != job_execution_input_hash(
effect_job,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. " "Built campaign job inputs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
@@ -246,8 +385,19 @@ def _assert_snapshot_matches_persisted_inputs(
queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses) queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses)
if ( if (
snapshot.queueable_job_count != queueable_count snapshot.queueable_job_count != queueable_count
or snapshot.job_manifest_sha256 != job_manifest_hash(jobs) or snapshot.job_manifest_sha256
or any(getattr(job, "execution_input_sha256", None) != job_execution_input_hash(job) for job in jobs) != job_manifest_hash(
jobs,
snapshot_version=snapshot.snapshot_version,
)
or any(
getattr(job, "execution_input_sha256", None)
!= job_execution_input_hash(
job,
snapshot_version=snapshot.snapshot_version,
)
for job in jobs
)
): ):
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. " "Built campaign job inputs changed after the execution snapshot was created. "
@@ -276,17 +426,20 @@ def ensure_execution_snapshot(
) )
except CampaignPathSecurityError as exc: except CampaignPathSecurityError as exc:
raise ExecutionSnapshotError(str(exc)) from exc raise ExecutionSnapshotError(str(exc)) from exc
_assert_version_mail_profile_boundary(raw_json) _assert_version_mail_profile_boundary(raw_json, require_profile=False)
if isinstance(version.execution_snapshot, dict): if isinstance(version.execution_snapshot, dict):
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION: stored_version = str(
version.execution_snapshot.get("snapshot_version") or ""
)
if stored_version not in SUPPORTED_SNAPSHOT_VERSIONS:
raise ExecutionSnapshotError( raise ExecutionSnapshotError(
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. " "This campaign has a legacy execution snapshot that may contain campaign-owned transport data. "
"It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate " "It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
"and rebuild a new campaign version." "and rebuild a new campaign version."
) )
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot) snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
expected = snapshot_hash(snapshot.model_dump(mode="json")) expected = snapshot_hash(version.execution_snapshot)
if not version.execution_snapshot_hash: if not version.execution_snapshot_hash:
raise ExecutionSnapshotError("Execution snapshot checksum is missing") raise ExecutionSnapshotError("Execution snapshot checksum is missing")
if version.execution_snapshot_hash != expected: if version.execution_snapshot_hash != expected:
@@ -303,11 +456,6 @@ def ensure_execution_snapshot(
from govoplan_campaign.backend.persistence.campaigns import load_version_config from govoplan_campaign.backend.persistence.campaigns import load_version_config
_, _, config = load_version_config(session, version.id) _, _, config = load_version_config(session, version.id)
profile_id = campaign_mail_profile_id(raw_json)
if not config.server.profile_capabilities.smtp_available:
raise ExecutionSnapshotError("The selected Mail profile has no SMTP configuration")
if profile_id is None:
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
jobs = ( jobs = (
session.query(CampaignJob) session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id) .filter(CampaignJob.campaign_version_id == version.id)
@@ -316,14 +464,33 @@ def ensure_execution_snapshot(
) )
if not jobs: if not jobs:
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery") raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
revisions = profile_transport_revisions(session, version) uses_mail = any(
if not revisions["smtp"]: DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision") for job in jobs
)
profile_id = campaign_mail_profile_id(raw_json)
summary: dict[str, Any] = {}
if uses_mail:
if not config.server.profile_capabilities.smtp_available:
raise ExecutionSnapshotError(
"The selected Mail profile has no SMTP configuration"
)
if profile_id is None:
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
summary = profile_delivery_summary(session, version)
if not summary.get("smtp_transport_revision"):
raise ExecutionSnapshotError(
"The selected Mail profile has no SMTP transport revision"
)
payload, digest = create_execution_snapshot( payload, digest = create_execution_snapshot(
version, version,
mail_profile_id=profile_id, mail_profile_id=profile_id,
smtp_transport_revision=revisions["smtp"], smtp_server_id=summary.get("smtp_server_id"),
imap_transport_revision=revisions["imap"], smtp_credential_id=summary.get("smtp_credential_id"),
imap_server_id=summary.get("imap_server_id"),
imap_credential_id=summary.get("imap_credential_id"),
smtp_transport_revision=summary.get("smtp_transport_revision"),
imap_transport_revision=summary.get("imap_transport_revision"),
delivery=config.delivery, delivery=config.delivery,
jobs=jobs, jobs=jobs,
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {}, build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
File diff suppressed because it is too large Load Diff
@@ -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
+331 -3
View File
@@ -1,14 +1,26 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest.mock import patch
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.capabilities import CampaignAccessService from govoplan_campaign.backend.capabilities import (
from govoplan_campaign.backend.db.models import Campaign, CampaignShare CampaignAccessService,
CampaignOwnershipService,
campaign_report_resource_id,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignShare,
CampaignVersion,
)
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransferError
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -69,10 +81,326 @@ class CampaignAccessProviderTests(unittest.TestCase):
self.assertTrue(any(item.kind == "share" and item.id == "share-write" for item in items)) self.assertTrue(any(item.kind == "share" and item.id == "share-write" for item in items))
self.assertFalse(any(item.kind == "share" and item.id == "share-read" for item in items)) self.assertFalse(any(item.kind == "share" and item.id == "share-read" for item in items))
def test_campaign_children_explain_their_parent_campaign_access(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-child",
tenant_id=TENANT_ID,
owner_user_id=OTHER_USER_ID,
external_id="child",
name="Child resources",
)
version = CampaignVersion(
id="version-child",
campaign_id=campaign.id,
version_number=1,
raw_json={},
)
job = CampaignJob(
id="job-child",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=1,
recipient_email="recipient@example.test",
)
session.add_all(
[
campaign,
version,
job,
CampaignShare(
id="share-child",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
target_type="group",
target_id=GROUP_ID,
permission="read",
),
]
)
session.commit()
service = CampaignAccessService()
principal = _principal(group_ids={GROUP_ID})
version_items = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_version",
resource_id=version.id,
action="campaigns:version:read",
)
job_items = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_delivery_job",
resource_id=job.id,
action="campaigns:delivery_job:read",
)
report_id = campaign_report_resource_id(
campaign_id=campaign.id,
version_id=version.id,
report_kind="delivery",
)
report_items = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_report",
resource_id=report_id,
action="campaigns:report:read",
)
for items, source in (
(version_items, "campaigns.version"),
(job_items, "campaigns.delivery_job"),
(report_items, "campaigns.report"),
):
child = next(item for item in items if item.source == source)
self.assertEqual(
child.details["authorization_inherited_from"],
{
"resource_type": "campaign",
"resource_id": campaign.id,
},
)
self.assertTrue(
any(
item.source == "campaigns.campaign"
and item.id == campaign.id
for item in items
)
)
self.assertTrue(
any(item.kind == "share" and item.id == "share-child" for item in items)
)
report = next(item for item in report_items if item.source == "campaigns.report")
self.assertEqual(report.details["campaign_version_id"], version.id)
self.assertEqual(report.details["report_kind"], "delivery")
self.assertFalse(report.details["persisted"])
def test_campaign_ownership_provider_requires_group_acceptance_authority(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-ownership",
tenant_id=TENANT_ID,
owner_user_id=USER_ID,
external_id="ownership",
name="Ownership",
)
session.add(campaign)
session.commit()
service = CampaignOwnershipService()
current_owner = OwnershipSubjectRef(type="user", id=USER_ID)
target_group = OwnershipSubjectRef(type="group", id=GROUP_ID)
ordinary_member = OwnershipSubjectRef(
type="user",
id=OTHER_USER_ID,
group_ids=frozenset({GROUP_ID}),
)
group_manager = OwnershipSubjectRef(
type="user",
id=OTHER_USER_ID,
group_ids=frozenset({GROUP_ID}),
scopes=frozenset({"campaigns:ownership:accept_group"}),
)
denied = service.authorize_ownership_action(
session,
tenant_id=TENANT_ID,
resource_id=campaign.id,
action="accept_group_transfer",
actor=ordinary_member,
current_owner=current_owner,
target_owner=target_group,
)
allowed = service.authorize_ownership_action(
session,
tenant_id=TENANT_ID,
resource_id=campaign.id,
action="accept_group_transfer",
actor=group_manager,
current_owner=current_owner,
target_owner=target_group,
)
self.assertFalse(denied.allowed)
self.assertTrue(allowed.allowed)
@patch(
"govoplan_campaign.backend.capabilities._valid_campaign_owner_target",
return_value=None,
)
def test_campaign_ownership_request_requires_existing_read_access(
self,
_target_validation,
) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-request",
tenant_id=TENANT_ID,
owner_user_id=USER_ID,
external_id="request",
name="Request",
)
session.add(campaign)
session.commit()
service = CampaignOwnershipService()
requester = OwnershipSubjectRef(
type="user",
id=OTHER_USER_ID,
scopes=frozenset({"campaigns:campaign:read"}),
)
current_owner = OwnershipSubjectRef(type="user", id=USER_ID)
denied = service.authorize_ownership_action(
session,
tenant_id=TENANT_ID,
resource_id=campaign.id,
action="request_ownership",
actor=requester,
current_owner=current_owner,
target_owner=requester,
)
session.add(
CampaignShare(
id="share-requester",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
target_type="user",
target_id=OTHER_USER_ID,
permission="read",
)
)
session.commit()
allowed = service.authorize_ownership_action(
session,
tenant_id=TENANT_ID,
resource_id=campaign.id,
action="request_ownership",
actor=requester,
current_owner=current_owner,
target_owner=requester,
)
self.assertFalse(denied.allowed)
self.assertTrue(allowed.allowed)
@patch(
"govoplan_campaign.backend.capabilities._valid_campaign_owner_target",
return_value=None,
)
def test_campaign_owner_proposal_requires_share_authority(
self,
_target_validation,
) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-proposal",
tenant_id=TENANT_ID,
owner_user_id=USER_ID,
external_id="proposal",
name="Proposal",
)
session.add(campaign)
session.commit()
service = CampaignOwnershipService()
current_owner = OwnershipSubjectRef(type="user", id=USER_ID)
target_owner = OwnershipSubjectRef(type="user", id=OTHER_USER_ID)
denied = service.authorize_ownership_action(
session,
tenant_id=TENANT_ID,
resource_id=campaign.id,
action="propose_transfer",
actor=current_owner,
current_owner=current_owner,
target_owner=target_owner,
)
allowed = service.authorize_ownership_action(
session,
tenant_id=TENANT_ID,
resource_id=campaign.id,
action="propose_transfer",
actor=OwnershipSubjectRef(
type="user",
id=USER_ID,
scopes=frozenset({"campaigns:campaign:share"}),
),
current_owner=current_owner,
target_owner=target_owner,
)
self.assertFalse(denied.allowed)
self.assertTrue(allowed.allowed)
def test_campaign_ownership_provider_applies_only_against_expected_owner(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-owner-apply",
tenant_id=TENANT_ID,
owner_user_id=USER_ID,
external_id="owner-apply",
name="Owner apply",
)
session.add(campaign)
session.commit()
service = CampaignOwnershipService()
service.apply_owner(
session,
tenant_id=TENANT_ID,
resource_id=campaign.id,
expected_owner=OwnershipSubjectRef(type="user", id=USER_ID),
target_owner=OwnershipSubjectRef(type="group", id=GROUP_ID),
actor=OwnershipSubjectRef(type="user", id=OTHER_USER_ID),
reason=None,
)
session.flush()
self.assertIsNone(campaign.owner_user_id)
self.assertEqual(campaign.owner_group_id, GROUP_ID)
with self.assertRaisesRegex(
OwnershipTransferError,
"changed while the transfer was pending",
):
service.apply_owner(
session,
tenant_id=TENANT_ID,
resource_id=campaign.id,
expected_owner=OwnershipSubjectRef(type="user", id=USER_ID),
target_owner=OwnershipSubjectRef(type="user", id=OTHER_USER_ID),
actor=OwnershipSubjectRef(type="user", id=USER_ID),
reason=None,
)
def _session(): def _session():
engine = create_engine("sqlite:///:memory:", future=True) engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, Campaign.__table__, CampaignShare.__table__]) Base.metadata.create_all(
bind=engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
Campaign.__table__,
CampaignShare.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
ChangeSequenceEntry.__table__,
],
)
return sessionmaker(bind=engine, future=True)() return sessionmaker(bind=engine, future=True)()
+63 -18
View File
@@ -9,12 +9,20 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User 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.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.reports.aggregate import ( from govoplan_campaign.backend.reports.aggregate import (
AggregateCampaignReportError, AggregateCampaignReportError,
generate_aggregate_campaign_report, 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_campaign.backend.schemas import ReportEmailRequest
from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base 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") campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
with ( 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, pytest.raises(HTTPException) as full_report_denied,
): ):
router.campaign_report( report_routes.campaign_report(
"campaign-1", "campaign-1",
session=session, session=session,
principal=principal, # type: ignore[arg-type] 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 assert "campaigns:recipient:read" in full_report_denied.value.detail
with ( 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, pytest.raises(HTTPException) as job_detail_denied,
): ):
router.get_job_detail( job_routes.get_job_detail(
"campaign-1", "campaign-1",
"job-1", "job-1",
session=session, 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 assert job_detail_denied.value.status_code == 403
with ( 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, pytest.raises(HTTPException) as report_email_denied,
): ):
router.email_campaign_report( report_routes.email_campaign_report(
"campaign-1", "campaign-1",
ReportEmailRequest(to=["auditor@example.test"]), ReportEmailRequest(to=["auditor@example.test"]),
session=session, 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 assert "campaigns:recipient:export" in report_email_denied.value.detail
with ( 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, pytest.raises(HTTPException) as diagnostics_denied,
): ):
router.get_job_diagnostics( job_routes.get_job_diagnostics(
"campaign-1", "campaign-1",
"job-1", "job-1",
session=session, session=session,
@@ -93,14 +105,14 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
safe_projection = Mock() safe_projection = Mock()
with ( with (
patch.object(router, "_get_campaign_for_principal") as acl, patch.object(campaign_routes, "_get_campaign_for_principal") as acl,
patch.object( patch.object(
router, campaign_routes,
"generate_aggregate_campaign_report", "generate_aggregate_campaign_report",
return_value=safe_projection, return_value=safe_projection,
) as generate, ) as generate,
): ):
result = router.aggregate_campaign_report( result = campaign_routes.aggregate_campaign_report(
"campaign-1", "campaign-1",
session=session, session=session,
principal=principal, # type: ignore[arg-type] 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: def test_aggregate_routes_require_report_read_permission(path: str) -> None:
route = next(item for item in router.router.routes if item.path == path) 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") dependency = next(
item for item in route.dependant.dependencies if item.name == "principal"
)
with pytest.raises(HTTPException) as denied: with pytest.raises(HTTPException) as denied:
dependency.call(_Principal()) dependency.call(_Principal())
@@ -145,7 +162,9 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
], ],
) )
with Session(engine) as session: 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( campaign = Campaign(
id="campaign-1", id="campaign-1",
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -165,7 +184,8 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
campaign.current_version_id = version.id campaign.current_version_id = version.id
session.add_all([campaign, version]) session.add_all([campaign, version])
for index in range(5): for index in range(5):
session.add(CampaignJob( session.add(
CampaignJob(
id=f"job-{index}", id=f"job-{index}",
tenant_id="tenant-1", tenant_id="tenant-1",
campaign_id=campaign.id, campaign_id=campaign.id,
@@ -178,7 +198,8 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
queue_status="queued", queue_status="queued",
send_status="smtp_accepted", send_status="smtp_accepted",
imap_status="not_requested", imap_status="not_requested",
)) )
)
session.commit() session.commit()
report = generate_aggregate_campaign_report( 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.population.denominator.value == 5
assert report.outcomes.smtp_accepted.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): with pytest.raises(AggregateCampaignReportError):
generate_aggregate_campaign_report( generate_aggregate_campaign_report(
session, 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)
+67 -2
View File
@@ -100,9 +100,9 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
cases = { cases = {
"block": ("build_failed", "blocked", 0, "block", False), "block": ("build_failed", "blocked", 0, "block", False),
"ask": ("built", "needs_review", 0, "ask", True), "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), "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(): for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
with self.subTest(behavior=behavior): with self.subTest(behavior=behavior):
@@ -133,6 +133,71 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
self.assertEqual(coverage_issues[0].behavior, issue_behavior) self.assertEqual(coverage_issues[0].behavior, issue_behavior)
self.assertEqual(result.built_messages[0].mime is not None, has_mime) 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: def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
+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 import pytest
from fastapi import HTTPException 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_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
from govoplan_core.core.concurrency import strong_resource_etag
def _principal() -> SimpleNamespace: def _principal() -> SimpleNamespace:
@@ -26,6 +29,7 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
raw_json={}, raw_json={},
current_flow="manual", current_flow="manual",
current_step="recipients", current_step="recipients",
edit_revision=1,
) )
def mutate(*_args, **kwargs): def mutate(*_args, **kwargs):
@@ -34,18 +38,26 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
return version return version
with ( with (
patch.object(router, "_get_campaign_for_principal"), patch.object(route_support, "_get_campaign_for_principal"),
patch.object(router, "_get_version_for_tenant", return_value=version), patch.object(route_support, "_get_version_for_tenant", return_value=version),
patch.object(router, "update_campaign_version", side_effect=mutate), patch.object(route_support, "update_campaign_version", side_effect=mutate),
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")), patch.object(route_support, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
): ):
with pytest.raises(HTTPException, match="audit unavailable") as captured: 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, session,
principal, # type: ignore[arg-type] principal, # type: ignore[arg-type]
"campaign-1", "campaign-1",
"version-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, autosave=True,
audit_action="campaign.version_autosaved", 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 return forked
with ( with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign), patch.object(version_routes, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_require_permission"), patch.object(version_routes, "_require_permission"),
patch.object(router, "_get_version_for_tenant", return_value=source), patch.object(version_routes, "_get_version_for_tenant", return_value=source),
patch.object(router, "_get_campaign_for_tenant", return_value=campaign), patch.object(version_routes, "_get_campaign_for_tenant", return_value=campaign),
patch.object(router, "fork_campaign_version_for_edit", side_effect=mutate), patch.object(version_routes, "fork_campaign_version_for_edit", side_effect=mutate),
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")), patch.object(version_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
): ):
with pytest.raises(RuntimeError, match="audit unavailable"): with pytest.raises(RuntimeError, match="audit unavailable"):
router.fork_version_for_edit( version_routes.fork_version_for_edit(
"campaign-1", "campaign-1",
"version-1", "version-1",
CampaignVersionUpdateRequest(), CampaignVersionUpdateRequest(),
@@ -102,12 +114,12 @@ def test_metadata_update_rolls_back_when_its_audit_record_cannot_be_written() ->
) )
with ( with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign), patch.object(campaign_routes, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_sync_campaign_metadata_to_current_version"), patch.object(campaign_routes, "_sync_campaign_metadata_to_current_version"),
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")), patch.object(campaign_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
): ):
with pytest.raises(RuntimeError, match="audit unavailable"): with pytest.raises(RuntimeError, match="audit unavailable"):
router.update_campaign_metadata_endpoint( campaign_routes.update_campaign_metadata_endpoint(
"campaign-1", "campaign-1",
CampaignUpdateRequest(name="New name"), CampaignUpdateRequest(name="New name"),
session=session, session=session,
+94
View File
@@ -0,0 +1,94 @@
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 _campaign_build_recovery_plan
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_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]
@@ -0,0 +1,165 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignVersion,
)
from govoplan_campaign.backend.persistence.versions import update_campaign_version
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.concurrency import RevisionConflictError
from govoplan_core.db.base import Base
class CampaignOptimisticConcurrencyTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
database_path = Path(self.temp_dir.name) / "campaign.db"
self.engine = create_engine(f"sqlite+pysqlite:///{database_path}")
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,
ChangeSequenceEntry.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignIssue.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
external_id="C-1",
name="Campaign",
current_version_id="version-1",
)
version = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
raw_json={
"version": "1.0",
"campaign": {
"id": "C-1",
"name": "Campaign",
"description": "Base",
},
},
)
session.add_all((campaign, version))
session.commit()
self.addCleanup(self.engine.dispose)
self.addCleanup(self.temp_dir.cleanup)
@staticmethod
def _update(
session: Session,
*,
expected_revision: int,
name: str,
) -> CampaignVersion:
with (
patch(
"govoplan_campaign.backend.persistence.versions._updated_runtime_json",
side_effect=lambda _session, **kwargs: kwargs["raw_json"],
),
):
return update_campaign_version(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
version_id="version-1",
raw_json={
"version": "1.0",
"campaign": {
"id": "C-1",
"name": name,
"description": "Base",
},
},
expected_revision=expected_revision,
)
def test_only_one_of_two_writers_can_commit_the_same_revision(self) -> None:
first = self.SessionLocal()
second = self.SessionLocal()
self.addCleanup(first.close)
self.addCleanup(second.close)
first.get(CampaignVersion, "version-1")
second.get(CampaignVersion, "version-1")
saved = self._update(
first,
expected_revision=1,
name="First writer",
)
self.assertEqual(saved.edit_revision, 2)
with self.assertRaises(RevisionConflictError) as raised:
self._update(
second,
expected_revision=1,
name="Second writer",
)
self.assertEqual(raised.exception.current_revision, 2)
self.assertEqual(raised.exception.submitted_base_revision, 1)
with self.SessionLocal() as verification:
current = verification.get(CampaignVersion, "version-1")
assert current is not None
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
self.assertEqual(current.edit_revision, 2)
def test_stale_revision_is_rejected_before_mutation(self) -> None:
with self.SessionLocal() as first:
self._update(
first,
expected_revision=1,
name="First writer",
)
with self.SessionLocal() as stale:
with self.assertRaises(RevisionConflictError):
self._update(
stale,
expected_revision=1,
name="Stale writer",
)
stale.rollback()
with self.SessionLocal() as verification:
current = verification.get(CampaignVersion, "version-1")
assert current is not None
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
if __name__ == "__main__":
unittest.main()
+219 -1
View File
@@ -5,10 +5,17 @@ from types import SimpleNamespace
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem
from govoplan_campaign.backend.reports.campaigns import ( from govoplan_campaign.backend.reports.campaigns import (
_JobReportAggregate,
_campaign_retention_projection,
_campaign_postbox_receipt_summary,
_job_evidence_row, _job_evidence_row,
_latest_by_job_id, _latest_by_job_id,
_load_delivery_info, _load_delivery_info,
_review_decision_summary,
_review_decisions_by_job,
generate_campaign_report, generate_campaign_report,
) )
@@ -82,7 +89,21 @@ def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
updated_at=_dt(), updated_at=_dt(),
) )
row = _job_evidence_row(job, latest_smtp=smtp, latest_imap=imap) row = _job_evidence_row(
job,
latest_smtp=smtp,
latest_imap=imap,
review_decision={
"decision": "accept",
"reason": "Recipient confirmed no attachment was expected.",
"actor_user_id": "reviewer-1",
"decided_at": "2026-07-08T12:30:00+00:00",
"review_key": "recipient-1",
"message_sha256": "abc123",
"issue_fingerprint": "def456",
"issue_codes": ["missing_optional_attachment"],
},
)
assert row["campaign_id"] == "campaign-1" assert row["campaign_id"] == "campaign-1"
assert row["campaign_version_id"] == "version-1" assert row["campaign_version_id"] == "version-1"
@@ -101,6 +122,38 @@ def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
assert "latest_imap_error_message" not in row assert "latest_imap_error_message" not in row
assert "eml_storage_key" not in row assert "eml_storage_key" not in row
assert "eml_local_path" not in row assert "eml_local_path" not in row
assert row["review_decision"] == "accept"
assert row["review_actor_user_id"] == "reviewer-1"
assert row["review_issue_codes"] == "missing_optional_attachment"
assert "review_message_sha256" not in row
def test_report_uses_only_review_decisions_for_the_current_build() -> None:
decision = {
"job_id": "job-1",
"decision": "accept",
"issue_codes": ["missing_optional_attachment"],
}
version = SimpleNamespace(
build_summary={"build_token": "build-2"},
editor_state={
"review_send": {
"build_token": "build-2",
"inspection_complete": True,
"issue_decisions": [decision],
}
},
)
decisions = _review_decisions_by_job(version)
assert decisions == {"job-1": decision}
assert _review_decision_summary(decisions) == {
"exception_decision_count": 1,
"by_issue_code": {"missing_optional_attachment": 1},
}
version.editor_state["review_send"]["build_token"] = "stale-build"
assert _review_decisions_by_job(version) == {}
def test_latest_by_job_id_keeps_highest_attempt_number() -> None: def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
@@ -161,6 +214,171 @@ def test_aggregate_report_omits_recipient_level_failures_by_default() -> None:
assert payload.call_args.kwargs["include_recent_failures"] is False assert payload.call_args.kwargs["include_recent_failures"] is False
def test_postbox_receipt_report_aggregates_provider_state_without_identities() -> None:
class _Query:
def join(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def all(self):
return [("delivery-1",), ("delivery-2",), ("delivery-1",)]
integration = SimpleNamespace(
receipt_evidence_available=True,
delivery_receipt_summaries=lambda session, **kwargs: {
"delivery-1": PostboxDeliveryReceiptSummaryRef(
delivery_id="delivery-1",
message_id="message-1",
postbox_id="postbox-1",
delivery_status="accepted",
accepted_at=_dt(),
currently_readable=True,
read_receipt_count=2,
acknowledged_receipt_count=1,
routed_message_count=1,
),
"delivery-2": PostboxDeliveryReceiptSummaryRef(
delivery_id="delivery-2",
message_id="message-2",
postbox_id="postbox-2",
delivery_status="accepted_vacant",
accepted_at=_dt(),
currently_readable=False,
expired_message_count=1,
),
},
)
session = SimpleNamespace(query=lambda *args: _Query())
with patch(
"govoplan_campaign.backend.reports.campaigns.postbox_integration",
return_value=integration,
):
report = _campaign_postbox_receipt_summary(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
version=SimpleNamespace(id="version-1"),
)
assert report["delivery_count"] == 2
assert report["currently_readable_delivery_count"] == 1
assert report["read_delivery_count"] == 1
assert report["acknowledged_delivery_count"] == 1
assert report["routed_message_count"] == 1
assert report["expired_message_count"] == 1
assert "account_id" not in repr(report)
assert "identity_id" not in repr(report)
def test_retention_projection_explains_defaults_and_redacted_evidence() -> None:
aggregate = _JobReportAggregate(
eml_retained=2,
eml_expired=1,
eml_not_generated=3,
)
version = SimpleNamespace(
raw_json={
"_retention": {
"raw_json_redacted": True,
"redacted_at": "2026-07-08T12:30:00+00:00",
}
},
validation_summary={
"_retention": {
"report_detail_redacted": True,
"redacted_at": "2026-07-08T12:30:00+00:00",
}
},
build_summary={"ok": True},
)
with patch(
"govoplan_campaign.backend.reports.campaigns.capability",
return_value=None,
):
projection = _campaign_retention_projection(
object(), # type: ignore[arg-type]
campaign=SimpleNamespace(id="campaign-1"),
version=version, # type: ignore[arg-type]
aggregate=aggregate,
postbox_receipts={
"status": "available",
"currently_readable_delivery_count": 2,
"expired_message_count": 1,
},
)
assert projection["policy_status"] == "defaults"
assert projection["effective_policy"]["store_raw_campaign_json"] is True
assert projection["evidence"]["raw_campaign_json"]["state"] == "redacted"
assert projection["evidence"]["stored_report_detail"]["state"] == "partially_redacted"
assert projection["evidence"]["generated_eml"] == {
"state": "partially_expired",
"retained_count": 2,
"expired_count": 1,
"not_generated_count": 3,
}
assert projection["evidence"]["postbox_copies"]["state"] == "partially_expired"
assert "generated_eml" in projection["privacy_impact"]["minimized_categories"]
def test_retention_projection_uses_only_public_policy_contract_metadata() -> None:
class _RetentionService:
privacy_policy_from_settings = lambda self, *args, **kwargs: None
privacy_policy_from_session = lambda self, *args, **kwargs: None
set_privacy_policy = lambda self, *args, **kwargs: None
parent_privacy_policy = lambda self, *args, **kwargs: None
parent_privacy_policy_sources = lambda self, *args, **kwargs: []
get_privacy_policy_for_scope = lambda self, *args, **kwargs: {}
set_privacy_policy_for_scope = lambda self, *args, **kwargs: {}
sanitize_audit_details_for_policy = lambda self, *args, **kwargs: {}
apply_retention_policy = lambda self, *args, **kwargs: {}
def effective_privacy_policy(self, *args, **kwargs):
return PrivacyRetentionPolicyItem(
generated_eml_retention_days=30,
stored_report_detail_retention_days=90,
)
def effective_privacy_policy_sources(self, *args, **kwargs):
return [{
"scope_type": "tenant",
"path": "tenant:tenant-1",
"label": "Tenant",
"applied_fields": ["generated_eml_retention_days"],
"policy": {"generated_eml_retention_days": 30},
"provider_secret": "must-not-leak",
}]
with patch(
"govoplan_campaign.backend.reports.campaigns.capability",
return_value=_RetentionService(),
):
projection = _campaign_retention_projection(
object(), # type: ignore[arg-type]
campaign=SimpleNamespace(id="campaign-1"),
version=SimpleNamespace(
raw_json={},
validation_summary={},
build_summary={},
), # type: ignore[arg-type]
aggregate=_JobReportAggregate(),
postbox_receipts={"status": "not_applicable"},
)
assert projection["policy_status"] == "configured"
assert projection["effective_policy"]["generated_eml_retention_days"] == 30
assert projection["sources"] == [{
"scope_type": "tenant",
"path": "tenant:tenant-1",
"label": "Tenant",
"applied_fields": ["generated_eml_retention_days"],
}]
assert "must-not-leak" not in repr(projection)
class CampaignReportProjectionTests(unittest.TestCase): class CampaignReportProjectionTests(unittest.TestCase):
def test_evidence_projection(self) -> None: def test_evidence_projection(self) -> None:
test_job_evidence_row_contains_transport_and_message_evidence() test_job_evidence_row_contains_transport_and_message_evidence()
+2 -2
View File
@@ -289,7 +289,7 @@ def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_auth
topic for topic in CAMPAIGN_USER_DOCUMENTATION topic for topic in CAMPAIGN_USER_DOCUMENTATION
if topic.id == "campaigns.workflow.queue-delivery" if topic.id == "campaigns.workflow.queue-delivery"
) )
assert any(link.href == "/operator" for link in queue_topic.links) assert any(link.href == "/campaigns/queue" for link in queue_topic.links)
def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None: def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None:
@@ -361,7 +361,7 @@ def test_aggregate_report_task_never_implies_recipient_detail_or_export_authorit
item for item in CAMPAIGN_USER_DOCUMENTATION item for item in CAMPAIGN_USER_DOCUMENTATION
if item.id == "campaigns.workflow.view-aggregate-delivery-report" if item.id == "campaigns.workflow.view-aggregate-delivery-report"
) )
assert topic.metadata["route"] == "/reports" assert topic.metadata["route"] == "/campaigns/reports"
assert "export" in topic.metadata["verification"].lower() assert "export" in topic.metadata["verification"].lower()
+1 -1
View File
@@ -94,7 +94,7 @@ def _ensure(session: _Session, version) -> None:
ensure_execution_snapshot(session, version) # type: ignore[arg-type] ensure_execution_snapshot(session, version) # type: ignore[arg-type]
def test_v5_snapshot_requires_its_persisted_checksum() -> None: def test_current_snapshot_requires_its_persisted_checksum() -> None:
job = _job() job = _job()
version = _snapshotted_version(job) version = _snapshotted_version(job)
version.execution_snapshot_hash = None version.execution_snapshot_hash = None
+38 -6
View File
@@ -7,9 +7,11 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from sqlalchemy import create_engine, text from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import create_engine, inspect, text
from govoplan_campaign.backend import router from govoplan_campaign.backend.routes import delivery as router
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
CampaignJob, CampaignJob,
ImapAppendAttempt, ImapAppendAttempt,
@@ -128,6 +130,10 @@ def test_post_provider_persistence_failure_freezes_imap_retry() -> None:
mail_profile_id="profile-1", mail_profile_id="profile-1",
smtp_transport_revision="smtp-revision", smtp_transport_revision="smtp-revision",
imap_transport_revision="imap-revision", imap_transport_revision="imap-revision",
smtp_server_id=None,
smtp_credential_id=None,
imap_server_id=None,
imap_credential_id=None,
delivery=SimpleNamespace( delivery=SimpleNamespace(
imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"), imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"),
), ),
@@ -218,6 +224,31 @@ def test_imap_claim_migration_preserves_and_renumbers_duplicate_attempts() -> No
] ]
def test_imap_attempt_claim_repair_adds_only_the_missing_column() -> None:
migration = importlib.import_module(
"govoplan_campaign.backend.migrations.versions.e9f0a1b2c3d4_v0114_repair_imap_attempt_claim"
)
engine = create_engine("sqlite+pysqlite:///:memory:")
with engine.begin() as connection:
connection.execute(
text(
"CREATE TABLE imap_append_attempts ("
"id VARCHAR(36) PRIMARY KEY, job_id VARCHAR(36) NOT NULL)"
)
)
context = MigrationContext.configure(connection)
with patch.object(migration, "op", Operations(context)):
migration.upgrade()
migration.upgrade()
columns = {
column["name"]
for column in inspect(connection).get_columns("imap_append_attempts")
}
assert columns == {"id", "job_id", "claim_token"}
@pytest.mark.parametrize("decision", ["smtp_accepted", "not_sent", "imap_appended", "imap_not_appended"]) @pytest.mark.parametrize("decision", ["smtp_accepted", "not_sent", "imap_appended", "imap_not_appended"])
def test_reconciliation_requires_an_evidence_note(decision: str) -> None: def test_reconciliation_requires_an_evidence_note(decision: str) -> None:
with pytest.raises(ValueError, match="evidence note"): with pytest.raises(ValueError, match="evidence note"):
@@ -258,6 +289,7 @@ def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryabl
imap_claimed_at=datetime.now(timezone.utc), imap_claimed_at=datetime.now(timezone.utc),
imap_claim_token="claim-1", imap_claim_token="claim-1",
last_error="unknown", last_error="unknown",
delivery_provenance={},
) )
attempt = SimpleNamespace( attempt = SimpleNamespace(
id="attempt-1", id="attempt-1",
@@ -379,14 +411,14 @@ def test_reconciliation_rolls_back_state_when_audit_fails(
return {"decision": decision, "job_id": "job-1"} return {"decision": decision, "job_id": "job-1"}
with ( with (
patch("govoplan_campaign.backend.router._get_campaign_for_principal"), patch("govoplan_campaign.backend.routes.delivery._get_campaign_for_principal"),
patch("govoplan_campaign.backend.router._require_permission"), patch("govoplan_campaign.backend.routes.delivery._require_permission"),
patch( patch(
"govoplan_campaign.backend.router.reconcile_job_outcome", "govoplan_campaign.backend.routes.delivery.reconcile_job_outcome",
side_effect=mutate_without_commit, side_effect=mutate_without_commit,
), ),
patch( patch(
"govoplan_campaign.backend.router.audit_from_principal", "govoplan_campaign.backend.routes.delivery.audit_from_principal",
side_effect=RuntimeError("audit unavailable"), side_effect=RuntimeError("audit unavailable"),
), ),
): ):
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.router import ( from govoplan_campaign.backend.services.job_queries import (
_campaign_jobs_grid_filter_expressions, _campaign_jobs_grid_filter_expressions,
_campaign_jobs_ordering, _campaign_jobs_ordering,
_campaign_jobs_page_response, _campaign_jobs_page_response,
+7 -6
View File
@@ -6,7 +6,8 @@ from unittest.mock import call, patch
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from govoplan_campaign.backend import router from govoplan_campaign.backend import route_support
from govoplan_campaign.backend.routes import versions as router
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
from govoplan_campaign.backend.campaign.mail_profile_boundary import ( from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError, CampaignMailProfileBoundaryError,
@@ -73,7 +74,7 @@ def test_mail_profile_documentation_is_classified_for_adaptive_views() -> None:
def test_campaign_mail_contract_rejects_every_legacy_server_field(legacy_key: str) -> None: def test_campaign_mail_contract_rejects_every_legacy_server_field(legacy_key: str) -> None:
raw = _campaign_json({"mail_profile_id": "profile-1", legacy_key: {}}) raw = _campaign_json({"mail_profile_id": "profile-1", legacy_key: {}})
with pytest.raises(CampaignMailProfileBoundaryError, match="select an authorized Mail profile"): with pytest.raises(CampaignMailProfileBoundaryError, match="select authorized Mail resources"):
assert_campaign_uses_mail_profile_reference(raw) assert_campaign_uses_mail_profile_reference(raw)
@@ -138,7 +139,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
delivery=DeliveryConfig(), delivery=DeliveryConfig(),
) )
assert payload["snapshot_version"] == "5" assert payload["snapshot_version"] == "8"
assert payload["mail_profile_id"] == "profile-1" assert payload["mail_profile_id"] == "profile-1"
assert "smtp" not in payload assert "smtp" not in payload
assert "imap" not in payload assert "imap" not in payload
@@ -214,7 +215,7 @@ def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
patch.object(router, "_get_campaign_for_principal", return_value=campaign), patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_require_permission"), patch.object(router, "_require_permission"),
patch.object(router, "_get_version_for_tenant", return_value=source), patch.object(router, "_get_version_for_tenant", return_value=source),
patch.object(router, "has_scope", return_value=False), patch.object(route_support, "has_scope", return_value=False),
patch.object(router, "fork_campaign_version_for_edit") as fork, patch.object(router, "fork_campaign_version_for_edit") as fork,
): ):
with pytest.raises(HTTPException) as captured: with pytest.raises(HTTPException) as captured:
@@ -234,8 +235,8 @@ def test_campaign_wide_effect_authorizes_every_affected_version() -> None:
session = object() session = object()
principal = SimpleNamespace(tenant_id="tenant-1") principal = SimpleNamespace(tenant_id="tenant-1")
with patch.object(router, "_require_campaign_profile_use_if_needed") as require_profile: with patch.object(route_support, "_require_campaign_profile_use_if_needed") as require_profile:
router._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test route_support._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
session, # type: ignore[arg-type] session, # type: ignore[arg-type]
principal, # type: ignore[arg-type] principal, # type: ignore[arg-type]
"campaign-1", "campaign-1",
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from govoplan_campaign.backend.manifest import (
CAMPAIGN_MODULE_REQUIRED_ANY,
OPERATOR_QUEUE_REQUIRED_ANY,
OPERATOR_QUEUE_SURFACE_ID,
REPORTS_REQUIRED_ANY,
REPORTS_SURFACE_ID,
get_manifest,
)
from govoplan_core.core.registry import manifest_view_surfaces
def test_operator_queue_is_an_integrated_campaign_view() -> None:
manifest = get_manifest()
assert manifest.frontend is not None
assert "/operator" not in {item.path for item in manifest.nav_items}
assert "/operator" not in {item.path for item in manifest.frontend.nav_items}
routes = {route.path: route for route in manifest.frontend.routes}
assert "/operator" not in routes
queue = routes["/campaigns/queue"]
assert queue.component == "OperatorQueuePage"
assert queue.required_all == ("campaigns:campaign:read",)
assert queue.required_any == OPERATOR_QUEUE_REQUIRED_ANY
assert OPERATOR_QUEUE_SURFACE_ID == "campaigns.route.operator"
assert queue.surface_id == OPERATOR_QUEUE_SURFACE_ID
queue_surfaces = [
surface
for surface in manifest_view_surfaces(manifest)
if surface.id == OPERATOR_QUEUE_SURFACE_ID
]
assert len(queue_surfaces) == 1
assert queue_surfaces[0].description == "/campaigns/queue"
def test_aggregate_reports_are_an_integrated_campaign_view() -> None:
manifest = get_manifest()
assert manifest.frontend is not None
backend_nav = {item.path: item for item in manifest.nav_items}
frontend_nav = {item.path: item for item in manifest.frontend.nav_items}
assert "/reports" not in backend_nav
assert "/reports" not in frontend_nav
assert backend_nav["/campaigns"].required_any == CAMPAIGN_MODULE_REQUIRED_ANY
assert frontend_nav["/campaigns"].required_any == CAMPAIGN_MODULE_REQUIRED_ANY
routes = {route.path: route for route in manifest.frontend.routes}
assert "/reports" not in routes
report = routes["/campaigns/reports"]
assert report.component == "AggregateReportsPage"
assert report.required_any == REPORTS_REQUIRED_ANY
assert REPORTS_SURFACE_ID == "campaigns.route.reports"
assert report.surface_id == REPORTS_SURFACE_ID
report_surfaces = [
surface
for surface in manifest_view_surfaces(manifest)
if surface.id == REPORTS_SURFACE_ID
]
assert len(report_surfaces) == 1
assert report_surfaces[0].description == "/campaigns/reports"
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from unittest.mock import patch
from govoplan_core.core.object_storage import LocalFilesystemStorageBackend
from govoplan_campaign.backend.operational_checks import generated_eml_storage_check
def test_generated_eml_probe_reports_node_local_boundary(tmp_path) -> None:
with patch(
"govoplan_campaign.backend.operational_checks.configured_storage_backend",
return_value=LocalFilesystemStorageBackend(tmp_path),
):
result = generated_eml_storage_check()
assert result.state == "warning"
assert "node-local" in result.detail
assert list(tmp_path.rglob("*.probe")) == []
+172 -1
View File
@@ -5,7 +5,7 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from govoplan_campaign.backend import router from govoplan_campaign.backend.routes import campaigns as router
from govoplan_campaign.backend.campaign.models import CampaignConfig from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.validation import validate_campaign_config from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.persistence.versions import validate_campaign_partial from govoplan_campaign.backend.persistence.versions import validate_campaign_partial
@@ -78,6 +78,177 @@ class CampaignPartialValidationTests(unittest.TestCase):
self.assertTrue(response.available) self.assertTrue(response.available)
self.assertEqual(response.candidates[0].email, "ada@example.local") self.assertEqual(response.candidates[0].email, "ada@example.local")
def test_address_source_snapshot_preserves_governance_exclusions(self) -> None:
class RecipientSourceCapability:
def snapshot(self, _session, _principal, **kwargs):
self.kwargs = kwargs
return {
"source_id": "addresses:address_book:book-1",
"source_label": "Residents",
"source_kind": "local",
"source_revision": "revision-2",
"generated_at": "2026-07-31T10:00:00+00:00",
"purpose": "campaign_delivery",
"recipients": [
{
"contact_id": "contact-1",
"display_name": "Ada Lovelace",
"email": "ada@example.local",
"fields": {},
"provenance": {"channel_decision": {"governance_state": "opted_in"}},
}
],
"excluded": [
{
"contact_id": "contact-2",
"display_name": "Grace Hopper",
"channel": "email",
"target": "grace@example.local",
"status": "suppressed",
"reason_code": "addresses.channel.opted_out",
"provenance": {"channel_decision": {"governance_state": "opted_out"}},
}
],
"provenance": {"governance_applied": True},
}
capability = RecipientSourceCapability()
payload = router.CampaignRecipientAddressSourceSnapshotRequest(
source_id="addresses:address_book:book-1"
)
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(
router, "_registry_capability", return_value=capability
):
response = router.snapshot_campaign_recipient_address_source(
"campaign-1", payload, session=object(), principal=object()
)
self.assertEqual(capability.kwargs["purpose"], "campaign_delivery")
self.assertEqual(capability.kwargs["requested_channels"], ("email",))
self.assertEqual(response.included_count, 1)
self.assertEqual(response.excluded_count, 1)
self.assertEqual(response.excluded[0].reason_code, "addresses.channel.opted_out")
self.assertTrue(response.provenance["governance_applied"])
def test_distribution_list_preview_preserves_provider_and_route_evidence(self) -> None:
class ExpansionCapability:
def expand(self, _session, _principal, *, request):
self.request = request
return _distribution_expansion_payload()
capability = ExpansionCapability()
payload = router.CampaignDistributionListExpansionRequest(
list_id="11111111-1111-1111-1111-111111111111",
revision=3,
requested_channels=["email", "postal"],
parameters={"district": "north"},
)
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(
router, "_registry_capability", return_value=capability
):
response = router.preview_campaign_recipient_distribution_list(
"campaign-1", payload, session=object(), principal=object()
)
self.assertTrue(capability.request.preview)
self.assertFalse(capability.request.freeze)
self.assertEqual(capability.request.parameters, {"district": "north"})
self.assertEqual(response.source.revision, 3)
self.assertEqual(response.recipients[0].source_entry_ids, ["entry-1"])
self.assertEqual(response.recipients[0].channels[0].decision_provenance["policy"], "allow")
self.assertEqual(response.provider_evidence[0].actual_revision, "provider-r7")
def test_distribution_list_snapshot_requires_and_forwards_idempotency(self) -> None:
class ExpansionCapability:
def expand(self, _session, _principal, *, request):
self.request = request
payload = _distribution_expansion_payload()
payload["snapshot_id"] = "snapshot-1"
return payload
capability = ExpansionCapability()
payload = router.CampaignDistributionListExpansionRequest(
list_id="11111111-1111-1111-1111-111111111111",
requested_channels=["email"],
idempotency_key="campaign:one:list:one",
)
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(
router, "_registry_capability", return_value=capability
), patch.object(router, "audit_from_principal") as audit:
response = router.snapshot_campaign_recipient_distribution_list(
"campaign-1", payload, session=object(), principal=object()
)
self.assertTrue(capability.request.freeze)
self.assertFalse(capability.request.preview)
self.assertEqual(capability.request.idempotency_key, "campaign:one:list:one")
self.assertEqual(response.snapshot_id, "snapshot-1")
self.assertTrue(audit.call_args.kwargs["commit"])
def _distribution_expansion_payload() -> dict[str, object]:
source = {
"id": "11111111-1111-1111-1111-111111111111",
"tenant_id": "tenant-1",
"name": "Residents",
"revision_id": "revision-id-3",
"revision": 3,
"definition_hash": "definition-hash",
"definition_kind": "parameterized",
"entry_count": 1,
"parameters": [],
"provenance": {},
"metadata": {},
}
reference = {
"provider": "addresses",
"resource_type": "address_list",
"resource_id": "list-1",
"revision": "provider-r7",
"metadata": {},
}
recipient = {
"recipient_key": "contact:1",
"display_name": "Ada Lovelace",
"status": "usable",
"channels": [
{
"channel": "email",
"target": "ada@example.local",
"target_key": "email:ada@example.local",
"status": "usable",
"preferred": True,
"source": reference,
"decision_provenance": {"policy": "allow"},
}
],
"contact_id": "contact-1",
"source_entry_ids": ["entry-1"],
"explanations": [],
"attributes": {"district": "north"},
"provenance": {"provider": "addresses"},
}
return {
"source": source,
"request": {"parameters": {"district": "north"}},
"recipients": [recipient],
"excluded": [],
"diagnostics": [],
"provider_evidence": [
{
"provider": "addresses",
"source": reference,
"actual_revision": "provider-r7",
"stale": False,
"details": {},
}
],
"expansion_hash": "expansion-hash",
"generated_at": "2026-08-02T10:00:00+00:00",
"stale": False,
"truncated": False,
}
class CampaignSemanticValidationTests(unittest.TestCase): class CampaignSemanticValidationTests(unittest.TestCase):
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None: def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
@@ -0,0 +1,478 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
from govoplan_campaign.backend.db.models import JobSendStatus
from govoplan_campaign.backend.sending.jobs import (
SendJobResult,
_MailChannelOutcome,
_PrintChannelOutcome,
_final_multichannel_status,
_send_claimed_multichannel_job,
)
from govoplan_campaign.backend.sending.postbox_delivery import (
PostboxChannelOutcome,
)
def _context():
return SimpleNamespace(
message_bytes=b"message",
snapshot=SimpleNamespace(
delivery=SimpleNamespace(
postbox=SimpleNamespace(classification="internal")
)
),
)
def _job():
return SimpleNamespace(id="job-1")
class _Session:
def __init__(self, job) -> None:
self.job = job
def get(self, _model, _id):
return self.job
def add(self, _value) -> None:
return None
def commit(self) -> None:
return None
class PostboxFallbackOrchestrationTests(unittest.TestCase):
def test_mail_unknown_never_starts_print_fallback(self) -> None:
job = SimpleNamespace(id="job-1", print_status="ready")
expected = SendJobResult(
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(outcome_unknown=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_print_channel"
) as deliver_print,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=expected,
),
):
result = _send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_PRINT,
use_rate_limit=False,
enqueue_imap_task=False,
)
self.assertIs(result, expected)
deliver_print.assert_not_called()
def test_mail_preacceptance_rejection_starts_print_fallback(self) -> None:
job = SimpleNamespace(id="job-1", print_status="ready")
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(rejected_permanent=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_print_channel",
return_value=_PrintChannelOutcome(accepted=True),
) as deliver_print,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.PRINT_ACCEPTED.value,
attempt_number=1,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_PRINT,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_print.assert_called_once()
def test_mail_unknown_never_starts_postbox_fallback(self) -> None:
job = _job()
expected = SendJobResult(
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(outcome_unknown=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=PostboxChannelOutcome(),
),
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes"
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=expected,
),
):
result = _send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
self.assertIs(result, expected)
deliver_postbox.assert_not_called()
def test_mail_preacceptance_rejection_starts_postbox_fallback(self) -> None:
job = _job()
postbox_outcome = PostboxChannelOutcome(accepted=1)
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(rejected_permanent=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=PostboxChannelOutcome(),
),
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=postbox_outcome,
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.POSTBOX_ACCEPTED.value,
attempt_number=1,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_postbox.assert_called_once()
def test_accepted_postbox_fallback_prevents_later_mail_retry(self) -> None:
job = _job()
with (
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=PostboxChannelOutcome(
accepted=1,
rejected_temporary=1,
),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=PostboxChannelOutcome(accepted=2),
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.POSTBOX_ACCEPTED.value,
attempt_number=3,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_not_called()
deliver_postbox.assert_called_once()
def test_unknown_postbox_fallback_prevents_every_later_effect(self) -> None:
job = _job()
prior = PostboxChannelOutcome(outcome_unknown=1)
with (
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=prior,
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes"
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
),
) as finalize,
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_not_called()
deliver_postbox.assert_not_called()
self.assertIs(finalize.call_args.kwargs["postbox"], prior)
def test_postbox_acceptance_stops_mail_fallback(self) -> None:
job = _job()
with (
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=PostboxChannelOutcome(
accepted=1,
rejected_permanent=1,
),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.PARTIALLY_ACCEPTED.value,
attempt_number=2,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_not_called()
def test_all_postbox_rejections_start_mail_fallback(self) -> None:
job = _job()
with (
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=PostboxChannelOutcome(
rejected_temporary=1,
rejected_permanent=1,
),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(accepted=True),
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.SMTP_ACCEPTED.value,
attempt_number=1,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_called_once()
def test_explicit_dual_delivery_reports_partial_and_unknown(self) -> None:
self.assertEqual(
JobSendStatus.PARTIALLY_ACCEPTED.value,
_final_multichannel_status(
channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX,
mail=_MailChannelOutcome(accepted=True),
postbox=PostboxChannelOutcome(rejected_permanent=1),
),
)
self.assertEqual(
JobSendStatus.OUTCOME_UNKNOWN.value,
_final_multichannel_status(
channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX,
mail=_MailChannelOutcome(accepted=True),
postbox=PostboxChannelOutcome(outcome_unknown=1),
),
)
@pytest.mark.parametrize("policy", list(DeliveryChannelPolicy))
@pytest.mark.parametrize(
("mail", "postbox"),
[
(_MailChannelOutcome(outcome_unknown=True), PostboxChannelOutcome()),
(_MailChannelOutcome(accepted=True), PostboxChannelOutcome(outcome_unknown=1)),
(_MailChannelOutcome(outcome_unknown=True), PostboxChannelOutcome(accepted=1)),
],
)
def test_outcome_unknown_has_precedence_for_every_delivery_policy(
policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome,
postbox: PostboxChannelOutcome,
) -> None:
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == JobSendStatus.OUTCOME_UNKNOWN.value
@pytest.mark.parametrize("policy", list(DeliveryChannelPolicy))
@pytest.mark.parametrize(
("mail", "postbox", "expected"),
[
(
_MailChannelOutcome(rejected_permanent=True),
PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.FAILED_PERMANENT.value,
),
(
_MailChannelOutcome(rejected_temporary=True),
PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.FAILED_TEMPORARY.value,
),
(
_MailChannelOutcome(rejected_permanent=True),
PostboxChannelOutcome(rejected_temporary=1),
JobSendStatus.FAILED_TEMPORARY.value,
),
],
)
def test_rejection_precedence_is_exhaustive_for_every_delivery_policy(
policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome,
postbox: PostboxChannelOutcome,
expected: str,
) -> None:
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected
@pytest.mark.parametrize(
("policy", "mail", "postbox", "expected"),
[
(
DeliveryChannelPolicy.MAIL,
_MailChannelOutcome(accepted=True),
PostboxChannelOutcome(),
JobSendStatus.SMTP_ACCEPTED.value,
),
(
DeliveryChannelPolicy.POSTBOX,
None,
PostboxChannelOutcome(accepted=1),
JobSendStatus.POSTBOX_ACCEPTED.value,
),
(
DeliveryChannelPolicy.POSTBOX,
None,
PostboxChannelOutcome(accepted=1, rejected_permanent=1),
JobSendStatus.PARTIALLY_ACCEPTED.value,
),
(
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
_MailChannelOutcome(accepted=True),
PostboxChannelOutcome(accepted=1),
JobSendStatus.DELIVERED.value,
),
(
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
_MailChannelOutcome(accepted=True),
PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.PARTIALLY_ACCEPTED.value,
),
(
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
_MailChannelOutcome(rejected_permanent=True),
PostboxChannelOutcome(accepted=1),
JobSendStatus.PARTIALLY_ACCEPTED.value,
),
(
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
_MailChannelOutcome(rejected_permanent=True),
PostboxChannelOutcome(accepted=1),
JobSendStatus.POSTBOX_ACCEPTED.value,
),
(
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
_MailChannelOutcome(accepted=True),
PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.PARTIALLY_ACCEPTED.value,
),
(
DeliveryChannelPolicy.PRINT,
None,
PostboxChannelOutcome(),
JobSendStatus.PRINT_ACCEPTED.value,
),
],
)
def test_accepted_delivery_decision_table(
policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome,
expected: str,
) -> None:
print_output = _PrintChannelOutcome(accepted=True) if policy == DeliveryChannelPolicy.PRINT else None
assert _final_multichannel_status(
channel_policy=policy,
mail=mail,
postbox=postbox,
print_output=print_output,
) == expected
if __name__ == "__main__":
unittest.main()
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDirectoryEntryRef,
PostboxDeliveryRequest,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryResult,
PostboxTargetRef,
)
from govoplan_campaign.backend.integrations import (
PostboxCampaignIntegration,
PostboxDeliveryUnavailable,
)
class _PostboxDelivery:
def __init__(self) -> None:
self.requests = []
def deliver(self, session, request):
self.requests.append((session, request))
return PostboxDeliveryResult(
delivery_id="delivery-1",
postbox_id="postbox-1",
message_id="message-1",
address="intake@postbox",
status="accepted",
vacant=False,
holder_count=1,
)
def delivery_catalog(self, session, *, tenant_id):
return PostboxDeliveryCatalogRef()
def list_visible_postboxes(self, session, *, tenant_id, actor):
return ()
def resolve_postbox(
self,
session,
*,
tenant_id,
target,
materialize=False,
):
return PostboxDirectoryEntryRef(
id=target.postbox_id or "postbox-1",
tenant_id=tenant_id,
address="intake@postbox",
address_key="intake",
name="Intake",
status="active",
classification="internal",
)
def link_evidence(self, session, *, tenant_id, message_id, attachment):
del session, tenant_id, message_id, attachment
return None
def delivery_receipt_summaries(
self,
session,
*,
tenant_id,
producer_module,
delivery_ids,
):
del session
assert tenant_id == "tenant-1"
assert producer_module == "campaigns"
return {
delivery_id: PostboxDeliveryReceiptSummaryRef(
delivery_id=delivery_id,
message_id="message-1",
postbox_id="postbox-1",
delivery_status="accepted",
accepted_at=datetime(2026, 7, 30, tzinfo=UTC),
currently_readable=True,
read_receipt_count=1,
)
for delivery_id in delivery_ids
}
class PostboxCampaignIntegrationTests(unittest.TestCase):
def test_optional_delivery_boundary_is_typed_and_explicit(self) -> None:
delegate = _PostboxDelivery()
integration = PostboxCampaignIntegration(delegate, delegate, delegate)
request = PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id="postbox-1"),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id="job-1",
idempotency_key="campaign-1:job-1:postbox-1",
subject="Decision",
)
result = integration.deliver(object(), request)
self.assertTrue(integration.available)
self.assertTrue(integration.receipt_evidence_available)
self.assertEqual("delivery-1", result.delivery_id)
self.assertEqual(request, delegate.requests[0][1])
summaries = integration.delivery_receipt_summaries(
object(),
tenant_id="tenant-1",
delivery_ids=["delivery-1"],
)
self.assertEqual(1, summaries["delivery-1"].read_receipt_count)
def test_missing_postbox_is_reported_without_importing_module_code(
self,
) -> None:
integration = PostboxCampaignIntegration()
request = PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id="postbox-1"),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id="job-1",
idempotency_key="campaign-1:job-1:postbox-1",
subject="Decision",
)
self.assertFalse(integration.available)
self.assertFalse(integration.receipt_evidence_available)
self.assertEqual(
{},
integration.delivery_receipt_summaries(
object(),
tenant_id="tenant-1",
delivery_ids=["delivery-1"],
),
)
with self.assertRaises(PostboxDeliveryUnavailable):
integration.deliver(object(), request)
if __name__ == "__main__":
unittest.main()
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
from unittest.mock import patch
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDeliveryTemplateRef,
PostboxDirectoryEntryRef,
PostboxOrganizationFunctionTargetRef,
PostboxOrganizationUnitTargetRef,
)
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.postbox_targets import (
resolve_entry_postbox_targets,
)
from govoplan_campaign.backend.campaign.validation import (
validate_campaign_config,
)
from govoplan_campaign.backend.messages.models import MessageValidationStatus
def _config() -> CampaignConfig:
return CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {
"id": "campaign-1",
"name": "Postbox campaign",
"mode": "send",
},
"fields": [
{"name": "org_key", "type": "organization_unit"},
{"name": "function_key", "type": "organization_function"},
{"name": "case_key", "type": "string"},
],
"template": {
"subject": "Decision",
"text": "A decision is available.",
"body_mode": "text",
},
"entries": {
"inline": [
{
"id": "recipient-1",
"fields": {
"org_key": "finance",
"function_key": "caseworker",
"case_key": "case-42",
},
}
]
},
"delivery": {
"channel_policy": "postbox",
"postbox": {
"targets": [
{
"id": "direct",
"mode": "direct",
"address_key": "central-intake",
},
{
"id": "derived",
"mode": "derived",
"template_id": "template-1",
"organization_unit_field": "org_key",
"organization_unit_match": "slug",
"function_field": "function_key",
"function_match": "slug",
"context_field": "case_key",
},
]
},
},
}
)
def _catalog() -> PostboxDeliveryCatalogRef:
return PostboxDeliveryCatalogRef(
templates=(
PostboxDeliveryTemplateRef(
id="template-1",
slug="case-inbox",
name="Case inbox",
description=None,
published_revision_id="revision-1",
function_type_id=None,
scope_kind="tenant",
scope_id=None,
classification="internal",
allow_vacant_delivery=True,
),
),
organization_units=(
PostboxOrganizationUnitTargetRef(
id="unit-1",
slug="finance",
name="Finance",
functions=(
PostboxOrganizationFunctionTargetRef(
id="function-1",
slug="caseworker",
name="Caseworker",
),
),
),
),
)
class _PostboxIntegration:
def __init__(self) -> None:
self.targets: list[tuple[object, bool]] = []
def delivery_catalog(self, _session, *, tenant_id: str):
assert tenant_id == "tenant-1"
return _catalog()
def resolve_postbox(
self,
_session,
*,
tenant_id: str,
target,
materialize: bool,
):
self.targets.append((target, materialize))
if target.address_key == "central-intake":
return PostboxDirectoryEntryRef(
id="postbox-direct",
tenant_id=tenant_id,
address="central-intake@postbox",
address_key="central-intake",
name="Central intake",
status="active",
classification="internal",
holder_count=2,
vacant=False,
)
assert target.template_id == "template-1"
assert target.organization_unit_id == "unit-1"
assert target.function_id == "function-1"
assert target.context_key == "case-42"
return PostboxDirectoryEntryRef(
id="postbox-derived",
tenant_id=tenant_id,
address="finance.caseworker.case-42@postbox",
address_key="finance.caseworker.case-42",
name="Finance caseworker",
status="active",
classification="internal",
organization_unit_id="unit-1",
function_id="function-1",
context_key="case-42",
holder_count=1,
vacant=False,
)
def test_postbox_only_campaign_does_not_require_mail() -> None:
config = _config()
available = validate_campaign_config(config, postbox_available=True)
unavailable = validate_campaign_config(config, postbox_available=False)
assert {
issue.code
for issue in available.issues
if issue.severity.value == "error"
} == set()
assert "missing_mail_profile" not in {
issue.code for issue in unavailable.issues
}
assert "missing_sender" not in {
issue.code for issue in unavailable.issues
}
assert "postbox_unavailable" in {
issue.code for issue in unavailable.issues
}
def test_print_only_campaign_requires_templates_and_an_explicit_target_not_mail() -> None:
config = CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {"id": "campaign-print", "name": "Printed notice", "mode": "send"},
"template": {"subject": "Notice", "text": "Printed body", "body_mode": "text"},
"entries": {
"inline": [
{
"id": "recipient-1",
"channel_policy": "print",
"print_target": {
"channel": "postal",
"target": "Example Street 1",
"target_key": "postal:example-street-1",
},
}
]
},
"delivery": {
"channel_policy": "print",
"print": {"template_id": "template-1", "template_revision": 2},
},
}
)
available = validate_campaign_config(config, templates_available=True)
unavailable = validate_campaign_config(config, templates_available=False)
assert "missing_mail_profile" not in {issue.code for issue in available.issues}
assert "missing_sender" not in {issue.code for issue in available.issues}
assert "print_template_missing" not in {issue.code for issue in available.issues}
assert "print_target_missing" not in {issue.code for issue in available.issues}
assert "templates_unavailable" in {issue.code for issue in unavailable.issues}
def test_row_resolves_multiple_direct_and_field_derived_postboxes() -> None:
config = _config()
integration = _PostboxIntegration()
with patch(
"govoplan_campaign.backend.campaign.postbox_targets.postbox_integration",
return_value=integration,
):
targets, issues, status = resolve_entry_postbox_targets(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
config=config,
entry=config.entries.inline[0], # type: ignore[index]
validation_status=MessageValidationStatus.READY,
materialize=True,
)
assert issues == []
assert status == MessageValidationStatus.READY
assert [target["postbox_id"] for target in targets] == [
"postbox-direct",
"postbox-derived",
]
assert targets[1]["context_key"] == "case-42"
assert len(integration.targets) == 2
assert all(materialize for _target, materialize in integration.targets)
+282
View File
@@ -0,0 +1,282 @@
from __future__ import annotations
import hashlib
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignVersion,
JobPrintStatus,
PrintOutputAttempt,
)
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.messages.models import MessageValidationStatus
from govoplan_campaign.backend.persistence.campaigns import _resolve_built_print_outputs
from govoplan_campaign.backend.routes import versions as version_routes
from govoplan_campaign.backend.sending.jobs import _deliver_print_channel
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.templates import TemplateArtifactRef, TemplateRenderResult
from govoplan_core.db.base import Base
def test_print_acceptance_is_idempotent_per_frozen_artifact() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
PrintOutputAttempt.__table__,
ChangeSequenceEntry.__table__,
],
)
with Session(engine) as session:
job = CampaignJob(
id="job-print-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=1,
entry_id="entry-1",
recipient_email=None,
subject="Printable notice",
build_status="built",
validation_status="ready",
queue_status="draft",
send_status="not_queued",
print_status=JobPrintStatus.READY.value,
delivery_channel_policy="print",
resolved_attachments=[],
issues_snapshot=[],
resolved_print_output={
"render_id": "render-1",
"output_sha256": "a" * 64,
"template_id": "template-1",
"template_revision_id": "revision-1",
"template_hash": "b" * 64,
"input_hash": "c" * 64,
"recipient_key": "recipient-1",
"item_index": 0,
"route": {"channel": "postal", "target_key": "postal:1"},
},
)
session.add(job)
session.commit()
first = _deliver_print_channel(session, job=job)
second = _deliver_print_channel(session, job=job)
assert first.accepted is True
assert second.accepted is True
assert session.get(CampaignJob, job.id).print_attempt_count == 1
attempts = session.query(PrintOutputAttempt).all()
assert len(attempts) == 1
assert attempts[0].artifact_sha256 == "a" * 64
engine.dispose()
def test_print_build_uses_one_deterministic_template_render_request() -> None:
config = CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Printed notice", "mode": "send"},
"fields": [{"name": "case_number", "type": "string"}],
"template": {"subject": "Notice", "text": "Body", "body_mode": "text"},
"entries": {
"inline": [
{
"id": "entry-1",
"name": "Ada",
"channel_policy": "print",
"fields": {"case_number": "C-1"},
"print_target": {
"channel": "postal",
"target": "Example Street 1",
"target_key": "postal:example-street-1",
},
"distribution_source": {
"list_id": "list-1",
"list_revision": 3,
"expansion_hash": "expansion-1",
"recipient_key": "recipient-1",
},
}
]
},
"delivery": {
"channel_policy": "print",
"print": {
"template_id": "template-1",
"template_revision": 4,
"output_format": "html",
"persist_to_files": True,
},
},
}
)
draft = SimpleNamespace(
entry_index=1,
entry_id="entry-1",
delivery_channel_policy="print",
validation_status=MessageValidationStatus.READY,
)
built = [SimpleNamespace(draft=draft)]
requests = []
stored = {}
class Storage:
def put_bytes(self, key, data, **_kwargs):
stored[key] = data
class Templates:
def render(self, _session, _principal, *, request):
requests.append(request)
return TemplateRenderResult(
render_id="render-1",
template_id="template-1",
revision_id="revision-4",
revision=4,
template_hash="b" * 64,
input_hash="c" * 64,
renderer_version="templates-1",
output_format="html",
content_type="text/html",
filename="printed-notice.html",
item_count=1,
page_count=1,
output_sha256=hashlib.sha256(b"<p>Printed notice</p>").hexdigest(),
output_size_bytes=len(b"<p>Printed notice</p>"),
artifact=TemplateArtifactRef(
kind="bounded_download",
filename="printed-notice.html",
content_type="text/html",
size_bytes=len(b"<p>Printed notice</p>"),
sha256=hashlib.sha256(b"<p>Printed notice</p>").hexdigest(),
download_path="/api/v1/templates/renders/render-1/download",
),
payload=b"<p>Printed notice</p>",
)
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
version_number=2,
)
principal = SimpleNamespace(account_id="account-1")
storage = Storage()
with patch(
"govoplan_campaign.backend.persistence.campaigns.templates_integration",
return_value=Templates(),
):
first = _resolve_built_print_outputs(
object(), # type: ignore[arg-type]
storage=storage, # type: ignore[arg-type]
tenant_id="tenant-1",
build_id="build-1",
version=version, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
config=config,
built_messages=built,
entries_by_index={1: config.entries.inline[0]},
)
second = _resolve_built_print_outputs(
object(), # type: ignore[arg-type]
storage=storage, # type: ignore[arg-type]
tenant_id="tenant-1",
build_id="build-1",
version=version, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
config=config,
built_messages=built,
entries_by_index={1: config.entries.inline[0]},
)
assert requests[0].idempotency_key == requests[1].idempotency_key
assert requests[0].items[0]["case_number"] == "C-1"
assert requests[0].persist_to_files is True
assert first == second
assert first[1]["artifact"]["storage_key"] in stored
assert first[1]["artifact"]["download_path"] == (
"/api/v1/campaigns/campaign-1/versions/version-1/print-output/download"
)
assert first[1]["route"]["target_key"] == "postal:example-street-1"
class _RoutePrincipal:
tenant_id = "tenant-1"
account_id = "account-1"
user = SimpleNamespace(id="user-1")
def has(self, scope: str) -> bool:
return scope in {"campaigns:campaign:read", "campaigns:recipient:read"}
def test_print_download_is_authorized_by_campaign_and_hash_checked() -> None:
payload = b"<p>Printable output</p>"
digest = hashlib.sha256(payload).hexdigest()
version = SimpleNamespace(
id="version-1",
build_summary={
"print_output": {
"output_sha256": digest,
"template_id": "template-1",
"template_revision_id": "revision-1",
"artifact": {
"kind": "bounded_download",
"filename": "letters.html",
"content_type": "text/html",
"storage_key": "campaign-artifacts/tenant-1/letters.html",
},
}
},
)
storage = SimpleNamespace(get_bytes=lambda _key: payload)
with (
patch.object(version_routes, "_get_campaign_for_principal"),
patch.object(
version_routes,
"get_campaign_version_for_tenant",
return_value=version,
),
patch.object(version_routes, "_object_storage", return_value=storage),
patch.object(version_routes, "audit_from_principal") as audit,
):
response = version_routes.download_print_output(
"campaign-1",
"version-1",
session=Mock(),
principal=_RoutePrincipal(), # type: ignore[arg-type]
)
assert response.body == payload
assert response.headers["x-content-sha256"] == digest
audit.assert_called_once()
def test_print_download_requires_recipient_read_authority() -> None:
principal = _RoutePrincipal()
principal.has = lambda scope: scope == "campaigns:campaign:read" # type: ignore[method-assign]
with (
patch.object(version_routes, "_get_campaign_for_principal"),
pytest.raises(HTTPException) as denied,
):
version_routes.download_print_output(
"campaign-1",
"version-1",
session=Mock(),
principal=principal, # type: ignore[arg-type]
)
assert denied.value.status_code == 403
+100 -1
View File
@@ -2,12 +2,13 @@ from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from email.message import EmailMessage
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError from pydantic import ValidationError
from govoplan_campaign.backend import router from govoplan_campaign.backend.routes import reports as router
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from govoplan_campaign.backend.schemas import ReportEmailRequest from govoplan_campaign.backend.schemas import ReportEmailRequest
@@ -42,6 +43,16 @@ def test_report_email_recipient_schema_rejects_unsafe_addresses(recipients: list
ReportEmailRequest.model_validate({"to": recipients}) ReportEmailRequest.model_validate({"to": recipients})
def test_report_email_recipient_error_identifies_the_invalid_list_item() -> None:
with pytest.raises(ValidationError) as captured:
ReportEmailRequest.model_validate({
"to": ["first@example.test", "invalid recipient", "third@example.test"],
})
assert captured.value.errors()[0]["loc"] == ("to", 1)
assert "email addresses" in captured.value.errors()[0]["msg"]
def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> None: def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"], attach_jobs_csv=True) payload = ReportEmailRequest(to=["recipient@example.test"], attach_jobs_csv=True)
principal = SimpleNamespace(tenant_id="tenant-1") principal = SimpleNamespace(tenant_id="tenant-1")
@@ -173,3 +184,91 @@ def test_report_send_fails_closed_until_durable_mail_outbox_exists() -> None:
) )
generate_report.assert_not_called() generate_report.assert_not_called()
def test_live_report_submits_exact_message_to_durable_mail_command() -> None:
campaign = SimpleNamespace(
id="campaign-1",
tenant_id="tenant-1",
name="Campaign",
external_id="campaign-1",
)
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
raw_json={"server": {"mail_profile_id": "profile-1"}},
execution_snapshot={"snapshot_version": "7"},
)
config = SimpleNamespace(
server=SimpleNamespace(
profile_capabilities=SimpleNamespace(smtp_available=True)
),
)
snapshot = SimpleNamespace(
mail_profile_id="profile-1",
smtp_server_id="smtp-1",
smtp_credential_id="credential-1",
smtp_transport_revision="revision-1",
)
message = EmailMessage()
message["From"] = "Sender <sender@example.test>"
message["To"] = "recipient@example.test"
message["Subject"] = "Report"
message.set_content("content")
mail = SimpleNamespace(
durable_delivery_available=True,
submit_delivery_command=lambda _session, **_kwargs: {
"id": "command-1",
"status": "pending",
"duplicate": False,
},
)
class Session:
def get(self, _model, _id):
return campaign
with (
patch(
"govoplan_campaign.backend.reports.emailing._selected_version",
return_value=version,
),
patch(
"govoplan_campaign.backend.reports.emailing._load_config",
return_value=config,
),
patch(
"govoplan_campaign.backend.reports.emailing.ensure_execution_snapshot",
return_value=snapshot,
),
patch(
"govoplan_campaign.backend.reports.emailing.generate_campaign_report",
return_value={},
),
patch(
"govoplan_campaign.backend.reports.emailing.build_report_message",
return_value=message,
),
patch(
"govoplan_campaign.backend.reports.emailing._effective_from",
return_value=("sender@example.test", "Sender"),
),
patch(
"govoplan_campaign.backend.reports.emailing.mail_integration",
return_value=mail,
),
):
result = send_campaign_report_email(
Session(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
to=["recipient@example.test"],
idempotency_key="request-1",
created_by_user_id="user-1",
)
assert result.command_id == "command-1"
assert result.delivery_status == "pending"
assert result.sent is False
assert result.audit_dict()["recipient_count"] == 1
assert "recipient@example.test" not in repr(result.audit_dict())
+76 -12
View File
@@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
import stat
import unittest import unittest
from datetime import UTC, datetime from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_campaign.backend.response_security import public_campaign_payload from govoplan_campaign.backend.response_security import public_campaign_payload
from govoplan_campaign.backend.router import ( from govoplan_campaign.backend.services.job_queries import (
_job_attempts_payload, _job_attempts_payload,
_job_detail_payload, _job_detail_payload,
_job_diagnostics_payload, _job_diagnostics_payload,
@@ -57,6 +57,14 @@ def _job() -> SimpleNamespace:
} }
], ],
resolved_recipients={"to": [{"email": "person@example.test"}]}, resolved_recipients={"to": [{"email": "person@example.test"}]},
resolved_print_output={
"output_sha256": "print-sha256",
"artifact": {
"filename": "letters.html",
"storage_key": "campaign/private/letters.html",
"download_path": "/api/v1/campaigns/campaign-1/versions/version-1/print-output/download",
},
},
) )
@@ -88,6 +96,30 @@ def _imap_attempt() -> SimpleNamespace:
) )
def _postbox_attempt() -> SimpleNamespace:
return SimpleNamespace(
id="postbox-attempt-1",
target_index=0,
attempt_number=1,
status="accepted",
postbox_id="postbox-1",
address="intake@postbox",
holder_count=1,
vacant=False,
duplicate=False,
target_snapshot={"address": "intake@postbox"},
started_at=_now(),
finished_at=_now(),
error_code=None,
idempotency_key="private-idempotency-key",
provider_delivery_id="delivery-1",
provider_message_id="message-1",
evidence={"target_snapshot": {"address": "intake@postbox"}},
error_type=None,
error_message=None,
)
def test_public_payload_recursively_removes_infrastructure_locators() -> None: def test_public_payload_recursively_removes_infrastructure_locators() -> None:
source = { source = {
"campaign_file": "/tmp/campaign.json", "campaign_file": "/tmp/campaign.json",
@@ -214,6 +246,13 @@ def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
assert "claimed_at" not in job_payload assert "claimed_at" not in job_payload
assert "smtp_started_at" not in job_payload assert "smtp_started_at" not in job_payload
assert job_payload["attachments"] == [{"filename": "public.pdf"}] assert job_payload["attachments"] == [{"filename": "public.pdf"}]
assert job_payload["resolved_print_output"] == {
"output_sha256": "print-sha256",
"artifact": {
"filename": "letters.html",
"download_path": "/api/v1/campaigns/campaign-1/versions/version-1/print-output/download",
},
}
assert "claim_token" not in attempts["smtp"][0] assert "claim_token" not in attempts["smtp"][0]
assert "claim_token" not in attempts["imap"][0] assert "claim_token" not in attempts["imap"][0]
assert "smtp_response" not in attempts["smtp"][0] assert "smtp_response" not in attempts["smtp"][0]
@@ -243,6 +282,33 @@ def test_operator_diagnostics_include_claim_and_storage_details() -> None:
assert "provider-secret" in diagnostics["worker_claim"]["last_error"] assert "provider-secret" in diagnostics["worker_claim"]["last_error"]
def test_postbox_receipt_summary_is_aggregate_and_contains_no_holder_identity() -> None:
summary = PostboxDeliveryReceiptSummaryRef(
delivery_id="delivery-1",
message_id="message-1",
postbox_id="postbox-1",
delivery_status="accepted",
accepted_at=_now(),
current_holder_count=2,
currently_readable=True,
read_receipt_count=1,
acknowledged_receipt_count=1,
)
attempts = _job_attempts_payload(
[],
[],
[_postbox_attempt()], # type: ignore[list-item]
postbox_receipts={"delivery-1": summary},
)
payload = attempts["postbox"][0]
assert payload["receipt_summary_status"] == "available"
assert payload["receipt_summary"]["currently_readable"] is True
assert payload["receipt_summary"]["read_receipt_count"] == 1
assert "account_id" not in repr(payload["receipt_summary"])
assert "identity_id" not in repr(payload["receipt_summary"])
def test_diagnostics_permission_is_operator_only_by_default() -> None: def test_diagnostics_permission_is_operator_only_by_default() -> None:
from govoplan_campaign.backend.manifest import PERMISSIONS, ROLE_TEMPLATES from govoplan_campaign.backend.manifest import PERMISSIONS, ROLE_TEMPLATES
@@ -255,20 +321,18 @@ def test_diagnostics_permission_is_operator_only_by_default() -> None:
assert "campaigns:diagnostic:read" not in role_permissions["campaign_reviewer"] assert "campaigns:diagnostic:read" not in role_permissions["campaign_reviewer"]
def test_campaign_snapshot_with_inline_credentials_is_owner_only(tmp_path, monkeypatch) -> None: def test_database_campaign_config_is_not_mirrored_to_node_local_disk(tmp_path) -> None:
from govoplan_campaign.backend.persistence import campaigns as persistence from govoplan_campaign.backend.persistence import campaigns as persistence
snapshots = tmp_path / "snapshots" path = persistence._campaign_reference_path( # type: ignore[arg-type]
output = tmp_path / "generated" SimpleNamespace(
monkeypatch.setattr(persistence, "CAMPAIGN_SNAPSHOT_DIR", snapshots) source_base_path=str(tmp_path),
monkeypatch.setattr(persistence, "BUILD_OUTPUT_DIR", output) raw_json={"server": {"smtp": {"password": "secret"}}},
path = persistence._write_campaign_snapshot( # type: ignore[arg-type] )
SimpleNamespace(id="version-secret", raw_json={"server": {"smtp": {"password": "secret"}}})
) )
assert stat.S_IMODE(path.stat().st_mode) == 0o600 assert path == tmp_path / "campaign.json"
assert stat.S_IMODE(snapshots.stat().st_mode) == 0o700 assert list(tmp_path.iterdir()) == []
assert stat.S_IMODE(output.stat().st_mode) == 0o700
class ResponseSecurityTests(unittest.TestCase): class ResponseSecurityTests(unittest.TestCase):
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
from govoplan_campaign.backend.retention import (
_apply_eml_retention,
_apply_raw_json_retention,
_apply_report_detail_retention,
)
def _querying(items: list[object]) -> MagicMock:
session = MagicMock()
session.query.return_value.order_by.return_value.all.return_value = items
session.query.return_value.filter.return_value.order_by.return_value.all.return_value = items
return session
def test_raw_json_retention_preserves_hash_for_final_version() -> None:
now = datetime.now(timezone.utc)
version = SimpleNamespace(
campaign_id="campaign-1",
updated_at=now - timedelta(days=10),
workflow_state="completed",
raw_json={"version": "1.0", "campaign": {"name": "Sensitive"}},
schema_version="1.0",
execution_snapshot={"campaign_json_sha256": "sealed-hash"},
)
session = _querying([version])
policy = SimpleNamespace(
store_raw_campaign_json=True,
raw_campaign_json_retention_days=1,
)
result = _apply_raw_json_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["redacted"] == 1
assert version.raw_json == {
"version": "1.0",
"_retention": {
"raw_json_redacted": True,
"redacted_at": now.isoformat(),
"original_sha256": "sealed-hash",
},
}
session.add.assert_called_once_with(version)
def test_raw_json_retention_does_not_redact_unfinished_version() -> None:
now = datetime.now(timezone.utc)
original = {"version": "1.0", "campaign": {"name": "Editable"}}
version = SimpleNamespace(
campaign_id="campaign-1",
updated_at=now - timedelta(days=10),
workflow_state="editing",
raw_json=original,
schema_version="1.0",
execution_snapshot=None,
)
session = _querying([version])
policy = SimpleNamespace(
store_raw_campaign_json=True,
raw_campaign_json_retention_days=1,
)
result = _apply_raw_json_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["skipped_not_final"] == 1
assert version.raw_json is original
session.add.assert_not_called()
def test_report_detail_retention_keeps_aggregate_evidence() -> None:
now = datetime.now(timezone.utc)
version = SimpleNamespace(
campaign_id="campaign-1",
updated_at=now - timedelta(days=10),
validation_summary={"ready": 4, "issues": [{"entry_id": "secret"}]},
build_summary={"built": 3, "jobs": [{"recipient": "person@example.test"}]},
)
session = _querying([version])
policy = SimpleNamespace(stored_report_detail_retention_days=1)
result = _apply_report_detail_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["summaries_redacted"] == 2
assert version.validation_summary["ready"] == 4
assert "issues" not in version.validation_summary
assert version.build_summary["built"] == 3
assert "jobs" not in version.build_summary
assert version.validation_summary["_retention"]["report_detail_redacted"] is True
session.add.assert_called_once_with(version)
def test_eml_retention_removes_only_terminal_artifact(tmp_path) -> None:
now = datetime.now(timezone.utc)
eml_path = tmp_path / "message.eml"
eml_path.write_bytes(b"message")
job = SimpleNamespace(
campaign_id="campaign-1",
updated_at=now - timedelta(days=10),
queue_status="draft",
send_status="smtp_accepted",
imap_status="appended",
eml_local_path=str(eml_path),
eml_storage_key="campaigns/message.eml",
)
session = _querying([job])
policy = SimpleNamespace(generated_eml_retention_days=1)
result = _apply_eml_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["files_deleted"] == 1
assert result["metadata_cleared"] == 1
assert not eml_path.exists()
assert job.eml_local_path is None
assert job.eml_storage_key is None
session.add.assert_called_once_with(job)
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
)
from govoplan_campaign.backend.persistence.versions import (
_normalize_review_issue_decisions,
)
def test_attachment_ask_requires_reason_and_freezes_evidence() -> None:
job = _job(
issues=[
{
"code": "missing_optional_attachment",
"behavior": "ask",
"source": "attachments",
"details": {
"effective_policy": {
"effective_behavior": "ask",
}
},
}
]
)
with pytest.raises(
CampaignPersistenceError,
match="require an explicit reason",
):
_normalize_review_issue_decisions(
[job],
[],
user_id="reviewer-1",
build_token="build-1",
)
decisions = _normalize_review_issue_decisions(
[job],
[
{
"job_id": job.id,
"decision": "accept",
"reason": "Recipient confirmed that no attachment is expected.",
}
],
user_id="reviewer-1",
build_token="build-1",
decided_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
)
assert decisions == [
{
"job_id": "job-1",
"review_key": "entry-1",
"decision": "accept",
"reason": "Recipient confirmed that no attachment is expected.",
"actor_user_id": "reviewer-1",
"decided_at": "2026-07-30T12:00:00+00:00",
"build_token": "build-1",
"message_sha256": "a" * 64,
"issue_fingerprint": decisions[0]["issue_fingerprint"],
"issue_codes": ["missing_optional_attachment"],
}
]
assert len(decisions[0]["issue_fingerprint"]) == 64
def test_attachment_block_cannot_be_overridden_by_review_decision() -> None:
job = _job(
issues=[
{
"code": "missing_required_attachment",
"behavior": "block",
"source": "attachments",
}
]
)
decisions = _normalize_review_issue_decisions(
[job],
[],
user_id="reviewer-1",
build_token="build-1",
)
assert decisions == []
def _job(*, issues: list[dict[str, object]]) -> SimpleNamespace:
return SimpleNamespace(
id="job-1",
entry_id="entry-1",
entry_index=1,
validation_status="needs_review",
issues_snapshot=issues,
eml_sha256="a" * 64,
)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from collections import Counter
from govoplan_campaign.backend.router import router
from govoplan_campaign.backend.routes.attachments import router as attachments_router
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
from govoplan_campaign.backend.routes.delivery import router as delivery_router
from govoplan_campaign.backend.routes.jobs import router as jobs_router
from govoplan_campaign.backend.routes.reports import router as reports_router
from govoplan_campaign.backend.routes.sharing import router as sharing_router
from govoplan_campaign.backend.routes.versions import router as versions_router
def _operation_keys(candidate_router) -> list[tuple[str, str]]:
return [
(method, route.path)
for route in candidate_router.routes
for method in sorted(route.methods or ())
]
def test_campaign_router_composes_every_workflow_operation_once() -> None:
workflow_routers = (
campaigns_router,
versions_router,
jobs_router,
reports_router,
sharing_router,
delivery_router,
attachments_router,
)
expected = [
operation
for workflow_router in workflow_routers
for operation in _operation_keys(workflow_router)
]
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 71
assert not [operation for operation, count in Counter(actual).items() if count > 1]
def test_key_routes_are_owned_by_their_focused_router() -> None:
expectations = (
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
(
delivery_router,
(
"POST",
"/campaigns/{campaign_id}/versions/{version_id}/approval-request",
),
),
(
attachments_router,
(
"POST",
"/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
),
),
)
for workflow_router, operation in expectations:
assert operation in _operation_keys(workflow_router)
+1 -1
View File
@@ -4,7 +4,7 @@ from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from govoplan_campaign.backend import router from govoplan_campaign.backend.routes import delivery as router
from govoplan_campaign.backend.schemas import SendCampaignNowRequest from govoplan_campaign.backend.schemas import SendCampaignNowRequest
from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult
+2
View File
@@ -141,6 +141,8 @@ class CampaignQueueSelectionTests(unittest.TestCase):
) )
snapshot = SimpleNamespace( snapshot = SimpleNamespace(
mail_profile_id="profile-1", mail_profile_id="profile-1",
smtp_server_id="smtp-server-1",
smtp_credential_id="smtp-credential-1",
smtp_transport_revision="frozen", smtp_transport_revision="frozen",
delivery=SimpleNamespace( delivery=SimpleNamespace(
rate_limit=SimpleNamespace(messages_per_minute=60), rate_limit=SimpleNamespace(messages_per_minute=60),
+2 -2
View File
@@ -79,13 +79,13 @@ class ServerCampaignPathSecurityTests(unittest.TestCase):
"govoplan_campaign.backend.persistence.campaigns.files_integration", "govoplan_campaign.backend.persistence.campaigns.files_integration",
return_value=SimpleNamespace(available=True), return_value=SimpleNamespace(available=True),
), ),
patch("govoplan_campaign.backend.persistence.campaigns._write_campaign_snapshot") as write_snapshot, patch("govoplan_campaign.backend.persistence.campaigns._campaign_reference_path") as reference_path,
patch("govoplan_campaign.backend.persistence.campaigns.load_campaign_config_from_json") as load_config, patch("govoplan_campaign.backend.persistence.campaigns.load_campaign_config_from_json") as load_config,
): ):
with self.assertRaisesRegex(CampaignPathSecurityError, "template source paths"): with self.assertRaisesRegex(CampaignPathSecurityError, "template source paths"):
load_version_config(session, "version-1") # type: ignore[arg-type] load_version_config(session, "version-1") # type: ignore[arg-type]
write_snapshot.assert_not_called() reference_path.assert_not_called()
load_config.assert_not_called() load_config.assert_not_called()
def test_source_base_symlink_escape_is_rejected(self) -> None: def test_source_base_symlink_escape_is_rejected(self) -> None:
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_campaign.backend.routes.sharing import (
search_campaign_share_targets,
)
from govoplan_core.core.references import (
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
ReferenceOption,
ReferenceSearchPage,
)
class _Provider:
def __init__(self) -> None:
self.requests = []
def search_reference_options(self, session, principal, *, request):
del session, principal
self.requests.append(request)
return ReferenceSearchPage(
options=(
ReferenceOption(
value="membership-1",
label="Ada",
kind="membership",
source_module="access",
),
),
next_cursor="offset:1",
has_more=True,
)
class _Registry:
def __init__(self) -> None:
self.provider = _Provider()
def has_capability(self, name):
return name == CAPABILITY_ACCESS_REFERENCE_OPTIONS
def capability(self, name):
return self.provider if self.has_capability(name) else None
class CampaignShareTargetSearchTests(unittest.TestCase):
def test_search_uses_membership_references_and_retains_stale_targets(self) -> None:
registry = _Registry()
principal = SimpleNamespace(
tenant_id="tenant-1",
account_id="account-1",
membership_id="membership-1",
group_ids=frozenset(),
)
with (
patch(
"govoplan_campaign.backend.routes.sharing._get_campaign_for_principal"
),
patch(
"govoplan_campaign.backend.routes.sharing.get_registry",
return_value=registry,
),
):
response = search_campaign_share_targets(
"campaign-1",
"user",
q="Ada",
selected=["removed-membership"],
limit=25,
cursor="offset:0",
session=object(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
request = registry.provider.requests[0]
self.assertEqual("membership", request.kind)
self.assertEqual("tenant-1", request.tenant_id)
self.assertEqual("ada", request.query)
self.assertTrue(request.context["administrative"])
self.assertEqual(
["membership-1", "removed-membership"],
[option.value for option in response.options],
)
self.assertEqual("unavailable", response.options[1].availability)
self.assertTrue(response.has_more)
self.assertEqual("offset:1", response.next_cursor)
if __name__ == "__main__":
unittest.main()
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import hashlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from govoplan_core.core.object_storage import (
LocalFilesystemStorageBackend,
StorageBackendError,
)
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
_persist_built_eml_artifacts,
)
from govoplan_campaign.backend.retention import _apply_eml_retention
from govoplan_campaign.backend.sending.jobs import _load_eml_bytes_for_job
class _FailingStorage:
name = "test"
def __init__(self) -> None:
self.objects: dict[str, bytes] = {}
self.deleted: list[str] = []
def put_bytes(self, key: str, data: bytes, **_kwargs) -> None:
if self.objects:
raise StorageBackendError("object service unavailable")
self.objects[key] = data
def delete(self, key: str) -> None:
self.deleted.append(key)
self.objects.pop(key, None)
def test_partial_campaign_artifact_write_is_compensated(tmp_path: Path) -> None:
first = tmp_path / "first.eml"
second = tmp_path / "second.eml"
first.write_bytes(b"Message-ID: <first@example.test>\r\n\r\nfirst")
second.write_bytes(b"Message-ID: <second@example.test>\r\n\r\nsecond")
messages = [
SimpleNamespace(
draft=SimpleNamespace(
entry_index=1,
eml_path=str(first),
eml_size_bytes=None,
)
),
SimpleNamespace(
draft=SimpleNamespace(
entry_index=2,
eml_path=str(second),
eml_size_bytes=None,
)
),
]
storage = _FailingStorage()
with pytest.raises(CampaignPersistenceError, match="could not be persisted"):
_persist_built_eml_artifacts(
storage=storage, # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
version_id="version-1",
build_id="build-1",
built_messages=messages,
)
assert storage.objects == {}
assert len(storage.deleted) == 1
def test_worker_loads_and_verifies_campaign_eml_from_object_storage(
tmp_path: Path,
) -> None:
payload = b"Message-ID: <message@example.test>\r\n\r\nbody"
storage = LocalFilesystemStorageBackend(tmp_path)
storage.put_bytes("campaign-artifacts/message.eml", payload)
job = SimpleNamespace(
id="job-1",
eml_storage_key="campaign-artifacts/message.eml",
eml_local_path=None,
eml_size_bytes=len(payload),
eml_sha256=hashlib.sha256(payload).hexdigest(),
message_id_header="<message@example.test>",
)
with patch(
"govoplan_campaign.backend.sending.jobs.configured_storage_backend",
return_value=storage,
):
assert _load_eml_bytes_for_job(job) == payload
def test_retention_preserves_reference_when_object_store_is_unavailable() -> None:
job = SimpleNamespace(
campaign_id="campaign-1",
updated_at=datetime.now(timezone.utc) - timedelta(days=10),
queue_status="draft",
send_status="smtp_accepted",
imap_status="appended",
eml_local_path=None,
eml_storage_key="campaign-artifacts/message.eml",
)
session = MagicMock()
session.query.return_value.filter.return_value.order_by.return_value.all.return_value = [
job
]
storage = MagicMock()
storage.exists.side_effect = StorageBackendError("object service unavailable")
result = _apply_eml_retention(
session,
dry_run=False,
now=datetime.now(timezone.utc),
policy_for_campaign_id=lambda _campaign_id: SimpleNamespace(
generated_eml_retention_days=1
),
storage=storage,
)
assert result["delete_failed"] == 1
assert result["metadata_cleared"] == 0
assert job.eml_storage_key == "campaign-artifacts/message.eml"
session.add.assert_not_called()
+417
View File
@@ -0,0 +1,417 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from pydantic import ValidationError
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignVersion,
JobBuildStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
SendAttempt,
)
from govoplan_campaign.backend.schemas import CampaignSendJobRequest
from govoplan_campaign.backend.sending.jobs import (
QueueingError,
SendJobResult,
send_single_campaign_job,
)
from govoplan_campaign.backend.integrations import SmtpSendError
class _Mail:
def __init__(self, result: object | None = None, error: Exception | None = None):
self.result = result or SimpleNamespace(
accepted_count=1,
refused_recipients={},
)
self.error = error
self.send = Mock()
def wait_for_rate_limit(self, **_kwargs) -> None:
return None
def send_campaign_email_bytes(self, *_args, **_kwargs):
self.send(*_args, **_kwargs)
if self.error is not None:
raise self.error
return self.result
class CampaignSingleMessageActionTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
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,
ChangeSequenceEntry.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
SendAttempt.__table__,
CampaignMessageAction.__table__,
CampaignMessageActionAttempt.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
external_id="campaign-1",
name="Campaign",
current_version_id="version-1",
)
version = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
raw_json={},
validation_summary={"ok": True},
build_summary={"build_token": "build-1"},
)
job = CampaignJob(
id="job-1",
tenant_id="tenant-1",
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=1,
recipient_email="recipient@example.test",
eml_local_path="/tmp/message.eml",
eml_sha256="a" * 64,
eml_size_bytes=42,
build_status=JobBuildStatus.BUILT.value,
validation_status=JobValidationStatus.READY.value,
queue_status=JobQueueStatus.DRAFT.value,
send_status=JobSendStatus.NOT_QUEUED.value,
delivery_channel_policy="mail",
resolved_recipients={
"from": {"email": "sender@example.test"},
"to": [{"email": "recipient@example.test"}],
},
)
session.add_all((campaign, version, job))
session.commit()
self.addCleanup(self.engine.dispose)
@staticmethod
def _delivery_context():
return SimpleNamespace(
snapshot=SimpleNamespace(
mail_profile_id="profile-1",
smtp_server_id=None,
smtp_credential_id=None,
smtp_transport_revision="revision-1",
delivery=SimpleNamespace(
rate_limit=SimpleNamespace(messages_per_minute=60),
imap_append_sent=SimpleNamespace(enabled=False),
),
),
message_bytes=b"Subject: test\r\n\r\ncontent",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
)
def _run(
self,
session: Session,
*,
kind: str,
key: str,
mail: _Mail,
reason: str | None = None,
):
with (
patch(
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
),
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
return_value=self._delivery_context(),
),
patch(
"govoplan_campaign.backend.sending.jobs.mail_integration",
return_value=mail,
),
patch(
"govoplan_campaign.backend.sending.jobs.audit_event"
),
):
return send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind=kind,
idempotency_key=key,
actor_user_id=None,
reason=reason,
)
def test_test_action_is_idempotent_and_leaves_official_state_unchanged(self) -> None:
mail = _Mail()
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
job.attempt_count = 2
session.commit()
first = self._run(
session,
kind="test",
key="test-1",
mail=mail,
)
repeated = self._run(
session,
kind="test",
key="test-1",
mail=mail,
)
session.refresh(job)
self.assertEqual(first["status"], "accepted")
self.assertTrue(repeated["duplicate"])
self.assertEqual(job.send_status, JobSendStatus.FAILED_TEMPORARY.value)
self.assertEqual(job.attempt_count, 2)
self.assertEqual(mail.send.call_count, 1)
with self.assertRaisesRegex(QueueingError, "idempotency key"):
self._run(
session,
kind="single_resend",
key="test-1",
reason="Different command",
mail=mail,
)
def test_failed_resend_preserves_prior_success(self) -> None:
mail = _Mail(
error=SmtpSendError(
"Temporary delivery failure",
temporary=True,
)
)
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.sent_at = job.created_at
session.commit()
result = self._run(
session,
kind="single_resend",
key="resend-failed",
reason="Recipient requested another copy",
mail=mail,
)
session.refresh(job)
self.assertEqual(result["status"], "failed_temporary")
self.assertEqual(job.send_status, JobSendStatus.SMTP_ACCEPTED.value)
self.assertIsNotNone(job.sent_at)
def test_successful_resend_completes_previously_failed_message(self) -> None:
mail = _Mail()
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
job.send_status = JobSendStatus.FAILED_PERMANENT.value
job.last_error = "old failure"
session.commit()
with (
patch(
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
),
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
return_value=self._delivery_context(),
),
patch(
"govoplan_campaign.backend.sending.jobs.mail_integration",
return_value=mail,
),
patch(
"govoplan_campaign.backend.sending.jobs.files_integration"
) as files,
patch(
"govoplan_campaign.backend.sending.jobs._update_campaign_after_job"
),
patch(
"govoplan_campaign.backend.sending.jobs.audit_event"
),
):
result = send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind="single_resend",
idempotency_key="resend-success",
actor_user_id=None,
reason="Approved operator resend",
)
session.refresh(job)
self.assertEqual(result["status"], "accepted")
self.assertEqual(job.send_status, JobSendStatus.SMTP_ACCEPTED.value)
self.assertIsNotNone(job.sent_at)
files.return_value.mark_job_attachment_uses_sent.assert_called_once()
def test_unknown_resend_is_frozen_and_replay_does_not_send_again(self) -> None:
mail = _Mail(
error=SmtpSendError(
"Unknown delivery outcome",
outcome_unknown=True,
)
)
with self.SessionLocal() as session:
first = self._run(
session,
kind="single_resend",
key="resend-unknown",
reason="Approved operator resend",
mail=mail,
)
repeated = self._run(
session,
kind="single_resend",
key="resend-unknown",
reason="Approved operator resend",
mail=mail,
)
self.assertEqual(first["status"], "outcome_unknown")
self.assertTrue(repeated["duplicate"])
self.assertEqual(mail.send.call_count, 1)
def test_initial_send_links_the_canonical_attempt_and_replay_is_inert(self) -> None:
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
def canonical_send(_session: Session, **_kwargs):
attempt = SendAttempt(
job_id=job.id,
attempt_number=1,
status=JobSendStatus.SMTP_ACCEPTED.value,
started_at=job.created_at,
finished_at=job.created_at,
)
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.queue_status = JobQueueStatus.DRAFT.value
job.attempt_count = 1
_session.add_all((attempt, job))
_session.commit()
return SendJobResult(
job_id=job.id,
status=JobSendStatus.SMTP_ACCEPTED.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
),
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
return_value=self._delivery_context(),
),
patch(
"govoplan_campaign.backend.sending.jobs._prepare_single_job_for_send",
return_value="queued",
),
patch(
"govoplan_campaign.backend.sending.jobs.send_campaign_job",
side_effect=canonical_send,
) as send,
patch(
"govoplan_campaign.backend.sending.jobs._emit_campaign_status_notification"
),
patch(
"govoplan_campaign.backend.sending.jobs.audit_event"
),
):
first = send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind="single_send",
idempotency_key="initial-1",
actor_user_id=None,
)
repeated = send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind="single_send",
idempotency_key="initial-1",
actor_user_id=None,
)
self.assertEqual(first["status"], "accepted")
self.assertIsNotNone(first["linked_send_attempt_id"])
self.assertTrue(repeated["duplicate"])
self.assertEqual(send.call_count, 1)
def test_request_requires_explicit_kind_key_and_resend_reason(self) -> None:
with self.assertRaises(ValidationError):
CampaignSendJobRequest.model_validate({})
with self.assertRaises(ValidationError):
CampaignSendJobRequest.model_validate(
{
"kind": "single_resend",
"idempotency_key": "resend-1",
}
)
request = CampaignSendJobRequest.model_validate(
{
"kind": "test",
"idempotency_key": "test-1",
}
)
self.assertEqual(request.kind, "test")
if __name__ == "__main__":
unittest.main()
+3 -2
View File
@@ -6,7 +6,8 @@ from unittest.mock import Mock, patch
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from govoplan_campaign.backend import router from govoplan_campaign.backend import router as campaign_api
from govoplan_campaign.backend.routes import delivery as router
from govoplan_campaign.backend.delivery_policy import ( from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError, CampaignDeliveryPolicyError,
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS, DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
@@ -295,7 +296,7 @@ def test_delivery_endpoints_require_their_mode_permission_and_recipient_authorit
path: str, path: str,
required_scope: str, required_scope: str,
) -> None: ) -> None:
route = next(item for item in router.router.routes if item.path == path) 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") dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
with pytest.raises(HTTPException) as missing_mode_permission: with pytest.raises(HTTPException) as missing_mode_permission:
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import Campaign
from govoplan_campaign.backend.manifest import _tenant_summary_batch
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
class CampaignTenantSummaryBatchTests(unittest.TestCase):
def test_batch_summary_groups_all_tenants_in_one_query(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
Campaign.__table__,
ChangeSequenceEntry.__table__,
],
)
try:
with Session(engine) as session:
session.add_all(
[
Campaign(
id="campaign-1",
tenant_id="tenant-1",
external_id="one",
name="One",
),
Campaign(
id="campaign-2",
tenant_id="tenant-1",
external_id="two",
name="Two",
),
Campaign(
id="campaign-3",
tenant_id="tenant-2",
external_id="three",
name="Three",
),
]
)
session.commit()
query_count = 0
def count_query(*_args: object) -> None:
nonlocal query_count
query_count += 1
event.listen(engine, "before_cursor_execute", count_query)
try:
counts = _tenant_summary_batch(
session,
["tenant-1", "tenant-2", "tenant-empty"],
)
finally:
event.remove(engine, "before_cursor_execute", count_query)
self.assertEqual(1, query_count)
self.assertEqual({"campaigns": 2}, counts["tenant-1"])
self.assertEqual({"campaigns": 1}, counts["tenant-2"])
self.assertNotIn("tenant-empty", counts)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+9 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.11", "version": "0.1.12",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -17,20 +17,23 @@
"read-excel-file": "9.2.0" "read-excel-file": "9.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.12", "@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"scripts": { "scripts": {
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js", "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js", "test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js", "test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js", "test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs", "test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs", "test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs" "test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
+456 -11
View File
@@ -1,5 +1,9 @@
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types"; import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
import { apiDownload, apiFetch } from "./client"; import { apiDownload, apiFetch } from "./client";
import {
apiReferenceOptionProvider,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery"; import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui"; export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type { export type {
@@ -56,6 +60,8 @@ export type CampaignVersionListItem = {
id: string; id: string;
campaign_id: string; campaign_id: string;
version_number: number; version_number: number;
edit_revision: number;
strong_etag: string;
schema_version?: string; schema_version?: string;
source_filename?: string | null; source_filename?: string | null;
source_base_path?: string | null; source_base_path?: string | null;
@@ -101,6 +107,10 @@ export type CampaignDeltaResponse = {
watermark?: string | null; watermark?: string | null;
has_more: boolean; has_more: boolean;
full: boolean; full: boolean;
total: number;
page: number;
page_size: number;
pages: number;
}; };
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & { export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
@@ -185,6 +195,227 @@ export type CampaignRecipientAddressSourcesResponse = {
sources: CampaignRecipientAddressSource[]; sources: CampaignRecipientAddressSource[];
}; };
export type CampaignDistributionListParameter = {
key: string;
value_type: "string" | "integer" | "number" | "boolean" | "date" | "datetime" | "string_list";
label?: string | null;
required: boolean;
default?: unknown;
allowed_values: unknown[];
minimum?: number | null;
maximum?: number | null;
pattern?: string | null;
description?: string | null;
};
export type CampaignDistributionListSource = {
id: string;
tenant_id: string;
name: string;
revision_id: string;
revision: number;
definition_hash: string;
definition_kind: string;
description?: string | null;
status: string;
entry_count: number;
read_only: boolean;
stale: boolean;
parameters: CampaignDistributionListParameter[];
updated_at?: string | null;
provenance: Record<string, unknown>;
metadata: Record<string, unknown>;
};
export type CampaignDistributionListSourcesResponse = {
available: boolean;
expand_available: boolean;
sources: CampaignDistributionListSource[];
};
export type CampaignDistributionSourceReference = {
provider: string;
resource_type: string;
resource_id: string;
revision?: string | null;
fingerprint?: string | null;
label?: string | null;
metadata: Record<string, unknown>;
};
export type CampaignDistributionExplanation = {
code: string;
message: string;
severity: "info" | "warning" | "error" | string;
provider?: string | null;
source?: CampaignDistributionSourceReference | null;
provenance: Record<string, unknown>;
};
export type CampaignDistributionChannelCandidate = {
channel: "email" | "postal" | "internal_mail" | "portal" | string;
target: string;
target_key: string;
status: string;
contact_point_id?: string | null;
locale?: string | null;
preferred: boolean;
reason_code?: string | null;
explanation?: string | null;
source?: CampaignDistributionSourceReference | null;
decision_provenance: Record<string, unknown>;
};
export type CampaignDistributionRecipient = {
recipient_key: string;
display_name: string;
status: string;
channels: CampaignDistributionChannelCandidate[];
identity_id?: string | null;
account_id?: string | null;
contact_id?: string | null;
organization_unit_id?: string | null;
function_id?: string | null;
source_entry_ids: string[];
explanations: CampaignDistributionExplanation[];
attributes: Record<string, unknown>;
provenance: Record<string, unknown>;
};
export type CampaignDistributionProviderEvidence = {
provider: string;
source: CampaignDistributionSourceReference;
actual_revision?: string | null;
actual_fingerprint?: string | null;
stale: boolean;
generated_at?: string | null;
details: Record<string, unknown>;
};
export type CampaignDistributionListExpansion = {
source: CampaignDistributionListSource;
request: Record<string, unknown>;
recipients: CampaignDistributionRecipient[];
excluded: CampaignDistributionRecipient[];
diagnostics: CampaignDistributionExplanation[];
provider_evidence: CampaignDistributionProviderEvidence[];
expansion_hash: string;
generated_at?: string | null;
snapshot_id?: string | null;
stale: boolean;
truncated: boolean;
};
export type CampaignDistributionListExpansionInput = {
list_id: string;
revision?: number | null;
effective_at?: string | null;
purpose?: string;
requested_channels: Array<"email" | "postal" | "internal_mail" | "portal">;
parameters: Record<string, unknown>;
idempotency_key?: string | null;
};
export type CampaignPostboxDirectoryEntry = {
id: string;
address: string;
address_key: string;
name: string;
status: string;
classification: string;
organization_unit_id?: string | null;
organization_unit_name?: string | null;
function_id?: string | null;
function_name?: string | null;
context_key?: string | null;
holder_count: number;
vacant: boolean;
};
export type CampaignPostboxTemplate = {
id: string;
slug: string;
name: string;
description?: string | null;
published_revision_id: string;
function_type_id?: string | null;
scope_kind: string;
scope_id?: string | null;
classification: string;
allow_vacant_delivery: boolean;
};
export type CampaignPostboxFunction = {
id: string;
slug: string;
name: string;
function_type_id?: string | null;
};
export type CampaignPostboxOrganizationUnit = {
id: string;
slug: string;
name: string;
unit_type_id?: string | null;
parent_id?: string | null;
functions: CampaignPostboxFunction[];
};
export type CampaignPostboxCatalog = {
available: boolean;
postboxes: CampaignPostboxDirectoryEntry[];
templates: CampaignPostboxTemplate[];
organization_units: CampaignPostboxOrganizationUnit[];
};
export type CampaignCalendarCatalogEntry = {
id: string;
name: string;
color?: string | null;
timezone: string;
source_kind: string;
writable: boolean;
};
export type CampaignCalendarCatalog = {
available: boolean;
reason?: string | null;
calendars: CampaignCalendarCatalogEntry[];
};
export type CampaignPrintTemplateOutputProfile = {
id: string;
label: string;
output_format: "html" | "text";
media_type: string;
channel: string;
capabilities: string[];
page: Record<string, unknown>;
};
export type CampaignPrintTemplate = {
id: string;
name: string;
slug: string;
template_type: string;
status: string;
current_revision: number;
current_revision_id: string;
published_revision_id?: string | null;
description?: string | null;
read_only: boolean;
revision?: {
revision: number;
output_profiles: CampaignPrintTemplateOutputProfile[];
required_fields: Array<{path: string;label?: string | null;required: boolean;}>;
} | null;
};
export type CampaignPrintTemplatesResponse = {
available: boolean;
templates: CampaignPrintTemplate[];
};
export type CampaignRecipientSnapshotItem = { export type CampaignRecipientSnapshotItem = {
contact_id: string; contact_id: string;
display_name: string; display_name: string;
@@ -194,6 +425,18 @@ export type CampaignRecipientSnapshotItem = {
provenance: Record<string, unknown>; provenance: Record<string, unknown>;
}; };
export type CampaignRecipientSnapshotExcludedItem = {
contact_id: string;
display_name: string;
channel: string;
target: string;
contact_point_id?: string | null;
status: string;
reason_code?: string | null;
explanation?: string | null;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourceSnapshot = { export type CampaignRecipientAddressSourceSnapshot = {
source_id: string; source_id: string;
source_label: string; source_label: string;
@@ -201,6 +444,10 @@ export type CampaignRecipientAddressSourceSnapshot = {
source_revision: string; source_revision: string;
generated_at: string; generated_at: string;
recipients: CampaignRecipientSnapshotItem[]; recipients: CampaignRecipientSnapshotItem[];
excluded: CampaignRecipientSnapshotExcludedItem[];
included_count: number;
excluded_count: number;
purpose: string;
provenance: Record<string, unknown>; provenance: Record<string, unknown>;
}; };
@@ -214,6 +461,9 @@ export type CampaignVersionUpdatePayload = {
source_filename?: string | null; source_filename?: string | null;
source_base_path?: string | null; source_base_path?: string | null;
migrate_legacy_mail_settings?: boolean; migrate_legacy_mail_settings?: boolean;
base_revision?: number;
reconciliation_kind?: "none" | "auto_merge" | "manual";
resolved_conflict_paths?: string[];
}; };
export type CampaignPartialValidationPayload = { export type CampaignPartialValidationPayload = {
@@ -230,6 +480,43 @@ export type CampaignPartialValidationResponse = {
issues: Record<string, unknown>[]; issues: Record<string, unknown>[];
}; };
export type CampaignRetentionReport = {
policy_status: "configured" | "defaults" | "unavailable" | string;
policy_reason?: string | null;
effective_policy: {
store_raw_campaign_json?: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level?: string;
};
sources: Array<{
scope_type?: string | null;
path?: string | null;
label?: string | null;
applied_fields?: string[];
}>;
evidence: Record<string, {
state?: string;
redacted_at?: string | null;
retained_count?: number;
expired_count?: number;
not_generated_count?: number;
currently_readable_count?: number;
withdrawn_count?: number;
redacted_summary_count?: number;
summary_count?: number;
}>;
privacy_impact: {
state: string;
summary: string;
retained_categories: string[];
minimized_categories: string[];
};
};
export type CampaignSummary = { export type CampaignSummary = {
generated_at?: string; generated_at?: string;
selected_version_id?: string | null; selected_version_id?: string | null;
@@ -255,6 +542,8 @@ export type CampaignSummary = {
needs_attention?: number; needs_attention?: number;
sent?: number; sent?: number;
smtp_accepted?: number; smtp_accepted?: number;
postbox_accepted?: number;
print_accepted?: number;
failed?: number; failed?: number;
outcome_unknown?: number; outcome_unknown?: number;
not_attempted?: number; not_attempted?: number;
@@ -270,6 +559,8 @@ export type CampaignSummary = {
issues?: Record<string, unknown>; issues?: Record<string, unknown>;
attachments?: Record<string, unknown>; attachments?: Record<string, unknown>;
attempts?: Record<string, unknown>; attempts?: Record<string, unknown>;
postbox_receipts?: Record<string, unknown>;
retention?: CampaignRetentionReport;
delivery?: Record<string, unknown>; delivery?: Record<string, unknown>;
recent_failures?: Record<string, unknown>[]; recent_failures?: Record<string, unknown>[];
}; };
@@ -285,6 +576,17 @@ export type CampaignDeliveryOptions = {
campaign_id: string; campaign_id: string;
version_id: string; version_id: string;
worker_queue_available: boolean; worker_queue_available: boolean;
postbox_available: boolean;
approval_gate: {
configured?: boolean;
available?: boolean;
approved?: boolean;
state?: string;
explanation?: string | null;
request_id?: string;
request_revision?: number;
subject_digest?: string;
};
synchronous_send: { synchronous_send: {
allowed?: boolean; allowed?: boolean;
reason?: string | null; reason?: string | null;
@@ -299,6 +601,26 @@ export type CampaignDeliveryOptions = {
}; };
}; };
export type CampaignApprovalRequestPayload = {
title: string;
description?: string | null;
steps: Array<{
key: string;
label: string;
selectors: Array<{
kind: "account" | "group" | "role" | "function_assignment" | "any_account";
value: string;
}>;
required_approvals: number;
rejection_policy: "fail_fast" | "collect";
signature_required: boolean;
forbidden_evidence_roles: Array<"author" | "owner" | "validator" | "builder" | "reviewer">;
}>;
unique_actors_across_steps: boolean;
policy_refs: string[];
idempotency_key: string;
};
export type CampaignSendNowPayload = { export type CampaignSendNowPayload = {
version_id?: string | null; version_id?: string | null;
include_warnings?: boolean; include_warnings?: boolean;
@@ -404,11 +726,19 @@ export type CampaignMockSendPayload = {
export type CampaignReviewStatePayload = { export type CampaignReviewStatePayload = {
inspection_complete: boolean; inspection_complete: boolean;
reviewed_message_keys: string[]; reviewed_message_keys: string[];
issue_decisions: Array<{
job_id: string;
decision: "accept";
reason?: string | null;
}>;
}; };
export type CampaignSendJobPayload = { export type CampaignSendJobPayload = {
kind: "test" | "single_send" | "single_resend";
idempotency_key: string;
reason?: string;
context?: Record<string, unknown>;
include_warnings?: boolean; include_warnings?: boolean;
dry_run?: boolean;
use_rate_limit?: boolean; use_rate_limit?: boolean;
enqueue_imap_task?: boolean; enqueue_imap_task?: boolean;
}; };
@@ -448,6 +778,8 @@ export type CampaignJobDetailResponse = {
attempts: { attempts: {
smtp?: Record<string, unknown>[]; smtp?: Record<string, unknown>[];
imap?: Record<string, unknown>[]; imap?: Record<string, unknown>[];
postbox?: Record<string, unknown>[];
print?: Record<string, unknown>[];
}; };
}; };
@@ -458,6 +790,7 @@ export type CampaignReportEmailPayload = {
attach_jobs_csv?: boolean; attach_jobs_csv?: boolean;
attach_report_json?: boolean; attach_report_json?: boolean;
dry_run?: boolean; dry_run?: boolean;
idempotency_key?: string;
}; };
export type AggregateReportCount = { export type AggregateReportCount = {
@@ -488,6 +821,10 @@ export type AggregateCampaignReport = {
}; };
outcomes: { outcomes: {
smtp_accepted: AggregateReportCount; smtp_accepted: AggregateReportCount;
postbox_accepted: AggregateReportCount;
print_accepted: AggregateReportCount;
delivered: AggregateReportCount;
partially_accepted: AggregateReportCount;
failed: AggregateReportCount; failed: AggregateReportCount;
outcome_unknown: AggregateReportCount; outcome_unknown: AggregateReportCount;
queued_or_active: AggregateReportCount; queued_or_active: AggregateReportCount;
@@ -582,6 +919,63 @@ campaignId: string)
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`); return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
} }
export async function listCampaignRecipientDistributionLists(
settings: ApiSettings,
campaignId: string,
query = "")
: Promise<CampaignDistributionListSourcesResponse> {
const params = new URLSearchParams();
params.set("limit", "250");
if (query.trim()) params.set("query", query.trim());
const suffix = params.size ? `?${params.toString()}` : "";
return apiFetch<CampaignDistributionListSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists${suffix}`);
}
export async function previewCampaignRecipientDistributionList(
settings: ApiSettings,
campaignId: string,
payload: CampaignDistributionListExpansionInput)
: Promise<CampaignDistributionListExpansion> {
return apiFetch<CampaignDistributionListExpansion>(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists/preview`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function snapshotCampaignRecipientDistributionList(
settings: ApiSettings,
campaignId: string,
payload: CampaignDistributionListExpansionInput)
: Promise<CampaignDistributionListExpansion> {
return apiFetch<CampaignDistributionListExpansion>(settings, `/api/v1/campaigns/${campaignId}/recipient-distribution-lists/snapshot`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function getCampaignPostboxCatalog(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignPostboxCatalog> {
return apiFetch<CampaignPostboxCatalog>(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`);
}
export async function getCampaignCalendarInvitationCatalog(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignCalendarCatalog> {
return apiFetch<CampaignCalendarCatalog>(settings, `/api/v1/campaigns/${campaignId}/calendar-invitation-catalog`);
}
export async function listCampaignPrintTemplates(
settings: ApiSettings,
campaignId: string,
query = "")
: Promise<CampaignPrintTemplatesResponse> {
const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : "";
return apiFetch<CampaignPrintTemplatesResponse>(settings, `/api/v1/campaigns/${campaignId}/print-templates${suffix}`);
}
export async function snapshotCampaignRecipientAddressSource( export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -589,7 +983,7 @@ sourceId: string)
: Promise<CampaignRecipientAddressSourceSnapshot> { : Promise<CampaignRecipientAddressSourceSnapshot> {
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, { return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
method: "POST", method: "POST",
body: JSON.stringify({ source_id: sourceId }) body: JSON.stringify({ source_id: sourceId, purpose: "campaign_delivery" })
}); });
} }
@@ -725,10 +1119,12 @@ export async function updateCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignVersionUpdatePayload) payload: CampaignVersionUpdatePayload,
ifMatch: string)
: Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT", method: "PUT",
headers: { "If-Match": ifMatch },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
} }
@@ -749,10 +1145,12 @@ export async function autosaveCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignVersionUpdatePayload) payload: CampaignVersionUpdatePayload,
ifMatch: string)
: Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
method: "POST", method: "POST",
headers: { "If-Match": ifMatch },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
} }
@@ -811,7 +1209,7 @@ writeEml = true)
: Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
method: "POST", method: "POST",
body: JSON.stringify({ write_eml: writeEml }) body: JSON.stringify({ write_eml: writeEml, idempotency_key: crypto.randomUUID() })
}); });
} }
@@ -912,6 +1310,20 @@ versionId?: string)
); );
} }
export async function downloadCampaignPrintArtifact(
settings: ApiSettings,
downloadPath: string,
filename: string)
: Promise<void> {
if (![
"/api/v1/campaigns/",
"/api/v1/files/"
].some((prefix) => downloadPath.startsWith(prefix))) {
throw new Error("The printable output download path is invalid.");
}
await apiDownload(settings, downloadPath, filename || "campaign-print-output.html");
}
export async function emailCampaignReport( export async function emailCampaignReport(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -949,7 +1361,7 @@ export async function sendCampaignJob(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
jobId: string, jobId: string,
payload: CampaignSendJobPayload = {}) payload: CampaignSendJobPayload)
: Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, {
method: "POST", method: "POST",
@@ -961,12 +1373,13 @@ export async function resolveCampaignJobOutcome(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
jobId: string, jobId: string,
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended", decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended" | "postbox_accepted" | "postbox_not_accepted",
note?: string) note?: string,
attemptId?: string)
: Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
method: "POST", method: "POST",
body: JSON.stringify({ decision, note: note || null }) body: JSON.stringify({ decision, note: note || null, attempt_id: attemptId || null })
}); });
} }
@@ -1002,6 +1415,19 @@ versionId?: string | null)
return apiFetch<CampaignDeliveryOptions>(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`); return apiFetch<CampaignDeliveryOptions>(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`);
} }
export async function requestCampaignApproval(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignApprovalRequestPayload)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(
settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/approval-request`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function sendCampaignNow( export async function sendCampaignNow(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -1056,9 +1482,28 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`); return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
} }
export function campaignShareTargetProvider(
settings: ApiSettings,
campaignId: string,
targetType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/campaigns/${campaignId}/share-target-options`,
{ target_type: targetType }
);
}
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> { export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`); const pageSize = 500;
return response.shares; const shares: CampaignShare[] = [];
for (let page = 1; ; page += 1) {
const response = await apiFetch<{shares: CampaignShare[];pages?: number;}>(settings, `/api/v1/campaigns/${campaignId}/shares?page=${page}&page_size=${pageSize}`);
shares.push(...response.shares);
if (page >= (response.pages ?? 1)) {
return shares;
}
}
} }
export async function updateCampaignOwner( export async function updateCampaignOwner(
+62 -9
View File
@@ -1,11 +1,12 @@
import type { import type {
ApiSettings, ApiSettings,
MailConnectionTestResponse, MailConnectionTestResponse,
MailCredentialEnvelope,
MailImapFolderListResponse, MailImapFolderListResponse,
MailServerProfile, MailServerProfile,
MockMailboxMessageResponse MockMailboxMessageResponse
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPost } from "./client"; import { apiFetch, apiGetList, apiPath, apiPost } from "./client";
const profileActionEndpoints = { const profileActionEndpoints = {
smtp: "test-smtp", smtp: "test-smtp",
@@ -16,11 +17,21 @@ const profileActionEndpoints = {
function runProfileAction<TResponse>( function runProfileAction<TResponse>(
settings: ApiSettings, settings: ApiSettings,
profileId: string, profileId: string,
action: keyof typeof profileActionEndpoints action: keyof typeof profileActionEndpoints,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<TResponse> { ): Promise<TResponse> {
return apiPost<TResponse>( return apiPost<TResponse>(
settings, settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}` apiPath(
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`,
{
server_id: serverId,
credential_id: credentialId,
campaign_id: campaignId
}
)
); );
} }
@@ -31,16 +42,58 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact
}); });
} }
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> { export function createCampaignMailCredential(
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp"); settings: ApiSettings,
profileId: string,
campaignId: string,
payload: {
name: string;
username: string;
password: string;
server_ids: string[];
}
): Promise<MailCredentialEnvelope> {
return apiFetch<MailCredentialEnvelope>(
settings,
apiPath(
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/campaign-credentials`,
{ campaign_id: campaignId }
),
{
method: "POST",
body: JSON.stringify(payload)
}
);
} }
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> { export async function testMailProfileSmtp(
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap"); settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
} }
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> { export async function testMailProfileImap(
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders"); settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
}
export async function listMailProfileImapFolders(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailImapFolderListResponse> {
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
} }
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> { export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
@@ -414,7 +414,7 @@ type ZipArchiveColumnContext = {
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] { function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
return [ return [
{ {
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start", id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start",
render: (archive, index) => render: (archive, index) =>
<button <button
type="button" type="button"
@@ -592,6 +592,7 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAv
id: "path", id: "path",
header: "i18n:govoplan-campaign.path.519e3913", header: "i18n:govoplan-campaign.path.519e3913",
width: "minmax(260px, 1fr)", width: "minmax(260px, 1fr)",
maxWidth: 720,
resizable: true, resizable: true,
sortable: true, sortable: true,
filterable: true, filterable: true,

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