Compare commits
17
Commits
2c5519908a
...
v0.1.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c0750dd7 | ||
|
|
bfbb86564c | ||
|
|
c05bb8e474 | ||
|
|
99ef25b08f | ||
|
|
03100b77db | ||
|
|
0ac903c82d | ||
|
|
60776803c5 | ||
|
|
9a709b1264 | ||
|
|
af833ca38c | ||
|
|
50c509d161 | ||
|
|
9f4eab07f6 | ||
|
|
25a69b3fa9 | ||
|
|
24538c2a99 | ||
|
|
7d8579194d | ||
|
|
09c63de813 | ||
|
|
057e660b17 | ||
|
|
701c0fe184 |
@@ -31,7 +31,7 @@ The module has one required runtime dependency:
|
|||||||
Files and mail are optional module integrations declared in the campaign manifest:
|
Files and mail are optional module integrations declared in the campaign manifest:
|
||||||
|
|
||||||
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
|
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
|
||||||
- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable.
|
- `govoplan-mail` owns reusable profiles, encrypted SMTP/IMAP credentials, delivery policy checks, connection tests, and transport execution. Campaign JSON stores only `server.mail_profile_id`; inline transport settings and credentials are rejected. Without Mail, campaigns can still be authored, but profile validation and real delivery are unavailable.
|
||||||
|
|
||||||
Public campaign, version, job, and report responses expose business data and
|
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
|
||||||
@@ -91,7 +91,10 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
|||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
|
||||||
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
|
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
|
||||||
|
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
|
||||||
|
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
|
||||||
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
|
- [Recipient 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.
|
||||||
- [Example campaigns and release checklist](docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md) defines the maintained example scenarios and release gates.
|
- [Example campaigns and release checklist](docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md) defines the maintained example scenarios and release gates.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ been validated, built, reviewed, and locked.
|
|||||||
## Before First Live Use
|
## Before First Live Use
|
||||||
|
|
||||||
- Use dedicated non-production SMTP/IMAP credentials.
|
- Use dedicated non-production SMTP/IMAP credentials.
|
||||||
|
- Store and test those credentials in a Mail-module profile. Campaign must
|
||||||
|
contain only the selected `server.mail_profile_id` reference.
|
||||||
- Start the repository test bed in `dev/mail-testbed/` when a local
|
- Start the repository test bed in `dev/mail-testbed/` when a local
|
||||||
production-like SMTP/IMAP server is sufficient.
|
production-like SMTP/IMAP server is sufficient.
|
||||||
- Use a dedicated mailbox/folder for append-to-Sent tests.
|
- Use a dedicated mailbox/folder for append-to-Sent tests.
|
||||||
@@ -64,6 +66,13 @@ Before the first live send for a sender domain or mail-server profile:
|
|||||||
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
||||||
Re-run worker handling or reconcile if SMTP may already have accepted the
|
Re-run worker handling or reconcile if SMTP may already have accepted the
|
||||||
message.
|
message.
|
||||||
|
- IMAP `appending`: A worker owns the durable append claim. Do not start a
|
||||||
|
second append; if the worker cannot finish, reconcile only after checking the
|
||||||
|
mailbox.
|
||||||
|
- IMAP `outcome_unknown`: Never append automatically. An operator with
|
||||||
|
`campaigns:campaign:reconcile` must record an evidence note and resolve it as
|
||||||
|
`imap_appended` or `imap_not_appended`. Only the latter becomes explicitly
|
||||||
|
retryable.
|
||||||
|
|
||||||
## Reconciliation
|
## Reconciliation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
# Campaign Handbook
|
||||||
|
|
||||||
|
## Purpose and status
|
||||||
|
|
||||||
|
This is the canonical, multi-perspective handbook for the Campaign module. It
|
||||||
|
describes the current implementation, the operational contract it relies on,
|
||||||
|
and the remaining work required before Campaign can be presented as GovOPlaN's
|
||||||
|
maintained reference composition.
|
||||||
|
|
||||||
|
Use the section that matches the task at hand:
|
||||||
|
|
||||||
|
| Perspective | Start here |
|
||||||
|
| --- | --- |
|
||||||
|
| Campaign author | [Prepare a campaign](#prepare-a-campaign) |
|
||||||
|
| Reviewer | [Review and complete review](#review-and-complete-review) |
|
||||||
|
| Sender or delivery operator | [Deliver and resolve outcomes](#deliver-and-resolve-outcomes) |
|
||||||
|
| Campaign or tenant administrator | [Administration and policy](#administration-and-policy) |
|
||||||
|
| Platform operator | [Operations and recovery](#operations-and-recovery) |
|
||||||
|
| Integrator or developer | [Composition and integration contracts](#composition-and-integration-contracts) |
|
||||||
|
| Security, privacy, or audit reviewer | [Assurance model](#assurance-model) |
|
||||||
|
| Release reviewer | [Reference-composition acceptance](#reference-composition-acceptance) |
|
||||||
|
|
||||||
|
The shorter task documents remain useful companions:
|
||||||
|
|
||||||
|
- [Campaign delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md)
|
||||||
|
- [Mail profile boundary](MAIL_PROFILE_BOUNDARY.md)
|
||||||
|
- [Recipient import guide](RECIPIENT_IMPORT_GUIDE.md)
|
||||||
|
- [Recipient and Addresses boundary](RECIPIENT_ADDRESS_BOUNDARY.md)
|
||||||
|
- [Examples and release checklist](EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md)
|
||||||
|
|
||||||
|
## What Campaign is for
|
||||||
|
|
||||||
|
Campaign turns governed source data into individually built messages and then
|
||||||
|
controls their review, delivery, and evidence. It is intentionally a
|
||||||
|
composition module: it demonstrates how one user journey can use optional Mail,
|
||||||
|
Files, Addresses, and Notifications capabilities alongside Core access/audit
|
||||||
|
infrastructure without copying ownership from those modules. Policy may consume
|
||||||
|
Campaign context through a narrow capability; Campaign does not import Policy.
|
||||||
|
|
||||||
|
Campaign owns:
|
||||||
|
|
||||||
|
- the communication purpose, content, campaign-local fields, and templates;
|
||||||
|
- campaign-local recipient snapshots, exclusions, and personalization;
|
||||||
|
- message and attachment rules for a version;
|
||||||
|
- validation, review, build, queue, and delivery-control state;
|
||||||
|
- the durable jobs and attempts needed to explain delivery outcomes; and
|
||||||
|
- campaign-specific reports, shares, and frozen execution evidence.
|
||||||
|
|
||||||
|
Campaign does not own:
|
||||||
|
|
||||||
|
- SMTP/IMAP profiles, credentials, protocol adapters, or mailbox policy;
|
||||||
|
- long-lived address-book master data, consent lifecycle, or deduplication;
|
||||||
|
- managed file bytes, connector credentials, or external-file provenance;
|
||||||
|
- general-purpose workflow definitions; or
|
||||||
|
- global identity, organization, permission, or retention policy.
|
||||||
|
|
||||||
|
Those boundaries matter in both persistence and UI. A campaign references an
|
||||||
|
authorized Mail profile and managed file versions; it must never become a
|
||||||
|
second secret store, file store, or address directory.
|
||||||
|
|
||||||
|
## Process view
|
||||||
|
|
||||||
|
The supported process is a controlled progression, not a single "send" call:
|
||||||
|
|
||||||
|
```text
|
||||||
|
create/edit
|
||||||
|
-> validate and resolve policy/integrations
|
||||||
|
-> review warnings and blockers
|
||||||
|
-> build exact recipient messages
|
||||||
|
-> complete review and queue
|
||||||
|
-> SMTP attempt per job
|
||||||
|
-> optional IMAP append per accepted job
|
||||||
|
-> report, retry, reconcile, or correct
|
||||||
|
-> archive when no active/uncertain delivery remains
|
||||||
|
```
|
||||||
|
|
||||||
|
Campaign status summarizes the whole campaign. Version workflow state describes
|
||||||
|
the selected immutable/editable version. Each recipient job separately records
|
||||||
|
build, validation, queue, SMTP, and IMAP state. Operators must use the job-level
|
||||||
|
states when deciding whether another external effect is safe.
|
||||||
|
|
||||||
|
Important distinctions:
|
||||||
|
|
||||||
|
- **Validation lock** is the reversible lock created by a successful
|
||||||
|
validation/build path. Editing requires unlocking and invalidates derived
|
||||||
|
evidence as appropriate.
|
||||||
|
- **User lock** is an explicit audit-safe lock. A permanently locked or
|
||||||
|
delivery-final version is not edited in place; create an editable successor.
|
||||||
|
- **SMTP accepted** means the provider accepted the message. It does not prove
|
||||||
|
inbox delivery, reading, or business acknowledgement.
|
||||||
|
- **Outcome unknown** means an attempt may have had an external effect. It must
|
||||||
|
be investigated and reconciled before retry.
|
||||||
|
- **IMAP append** is a separate effect after SMTP acceptance. An append failure
|
||||||
|
is not evidence that sending failed.
|
||||||
|
- **Archive** preserves evidence. Draft-only campaigns without built, locked,
|
||||||
|
or delivery evidence may be deleted where policy allows; evidence-bearing
|
||||||
|
campaigns are archived instead.
|
||||||
|
|
||||||
|
## User tasks
|
||||||
|
|
||||||
|
### Prepare a campaign
|
||||||
|
|
||||||
|
1. Create a campaign and confirm its owner or owning group.
|
||||||
|
2. Define global settings, fields, templates, and recipient data.
|
||||||
|
3. Import one-off recipient data or select a reusable Addresses source when the
|
||||||
|
optional capability is installed. Review source provenance and stale-source
|
||||||
|
warnings; Campaign freezes the selected rows rather than following later
|
||||||
|
directory changes silently.
|
||||||
|
4. Select managed attachments through Files. Server/API campaigns do not accept
|
||||||
|
arbitrary local paths. Preview rules and unmatched files before building.
|
||||||
|
5. Open **Mail settings** and select an available Mail profile. The campaign
|
||||||
|
stores only `server.mail_profile_id`; it never accepts SMTP/IMAP settings,
|
||||||
|
usernames, passwords, or credential references.
|
||||||
|
6. Save the editable version, validate the relevant sections, and resolve every
|
||||||
|
blocking issue. Warnings remain explicit review decisions.
|
||||||
|
7. Build the exact messages and inspect recipient, addressing, template,
|
||||||
|
attachment, and generated-message evidence.
|
||||||
|
|
||||||
|
If a selected optional module is absent, Campaign remains loadable and explains
|
||||||
|
which function is unavailable. It must not fail startup because Mail, Files, or
|
||||||
|
Addresses is not installed.
|
||||||
|
|
||||||
|
### Review and complete review
|
||||||
|
|
||||||
|
The reviewer should verify the immutable candidate that will be delivered, not
|
||||||
|
just the authoring form:
|
||||||
|
|
||||||
|
1. Confirm purpose, owner, selected version, and recipient count.
|
||||||
|
2. Inspect blocking errors, warnings, exclusions, and recipients requiring
|
||||||
|
review.
|
||||||
|
3. Inspect representative and exceptional rendered messages, including From,
|
||||||
|
To/CC/BCC, Reply-To, subject, body, and attachment evidence.
|
||||||
|
4. Confirm the selected Mail profile is authorized for the campaign's current
|
||||||
|
tenant and owner context.
|
||||||
|
5. Confirm attachment behavior when a rule matches no files, ZIP/password
|
||||||
|
behavior, and any recipient-specific files.
|
||||||
|
6. Record review completion and the inspected message keys through the review
|
||||||
|
surface. The current baseline does not persist a distinct approve/reject
|
||||||
|
decision or review reason. If content, recipients, attachment inputs, owner
|
||||||
|
context, or non-secret transport identity changes, revalidate and rebuild.
|
||||||
|
|
||||||
|
Normal readers and reviewers see business state and safe evidence. Process-local
|
||||||
|
paths, storage keys, worker claim tokens, and raw provider diagnostics require
|
||||||
|
the dedicated diagnostic permission and must not leak through ordinary campaign,
|
||||||
|
version, job, or report responses.
|
||||||
|
|
||||||
|
The current Campaign Report Web UI additionally requires recipient-read access
|
||||||
|
and does not yet hide every action control that the actor lacks. The server
|
||||||
|
still authorizes each action, but an aggregate-only reader UI remains open
|
||||||
|
work; do not promise that experience from `campaigns:report:read` alone.
|
||||||
|
|
||||||
|
### Deliver and resolve outcomes
|
||||||
|
|
||||||
|
Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed
|
||||||
|
operator sequence.
|
||||||
|
|
||||||
|
At a minimum:
|
||||||
|
|
||||||
|
1. Queue only a validated, locked, built version. The current Web UI does not
|
||||||
|
expose Queue; use an authorized supporting client or API.
|
||||||
|
2. Use worker delivery for ordinary batches. Synchronous Send now has no
|
||||||
|
server-enforced job-count bound and must be used only after an operator has
|
||||||
|
deliberately confirmed a small controlled run.
|
||||||
|
3. Treat `smtp_accepted` as protected from ordinary retry.
|
||||||
|
4. Retry `failed_temporary` explicitly after inspecting the cause.
|
||||||
|
5. Include `failed_permanent` only after correcting the cause and making a
|
||||||
|
conscious override.
|
||||||
|
6. Never retry `outcome_unknown` blindly. Inspect SMTP/provider evidence and
|
||||||
|
reconcile it as **accepted** or **not sent**, with a note identifying the
|
||||||
|
evidence.
|
||||||
|
7. Process append-to-Sent only for SMTP-accepted jobs and investigate append
|
||||||
|
failure independently. Never retry an `outcome_unknown` IMAP append blindly:
|
||||||
|
reconcile mailbox evidence as **appended** or **not appended** with a note.
|
||||||
|
Only the latter becomes explicitly retryable, and neither decision resends
|
||||||
|
the already SMTP-accepted message.
|
||||||
|
8. Archive only after active and uncertain effects are resolved.
|
||||||
|
|
||||||
|
Pause stops new eligible work but cannot undo a provider effect already in
|
||||||
|
progress. Cancel marks work that has not yet produced a protected SMTP outcome;
|
||||||
|
it cannot recall accepted mail.
|
||||||
|
|
||||||
|
### Test and one-message actions
|
||||||
|
|
||||||
|
The current baseline includes mock send, queue dry-run, synchronous immediate
|
||||||
|
delivery, sending one selected job, retry selection, and unattempted-job
|
||||||
|
selection. Their audit and state behavior is not yet the final vocabulary
|
||||||
|
accepted for issue `govoplan-campaign#69`.
|
||||||
|
|
||||||
|
The intended distinction is:
|
||||||
|
|
||||||
|
- **Test:** deliver once to the configured test path, audit it, and leave the
|
||||||
|
production job unsent.
|
||||||
|
- **Single send:** send one unsent job, audit it, and mark its production effect
|
||||||
|
complete.
|
||||||
|
- **Single resend:** intentionally send one job again regardless of an earlier
|
||||||
|
failure or acceptance, with distinct authority, warning, reason, and audit.
|
||||||
|
|
||||||
|
Until that slice is implemented and target-tested, do not present the existing
|
||||||
|
buttons as a complete resend policy. Ordinary retry protection remains the safe
|
||||||
|
default.
|
||||||
|
|
||||||
|
## Data and evidence model
|
||||||
|
|
||||||
|
### Versions and snapshots
|
||||||
|
|
||||||
|
Editable campaign JSON is versioned. Build creates recipient jobs and an
|
||||||
|
execution snapshot. A new profile-only snapshot contains the stable Mail
|
||||||
|
profile id, delivery policy/evidence, and opaque Mail-issued transport
|
||||||
|
revisions. It contains no resolved SMTP/IMAP configuration or credentials.
|
||||||
|
|
||||||
|
At delivery time Mail re-authorizes the profile, compares the expected opaque
|
||||||
|
revisions, resolves credentials inside the same Mail-owned operation, and
|
||||||
|
performs the transport effect. Campaign receives only the sanitized result it
|
||||||
|
needs for job state and evidence. This same-call check prevents a
|
||||||
|
validate-then-use race across the module boundary.
|
||||||
|
|
||||||
|
Credential rotation that does not change the non-secret transport identity may
|
||||||
|
continue to satisfy the snapshot. A host, account identity, protocol, sender,
|
||||||
|
or other revisioned transport change stops delivery until the campaign is
|
||||||
|
revalidated and rebuilt.
|
||||||
|
|
||||||
|
### Existing legacy database records
|
||||||
|
|
||||||
|
The current Campaign database may contain versions with inline SMTP/IMAP
|
||||||
|
material. No separate historical Campaign JSON corpus exists. Inline transport
|
||||||
|
material in those rows is treated as inert legacy data and is never interpreted
|
||||||
|
as an executable Mail configuration:
|
||||||
|
|
||||||
|
- public responses remove legacy transport fields and secrets;
|
||||||
|
- validation, build, queue, retry, and delivery fail closed;
|
||||||
|
- an editable version changes to profile-only form only through an explicit
|
||||||
|
Mail-settings save; and
|
||||||
|
- a locked version is preserved and must be forked to an editable successor.
|
||||||
|
|
||||||
|
Normal database backup/restore and access controls cover those rows together
|
||||||
|
with the rest of the current database. There is no separate historical-JSON,
|
||||||
|
backup-scanning, or inline-secret migration program. Restoring an existing
|
||||||
|
legacy row preserves it as inert evidence and does not make it deliverable.
|
||||||
|
|
||||||
|
### Recipient and attachment evidence
|
||||||
|
|
||||||
|
The delivery record should be able to identify:
|
||||||
|
|
||||||
|
- source/import context and the frozen recipient row;
|
||||||
|
- effective addressing and message id;
|
||||||
|
- template inputs and unresolved-placeholder decisions;
|
||||||
|
- managed file/version ids, source provenance, checksums, and ZIP evidence;
|
||||||
|
- generated EML checksum and size;
|
||||||
|
- Mail profile reference and opaque transport revisions;
|
||||||
|
- each SMTP and IMAP attempt, its classification, safe provider response, and
|
||||||
|
reconciliation note; and
|
||||||
|
- actor/system trigger, timestamps, policy context, and corrections.
|
||||||
|
|
||||||
|
## Administration and policy
|
||||||
|
|
||||||
|
### Roles and permissions
|
||||||
|
|
||||||
|
The supplied role templates deliberately separate preparation, review, and
|
||||||
|
delivery:
|
||||||
|
|
||||||
|
- **Campaign manager** prepares, validates, and builds campaigns and recipients.
|
||||||
|
- **Campaign reviewer** inspects prepared material and records review
|
||||||
|
completion for the exact messages checked.
|
||||||
|
- **Campaign sender** queues, sends, pauses/resumes/cancels, retries, reconciles,
|
||||||
|
reads reports, and has diagnostic access.
|
||||||
|
|
||||||
|
Administrators may compose narrower roles from the declared permissions. Keep
|
||||||
|
these separations where institutional policy requires four-eyes approval. Mail
|
||||||
|
profile use additionally requires `mail:profile:use`; profile and credential
|
||||||
|
administration remains a Mail permission.
|
||||||
|
|
||||||
|
Campaign access is also constrained by tenant, owner/group context, and explicit
|
||||||
|
shares. A share grants only its declared campaign permission; it does not grant
|
||||||
|
Mail credentials, Files administration, or tenant-wide recipient access.
|
||||||
|
|
||||||
|
### Configuration checklist
|
||||||
|
|
||||||
|
- Install compatible Core and Campaign versions. Access and base audit are Core
|
||||||
|
infrastructure; install optional Mail, Files, Addresses, Notifications, and
|
||||||
|
Policy modules only where the configured journey requires them.
|
||||||
|
- Configure Mail profiles and policy in Mail, not in campaign fixtures or JSON.
|
||||||
|
- Configure Files storage/connectors and attachment permissions in Files.
|
||||||
|
- Define role assignments and separation-of-duty policy.
|
||||||
|
- Configure Redis/Celery for durable batch workers where production volume
|
||||||
|
requires it. Local execution is a development/small-run mode, not horizontal
|
||||||
|
worker coordination.
|
||||||
|
- Set rate limits for the target provider. Mail may use Redis for shared
|
||||||
|
throttling when present and a process-local fallback in development.
|
||||||
|
- Establish retention, deletion, archive, report-export, and diagnostic-access
|
||||||
|
policy before production recipient data is loaded.
|
||||||
|
- Use non-production SMTP/IMAP identities and the maintained examples before
|
||||||
|
allowing a production profile.
|
||||||
|
|
||||||
|
### Owner transfer
|
||||||
|
|
||||||
|
Mail profile visibility can depend on campaign owner or group. Transferring an
|
||||||
|
editable campaign with a selected profile clears/requires reselection and
|
||||||
|
revalidation. A locked or delivery-final version is never silently rewritten;
|
||||||
|
create an editable successor.
|
||||||
|
|
||||||
|
## Operations and recovery
|
||||||
|
|
||||||
|
### Health to observe
|
||||||
|
|
||||||
|
- database and migration health;
|
||||||
|
- compatible module interface versions;
|
||||||
|
- queue publication, worker heartbeat, claim age, and backlog;
|
||||||
|
- SMTP/IMAP profile test result and deployment egress policy;
|
||||||
|
- Mail profile authorization/transport-revision mismatch;
|
||||||
|
- Files resolution and frozen attachment availability;
|
||||||
|
- counts by queue, SMTP, IMAP, and reconciliation state; and
|
||||||
|
- audit and report generation failures.
|
||||||
|
|
||||||
|
### Worker interruption
|
||||||
|
|
||||||
|
A crashed worker can leave a job claimed or sending. Do not infer non-delivery
|
||||||
|
from worker loss. If the SMTP boundary may have been crossed, classify the
|
||||||
|
outcome as unknown and reconcile from external evidence. Only work that is
|
||||||
|
provably unattempted may be returned to an ordinary queue.
|
||||||
|
|
||||||
|
### Retry and reconciliation
|
||||||
|
|
||||||
|
Retries create new attempt evidence; they do not overwrite the previous
|
||||||
|
attempt. Reconciliation is a privileged factual correction supported by an
|
||||||
|
operator note. Repeated automated requests should be idempotent for the same
|
||||||
|
eligible job state; a deliberate resend is a different, not-yet-finalized
|
||||||
|
business action and must never be disguised as a retry.
|
||||||
|
|
||||||
|
### Backups and restoration
|
||||||
|
|
||||||
|
Back up Campaign, Mail, Files, Core/Audit, and shared storage consistently for
|
||||||
|
the composition. A database restore without generated EML/file storage, or file
|
||||||
|
storage without matching metadata, does not reconstruct the evidence chain.
|
||||||
|
After restore, keep outbound delivery paused until queue/attempt state and
|
||||||
|
provider evidence have been reconciled; never let restored accepted jobs send
|
||||||
|
again merely because a queue message was lost.
|
||||||
|
|
||||||
|
### Incident handling
|
||||||
|
|
||||||
|
1. Pause new delivery when duplicate or unknown effects are possible.
|
||||||
|
2. Preserve database, queue, provider, worker, and audit evidence.
|
||||||
|
3. Scope affected campaigns/jobs without exposing recipient content broadly.
|
||||||
|
4. Reconcile uncertain jobs individually or through an approved bounded tool.
|
||||||
|
5. Correct configuration in its owning module, then revalidate/rebuild where
|
||||||
|
revisioned transport inputs changed.
|
||||||
|
6. Record the incident reference and recovery rationale in audit evidence.
|
||||||
|
|
||||||
|
## Composition and integration contracts
|
||||||
|
|
||||||
|
Campaign has one required platform dependency: Core. Optional module behavior
|
||||||
|
is discovered through versioned, Core-mediated capabilities; Campaign must not
|
||||||
|
import optional sibling ORM, services, or WebUI implementation.
|
||||||
|
|
||||||
|
Current principal contracts include:
|
||||||
|
|
||||||
|
| Contract | Direction | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `mail.campaign_delivery` 0.2.x | Mail -> Campaign | Summarize a known reference; authorize and revision-gate it; send/append using Mail-owned configuration and credentials; return sanitized results |
|
||||||
|
| `files.campaign_attachments` 0.1.x | Files -> Campaign | Select/materialize governed file versions and preserve campaign usage/evidence |
|
||||||
|
| `addresses.lookup` 0.1.x | Addresses -> Campaign | Optional address suggestions |
|
||||||
|
| `addresses.recipient_source` 0.1.x | Addresses -> Campaign | Optional versioned recipient-source snapshots |
|
||||||
|
| `campaigns.access` 0.1.x | Campaign -> platform | Explain campaign access/existence without exporting ORM objects |
|
||||||
|
| `campaigns.mail_policy_context` 0.1.x | Campaign -> Mail | Resolve campaign tenant/owner context for Mail policy |
|
||||||
|
| `campaigns.delivery_tasks` 0.1.x | Campaign -> workers | Execute narrow queued send/append tasks |
|
||||||
|
| `campaigns.retention` 0.1.x | Campaign -> retention | Apply Campaign-owned retention behavior |
|
||||||
|
|
||||||
|
Breaking payload or ownership changes require an interface-version bump and a
|
||||||
|
release-composition alignment gate. Optional absence must be tested physically,
|
||||||
|
not only hidden in navigation.
|
||||||
|
|
||||||
|
### External API expectations
|
||||||
|
|
||||||
|
- Tenant and campaign access are evaluated for every operation.
|
||||||
|
- Writes require CSRF/auth behavior supplied by Core and the specific declared
|
||||||
|
scope.
|
||||||
|
- Queue/retry/reconcile endpoints operate on persisted state and return safe
|
||||||
|
summaries; initiating HTTP success is not proof of external delivery.
|
||||||
|
- Delta endpoints are optimization surfaces, not a separate source of truth.
|
||||||
|
- Report exports contain permitted business/evidence fields but no credentials,
|
||||||
|
local paths, storage keys, or worker claims.
|
||||||
|
|
||||||
|
## Assurance model
|
||||||
|
|
||||||
|
### Security invariants
|
||||||
|
|
||||||
|
- No new campaign payload, fixture, response, or execution snapshot contains
|
||||||
|
SMTP/IMAP settings or credentials.
|
||||||
|
- Mail resolves credentials and performs transports inside Mail-owned calls.
|
||||||
|
- Every real connector peer is validated and pinned at connection time under
|
||||||
|
deployment-wide private-network policy.
|
||||||
|
- API/server attachment paths use managed Files references; arbitrary and
|
||||||
|
traversal-capable local paths are rejected.
|
||||||
|
- Public responses recursively remove infrastructure locators and secret-like
|
||||||
|
legacy fields.
|
||||||
|
- Accepted and outcome-unknown jobs are protected from ordinary retry.
|
||||||
|
- Diagnostic permission is separate from campaign read/report access.
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
|
||||||
|
Recipient fields and rendered messages may contain personal or sensitive data.
|
||||||
|
Grant recipient read/export, report export, and diagnostic access separately.
|
||||||
|
Prefer aggregate status for readers who do not need recipient detail. Define
|
||||||
|
purpose, lawful basis, minimization, export control, and retention before the
|
||||||
|
campaign starts; do not use Campaign as a substitute consent or address-master
|
||||||
|
system.
|
||||||
|
|
||||||
|
### Audit and destructive actions
|
||||||
|
|
||||||
|
Material authoring, validation, locking, review, queueing, send, retry,
|
||||||
|
reconciliation, sharing, owner transfer, archive, and permitted deletion actions
|
||||||
|
emit attributable evidence. Audit details must be non-secret and should refer to
|
||||||
|
stable ids rather than repeat message bodies or credentials.
|
||||||
|
|
||||||
|
Draft deletion is allowed only while no audit-relevant build, delivery job, or
|
||||||
|
lock exists. Evidence-bearing campaigns are archived. Destructive module
|
||||||
|
retirement remains a separately confirmed installer operation with backup and
|
||||||
|
retirement evidence.
|
||||||
|
|
||||||
|
## Reference-composition acceptance
|
||||||
|
|
||||||
|
Campaign is ready to serve as the demonstration module only when all of the
|
||||||
|
following are repeatable in a pinned clean installation:
|
||||||
|
|
||||||
|
1. The maintained examples validate and build with Mail/Files present, and
|
||||||
|
Campaign still starts with each optional module absent.
|
||||||
|
2. A user can import recipients, select managed files, choose an authorized Mail
|
||||||
|
profile, validate, review, build, and queue without entering transport ids or
|
||||||
|
secrets manually.
|
||||||
|
3. Campaign JSON and all ordinary APIs reject/omit inline transport material;
|
||||||
|
legacy records are visible as migration-required and cannot execute.
|
||||||
|
4. SMTP success, temporary/permanent failure, connection loss, worker loss,
|
||||||
|
outcome unknown, retry, reconciliation, IMAP success, and IMAP failure have
|
||||||
|
tested, non-duplicating outcomes against the target environment.
|
||||||
|
5. Author, reviewer, sender/operator, reader, and administrator views use the
|
||||||
|
central component system and expose only task-relevant actions.
|
||||||
|
6. Reports and audit can reconstruct recipient/message/file/profile/attempt
|
||||||
|
evidence without exposing secrets or ordinary-reader infrastructure details.
|
||||||
|
7. Clean install, upgrade, backup/restore, module permutations, version
|
||||||
|
alignment, security audit, and target SMTP/IMAP tests pass.
|
||||||
|
8. This handbook and its adaptive Docs topics match the shipped UI wording and
|
||||||
|
distinguish implemented behavior from planned work.
|
||||||
|
|
||||||
|
## Explicitly planned, not yet claimed
|
||||||
|
|
||||||
|
The following are part of the selected reference journey but are not implied by
|
||||||
|
the current baseline:
|
||||||
|
|
||||||
|
- the final audited **test / single send / single resend** semantics;
|
||||||
|
- reusable SMTP batch sessions and their measured throughput benefit;
|
||||||
|
- durable, idempotent Campaign report delivery through a Mail-owned outbox
|
||||||
|
([`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17));
|
||||||
|
- a fully packaged one-command Campaign reference composition with production
|
||||||
|
policy presets and target-provider certification;
|
||||||
|
- function-bound Postbox delivery (stage 2 of the reference program);
|
||||||
|
- generic workflow-driven campaign transitions.
|
||||||
|
|
||||||
|
Each item needs an owning issue, implementation, failure tests, documentation,
|
||||||
|
and release evidence before the wording above can move from planned to current.
|
||||||
@@ -16,8 +16,8 @@ schema and are safe to run in non-production environments.
|
|||||||
- campaign with global attachments and recipient-specific attachment rules
|
- campaign with global attachments and recipient-specific attachment rules
|
||||||
- campaign with password-protected ZIP attachments
|
- campaign with password-protected ZIP attachments
|
||||||
- campaign using a reusable mail profile from the mail module
|
- campaign using a reusable mail profile from the mail module
|
||||||
- campaign using inline SMTP/IMAP settings where policy allows campaign-local
|
- legacy inline SMTP/IMAP campaign rejected with an actionable profile-migration
|
||||||
settings
|
error and no returned transport data
|
||||||
- campaign with validation warnings that may be sent only after explicit review
|
- campaign with validation warnings that may be sent only after explicit review
|
||||||
- campaign with blocked recipients or attachment errors that must not be sent
|
- campaign with blocked recipients or attachment errors that must not be sent
|
||||||
- mock delivery campaign that captures SMTP and IMAP append messages in the mail
|
- mock delivery campaign that captures SMTP and IMAP append messages in the mail
|
||||||
@@ -28,8 +28,8 @@ schema and are safe to run in non-production environments.
|
|||||||
|
|
||||||
- Examples must not contain production recipient data or production credentials.
|
- Examples must not contain production recipient data or production credentials.
|
||||||
- Attachment examples should use deterministic small files and checksums.
|
- Attachment examples should use deterministic small files and checksums.
|
||||||
- Secret values must be represented through saved-credential placeholders or
|
- Mail secrets belong only to encrypted Mail profiles and must never appear in
|
||||||
secret references.
|
a campaign fixture, placeholder, or campaign-local secret reference.
|
||||||
- Examples that require optional modules must declare the required modules and
|
- Examples that require optional modules must declare the required modules and
|
||||||
capabilities in their README or fixture metadata.
|
capabilities in their README or fixture metadata.
|
||||||
- Examples must stay valid when files or mail modules are physically absent,
|
- Examples must stay valid when files or mail modules are physically absent,
|
||||||
@@ -49,8 +49,10 @@ Before tagging a campaign release:
|
|||||||
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path.
|
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path.
|
||||||
- Confirm reusable mail profile selection is revalidated after campaign owner
|
- Confirm reusable mail profile selection is revalidated after campaign owner
|
||||||
transfer.
|
transfer.
|
||||||
- Confirm inline SMTP/IMAP settings are hidden or blocked when policy disables
|
- Confirm every inline SMTP/IMAP field is rejected on import/write, omitted
|
||||||
campaign-local mail settings.
|
from responses, and blocked from legacy execution until explicitly migrated.
|
||||||
|
- Confirm execution snapshots store only the Mail profile reference and
|
||||||
|
opaque Mail-owned transport revisions, not resolved transport material.
|
||||||
- Confirm delivery reports include SMTP outcome, IMAP append outcome, latest
|
- Confirm delivery reports include SMTP outcome, IMAP append outcome, latest
|
||||||
error, generated EML reference, and attachment evidence.
|
error, generated EML reference, and attachment evidence.
|
||||||
- Confirm retries cannot resend messages already accepted by SMTP unless an
|
- Confirm retries cannot resend messages already accepted by SMTP unless an
|
||||||
@@ -71,6 +73,6 @@ Use the Review & Send preflight panel and the delivery runbook together:
|
|||||||
2. Build exact messages.
|
2. Build exact messages.
|
||||||
3. Review warnings, generated recipients, body content, and attachment evidence.
|
3. Review warnings, generated recipients, body content, and attachment evidence.
|
||||||
4. Run mock delivery if available for the release channel.
|
4. Run mock delivery if available for the release channel.
|
||||||
5. Test SMTP and IMAP settings against non-production infrastructure.
|
5. Test the selected Mail profile against non-production infrastructure.
|
||||||
6. Send only after queue, rate limit, and append-to-Sent behavior are understood.
|
6. Send only after queue, rate limit, and append-to-Sent behavior are understood.
|
||||||
7. Reconcile failed, unknown, or pending jobs from the report/audit surfaces.
|
7. Reconcile failed, unknown, or pending jobs from the report/audit surfaces.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Campaign and Mail Profile Boundary
|
||||||
|
|
||||||
|
## Product view
|
||||||
|
|
||||||
|
A campaign chooses an authorized Mail profile. It does not define a mail
|
||||||
|
server. The Campaign module owns recipients, content, attachment rules,
|
||||||
|
delivery intent, review state, and delivery evidence. The Mail module owns the
|
||||||
|
SMTP/IMAP endpoints, encrypted credentials, connection tests, profile policy,
|
||||||
|
and runtime transport adapters.
|
||||||
|
|
||||||
|
The persisted campaign contract is therefore deliberately narrow:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"mail_profile_id": "stable-mail-profile-id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No `server.smtp`, `server.imap`, `server.credentials`, credential-inheritance
|
||||||
|
override, or password is valid campaign JSON.
|
||||||
|
|
||||||
|
## User journey
|
||||||
|
|
||||||
|
1. A Mail administrator creates and tests a reusable profile in Mail.
|
||||||
|
2. A campaign author opens **Mail settings** and selects one profile available
|
||||||
|
for the campaign's tenant, owner, and policy context.
|
||||||
|
3. Campaign stores only the stable profile identifier.
|
||||||
|
4. Validation asks Mail to authorize the active profile and returns only
|
||||||
|
availability flags plus opaque Mail-owned transport revisions. Reading that
|
||||||
|
summary does not decrypt credentials.
|
||||||
|
5. Build stores the profile identifier, delivery policy, job manifest, and
|
||||||
|
non-secret transport revisions as execution evidence. It stores no
|
||||||
|
resolved host, username, password, or other transport material.
|
||||||
|
6. A delivery worker invokes one Mail-owned effect operation. Mail re-authorizes
|
||||||
|
the profile, resolves credentials, compares the expected transport
|
||||||
|
revision, checks policy, and sends or appends without returning transport
|
||||||
|
material to Campaign. If the selected profile or its non-secret transport settings
|
||||||
|
changed after build, delivery stops until the campaign is revalidated and
|
||||||
|
rebuilt. A password rotation that leaves the transport identity unchanged
|
||||||
|
does not invalidate the build.
|
||||||
|
7. Mail returns only Campaign-owned envelope addresses, counts, sanitized
|
||||||
|
refusal classifications/status codes, or the selected Sent folder. Raw
|
||||||
|
server banners, provider bytes, host details, and credentials never enter
|
||||||
|
Campaign attempts or public evidence.
|
||||||
|
|
||||||
|
Non-dry Campaign report email currently fails closed. It must not bypass the
|
||||||
|
durable job/effect model through a direct SMTP call. Re-enabling it requires the
|
||||||
|
Mail-owned idempotent outbox, attempt, unknown-outcome, and reconciliation path
|
||||||
|
tracked in
|
||||||
|
[`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17).
|
||||||
|
Report generation and dry-run validation remain separate from an external
|
||||||
|
effect; recipient-level exports require recipient-export authorization.
|
||||||
|
|
||||||
|
Campaign authors need `mail:profile:use` in addition to the relevant Campaign
|
||||||
|
permission. Profile visibility remains governed by Mail policy and campaign
|
||||||
|
owner context.
|
||||||
|
|
||||||
|
## Existing database rows and migration
|
||||||
|
|
||||||
|
The current database can contain campaign versions with inline SMTP/IMAP
|
||||||
|
settings or credentials. There is no separate historical Campaign JSON corpus
|
||||||
|
to import or remediate. GovOPlaN treats those inline fields as inert legacy
|
||||||
|
material and does not delete or rewrite the stored audit rows automatically:
|
||||||
|
|
||||||
|
- API responses omit all legacy transport fields and secrets and expose a
|
||||||
|
`mail_profile_migration_required` marker.
|
||||||
|
- validation, build, queue, retry, and delivery fail closed with an actionable
|
||||||
|
profile-migration error;
|
||||||
|
- unrelated edits cannot silently scrub the legacy fields;
|
||||||
|
- an editable version is migrated only through an explicit Mail-settings save
|
||||||
|
with an authorized profile; and
|
||||||
|
- a locked version remains unchanged. Creating its editable successor records
|
||||||
|
the migration while retaining the locked source as audit evidence.
|
||||||
|
|
||||||
|
Legacy execution snapshots are likewise retained but cannot be used for
|
||||||
|
delivery. Revalidate and rebuild an editable profile-only version. Normal
|
||||||
|
database backup, restore, encryption, and access controls apply to the current
|
||||||
|
database as a whole; the product does not define a separate historical-JSON or
|
||||||
|
inline-secret recovery workflow. A restored legacy row remains inert and
|
||||||
|
fail-closed under the same rules.
|
||||||
|
|
||||||
|
## Operator checks
|
||||||
|
|
||||||
|
Before live delivery, confirm that:
|
||||||
|
|
||||||
|
- the profile is active and still authorized for the campaign owner;
|
||||||
|
- SMTP and optional IMAP profile tests pass;
|
||||||
|
- validation and build occurred after the latest transport-identity change;
|
||||||
|
- append-to-Sent is enabled only when the selected profile has IMAP; and
|
||||||
|
- reports show the profile-bound snapshot revisions and delivery outcomes,
|
||||||
|
never credentials or resolved transport configuration.
|
||||||
+3
-3
@@ -15,7 +15,7 @@ campaign schema and do not require production data.
|
|||||||
| `recipient-attachment-rules` | core, access, campaigns; optional files | Match recipient-specific attachment rules and verify per-recipient evidence. |
|
| `recipient-attachment-rules` | core, access, campaigns; optional files | Match recipient-specific attachment rules and verify per-recipient evidence. |
|
||||||
| `zip-protected` | core, access, campaigns | Build password-protected AES ZIP output and verify password-source metadata. |
|
| `zip-protected` | core, access, campaigns | Build password-protected AES ZIP output and verify password-source metadata. |
|
||||||
| `mail-profile-send` | core, access, campaigns, mail | Select a reusable mail profile and send through the GreenMail test bed. |
|
| `mail-profile-send` | core, access, campaigns, mail | Select a reusable mail profile and send through the GreenMail test bed. |
|
||||||
| `inline-mail-settings` | core, access, campaigns, mail | Use campaign-local SMTP/IMAP settings only when policy allows it. |
|
| `legacy-inline-mail-rejected` | core, access, campaigns, mail | Confirm legacy campaign-local SMTP/IMAP data fails closed and requires explicit profile migration. |
|
||||||
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. |
|
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. |
|
||||||
| `blocked-send` | core, access, campaigns | Confirm blocked recipients or missing attachments cannot be queued. |
|
| `blocked-send` | core, access, campaigns | Confirm blocked recipients or missing attachments cannot be queued. |
|
||||||
| `mock-delivery` | core, access, campaigns, mail with dev capability | Capture messages in the development mailbox. |
|
| `mock-delivery` | core, access, campaigns, mail with dev capability | Capture messages in the development mailbox. |
|
||||||
@@ -26,8 +26,8 @@ campaign schema and do not require production data.
|
|||||||
- Do not commit real recipients, mail credentials, or production attachment
|
- Do not commit real recipients, mail credentials, or production attachment
|
||||||
names.
|
names.
|
||||||
- Keep attachments deterministic and small.
|
- Keep attachments deterministic and small.
|
||||||
- Store secrets as placeholders, saved-credential references, or local `.env`
|
- Store transport secrets only in encrypted Mail profiles or local `.env`
|
||||||
values consumed by the test bed.
|
values consumed directly by the test bed, never in campaign JSON.
|
||||||
- Declare optional module requirements in fixture metadata.
|
- Declare optional module requirements in fixture metadata.
|
||||||
- Fixtures must not import files or mail modules directly; optional behavior is
|
- Fixtures must not import files or mail modules directly; optional behavior is
|
||||||
discovered through core module metadata and capabilities.
|
discovered through core module metadata and capabilities.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-campaign"
|
name = "govoplan-campaign"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from jsonschema import Draft202012Validator, FormatChecker
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
|
||||||
|
from .mail_profile_boundary import assert_campaign_uses_mail_profile_reference
|
||||||
from .models import CampaignConfig
|
from .models import CampaignConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ def load_campaign_config(
|
|||||||
schema_path: str | Path | None = None,
|
schema_path: str | Path | None = None,
|
||||||
) -> CampaignConfig:
|
) -> CampaignConfig:
|
||||||
data = load_campaign_json(path)
|
data = load_campaign_json(path)
|
||||||
|
assert_campaign_uses_mail_profile_reference(data)
|
||||||
if validate_schema:
|
if validate_schema:
|
||||||
validate_against_schema(data, schema_path=schema_path)
|
validate_against_schema(data, schema_path=schema_path)
|
||||||
return CampaignConfig.model_validate(data)
|
return CampaignConfig.model_validate(data)
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
CAMPAIGN_MAIL_SERVER_KEYS = frozenset({"mail_profile_id"})
|
||||||
|
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"})
|
||||||
|
CAMPAIGN_OPT_IN_KEYS = frozenset(
|
||||||
|
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
|
||||||
|
)
|
||||||
|
CAMPAIGN_REVIEW_STATE_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"build_token",
|
||||||
|
"inspection_complete",
|
||||||
|
"reviewed_message_keys",
|
||||||
|
"updated_at",
|
||||||
|
"updated_by_user_id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignMailProfileBoundaryError(ValueError):
|
||||||
|
"""Raised when campaign JSON owns mail transport configuration.
|
||||||
|
|
||||||
|
SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON
|
||||||
|
may select one Mail-owned profile, but it must never copy or override that
|
||||||
|
profile's transport configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_opt_ins(value: Any) -> dict[str, bool]:
|
||||||
|
if not isinstance(value, dict) or any(
|
||||||
|
key not in CAMPAIGN_OPT_IN_KEYS for key in value
|
||||||
|
):
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign editor opt_ins contains unsupported fields"
|
||||||
|
)
|
||||||
|
if any(not isinstance(item, bool) for item in value.values()):
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign editor opt_ins values must be booleans"
|
||||||
|
)
|
||||||
|
return copy.deepcopy(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_field_override(key: Any, value: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(key, str)
|
||||||
|
and bool(key.strip())
|
||||||
|
and len(key) <= 256
|
||||||
|
and isinstance(value, bool)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_field_overrides(value: Any) -> dict[str, bool]:
|
||||||
|
if not isinstance(value, dict) or len(value) > 10_000:
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign editor field_overrides must be a bounded object"
|
||||||
|
)
|
||||||
|
if any(not _is_valid_field_override(key, item) for key, item in value.items()):
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign editor field_overrides must map short field names to booleans"
|
||||||
|
)
|
||||||
|
return copy.deepcopy(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_required_string(value: Any, *, max_length: int, error: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip() or len(value) > max_length:
|
||||||
|
raise CampaignMailProfileBoundaryError(error)
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_campaign_editor_state(
|
||||||
|
value: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
allow_server_review_state: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate the bounded Campaign-owned UI metadata contract.
|
||||||
|
|
||||||
|
Arbitrary editor metadata would be a second, weakly typed persistence and
|
||||||
|
response channel for Mail credentials. Review evidence is server-owned and
|
||||||
|
cannot be supplied through ordinary version create/update requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise CampaignMailProfileBoundaryError("Campaign editor state must be an object")
|
||||||
|
allowed = set(CAMPAIGN_CLIENT_EDITOR_STATE_KEYS)
|
||||||
|
if allow_server_review_state:
|
||||||
|
allowed.add("review_send")
|
||||||
|
if any(key not in allowed for key in value):
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign editor state contains unsupported or transport-owned fields"
|
||||||
|
)
|
||||||
|
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
if "created_from" in value:
|
||||||
|
result["created_from"] = _validated_required_string(
|
||||||
|
value["created_from"],
|
||||||
|
max_length=128,
|
||||||
|
error="Campaign editor created_from must be a short string",
|
||||||
|
)
|
||||||
|
if "opt_ins" in value:
|
||||||
|
result["opt_ins"] = _validated_opt_ins(value["opt_ins"])
|
||||||
|
if "field_overrides" in value:
|
||||||
|
result["field_overrides"] = _validated_field_overrides(
|
||||||
|
value["field_overrides"]
|
||||||
|
)
|
||||||
|
if "review_send" in value:
|
||||||
|
result["review_send"] = _validated_server_review_state(value["review_send"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def public_campaign_editor_state(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
include_diagnostics: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return only known non-secret UI metadata from current or legacy rows."""
|
||||||
|
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key in CAMPAIGN_CLIENT_EDITOR_STATE_KEYS:
|
||||||
|
if key not in value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result.update(validate_campaign_editor_state({key: value[key]}))
|
||||||
|
except CampaignMailProfileBoundaryError:
|
||||||
|
continue
|
||||||
|
if "review_send" in value:
|
||||||
|
try:
|
||||||
|
review_state = _validated_server_review_state(value["review_send"])
|
||||||
|
if not include_diagnostics:
|
||||||
|
review_state.pop("build_token", None)
|
||||||
|
result["review_send"] = review_state
|
||||||
|
except CampaignMailProfileBoundaryError:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
|
||||||
|
"""Copy only client-owned safe metadata into a new editable version."""
|
||||||
|
|
||||||
|
state = public_campaign_editor_state(value)
|
||||||
|
state.pop("review_send", None)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_reviewed_message_key(value: Any) -> bool:
|
||||||
|
return isinstance(value, str) and bool(value.strip()) and len(value) <= 512
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_reviewed_message_keys(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, list) or len(value) > 100_000:
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign reviewed message keys are invalid"
|
||||||
|
)
|
||||||
|
if any(not _is_valid_reviewed_message_key(key) for key in value):
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign reviewed message keys are invalid"
|
||||||
|
)
|
||||||
|
return list(dict.fromkeys(key.strip() for key in value))
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_review_actor(value: Any) -> str | None:
|
||||||
|
if value is not None and (not isinstance(value, str) or len(value) > 256):
|
||||||
|
raise CampaignMailProfileBoundaryError("Campaign review actor is invalid")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_server_review_state(value: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict) or any(
|
||||||
|
key not in CAMPAIGN_REVIEW_STATE_KEYS for key in value
|
||||||
|
):
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign review editor state is invalid"
|
||||||
|
)
|
||||||
|
build_token = value.get("build_token")
|
||||||
|
inspected = value.get("inspection_complete")
|
||||||
|
keys = value.get("reviewed_message_keys", [])
|
||||||
|
updated_at = value.get("updated_at")
|
||||||
|
updated_by = value.get("updated_by_user_id")
|
||||||
|
validated_build_token = _validated_required_string(
|
||||||
|
build_token,
|
||||||
|
max_length=256,
|
||||||
|
error="Campaign review build token is invalid",
|
||||||
|
)
|
||||||
|
if not isinstance(inspected, bool):
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign review completion state is invalid"
|
||||||
|
)
|
||||||
|
validated_keys = _validated_reviewed_message_keys(keys)
|
||||||
|
validated_updated_at = _validated_required_string(
|
||||||
|
updated_at,
|
||||||
|
max_length=128,
|
||||||
|
error="Campaign review timestamp is invalid",
|
||||||
|
)
|
||||||
|
validated_updated_by = _validated_review_actor(updated_by)
|
||||||
|
return {
|
||||||
|
"build_token": validated_build_token,
|
||||||
|
"inspection_complete": inspected,
|
||||||
|
"reviewed_message_keys": validated_keys,
|
||||||
|
"updated_at": validated_updated_at,
|
||||||
|
"updated_by_user_id": validated_updated_by,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None:
|
||||||
|
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||||
|
if not isinstance(server, dict):
|
||||||
|
return None
|
||||||
|
value = server.get("mail_profile_id")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]:
|
||||||
|
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||||
|
if not isinstance(server, dict):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS]
|
||||||
|
if "mail_profile_id" in server:
|
||||||
|
profile_id = server["mail_profile_id"]
|
||||||
|
if not isinstance(profile_id, str) or not profile_id.strip():
|
||||||
|
violations.append("/server/mail_profile_id")
|
||||||
|
return tuple(violations)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_campaign_uses_mail_profile_reference(
|
||||||
|
raw_json: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
require_profile: bool = False,
|
||||||
|
) -> None:
|
||||||
|
violations = campaign_mail_profile_boundary_violations(raw_json)
|
||||||
|
if violations:
|
||||||
|
fields = ", ".join(violations)
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign JSON may only reference a Mail-module profile through "
|
||||||
|
f"server.mail_profile_id; remove campaign-local SMTP/IMAP settings ({fields}), "
|
||||||
|
"select an authorized Mail profile, and save a new campaign version."
|
||||||
|
)
|
||||||
|
if require_profile and campaign_mail_profile_id(raw_json) is None:
|
||||||
|
raise CampaignMailProfileBoundaryError(
|
||||||
|
"Campaign delivery requires server.mail_profile_id. Select an authorized, active "
|
||||||
|
"profile from the Mail module and validate the campaign again."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]:
|
||||||
|
"""Return the complete public/persisted Campaign-to-Mail contract."""
|
||||||
|
|
||||||
|
profile_id = campaign_mail_profile_id(raw_json)
|
||||||
|
return {"mail_profile_id": profile_id} if profile_id else {}
|
||||||
@@ -6,15 +6,6 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
from govoplan_core.mail.config import (
|
|
||||||
ImapConfig,
|
|
||||||
ImapServerConfig,
|
|
||||||
SmtpConfig,
|
|
||||||
SmtpServerConfig,
|
|
||||||
TransportCredentials,
|
|
||||||
normalize_split_transport_credentials,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StrictModel(BaseModel):
|
class StrictModel(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
@@ -115,37 +106,14 @@ class FieldDefinition(StrictModel):
|
|||||||
can_override: bool = True
|
can_override: bool = True
|
||||||
|
|
||||||
|
|
||||||
class MailServerCredentials(StrictModel):
|
class MailProfileCapabilities(StrictModel):
|
||||||
smtp: TransportCredentials = Field(default_factory=TransportCredentials)
|
smtp_available: bool = False
|
||||||
imap: TransportCredentials = Field(default_factory=TransportCredentials)
|
imap_available: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ServerConfig(StrictModel):
|
class ServerConfig(StrictModel):
|
||||||
mail_profile_id: str | None = None
|
mail_profile_id: str | None = None
|
||||||
inherit_smtp_credentials: bool = True
|
profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities)
|
||||||
inherit_imap_credentials: bool = True
|
|
||||||
smtp: SmtpServerConfig | None = None
|
|
||||||
imap: ImapServerConfig | None = None
|
|
||||||
credentials: MailServerCredentials = Field(default_factory=MailServerCredentials)
|
|
||||||
|
|
||||||
@model_validator(mode="before")
|
|
||||||
@classmethod
|
|
||||||
def normalize_legacy_credentials(cls, value: Any) -> Any:
|
|
||||||
return normalize_split_transport_credentials(value)
|
|
||||||
|
|
||||||
def runtime_smtp_config(self) -> SmtpConfig | None:
|
|
||||||
if self.smtp is None:
|
|
||||||
return None
|
|
||||||
payload = self.smtp.model_dump(mode="json")
|
|
||||||
payload.update(self.credentials.smtp.model_dump(mode="json", exclude_none=True))
|
|
||||||
return SmtpConfig.model_validate(payload)
|
|
||||||
|
|
||||||
def runtime_imap_config(self) -> ImapConfig | None:
|
|
||||||
if self.imap is None:
|
|
||||||
return None
|
|
||||||
payload = self.imap.model_dump(mode="json")
|
|
||||||
payload.update(self.credentials.imap.model_dump(mode="json", exclude_none=True))
|
|
||||||
return ImapConfig.model_validate(payload)
|
|
||||||
|
|
||||||
|
|
||||||
class RecipientConfig(StrictModel):
|
class RecipientConfig(StrictModel):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Iterable
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from .addressing import effective_address_lists
|
||||||
from .field_values import ignored_entry_field_overrides
|
from .field_values import ignored_entry_field_overrides
|
||||||
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
|
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
|
||||||
from ..attachments.resolver import resolve_campaign_attachments
|
from ..attachments.resolver import resolve_campaign_attachments
|
||||||
@@ -338,52 +339,72 @@ def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> li
|
|||||||
|
|
||||||
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||||
issues: list[SemanticIssue] = []
|
issues: list[SemanticIssue] = []
|
||||||
runtime_imap = config.server.runtime_imap_config()
|
profile_id = (config.server.mail_profile_id or "").strip()
|
||||||
if config.delivery.imap_append_sent.enabled:
|
if (config.campaign.mode == "send" or config.delivery.imap_append_sent.enabled) and not profile_id:
|
||||||
issues.extend(_imap_delivery_issues(runtime_imap))
|
|
||||||
runtime_smtp = config.server.runtime_smtp_config()
|
|
||||||
if config.campaign.mode == "send" and not runtime_smtp:
|
|
||||||
issues.append(
|
issues.append(
|
||||||
_issue(
|
_issue(
|
||||||
Severity.ERROR,
|
Severity.ERROR,
|
||||||
"missing_smtp_config",
|
"missing_mail_profile",
|
||||||
"campaign mode is 'send', but no server.smtp configuration is present",
|
"Select an authorized Mail-module profile; campaigns cannot store SMTP/IMAP settings or credentials.",
|
||||||
"/server/smtp",
|
"/server/mail_profile_id",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if runtime_smtp:
|
capabilities = config.server.profile_capabilities
|
||||||
missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")]
|
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available:
|
||||||
if missing:
|
issues.append(
|
||||||
issues.append(
|
_issue(
|
||||||
_issue(
|
Severity.ERROR,
|
||||||
Severity.WARNING,
|
"mail_profile_without_smtp",
|
||||||
"incomplete_smtp_config",
|
"campaign mode is 'send', but the selected Mail profile has no SMTP configuration",
|
||||||
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
|
"/server/mail_profile_id",
|
||||||
"/server/smtp",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
if config.delivery.imap_append_sent.enabled and profile_id and not capabilities.imap_available:
|
||||||
|
issues.append(
|
||||||
|
_issue(
|
||||||
|
Severity.ERROR,
|
||||||
|
"mail_profile_without_imap",
|
||||||
|
"IMAP append is enabled, but the selected Mail profile has no IMAP configuration",
|
||||||
|
"/server/mail_profile_id",
|
||||||
|
)
|
||||||
|
)
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def _imap_delivery_issues(runtime_imap: object | None) -> list[SemanticIssue]:
|
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||||
if runtime_imap is None:
|
"""Require Campaign-owned sender data before a send-mode build."""
|
||||||
|
|
||||||
|
if config.campaign.mode != "send":
|
||||||
|
return []
|
||||||
|
if config.entries.is_inline:
|
||||||
return [
|
return [
|
||||||
_issue(
|
_issue(
|
||||||
Severity.WARNING,
|
Severity.ERROR,
|
||||||
"delivery_imap_enabled_without_server_imap",
|
"missing_sender",
|
||||||
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
|
"No effective From address is configured; Campaign must resolve the sender before building.",
|
||||||
"/delivery/imap_append_sent/enabled",
|
f"/entries/inline/{index}/from",
|
||||||
)
|
)
|
||||||
|
for index, entry in enumerate(config.entries.inline or [])
|
||||||
|
if entry.active and not effective_address_lists(config, entry)["from"]
|
||||||
]
|
]
|
||||||
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
|
if config.recipients.from_:
|
||||||
if not missing:
|
return []
|
||||||
|
defaults = config.entries.defaults
|
||||||
|
mapping_can_supply_sender = bool(
|
||||||
|
config.recipients.allow_individual_from
|
||||||
|
and (
|
||||||
|
(defaults is not None and defaults.from_)
|
||||||
|
or "from.email" in (config.entries.mapping or {})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if mapping_can_supply_sender:
|
||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
_issue(
|
_issue(
|
||||||
Severity.ERROR,
|
Severity.ERROR,
|
||||||
"incomplete_imap_config",
|
"missing_sender",
|
||||||
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
|
"Configure recipients.from, or allow and map an individual From address, before building a send-mode campaign.",
|
||||||
"/server/imap",
|
"/recipients/from",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -602,6 +623,7 @@ def validate_campaign_config(
|
|||||||
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))
|
||||||
|
issues.extend(_sender_issues(config))
|
||||||
|
|
||||||
entries = _entries_validation(
|
entries = _entries_validation(
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ class JobSendStatus(StrEnum):
|
|||||||
class JobImapStatus(StrEnum):
|
class JobImapStatus(StrEnum):
|
||||||
NOT_REQUESTED = "not_requested"
|
NOT_REQUESTED = "not_requested"
|
||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
|
APPENDING = "appending"
|
||||||
APPENDED = "appended"
|
APPENDED = "appended"
|
||||||
|
OUTCOME_UNKNOWN = "outcome_unknown"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
SKIPPED = "skipped"
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
@@ -213,6 +215,12 @@ class CampaignVersion(Base, TimestampMixin):
|
|||||||
|
|
||||||
campaign: Mapped[Campaign] = relationship(back_populates="versions")
|
campaign: Mapped[Campaign] = relationship(back_populates="versions")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mail_profile_migration_required(self) -> bool:
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_boundary_violations
|
||||||
|
|
||||||
|
return bool(campaign_mail_profile_boundary_violations(self.raw_json))
|
||||||
|
|
||||||
|
|
||||||
class CampaignJob(Base, TimestampMixin):
|
class CampaignJob(Base, TimestampMixin):
|
||||||
__tablename__ = "campaign_jobs"
|
__tablename__ = "campaign_jobs"
|
||||||
@@ -232,6 +240,7 @@ class CampaignJob(Base, TimestampMixin):
|
|||||||
eml_local_path: Mapped[str | None] = mapped_column(String(1000))
|
eml_local_path: Mapped[str | None] = mapped_column(String(1000))
|
||||||
eml_size_bytes: Mapped[int | None] = mapped_column(Integer)
|
eml_size_bytes: Mapped[int | None] = mapped_column(Integer)
|
||||||
eml_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
eml_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
execution_input_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True)
|
build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True)
|
||||||
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
|
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
|
||||||
@@ -246,6 +255,8 @@ class CampaignJob(Base, TimestampMixin):
|
|||||||
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
smtp_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
smtp_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
outcome_unknown_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
outcome_unknown_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
imap_claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
imap_claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
@@ -315,10 +326,14 @@ class SendAttempt(Base, TimestampMixin):
|
|||||||
|
|
||||||
class ImapAppendAttempt(Base, TimestampMixin):
|
class ImapAppendAttempt(Base, TimestampMixin):
|
||||||
__tablename__ = "imap_append_attempts"
|
__tablename__ = "imap_append_attempts"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("job_id", "attempt_number", name="uq_imap_append_attempts_job_attempt"),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
|
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
folder: Mapped[str | None] = mapped_column(String(500))
|
folder: Mapped[str | None] = mapped_column(String(500))
|
||||||
status: Mapped[str] = mapped_column(String(50), nullable=False)
|
status: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
error_message: Mapped[str | None] = mapped_column(Text)
|
error_message: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|||||||
@@ -0,0 +1,705 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
|
||||||
|
|
||||||
|
|
||||||
|
_CAMPAIGN_USER_SCOPES = (
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:copy",
|
||||||
|
"campaigns:campaign:archive",
|
||||||
|
"campaigns:campaign:delete",
|
||||||
|
"campaigns:campaign:share",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:campaign:build",
|
||||||
|
"campaigns:campaign:review",
|
||||||
|
"campaigns:campaign:send_test",
|
||||||
|
"campaigns:campaign:queue",
|
||||||
|
"campaigns:campaign:control",
|
||||||
|
"campaigns:campaign:send",
|
||||||
|
"campaigns:campaign:retry",
|
||||||
|
"campaigns:campaign:reconcile",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
"campaigns:recipient:export",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"campaigns:report:export",
|
||||||
|
"campaigns:report:send",
|
||||||
|
)
|
||||||
|
|
||||||
|
_CAMPAIGN_HELP_CONTEXTS = (
|
||||||
|
"campaigns.list",
|
||||||
|
"campaign.overview",
|
||||||
|
)
|
||||||
|
|
||||||
|
_FILES_INTEGRATION = "files.campaign_attachments"
|
||||||
|
_MAIL_INTEGRATION = "mail.campaign_delivery"
|
||||||
|
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
|
||||||
|
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
|
||||||
|
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_topic(
|
||||||
|
*,
|
||||||
|
topic_id: str,
|
||||||
|
title: str,
|
||||||
|
summary: str,
|
||||||
|
body: str,
|
||||||
|
order: int,
|
||||||
|
audience: tuple[str, ...],
|
||||||
|
required_scopes: tuple[str, ...],
|
||||||
|
route: str,
|
||||||
|
screen: str,
|
||||||
|
help_contexts: tuple[str, ...],
|
||||||
|
prerequisites: tuple[str, ...],
|
||||||
|
steps: tuple[str, ...],
|
||||||
|
outcome: str,
|
||||||
|
verification: str,
|
||||||
|
related_topic_ids: tuple[str, ...] = (),
|
||||||
|
required_modules: tuple[str, ...] = ("campaigns",),
|
||||||
|
required_capabilities: tuple[str, ...] = (),
|
||||||
|
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||||
|
related_modules: tuple[str, ...] = (),
|
||||||
|
limitations: tuple[str, ...] = (),
|
||||||
|
) -> DocumentationTopic:
|
||||||
|
metadata: dict[str, object] = {
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": route,
|
||||||
|
"screen": screen,
|
||||||
|
"help_contexts": list(help_contexts),
|
||||||
|
"prerequisites": list(prerequisites),
|
||||||
|
"steps": list(steps),
|
||||||
|
"outcome": outcome,
|
||||||
|
"verification": verification,
|
||||||
|
"related_topic_ids": list(related_topic_ids),
|
||||||
|
}
|
||||||
|
if limitations:
|
||||||
|
metadata["limitations"] = list(limitations)
|
||||||
|
return DocumentationTopic(
|
||||||
|
id=topic_id,
|
||||||
|
title=title,
|
||||||
|
summary=summary,
|
||||||
|
body=body,
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=audience,
|
||||||
|
order=order,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=required_modules,
|
||||||
|
required_capabilities=required_capabilities,
|
||||||
|
required_scopes=required_scopes,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=links,
|
||||||
|
related_modules=related_modules,
|
||||||
|
unlocks=(outcome,),
|
||||||
|
source_module_id="campaigns",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAMPAIGN_USER_DOCUMENTATION = (
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.create-campaign",
|
||||||
|
title="Create a campaign",
|
||||||
|
summary="Start a governed campaign as an editable draft and complete its purpose and ownership before adding delivery data.",
|
||||||
|
body="A new campaign starts with one editable working version. Creating it does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
|
||||||
|
order=30,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
||||||
|
route="/campaigns",
|
||||||
|
screen="Campaigns",
|
||||||
|
help_contexts=("campaigns.list",),
|
||||||
|
prerequisites=("You may create campaigns in the active tenant.",),
|
||||||
|
steps=(
|
||||||
|
"Open Campaigns and select New campaign.",
|
||||||
|
"Use the creation wizard to enter a clear name, identifier, and purpose.",
|
||||||
|
"Open the new campaign and confirm its owner before adding recipient or delivery data.",
|
||||||
|
"Continue through the preparation sections and save the editable working version.",
|
||||||
|
),
|
||||||
|
outcome="An owned campaign draft with an editable working version.",
|
||||||
|
verification="The Campaign overview shows the new campaign as a draft and identifies its current working version.",
|
||||||
|
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.import-recipients"),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.create-editable-successor",
|
||||||
|
title="Create an editable successor",
|
||||||
|
summary="Continue work after a permanent or delivery-final lock without rewriting the preserved version.",
|
||||||
|
body="Create editable copy means creating the campaign's next working version. Validation locks and temporary user locks are removed in place instead; they must not create parallel drafts.",
|
||||||
|
order=31,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"),
|
||||||
|
route="/campaigns/{campaign_id}",
|
||||||
|
screen="Campaign workspace",
|
||||||
|
help_contexts=("campaign.overview", "campaign.audit"),
|
||||||
|
prerequisites=(
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
"Its current version is permanently user-locked or delivery-final.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open the locked current version and review the reason it is read-only.",
|
||||||
|
"Select Create editable copy in the locked-version notice.",
|
||||||
|
"If the notice instead offers an unlock action, unlock that validation or temporary user lock rather than creating a successor.",
|
||||||
|
"Review the new working version, reselect any Mail profile when requested, and validate and build again before delivery.",
|
||||||
|
),
|
||||||
|
outcome="A new editable working version while the source version and its evidence remain unchanged.",
|
||||||
|
verification="The Campaign overview identifies a new current version number and the earlier version remains in history.",
|
||||||
|
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.mail-profile-user-journey"),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.import-recipients",
|
||||||
|
title="Import recipients into a campaign",
|
||||||
|
summary="Turn a text, CSV, or spreadsheet table into reviewed campaign-local recipient rows with source provenance.",
|
||||||
|
body="Import copies valid rows into the editable campaign version. Invalid rows stay visible during preview instead of disappearing, and later changes to the source file do not silently change the saved campaign.",
|
||||||
|
order=33,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/recipients",
|
||||||
|
screen="Recipient data",
|
||||||
|
help_contexts=("campaign.recipients", "campaign.recipient-data"),
|
||||||
|
prerequisites=(
|
||||||
|
"The current campaign version is editable and you have write access to it.",
|
||||||
|
"The source is tabular and contains only data needed for this campaign.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Recipient data and select Import.",
|
||||||
|
"Upload or paste the table, then confirm its encoding, sheet, header rows, and separator where applicable.",
|
||||||
|
"Map address and campaign fields, choose append or replace, and review every invalid or excluded row.",
|
||||||
|
"Select Import valid rows, inspect the resulting recipient table, and save the campaign page.",
|
||||||
|
),
|
||||||
|
outcome="A campaign-local recipient snapshot with mapping and source evidence.",
|
||||||
|
verification="Reopen Recipient data, confirm the expected row count and addressing, then validate before building messages.",
|
||||||
|
related_topic_ids=("campaigns.workflow.import-address-source", "campaigns.workflow.prepare-validate-and-build"),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.import-address-source",
|
||||||
|
title="Import a recipient source from Addresses",
|
||||||
|
summary="Copy a permitted reusable address book or list into the campaign as a traceable versioned snapshot.",
|
||||||
|
body="Campaign does not follow the address source live. It records the selected source revision and warns when a newer source revision is available, so re-import is always an explicit author action.",
|
||||||
|
order=32,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_modules=("campaigns", "addresses"),
|
||||||
|
required_capabilities=(_ADDRESSES_SOURCE_INTEGRATION,),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/recipients",
|
||||||
|
screen="Recipient data",
|
||||||
|
help_contexts=("campaign.recipients", "campaign.recipient-data"),
|
||||||
|
prerequisites=(
|
||||||
|
"Addresses offers at least one source visible to you.",
|
||||||
|
"The current campaign version is editable and you have write access to it.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Recipient data and select Import address book/list.",
|
||||||
|
"Choose the permitted source and review the returned snapshot and its revision.",
|
||||||
|
"Choose append or replace, import the snapshot, inspect the recipient rows, and save.",
|
||||||
|
"When a stale-source warning appears later, compare the source and re-import deliberately if the campaign should use the new revision.",
|
||||||
|
),
|
||||||
|
outcome="A campaign-local recipient snapshot whose Addresses source and revision can be traced.",
|
||||||
|
verification="Recipient data records the source provenance, and a later source revision does not alter the saved rows until you re-import it.",
|
||||||
|
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
|
||||||
|
related_modules=("addresses",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.use-managed-attachments",
|
||||||
|
title="Use managed files as campaign attachments",
|
||||||
|
summary="Select governed file versions, preview rule matches, and preserve exactly which files were used for the campaign build.",
|
||||||
|
body="Managed attachments remain owned by Files. Campaign stores governed references and frozen build evidence; it does not copy Files administration authority or accept arbitrary server paths.",
|
||||||
|
order=34,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_modules=("campaigns", "files"),
|
||||||
|
required_capabilities=(_FILES_INTEGRATION,),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:share",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/files",
|
||||||
|
screen="Attachments",
|
||||||
|
help_contexts=("campaign.attachments",),
|
||||||
|
prerequisites=(
|
||||||
|
"The current campaign version is editable and you have write access to it.",
|
||||||
|
"The required file versions exist in Files and may be shared with this campaign.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Attachments and choose managed files or patterns from Files.",
|
||||||
|
"Define campaign-wide and recipient-specific rules, including unmatched-file and archive behavior.",
|
||||||
|
"Preview the matches and link the exact managed versions to the campaign.",
|
||||||
|
"Save, validate, and build, then inspect the resolved attachment evidence in Review and send.",
|
||||||
|
),
|
||||||
|
outcome="Governed file versions linked to the campaign and frozen into the exact build evidence.",
|
||||||
|
verification="Confirm the expected filenames, paths, matches, and link state in the Campaign UI. Exact managed versions and checksums remain retained in build evidence for authorized supporting tools.",
|
||||||
|
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
|
DocumentationLink(label="Files", href="/files", kind="runtime"),
|
||||||
|
),
|
||||||
|
related_modules=("files",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.queue-delivery",
|
||||||
|
title="Queue a campaign for controlled delivery",
|
||||||
|
summary="Place an exact reviewed build into the worker queue while preserving recipient-level state and retry protection.",
|
||||||
|
body="Queueing is a controlled state change, not proof of delivery. Ordinary batches should use background workers, and accepted or outcome-unknown effects remain protected from blind retry.",
|
||||||
|
order=35,
|
||||||
|
audience=("campaign_sender", "campaign_operator"),
|
||||||
|
required_modules=("campaigns", "mail"),
|
||||||
|
required_capabilities=(_MAIL_INTEGRATION,),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:queue",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/review",
|
||||||
|
screen="Review and send",
|
||||||
|
help_contexts=(),
|
||||||
|
prerequisites=(
|
||||||
|
"The selected version is validated, locked, built, and reviewed.",
|
||||||
|
"Its Mail profile remains available and authorized for the current campaign context.",
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Review and send and confirm the selected version, build, recipients, warnings, and review completion.",
|
||||||
|
"Use an authorized supporting client to run the queue dry run and resolve every blocker.",
|
||||||
|
"Invoke the Queue action through that client so background workers can process the build.",
|
||||||
|
"Open the campaign Report and monitor queue, SMTP, IMAP, failure, and uncertain-outcome counts.",
|
||||||
|
),
|
||||||
|
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
|
||||||
|
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
|
||||||
|
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
|
||||||
|
related_modules=("mail", "notifications"),
|
||||||
|
limitations=("The current Campaign Web UI does not expose the Queue action; use an authorized supporting client or API.",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.send-small-controlled-run",
|
||||||
|
title="Send a campaign immediately",
|
||||||
|
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
|
||||||
|
body="The current synchronous action does not enforce a server-side job-count limit. It must not replace the worker queue for ordinary batches, but it does preserve the same protected SMTP, IMAP, retry, and uncertain-outcome rules.",
|
||||||
|
order=36,
|
||||||
|
audience=("campaign_sender", "campaign_operator"),
|
||||||
|
required_modules=("campaigns", "mail"),
|
||||||
|
required_capabilities=(_MAIL_INTEGRATION,),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:send",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/review",
|
||||||
|
screen="Review and send",
|
||||||
|
help_contexts=("campaign.review-send",),
|
||||||
|
prerequisites=(
|
||||||
|
"The selected version is validated, locked, built, and reviewed.",
|
||||||
|
"An operator has deliberately confirmed that this is a small controlled run; the server does not currently enforce that bound.",
|
||||||
|
"Its Mail profile remains available and authorized for the current campaign context.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Review and send and inspect the exact build and delivery readiness checks.",
|
||||||
|
"Confirm that this is an intentionally small controlled run; use Queue for an ordinary batch.",
|
||||||
|
"Select Send now and confirm the protected delivery action.",
|
||||||
|
"Open Report and inspect each resulting delivery state before attempting any recovery action.",
|
||||||
|
),
|
||||||
|
outcome="A synchronous real delivery run with recipient-level attempt and outcome evidence.",
|
||||||
|
verification="The Report distinguishes accepted, failed, unattempted, cancelled, and outcome-unknown jobs and retains their attempt history.",
|
||||||
|
related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"),
|
||||||
|
related_modules=("mail",),
|
||||||
|
limitations=("No server-side synchronous job-count limit is currently enforced; use Queue and workers for ordinary batches.",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.view-delivery-report",
|
||||||
|
title="Review campaign delivery details",
|
||||||
|
summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.",
|
||||||
|
body="The current Report UI requires recipient-read authority in addition to report access. Infrastructure diagnostics remain separately authorized, and every report action is still checked by the server even where the current UI has not yet hidden an unavailable control.",
|
||||||
|
order=37,
|
||||||
|
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
|
||||||
|
route="/campaigns/{campaign_id}/report",
|
||||||
|
screen="Campaign Report",
|
||||||
|
help_contexts=("campaign.report",),
|
||||||
|
prerequisites=("You may read the selected campaign and its report.",),
|
||||||
|
steps=(
|
||||||
|
"Open the campaign and select Report.",
|
||||||
|
"Review totals for queued, accepted, failed, cancelled, unattempted, and outcome-unknown jobs.",
|
||||||
|
"Inspect the authorized recipient-level rows and attempt history.",
|
||||||
|
"Treat outcome-unknown jobs as unresolved and hand them to an authorized operator for evidence-backed reconciliation.",
|
||||||
|
),
|
||||||
|
outcome="A recipient-aware view of delivery progress and outcomes with diagnostics still separately protected.",
|
||||||
|
verification="The report totals and job rows match the selected campaign version, and infrastructure diagnostics are absent unless separately authorized.",
|
||||||
|
related_topic_ids=("campaigns.workflow.retry-and-reconcile", "campaigns.workflow.export-delivery-report"),
|
||||||
|
limitations=("An aggregate-only Report Web UI for readers without recipient-read authority is not implemented yet.",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.export-delivery-report",
|
||||||
|
title="Export recipient delivery results",
|
||||||
|
summary="Download an authorized CSV snapshot of recipient-level campaign delivery results for controlled downstream use.",
|
||||||
|
body="A report export contains personal and delivery evidence. Store, transmit, retain, and delete it according to the campaign's purpose and the applicable export and retention policy.",
|
||||||
|
order=38,
|
||||||
|
audience=("campaign_report_exporter", "campaign_auditor"),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"campaigns:report:export",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:export",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/report",
|
||||||
|
screen="Campaign Report",
|
||||||
|
help_contexts=("campaign.report", "campaign.audit"),
|
||||||
|
prerequisites=(
|
||||||
|
"You may export both campaign reports and recipient data.",
|
||||||
|
"The export has an approved purpose and destination.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open the campaign Report and select the intended version.",
|
||||||
|
"Confirm that recipient-level export is necessary for the task.",
|
||||||
|
"Select Download CSV and store the result only in the approved location.",
|
||||||
|
"Verify the selected version and row counts, then apply the required retention or deletion rule to the downloaded copy.",
|
||||||
|
),
|
||||||
|
outcome="A point-in-time CSV export of authorized campaign job results.",
|
||||||
|
verification="The downloaded file identifies the intended campaign/version and contains business evidence without credentials or ordinary-reader infrastructure details.",
|
||||||
|
related_topic_ids=("campaigns.workflow.view-delivery-report",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.share-campaign",
|
||||||
|
title="Share a campaign",
|
||||||
|
summary="Grant a user or group explicit view or write access to one campaign without widening their platform permissions.",
|
||||||
|
body="A share can only narrow access to the selected campaign within the recipient's existing role. It never grants Mail profile use, Files authority, tenant-wide recipient access, or a missing Campaign action.",
|
||||||
|
order=39,
|
||||||
|
audience=("campaign_owner", "campaign_access_manager"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:share"),
|
||||||
|
route="/campaigns/{campaign_id}/global-settings",
|
||||||
|
screen="Ownership and sharing",
|
||||||
|
help_contexts=("campaign.overview", "campaign.global-settings"),
|
||||||
|
prerequisites=(
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
"The target user or group already has an appropriate Campaign role for the intended actions.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Global settings and find Ownership and sharing.",
|
||||||
|
"Select Share, choose a user or group, and choose Can view or Can edit and operate.",
|
||||||
|
"Save the share and verify the target appears in the sharing table with the intended level.",
|
||||||
|
"Use the row action to revoke the explicit share when it is no longer needed.",
|
||||||
|
),
|
||||||
|
outcome="Explicit campaign access for the selected subject, bounded by that subject's existing permissions.",
|
||||||
|
verification="The sharing table shows the active target and level; revocation removes its explicit access while preserving the audit record.",
|
||||||
|
related_topic_ids=("campaigns.reference.composition-assurance",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.archive-campaign",
|
||||||
|
title="Archive a campaign",
|
||||||
|
summary="Remove a completed campaign from active work while preserving its versions, outcomes, and audit evidence.",
|
||||||
|
body="Archive only after queued, sending, and outcome-unknown work has been resolved. Archiving preserves evidence and is the correct lifecycle action for any campaign with build, lock, or delivery history.",
|
||||||
|
order=40,
|
||||||
|
audience=("campaign_owner", "campaign_records_manager"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:archive"),
|
||||||
|
route="/campaigns/{campaign_id}",
|
||||||
|
screen="Campaign lifecycle",
|
||||||
|
help_contexts=("campaign.overview", "campaign.report", "campaign.audit"),
|
||||||
|
prerequisites=(
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
"No delivery job is queued, sending, or awaiting uncertain-outcome reconciliation.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Report and resolve every active or outcome-unknown delivery state.",
|
||||||
|
"Confirm that the campaign should leave active work while its evidence remains retained.",
|
||||||
|
"Invoke the authorized Archive action from a supporting client.",
|
||||||
|
"Reopen or query the campaign and confirm its state is Archived.",
|
||||||
|
),
|
||||||
|
outcome="An archived campaign whose versions, reports, and audit evidence remain preserved.",
|
||||||
|
verification="The campaign state is Archived and its report remains available. Ask an authorized audit reader to verify the platform audit event.",
|
||||||
|
related_topic_ids=("campaigns.workflow.view-delivery-report", "campaigns.workflow.delete-untouched-draft"),
|
||||||
|
limitations=(
|
||||||
|
"The current Campaign Web UI does not yet expose the archive action; use an authorized supporting client or API.",
|
||||||
|
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.delete-untouched-draft",
|
||||||
|
title="Delete an untouched campaign draft",
|
||||||
|
summary="Immediately remove a draft that has no protected build, lock, publication, snapshot, or delivery evidence.",
|
||||||
|
body="Deletion is deliberately narrower than archive. It marks an eligible draft deleted and emits an audit record; it cannot erase a campaign that already carries evidence that must be retained.",
|
||||||
|
order=41,
|
||||||
|
audience=("campaign_owner", "campaign_records_manager"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:delete"),
|
||||||
|
route="/campaigns/{campaign_id}",
|
||||||
|
screen="Campaign lifecycle",
|
||||||
|
help_contexts=("campaign.overview", "campaign.audit"),
|
||||||
|
prerequisites=(
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
"The campaign is still a draft and has no jobs, locks, published version, or execution snapshot.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Confirm that the draft is not needed and contains no evidence that should be retained.",
|
||||||
|
"Invoke the authorized Delete action from a supporting client and confirm the destructive action.",
|
||||||
|
"If deletion is refused because protected evidence exists, archive the campaign after resolving any active delivery state.",
|
||||||
|
"Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.",
|
||||||
|
),
|
||||||
|
outcome="An eligible untouched draft removed from active Campaigns with attributable deletion evidence.",
|
||||||
|
verification="The draft is no longer returned as an active campaign. Ask an authorized audit reader to verify who deleted it and when through the platform audit surface.",
|
||||||
|
related_topic_ids=("campaigns.workflow.archive-campaign",),
|
||||||
|
limitations=(
|
||||||
|
"The current Campaign Web UI does not yet expose the delete action; use an authorized supporting client or API.",
|
||||||
|
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||||
|
"""Describe the current Campaign composition without resolving module data.
|
||||||
|
|
||||||
|
The provider deliberately uses only the actor's permission evaluator and the
|
||||||
|
registry's declared contracts. Resource access, policy decisions, and
|
||||||
|
campaign state remain authoritative at the point where an action is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if context.documentation_type != "user":
|
||||||
|
return ()
|
||||||
|
principal = context.principal
|
||||||
|
if principal is None or not _has_any_scope(principal, _CAMPAIGN_USER_SCOPES):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
mail_available = _integration_available(context.registry, _MAIL_INTEGRATION)
|
||||||
|
current_configuration = list(_actor_capabilities(principal, mail_available=mail_available))
|
||||||
|
integration_configuration, integration_limitations = _integration_summary(context.registry, principal)
|
||||||
|
current_configuration.extend(integration_configuration)
|
||||||
|
limitations = [
|
||||||
|
"Access to a particular campaign still depends on its owner or sharing rules and on the campaign's current state.",
|
||||||
|
"Profile, file, and address pickers re-evaluate the actual resources visible in the selected campaign; this overview does not claim that an eligible item currently exists.",
|
||||||
|
*integration_limitations,
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.current-composition",
|
||||||
|
title="Your Campaign role and installed composition",
|
||||||
|
summary="This guide separates actions authorized by your role from optional services connected to Campaign in this installation.",
|
||||||
|
body=_composition_body(current_configuration, limitations),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=("campaign_user",),
|
||||||
|
order=29,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
any_scopes=_CAMPAIGN_USER_SCOPES,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||||
|
related_modules=("mail", "files", "addresses", "notifications"),
|
||||||
|
source_module_id="campaigns",
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"route": "/campaigns",
|
||||||
|
"screen": "Campaigns",
|
||||||
|
"help_contexts": list(_CAMPAIGN_HELP_CONTEXTS),
|
||||||
|
"current_configuration": current_configuration,
|
||||||
|
"limitations": limitations,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str, ...]:
|
||||||
|
capabilities: list[str] = []
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:read",), "Open campaigns shared with you and inspect their business state.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:update",), "Edit eligible working campaign versions.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:copy",), "Create an editable successor from an eligible existing version.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:recipient:read",), "Inspect recipients and recipient-specific campaign data.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:recipient:write",), "Add and edit recipient rows.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:recipient:import",), "Import recipient snapshots.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
|
||||||
|
if mail_available:
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:control",), "Pause, resume, or cancel eligible delivery jobs.")
|
||||||
|
if mail_available:
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:send",), "Start an eligible real delivery run.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:retry",), "Retry delivery jobs whose recorded state permits a retry.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:reconcile",), "Reconcile delivery effects whose outcome is uncertain.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:report:read",), "View authorized campaign delivery reports.")
|
||||||
|
_append_if(
|
||||||
|
capabilities,
|
||||||
|
principal,
|
||||||
|
("campaigns:report:export", "campaigns:recipient:export"),
|
||||||
|
"Export authorized delivery and recipient report data.",
|
||||||
|
require_all=True,
|
||||||
|
)
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:share",), "Manage explicit access to eligible campaigns.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:archive",), "Archive eligible campaigns while retaining their evidence.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:delete",), "Delete eligible untouched drafts.")
|
||||||
|
if mail_available:
|
||||||
|
_append_if(
|
||||||
|
capabilities,
|
||||||
|
principal,
|
||||||
|
("campaigns:report:send", "campaigns:report:read", "mail:profile:use"),
|
||||||
|
"Send an authorized campaign report through a Mail profile.",
|
||||||
|
require_all=True,
|
||||||
|
)
|
||||||
|
return tuple(capabilities)
|
||||||
|
|
||||||
|
|
||||||
|
def _integration_summary(registry: object, principal: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||||
|
configured: list[str] = []
|
||||||
|
limitations: list[str] = []
|
||||||
|
|
||||||
|
mail_available = _integration_available(registry, _MAIL_INTEGRATION)
|
||||||
|
can_select_mail_profile = _has_all_scopes(
|
||||||
|
principal,
|
||||||
|
("campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"),
|
||||||
|
)
|
||||||
|
can_deliver_with_mail = _has_scope(principal, "mail:profile:use") and _has_any_scope(
|
||||||
|
principal,
|
||||||
|
("campaigns:campaign:queue", "campaigns:campaign:send", "campaigns:campaign:retry"),
|
||||||
|
)
|
||||||
|
if mail_available and can_select_mail_profile:
|
||||||
|
configured.append("Installed composition: Campaign can open Mail's actor-filtered profile picker while you edit; eligible profiles are resolved in the selected campaign context.")
|
||||||
|
elif mail_available and can_deliver_with_mail:
|
||||||
|
configured.append("Installed composition: Mail-backed Campaign delivery is connected, and the selected profile is re-authorized for each delivery action.")
|
||||||
|
elif mail_available:
|
||||||
|
limitations.append("Mail delivery is configured, but selecting a Mail profile is not included in your current tasks.")
|
||||||
|
else:
|
||||||
|
limitations.append("Mail-backed Campaign delivery is not available in the current composition.")
|
||||||
|
|
||||||
|
files_available = _integration_available(registry, _FILES_INTEGRATION)
|
||||||
|
can_preview_files = _has_all_scopes(
|
||||||
|
principal,
|
||||||
|
("campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"),
|
||||||
|
)
|
||||||
|
can_link_files = _has_all_scopes(
|
||||||
|
principal,
|
||||||
|
(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:share",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if files_available and can_link_files:
|
||||||
|
configured.append("Installed composition: Managed file versions can be selected, previewed, and linked as governed campaign attachments when the selected campaign and files pass their resource checks.")
|
||||||
|
elif files_available and can_preview_files:
|
||||||
|
configured.append("Installed composition: Managed campaign attachments can be previewed; linking a version requires campaign-update, validation, and attachment-sharing authority.")
|
||||||
|
elif files_available:
|
||||||
|
limitations.append("Managed attachments are configured, but they are not included in your current Campaign tasks.")
|
||||||
|
else:
|
||||||
|
limitations.append("Managed file attachments are not available in the current composition; campaign-owned uploads remain separate.")
|
||||||
|
|
||||||
|
lookup_available = _integration_available(registry, _ADDRESSES_LOOKUP_INTEGRATION)
|
||||||
|
source_available = _integration_available(registry, _ADDRESSES_SOURCE_INTEGRATION)
|
||||||
|
if lookup_available and _has_scope(principal, "campaigns:recipient:read"):
|
||||||
|
configured.append("Installed composition: Address records can be looked up while preparing recipients.")
|
||||||
|
elif lookup_available:
|
||||||
|
limitations.append("Address lookup is configured, but recipient inspection is not included in your current Campaign tasks.")
|
||||||
|
else:
|
||||||
|
limitations.append("Connected address lookup is not available in the current composition.")
|
||||||
|
can_import_source = _has_all_scopes(
|
||||||
|
principal,
|
||||||
|
(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if source_available and can_import_source:
|
||||||
|
configured.append("Installed composition: The actor-filtered Addresses source picker can copy a selected source into a campaign as a traceable recipient snapshot.")
|
||||||
|
elif source_available:
|
||||||
|
limitations.append("Connected recipient sources are configured, but source import is not included in your current Campaign tasks.")
|
||||||
|
else:
|
||||||
|
limitations.append("Connected recipient-source snapshots are not available; direct campaign imports remain available when authorized.")
|
||||||
|
|
||||||
|
if _integration_available(registry, _NOTIFICATIONS_INTEGRATION):
|
||||||
|
configured.append("Installed composition: In-app notifications can report important Campaign delivery status changes.")
|
||||||
|
else:
|
||||||
|
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
||||||
|
|
||||||
|
return tuple(configured), tuple(limitations)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_if(
|
||||||
|
target: list[str],
|
||||||
|
principal: object,
|
||||||
|
scopes: tuple[str, ...],
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
require_all: bool = False,
|
||||||
|
) -> None:
|
||||||
|
allowed = _has_all_scopes(principal, scopes) if require_all else _has_any_scope(principal, scopes)
|
||||||
|
if allowed:
|
||||||
|
target.append(f"Role authorization: {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_scope(principal: object, scope: str) -> bool:
|
||||||
|
checker = getattr(principal, "has", None)
|
||||||
|
if not callable(checker):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(checker(scope))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_any_scope(principal: object, scopes: tuple[str, ...]) -> bool:
|
||||||
|
return any(_has_scope(principal, scope) for scope in scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_all_scopes(principal: object, scopes: tuple[str, ...]) -> bool:
|
||||||
|
return all(_has_scope(principal, scope) for scope in scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _integration_available(registry: object, interface_name: str) -> bool:
|
||||||
|
return _registry_has_interface(registry, interface_name) and _registry_has_capability(registry, interface_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_has_interface(registry: object, interface_name: str) -> bool:
|
||||||
|
manifests_provider = getattr(registry, "manifests", None)
|
||||||
|
if not callable(manifests_provider):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
manifests = tuple(manifests_provider())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
getattr(provider, "name", None) == interface_name
|
||||||
|
for manifest in manifests
|
||||||
|
for provider in (getattr(manifest, "provides_interfaces", ()) or ())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_has_capability(registry: object, capability_name: str) -> bool:
|
||||||
|
capability_checker = getattr(registry, "has_capability", None)
|
||||||
|
if not callable(capability_checker):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(capability_checker(capability_name))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _composition_body(current_configuration: list[str], limitations: list[str]) -> str:
|
||||||
|
del current_configuration, limitations
|
||||||
|
return "The facts below are calculated for the current actor and installed module contracts. They do not bypass campaign ownership, sharing, state, or operation-time resource and policy checks."
|
||||||
@@ -34,9 +34,16 @@ class ImapConfigurationError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class ImapAppendError(RuntimeError):
|
class ImapAppendError(RuntimeError):
|
||||||
def __init__(self, message: str, *, temporary: bool | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
temporary: bool | None = None,
|
||||||
|
outcome_unknown: bool = False,
|
||||||
|
) -> None:
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.temporary = temporary
|
self.temporary = temporary
|
||||||
|
self.outcome_unknown = outcome_unknown
|
||||||
|
|
||||||
|
|
||||||
class MailProfileError(OptionalModuleUnavailable):
|
class MailProfileError(OptionalModuleUnavailable):
|
||||||
@@ -139,17 +146,6 @@ class MailCampaignIntegration:
|
|||||||
raise MailProfileError("Mail module is not available")
|
raise MailProfileError("Mail module is not available")
|
||||||
return self._delegate
|
return self._delegate
|
||||||
|
|
||||||
def materialize_campaign_mail_profile_config(self, session: Any, **kwargs: Any) -> dict[str, Any]:
|
|
||||||
if self._delegate is None:
|
|
||||||
raw_json = kwargs.get("raw_json")
|
|
||||||
if self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {}):
|
|
||||||
raise MailProfileError("Campaign mail-server profiles require the mail module")
|
|
||||||
return dict(raw_json) if isinstance(raw_json, dict) else {}
|
|
||||||
try:
|
|
||||||
return self._delegate.materialize_campaign_mail_profile_config(session, **kwargs)
|
|
||||||
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
|
|
||||||
raise MailProfileError(str(exc)) from exc
|
|
||||||
|
|
||||||
def assert_campaign_mail_policy_allows_json(self, session: Any, **kwargs: Any) -> None:
|
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")
|
||||||
@@ -162,72 +158,50 @@ class MailCampaignIntegration:
|
|||||||
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 assert_mail_policy_allows_send(self, session: Any, **kwargs: Any) -> None:
|
|
||||||
delegate = self._require()
|
|
||||||
try:
|
|
||||||
return delegate.assert_mail_policy_allows_send(session, **kwargs)
|
|
||||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
|
||||||
raise MailProfileError(str(exc)) from exc
|
|
||||||
|
|
||||||
def mail_profile_id_from_campaign_json(self, raw_json: dict[str, Any]) -> str | None:
|
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
|
||||||
if profile_id is None and isinstance(server, dict):
|
|
||||||
profile_id = server.get("profile_id")
|
|
||||||
return str(profile_id).strip() if profile_id else None
|
return str(profile_id).strip() if profile_id else None
|
||||||
|
|
||||||
def ensure_mail_profile_allowed_for_campaign(self, session: Any, **kwargs: Any) -> Any:
|
def campaign_profile_delivery_summary(self, session: Any, **kwargs: Any) -> dict[str, Any]:
|
||||||
delegate = self._require()
|
delegate = self._require()
|
||||||
try:
|
try:
|
||||||
return delegate.ensure_mail_profile_allowed_for_campaign(session, **kwargs)
|
return delegate.campaign_profile_delivery_summary(session, **kwargs)
|
||||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
raise MailProfileError(str(exc)) from exc
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
def smtp_config_from_profile(self, profile: Any) -> Any:
|
|
||||||
return self._require().smtp_config_from_profile(profile)
|
|
||||||
|
|
||||||
def imap_config_from_profile(self, profile: Any) -> Any:
|
|
||||||
return self._require().imap_config_from_profile(profile)
|
|
||||||
|
|
||||||
def effective_profile_credentials_inherited(self, session: Any, **kwargs: Any) -> bool:
|
|
||||||
return self._require().effective_profile_credentials_inherited(session, **kwargs)
|
|
||||||
|
|
||||||
def apply_campaign_credentials(self, profile_payload: dict[str, Any], server: dict[str, Any], protocol: str) -> dict[str, Any]:
|
|
||||||
return self._require().apply_campaign_credentials(profile_payload, server, protocol)
|
|
||||||
|
|
||||||
def wait_for_rate_limit(self, **kwargs: Any) -> None:
|
def wait_for_rate_limit(self, **kwargs: Any) -> None:
|
||||||
if self._delegate is None:
|
if self._delegate is None:
|
||||||
return None
|
return None
|
||||||
return self._delegate.wait_for_rate_limit(**kwargs)
|
return self._delegate.wait_for_rate_limit(**kwargs)
|
||||||
|
|
||||||
def send_email_bytes(self, *args: Any, **kwargs: Any) -> Any:
|
def send_campaign_email_bytes(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
delegate = self._require()
|
delegate = self._require()
|
||||||
try:
|
try:
|
||||||
return delegate.send_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(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:
|
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:
|
||||||
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
def append_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
|
def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
delegate = self._require()
|
delegate = self._require()
|
||||||
try:
|
try:
|
||||||
return delegate.append_message_to_sent(*args, **kwargs)
|
return delegate.append_campaign_message_to_sent(*args, **kwargs)
|
||||||
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
|
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
|
||||||
raise ImapAppendError(str(exc), temporary=getattr(exc, "temporary", None)) from exc
|
raise ImapAppendError(
|
||||||
|
str(exc),
|
||||||
|
temporary=getattr(exc, "temporary", None),
|
||||||
|
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||||
|
) from exc
|
||||||
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
|
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:
|
||||||
def send_email_message(self, *args: Any, **kwargs: Any) -> Any:
|
raise MailProfileError(str(exc)) from exc
|
||||||
delegate = self._require()
|
|
||||||
try:
|
|
||||||
return delegate.send_email_message(*args, **kwargs)
|
|
||||||
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
|
|
||||||
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
|
|
||||||
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
|
|
||||||
raise SmtpConfigurationError(str(exc)) from exc
|
|
||||||
|
|
||||||
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"):
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ from govoplan_core.core.campaigns import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
@@ -25,6 +28,7 @@ from govoplan_core.core.modules import (
|
|||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
|
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
|
||||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||||
|
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
|
||||||
|
|
||||||
register_campaign_change_tracking()
|
register_campaign_change_tracking()
|
||||||
|
|
||||||
@@ -53,13 +57,13 @@ PERMISSIONS = (
|
|||||||
_permission("campaigns:campaign:share", "Share campaigns", "Grant or revoke explicit campaign access.", "Campaigns"),
|
_permission("campaigns:campaign:share", "Share campaigns", "Grant or revoke explicit campaign access.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"),
|
_permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"),
|
_permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:review", "Approve campaign review", "Approve or reject built messages and review conditions.", "Campaigns"),
|
_permission("campaigns:campaign:review", "Complete campaign review", "Record review completion and the exact built messages inspected.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
|
_permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
|
_permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"),
|
_permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
|
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
|
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"),
|
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP or IMAP attempts after inspection.", "Campaigns"),
|
||||||
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
|
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
|
||||||
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
|
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
|
||||||
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
|
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
|
||||||
@@ -91,7 +95,7 @@ ROLE_TEMPLATES = (
|
|||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="campaign_reviewer",
|
slug="campaign_reviewer",
|
||||||
name="Campaign reviewer",
|
name="Campaign reviewer",
|
||||||
description="Inspect and approve prepared campaign messages.",
|
description="Inspect prepared campaign messages and record review completion.",
|
||||||
permissions=(
|
permissions=(
|
||||||
"campaigns:campaign:read",
|
"campaigns:campaign:read",
|
||||||
"campaigns:campaign:validate",
|
"campaigns:campaign:validate",
|
||||||
@@ -144,7 +148,7 @@ def _campaigns_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.8",
|
version="0.1.9",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("files", "mail", "notifications", "addresses"),
|
optional_dependencies=("files", "mail", "notifications", "addresses"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
@@ -163,8 +167,8 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name="mail.campaign_delivery",
|
name="mail.campaign_delivery",
|
||||||
version_min="0.1.0",
|
version_min="0.2.0",
|
||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.3.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -258,6 +262,307 @@ manifest = ModuleManifest(
|
|||||||
label="Campaigns",
|
label="Campaigns",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
documentation=(
|
||||||
|
*CAMPAIGN_USER_DOCUMENTATION,
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.mail-profile-user-journey",
|
||||||
|
title="Choose a Mail profile for campaign delivery",
|
||||||
|
summary="Campaigns reference an authorized Mail profile and never store SMTP/IMAP settings or credentials.",
|
||||||
|
body="Open the campaign Mail settings, select an available profile, test it through Mail, and save. Validation and delivery recheck profile authorization. A changed transport identity requires a new validation and build.",
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||||
|
order=46,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns", "mail"),
|
||||||
|
required_scopes=("campaigns:campaign:update", "mail:profile:use"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
|
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
|
||||||
|
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("mail",),
|
||||||
|
unlocks=("Profile-backed SMTP delivery and optional IMAP append-to-Sent.",),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/campaigns/{campaign_id}/mail-settings",
|
||||||
|
"screen": "Campaign Mail settings",
|
||||||
|
"help_contexts": ["campaign.server-settings"],
|
||||||
|
"prerequisites": [
|
||||||
|
"Campaign and Mail are installed.",
|
||||||
|
"You may edit the current campaign version and use at least one authorized Mail profile.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Open the campaign and go to Mail settings.",
|
||||||
|
"Select an available Mail profile; Campaign stores only its stable identifier.",
|
||||||
|
"Test SMTP and, when configured, IMAP through the Mail module.",
|
||||||
|
"Save, validate, and build the campaign before queueing delivery.",
|
||||||
|
],
|
||||||
|
"outcome": "The editable campaign version references an authorized Mail-owned delivery profile without copying transport settings or credentials.",
|
||||||
|
"verification": "Reopen Mail settings, confirm the selected profile, then run validation and verify that the build completes without profile-drift errors.",
|
||||||
|
"related_topic_ids": [
|
||||||
|
"campaigns.mail-profile-governance",
|
||||||
|
"campaigns.mail-profile-operations",
|
||||||
|
"mail.profile-ownership-and-consumers",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.mail-profile-governance",
|
||||||
|
title="Govern Campaign-to-Mail profile references",
|
||||||
|
summary="Mail owns transport definitions and encrypted credentials; Campaign owns only the selected profile reference and delivery evidence.",
|
||||||
|
body="Grant mail:profile:use to campaign authors, constrain profile availability through Mail policy, and keep effective credential inheritance enabled. Inline transport fields are rejected. Legacy records remain unchanged until an explicit, audited profile migration creates or updates an editable version.",
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("tenant_admin", "mail_admin", "campaign_admin"),
|
||||||
|
order=47,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns", "mail"),
|
||||||
|
any_scopes=("mail:profile:write", "admin:policies:read", "system:settings:read"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
|
||||||
|
DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"),
|
||||||
|
DocumentationLink(label="Mail profile boundary", href="govoplan-campaign/docs/MAIL_PROFILE_BOUNDARY.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("mail", "access"),
|
||||||
|
unlocks=("Auditable, reusable transport configuration across campaigns.",),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"route": "/campaigns/{campaign_id}/mail-policy",
|
||||||
|
"screen": "Campaign Mail policy",
|
||||||
|
"section": "Profile authorization and credential inheritance",
|
||||||
|
"related_topic_ids": [
|
||||||
|
"campaigns.mail-profile-user-journey",
|
||||||
|
"campaigns.mail-profile-operations",
|
||||||
|
"mail.profile-ownership-and-consumers",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.mail-profile-operations",
|
||||||
|
title="Operate profile-backed campaign delivery",
|
||||||
|
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
|
||||||
|
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation.",
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
||||||
|
order=48,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns", "mail"),
|
||||||
|
any_scopes=("campaigns:diagnostic:read", "campaigns:campaign:reconcile", "mail:profile:test"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
|
||||||
|
DocumentationLink(label="Campaign reports", href="/reports", kind="runtime"),
|
||||||
|
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("mail", "audit"),
|
||||||
|
unlocks=("Fail-closed recovery without exposing Mail credentials.",),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"route": "/operator",
|
||||||
|
"screen": "Campaign operator queue",
|
||||||
|
"section": "Profile-backed delivery recovery",
|
||||||
|
"related_topic_ids": [
|
||||||
|
"campaigns.mail-profile-user-journey",
|
||||||
|
"campaigns.mail-profile-governance",
|
||||||
|
"mail.profile-ownership-and-consumers",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.workflow.prepare-validate-and-build",
|
||||||
|
title="Prepare, validate, and build a campaign",
|
||||||
|
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
|
||||||
|
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build.",
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
order=49,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
required_scopes=("campaigns:campaign:update", "campaigns:campaign:validate", "campaigns:campaign:build"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
|
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
|
||||||
|
DocumentationLink(label="Recipient import guide", href="govoplan-campaign/docs/RECIPIENT_IMPORT_GUIDE.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("addresses", "files", "mail"),
|
||||||
|
unlocks=("A reviewable build whose exact recipient-specific effects can be inspected before delivery.",),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/campaigns/{campaign_id}/global-settings",
|
||||||
|
"screen": "Campaign workspace",
|
||||||
|
"help_contexts": [
|
||||||
|
"campaign.overview",
|
||||||
|
"campaign.settings",
|
||||||
|
"campaign.fields",
|
||||||
|
"campaign.template",
|
||||||
|
"campaign.attachments",
|
||||||
|
"campaign.recipients",
|
||||||
|
"campaign.recipient-data",
|
||||||
|
"campaign.server-settings",
|
||||||
|
"campaign.global-settings",
|
||||||
|
"campaign.review-send",
|
||||||
|
"campaign.json",
|
||||||
|
],
|
||||||
|
"prerequisites": [
|
||||||
|
"The campaign has an owner and a clear communication purpose.",
|
||||||
|
"You may edit, validate, and build the selected campaign version.",
|
||||||
|
"Optional source modules needed by this campaign are installed and authorized.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Set campaign-wide fields and purpose, then define the recipient fields and templates.",
|
||||||
|
"Import or select recipients and inspect provenance, exclusions, and review-required rows.",
|
||||||
|
"Select managed attachment versions and an authorized Mail profile when those capabilities are used.",
|
||||||
|
"Validate the relevant sections and resolve every blocker without hiding warnings.",
|
||||||
|
"Build the selected version and inspect representative and exceptional rendered messages.",
|
||||||
|
],
|
||||||
|
"outcome": "The selected version has exact built messages and frozen source evidence ready for an independent review.",
|
||||||
|
"verification": "Open Review, confirm the build belongs to the intended version, and inspect counts, warnings, recipients, addressing, rendered content, and attachment evidence.",
|
||||||
|
"related_topic_ids": [
|
||||||
|
"campaigns.workflow.complete-review",
|
||||||
|
"campaigns.mail-profile-user-journey",
|
||||||
|
"files.workflow.import-managed-snapshot",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.workflow.complete-review",
|
||||||
|
title="Inspect built messages and complete review",
|
||||||
|
summary="Review the exact immutable candidate and record which built messages were inspected before delivery is enabled.",
|
||||||
|
body="Review completion records the inspected message keys for the selected build. The current baseline does not persist a separate approve/reject decision or review reason, so do not present completion as a richer decision record. Any material input or non-secret transport-identity change requires validation and a new build.",
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=("campaign_reviewer",),
|
||||||
|
order=50,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:review"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
|
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("files", "mail"),
|
||||||
|
unlocks=("An attributable review-completion record for the exact built messages inspected.",),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/campaigns/{campaign_id}/review",
|
||||||
|
"screen": "Review and send",
|
||||||
|
"help_contexts": ["campaign.review-send"],
|
||||||
|
"prerequisites": [
|
||||||
|
"The selected campaign version is validated and built.",
|
||||||
|
"You may read the campaign and complete its review.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Confirm the campaign, owner, selected version, recipient count, warnings, and exclusions.",
|
||||||
|
"Inspect representative and exceptional messages, addressing, templates, and attachment evidence.",
|
||||||
|
"Confirm that the selected Mail profile is suitable and authorized for the current context.",
|
||||||
|
"Record review completion for the exact message keys inspected.",
|
||||||
|
],
|
||||||
|
"outcome": "The reviewed build is eligible for a separately authorized queue or send action.",
|
||||||
|
"verification": "Reload Review and confirm completion is tied to the same version and message build; changed inputs must invalidate or supersede it.",
|
||||||
|
"related_topic_ids": [
|
||||||
|
"campaigns.workflow.prepare-validate-and-build",
|
||||||
|
"campaigns.workflow.retry-and-reconcile",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.workflow.retry-and-reconcile",
|
||||||
|
title="Retry only known failures and reconcile uncertain effects",
|
||||||
|
summary="Keep safe-to-retry failures separate from SMTP or IMAP effects whose outcome is unknown.",
|
||||||
|
body="A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown SMTP or IMAP effect. Inspect external evidence, reconcile SMTP as accepted or not sent, and reconcile IMAP as appended or not appended; repairing Sent never resends accepted SMTP mail.",
|
||||||
|
layer="evidence",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("campaign_sender", "campaign_operator"),
|
||||||
|
order=51,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
any_scopes=("campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:diagnostic:read"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
|
||||||
|
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("mail", "audit"),
|
||||||
|
unlocks=("Evidence-backed recovery without accidental duplicate external effects.",),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/operator",
|
||||||
|
"screen": "Campaign operator queue",
|
||||||
|
"help_contexts": ["campaign.review-send", "campaign.report", "campaign.audit"],
|
||||||
|
"prerequisites": [
|
||||||
|
"You may perform the selected retry or reconciliation action.",
|
||||||
|
"Provider, mailbox, worker, and campaign evidence has been preserved.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Classify the job and latest SMTP and IMAP attempts independently.",
|
||||||
|
"Retry only an explicitly temporary, permanent-with-override, or unattempted eligible state.",
|
||||||
|
"For an unknown effect, inspect provider or mailbox evidence and record the factual reconciliation with a note.",
|
||||||
|
"Verify the resulting protected state before allowing more work for that job.",
|
||||||
|
],
|
||||||
|
"outcome": "Every investigated job is either protected as effected, explicitly retryable, or still visibly unresolved.",
|
||||||
|
"verification": "Confirm the previous attempt remains in history, a retry has a new attempt number, and no accepted SMTP effect was repeated to repair IMAP state.",
|
||||||
|
"related_topic_ids": [
|
||||||
|
"campaigns.mail-profile-operations",
|
||||||
|
"campaigns.reference.composition-assurance",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.reference.composition-assurance",
|
||||||
|
title="Assure the Campaign reference composition",
|
||||||
|
summary="Release Campaign only with aligned contracts, role-safe surfaces, durable effect evidence, optional-module isolation, and recoverable data.",
|
||||||
|
body="Campaign is a reference composition only when Core, Mail, Files, Addresses, workers, storage, policies, and documentation are tested in the exact installed combination. Normal readers see business state rather than paths, storage keys, worker claims, or raw provider diagnostics; diagnostic and export authority remain separate.",
|
||||||
|
layer="evidence",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("platform_operator", "security_reviewer", "release_reviewer", "integrator"),
|
||||||
|
order=52,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
any_scopes=("campaigns:diagnostic:read", "campaigns:report:read", "system:settings:read", "admin:modules:read"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
|
||||||
|
DocumentationLink(label="Reference examples and release checklist", href="govoplan-campaign/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("mail", "files", "addresses", "audit"),
|
||||||
|
unlocks=("A repeatable, supportable Campaign demonstration rather than an unverified module assembly.",),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"route": "/campaigns",
|
||||||
|
"screen": "Campaign reference composition",
|
||||||
|
"section": "Release, security, integration, and recovery assurance",
|
||||||
|
"verification": "Run the maintained examples, module-permutation tests, migration and restore drills, target SMTP/IMAP checks, version-alignment gate, WebUI/i18n checks, and full security audit for the pinned composition.",
|
||||||
|
"related_topic_ids": [
|
||||||
|
"campaigns.workflow.prepare-validate-and-build",
|
||||||
|
"campaigns.workflow.complete-review",
|
||||||
|
"campaigns.workflow.retry-and-reconcile",
|
||||||
|
"mail.reference.credentials-egress-retirement",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation_providers=(documentation_topics,),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
|
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
|
||||||
"govoplan_campaign.backend.capabilities",
|
"govoplan_campaign.backend.capabilities",
|
||||||
|
|||||||
@@ -590,6 +590,25 @@ def _validate_required_recipients(
|
|||||||
return MessageValidationStatus.EXCLUDED
|
return MessageValidationStatus.EXCLUDED
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_required_sender(
|
||||||
|
senders: list[RecipientConfig],
|
||||||
|
issues: list[MessageIssue],
|
||||||
|
validation_status: MessageValidationStatus,
|
||||||
|
) -> MessageValidationStatus:
|
||||||
|
if senders:
|
||||||
|
return validation_status
|
||||||
|
issues.append(
|
||||||
|
MessageIssue(
|
||||||
|
severity="error",
|
||||||
|
code="missing_sender",
|
||||||
|
message="No effective From address is configured; Campaign must resolve the sender before building.",
|
||||||
|
behavior="block",
|
||||||
|
source="recipients",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return MessageValidationStatus.BLOCKED
|
||||||
|
|
||||||
|
|
||||||
def _render_message_template(
|
def _render_message_template(
|
||||||
config: CampaignConfig,
|
config: CampaignConfig,
|
||||||
campaign_file: str | Path,
|
campaign_file: str | Path,
|
||||||
@@ -784,6 +803,11 @@ 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)
|
||||||
|
|
||||||
|
context.validation_status = _validate_required_sender(
|
||||||
|
context.senders,
|
||||||
|
context.issues,
|
||||||
|
context.validation_status,
|
||||||
|
)
|
||||||
context.validation_status = _validate_required_recipients(
|
context.validation_status = _validate_required_recipients(
|
||||||
config,
|
config,
|
||||||
context.recipients,
|
context.recipients,
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
"""add durable IMAP append claim and attempt idempotency
|
||||||
|
|
||||||
|
Revision ID: 3c4d5e6f8192
|
||||||
|
Revises: 2c3d4e5f7081
|
||||||
|
Create Date: 2026-07-21 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "3c4d5e6f8192"
|
||||||
|
down_revision = "2c3d4e5f7081"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("campaign_jobs") as batch:
|
||||||
|
batch.add_column(sa.Column("imap_claimed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
batch.add_column(sa.Column("imap_claim_token", sa.String(length=36), nullable=True))
|
||||||
|
batch.create_index("ix_campaign_jobs_imap_claim_token", ["imap_claim_token"], unique=False)
|
||||||
|
with op.batch_alter_table("imap_append_attempts") as batch:
|
||||||
|
batch.add_column(sa.Column("claim_token", sa.String(length=36), nullable=True))
|
||||||
|
_renumber_attempts()
|
||||||
|
with op.batch_alter_table("imap_append_attempts") as batch:
|
||||||
|
batch.create_unique_constraint(
|
||||||
|
"uq_imap_append_attempts_job_attempt",
|
||||||
|
["job_id", "attempt_number"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("imap_append_attempts") as batch:
|
||||||
|
batch.drop_constraint("uq_imap_append_attempts_job_attempt", type_="unique")
|
||||||
|
batch.drop_column("claim_token")
|
||||||
|
with op.batch_alter_table("campaign_jobs") as batch:
|
||||||
|
batch.drop_index("ix_campaign_jobs_imap_claim_token")
|
||||||
|
batch.drop_column("imap_claim_token")
|
||||||
|
batch.drop_column("imap_claimed_at")
|
||||||
|
|
||||||
|
|
||||||
|
def _renumber_attempts() -> None:
|
||||||
|
"""Make historical attempt numbers unique per job before constraining them."""
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = list(
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, job_id FROM imap_append_attempts "
|
||||||
|
"ORDER BY job_id, created_at, id"
|
||||||
|
)
|
||||||
|
).mappings()
|
||||||
|
)
|
||||||
|
per_job: dict[str, int] = {}
|
||||||
|
for row in rows:
|
||||||
|
job_id = str(row["job_id"])
|
||||||
|
attempt_number = per_job.get(job_id, 0) + 1
|
||||||
|
per_job[job_id] = attempt_number
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE imap_append_attempts SET attempt_number = :attempt_number "
|
||||||
|
"WHERE id = :attempt_id"
|
||||||
|
),
|
||||||
|
{"attempt_number": attempt_number, "attempt_id": row["id"]},
|
||||||
|
)
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"""seal each campaign delivery job's immutable execution input
|
||||||
|
|
||||||
|
Revision ID: 4d5e6f7a9203
|
||||||
|
Revises: 3c4d5e6f8192
|
||||||
|
Create Date: 2026-07-21 00:00:01.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "4d5e6f7a9203"
|
||||||
|
down_revision = "3c4d5e6f8192"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("campaign_jobs") as batch:
|
||||||
|
batch.add_column(sa.Column("execution_input_sha256", sa.String(length=64), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("campaign_jobs") as batch:
|
||||||
|
batch.drop_column("execution_input_sha256")
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
"""add durable IMAP append claim and attempt idempotency
|
||||||
|
|
||||||
|
Revision ID: 3c4d5e6f8192
|
||||||
|
Revises: 2c3d4e5f7081
|
||||||
|
Create Date: 2026-07-21 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "3c4d5e6f8192"
|
||||||
|
down_revision = "2c3d4e5f7081"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("campaign_jobs") as batch:
|
||||||
|
batch.add_column(sa.Column("imap_claimed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
batch.add_column(sa.Column("imap_claim_token", sa.String(length=36), nullable=True))
|
||||||
|
batch.create_index("ix_campaign_jobs_imap_claim_token", ["imap_claim_token"], unique=False)
|
||||||
|
with op.batch_alter_table("imap_append_attempts") as batch:
|
||||||
|
batch.add_column(sa.Column("claim_token", sa.String(length=36), nullable=True))
|
||||||
|
_renumber_attempts()
|
||||||
|
with op.batch_alter_table("imap_append_attempts") as batch:
|
||||||
|
batch.create_unique_constraint(
|
||||||
|
"uq_imap_append_attempts_job_attempt",
|
||||||
|
["job_id", "attempt_number"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("imap_append_attempts") as batch:
|
||||||
|
batch.drop_constraint("uq_imap_append_attempts_job_attempt", type_="unique")
|
||||||
|
batch.drop_column("claim_token")
|
||||||
|
with op.batch_alter_table("campaign_jobs") as batch:
|
||||||
|
batch.drop_index("ix_campaign_jobs_imap_claim_token")
|
||||||
|
batch.drop_column("imap_claim_token")
|
||||||
|
batch.drop_column("imap_claimed_at")
|
||||||
|
|
||||||
|
|
||||||
|
def _renumber_attempts() -> None:
|
||||||
|
"""Make historical attempt numbers unique per job before constraining them."""
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = list(
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, job_id FROM imap_append_attempts "
|
||||||
|
"ORDER BY job_id, created_at, id"
|
||||||
|
)
|
||||||
|
).mappings()
|
||||||
|
)
|
||||||
|
per_job: dict[str, int] = {}
|
||||||
|
for row in rows:
|
||||||
|
job_id = str(row["job_id"])
|
||||||
|
attempt_number = per_job.get(job_id, 0) + 1
|
||||||
|
per_job[job_id] = attempt_number
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE imap_append_attempts SET attempt_number = :attempt_number "
|
||||||
|
"WHERE id = :attempt_id"
|
||||||
|
),
|
||||||
|
{"attempt_number": attempt_number, "attempt_id": row["id"]},
|
||||||
|
)
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"""seal each campaign delivery job's immutable execution input
|
||||||
|
|
||||||
|
Revision ID: 4d5e6f7a9203
|
||||||
|
Revises: 3c4d5e6f8192
|
||||||
|
Create Date: 2026-07-21 00:00:01.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "4d5e6f7a9203"
|
||||||
|
down_revision = "3c4d5e6f8192"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("campaign_jobs") as batch:
|
||||||
|
batch.add_column(sa.Column("execution_input_sha256", sa.String(length=64), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("campaign_jobs") as batch:
|
||||||
|
batch.drop_column("execution_input_sha256")
|
||||||
@@ -27,10 +27,14 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
JobValidationStatus,
|
JobValidationStatus,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema
|
from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
|
assert_campaign_uses_mail_profile_reference,
|
||||||
|
campaign_mail_profile_id,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
from govoplan_campaign.backend.messages.models import MessageDraft
|
||||||
from govoplan_campaign.backend.sending.execution import create_execution_snapshot
|
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions
|
||||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||||
@@ -61,15 +65,26 @@ def load_campaign_config_from_json(
|
|||||||
owner_user_id: str | None = None,
|
owner_user_id: str | None = None,
|
||||||
owner_group_id: str | None = None,
|
owner_group_id: str | None = None,
|
||||||
) -> CampaignConfig:
|
) -> CampaignConfig:
|
||||||
materialized = mail_integration().materialize_campaign_mail_profile_config(
|
# Validate the persisted Campaign-to-Mail contract before asking Mail for a
|
||||||
session,
|
# non-secret capability summary. Campaign never receives resolved transport
|
||||||
tenant_id=tenant_id,
|
# settings, account identities, or credentials.
|
||||||
raw_json=raw_json,
|
assert_campaign_uses_mail_profile_reference(raw_json)
|
||||||
campaign_id=campaign_id,
|
validate_against_schema(raw_json)
|
||||||
owner_user_id=owner_user_id,
|
materialized = copy.deepcopy(raw_json)
|
||||||
owner_group_id=owner_group_id,
|
profile_id = campaign_mail_profile_id(raw_json)
|
||||||
)
|
if profile_id:
|
||||||
validate_against_schema(materialized)
|
summary = mail_integration().campaign_profile_delivery_summary(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
owner_user_id=owner_user_id,
|
||||||
|
owner_group_id=owner_group_id,
|
||||||
|
)
|
||||||
|
materialized.setdefault("server", {})["profile_capabilities"] = {
|
||||||
|
"smtp_available": bool(summary.get("smtp_available")),
|
||||||
|
"imap_available": bool(summary.get("imap_available")),
|
||||||
|
}
|
||||||
return CampaignConfig.model_validate(materialized)
|
return CampaignConfig.model_validate(materialized)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,6 +157,7 @@ def create_campaign_version_from_json(
|
|||||||
raw_json: dict[str, Any],
|
raw_json: dict[str, Any],
|
||||||
source_filename: str | None = None,
|
source_filename: str | None = None,
|
||||||
source_base_path: str | None = None,
|
source_base_path: str | None = None,
|
||||||
|
commit: bool = True,
|
||||||
) -> tuple[Campaign, CampaignVersion]:
|
) -> tuple[Campaign, CampaignVersion]:
|
||||||
assert_server_safe_campaign_paths(
|
assert_server_safe_campaign_paths(
|
||||||
raw_json,
|
raw_json,
|
||||||
@@ -196,8 +212,11 @@ def create_campaign_version_from_json(
|
|||||||
session.flush()
|
session.flush()
|
||||||
campaign.current_version_id = version.id
|
campaign.current_version_id = version.id
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
_write_campaign_snapshot(version)
|
if commit:
|
||||||
session.commit()
|
_write_campaign_snapshot(version)
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return campaign, version
|
return campaign, version
|
||||||
|
|
||||||
|
|
||||||
@@ -485,13 +504,19 @@ def build_campaign_version(
|
|||||||
[job for job, _message in job_build_pairs],
|
[job for job, _message in job_build_pairs],
|
||||||
stage="built",
|
stage="built",
|
||||||
)
|
)
|
||||||
runtime_smtp = managed_config.server.runtime_smtp_config()
|
if not managed_config.server.profile_capabilities.smtp_available:
|
||||||
if not runtime_smtp:
|
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created")
|
||||||
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created")
|
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||||
|
if profile_id is None:
|
||||||
|
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages")
|
||||||
|
revisions = profile_transport_revisions(session, version)
|
||||||
|
if not revisions["smtp"]:
|
||||||
|
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision")
|
||||||
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
||||||
version,
|
version,
|
||||||
smtp=runtime_smtp,
|
mail_profile_id=profile_id,
|
||||||
imap=managed_config.server.runtime_imap_config(),
|
smtp_transport_revision=revisions["smtp"],
|
||||||
|
imap_transport_revision=revisions["imap"],
|
||||||
delivery=managed_config.delivery,
|
delivery=managed_config.delivery,
|
||||||
jobs=[job for job, _message in job_build_pairs],
|
jobs=[job for job, _message in job_build_pairs],
|
||||||
build_summary=report_json,
|
build_summary=report_json,
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
JobSendStatus,
|
JobSendStatus,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
|
campaign_editor_state_for_edit,
|
||||||
|
campaign_mail_profile_boundary_violations,
|
||||||
|
campaign_mail_profile_id,
|
||||||
|
assert_campaign_uses_mail_profile_reference,
|
||||||
|
public_campaign_mail_server,
|
||||||
|
validate_campaign_editor_state,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||||
from govoplan_campaign.backend.persistence.campaigns import (
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
CampaignPersistenceError,
|
CampaignPersistenceError,
|
||||||
@@ -106,25 +114,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
|||||||
},
|
},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {},
|
||||||
"inherit_smtp_credentials": True,
|
|
||||||
"inherit_imap_credentials": True,
|
|
||||||
"smtp": {
|
|
||||||
"host": "",
|
|
||||||
"port": 587,
|
|
||||||
"security": "starttls",
|
|
||||||
},
|
|
||||||
"imap": {
|
|
||||||
"host": "",
|
|
||||||
"port": 993,
|
|
||||||
"security": "tls",
|
|
||||||
"sent_folder": "auto",
|
|
||||||
},
|
|
||||||
"credentials": {
|
|
||||||
"smtp": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
|
|
||||||
"imap": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": [],
|
"from": [],
|
||||||
"allow_individual_from": False,
|
"allow_individual_from": False,
|
||||||
@@ -218,6 +208,7 @@ def create_minimal_campaign(
|
|||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
current_flow: str = CampaignVersionFlow.CREATE.value,
|
current_flow: str = CampaignVersionFlow.CREATE.value,
|
||||||
current_step: str = "basics",
|
current_step: str = "basics",
|
||||||
|
commit: bool = True,
|
||||||
) -> tuple[Campaign, CampaignVersion]:
|
) -> tuple[Campaign, CampaignVersion]:
|
||||||
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
|
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -251,8 +242,11 @@ def create_minimal_campaign(
|
|||||||
session.flush()
|
session.flush()
|
||||||
campaign.current_version_id = version.id
|
campaign.current_version_id = version.id
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
_write_campaign_snapshot(version)
|
if commit:
|
||||||
session.commit()
|
_write_campaign_snapshot(version)
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return campaign, version
|
return campaign, version
|
||||||
|
|
||||||
|
|
||||||
@@ -347,6 +341,8 @@ def fork_campaign_version_for_edit(
|
|||||||
source_filename: str | None = None,
|
source_filename: str | None = None,
|
||||||
source_base_path: str | None = None,
|
source_base_path: str | None = None,
|
||||||
autosave: bool = True,
|
autosave: bool = True,
|
||||||
|
migrate_legacy_mail_settings: bool = False,
|
||||||
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
"""Create the next sole working version from immutable campaign history.
|
"""Create the next sole working version from immutable campaign history.
|
||||||
|
|
||||||
@@ -371,7 +367,16 @@ def fork_campaign_version_for_edit(
|
|||||||
"Create the next working copy from the campaign's current immutable version."
|
"Create the next working copy from the campaign's current immutable version."
|
||||||
)
|
)
|
||||||
|
|
||||||
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
|
source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
|
||||||
|
source_requires_mail_migration = bool(campaign_mail_profile_boundary_violations(source_json))
|
||||||
|
if source_requires_mail_migration and not migrate_legacy_mail_settings:
|
||||||
|
raise CampaignPersistenceError(
|
||||||
|
"This version contains legacy campaign-local SMTP/IMAP settings. Create the editable copy from "
|
||||||
|
"the Mail settings migration action so the audit record is preserved and the copy uses a Mail profile."
|
||||||
|
)
|
||||||
|
base_json = raw_json if raw_json is not None else copy.deepcopy(source_json)
|
||||||
|
if source_requires_mail_migration and raw_json is None:
|
||||||
|
base_json["server"] = public_campaign_mail_server(source_json)
|
||||||
assert_server_safe_campaign_paths(
|
assert_server_safe_campaign_paths(
|
||||||
base_json,
|
base_json,
|
||||||
source_filename=source_filename,
|
source_filename=source_filename,
|
||||||
@@ -379,6 +384,7 @@ def fork_campaign_version_for_edit(
|
|||||||
managed_files_available=files_integration().available,
|
managed_files_available=files_integration().available,
|
||||||
)
|
)
|
||||||
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
||||||
|
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||||
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||||
|
|
||||||
new_version = CampaignVersion(
|
new_version = CampaignVersion(
|
||||||
@@ -392,7 +398,11 @@ def fork_campaign_version_for_edit(
|
|||||||
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
|
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
|
||||||
current_step=current_step if current_step is not None else source.current_step,
|
current_step=current_step if current_step is not None else source.current_step,
|
||||||
is_complete=False,
|
is_complete=False,
|
||||||
editor_state=editor_state if editor_state is not None else copy.deepcopy(source.editor_state or {}),
|
editor_state=(
|
||||||
|
validate_campaign_editor_state(editor_state)
|
||||||
|
if editor_state is not None
|
||||||
|
else campaign_editor_state_for_edit(source.editor_state)
|
||||||
|
),
|
||||||
autosaved_at=datetime.now(UTC) if autosave else None,
|
autosaved_at=datetime.now(UTC) if autosave else None,
|
||||||
)
|
)
|
||||||
session.add(new_version)
|
session.add(new_version)
|
||||||
@@ -402,8 +412,11 @@ def fork_campaign_version_for_edit(
|
|||||||
campaign.current_version_id = new_version.id
|
campaign.current_version_id = new_version.id
|
||||||
campaign.status = CampaignStatus.DRAFT.value
|
campaign.status = CampaignStatus.DRAFT.value
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
_write_campaign_snapshot(new_version)
|
if commit:
|
||||||
session.commit()
|
_write_campaign_snapshot(new_version)
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return new_version
|
return new_version
|
||||||
|
|
||||||
|
|
||||||
@@ -461,6 +474,7 @@ def unlock_validated_campaign_version(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
version_id: str,
|
version_id: str,
|
||||||
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
"""Unlock a validation snapshot so it can be edited again.
|
"""Unlock a validation snapshot so it can be edited again.
|
||||||
|
|
||||||
@@ -510,7 +524,10 @@ def unlock_validated_campaign_version(
|
|||||||
campaign.status = CampaignStatus.DRAFT.value
|
campaign.status = CampaignStatus.DRAFT.value
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.commit()
|
if commit:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return version
|
return version
|
||||||
|
|
||||||
def update_campaign_version(
|
def update_campaign_version(
|
||||||
@@ -528,6 +545,8 @@ def update_campaign_version(
|
|||||||
source_filename: str | None = None,
|
source_filename: str | None = None,
|
||||||
source_base_path: str | None = None,
|
source_base_path: str | None = None,
|
||||||
autosave: bool = False,
|
autosave: bool = False,
|
||||||
|
migrate_legacy_mail_settings: bool = False,
|
||||||
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
if raw_json is not None or source_filename is not None or source_base_path is not None:
|
if raw_json is not None or source_filename is not None or source_base_path is not None:
|
||||||
assert_server_safe_campaign_paths(
|
assert_server_safe_campaign_paths(
|
||||||
@@ -547,6 +566,18 @@ def update_campaign_version(
|
|||||||
|
|
||||||
if raw_json is not None:
|
if raw_json is not None:
|
||||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
||||||
|
if campaign_mail_profile_boundary_violations(version.raw_json) and not migrate_legacy_mail_settings:
|
||||||
|
raise CampaignPersistenceError(
|
||||||
|
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
|
||||||
|
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
|
||||||
|
"will not be changed automatically."
|
||||||
|
)
|
||||||
|
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||||
|
if campaign_mail_profile_boundary_violations(version.raw_json) and campaign_mail_profile_id(runtime_json) is None:
|
||||||
|
raise CampaignPersistenceError(
|
||||||
|
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
|
||||||
|
"Select a Mail profile before saving."
|
||||||
|
)
|
||||||
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||||
version.raw_json = runtime_json
|
version.raw_json = runtime_json
|
||||||
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
||||||
@@ -561,7 +592,7 @@ def update_campaign_version(
|
|||||||
if is_complete is not None:
|
if is_complete is not None:
|
||||||
version.is_complete = is_complete
|
version.is_complete = is_complete
|
||||||
if editor_state is not None:
|
if editor_state is not None:
|
||||||
version.editor_state = editor_state
|
version.editor_state = validate_campaign_editor_state(editor_state)
|
||||||
if source_filename is not None:
|
if source_filename is not None:
|
||||||
version.source_filename = source_filename
|
version.source_filename = source_filename
|
||||||
if source_base_path is not None:
|
if source_base_path is not None:
|
||||||
@@ -584,8 +615,11 @@ def update_campaign_version(
|
|||||||
session.add(version)
|
session.add(version)
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.flush()
|
session.flush()
|
||||||
_write_campaign_snapshot(version)
|
if commit:
|
||||||
session.commit()
|
_write_campaign_snapshot(version)
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
@@ -598,6 +632,7 @@ def update_campaign_review_state(
|
|||||||
inspection_complete: bool,
|
inspection_complete: bool,
|
||||||
reviewed_message_keys: list[str],
|
reviewed_message_keys: list[str],
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
"""Persist review acknowledgement without mutating the locked campaign data.
|
"""Persist review acknowledgement without mutating the locked campaign data.
|
||||||
|
|
||||||
@@ -628,7 +663,10 @@ def update_campaign_review_state(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.commit()
|
if commit:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
@@ -710,6 +748,7 @@ def lock_campaign_version_temporarily(
|
|||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
version_id: str,
|
version_id: str,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
"""Apply a reversible user-requested lock without changing workflow state."""
|
"""Apply a reversible user-requested lock without changing workflow state."""
|
||||||
|
|
||||||
@@ -734,7 +773,10 @@ def lock_campaign_version_temporarily(
|
|||||||
version.user_locked_at = datetime.now(UTC)
|
version.user_locked_at = datetime.now(UTC)
|
||||||
version.user_locked_by_user_id = user_id
|
version.user_locked_by_user_id = user_id
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.commit()
|
if commit:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
@@ -744,6 +786,7 @@ def unlock_user_locked_campaign_version(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
version_id: str,
|
version_id: str,
|
||||||
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
"""Remove a reversible user lock without invalidating campaign data."""
|
"""Remove a reversible user lock without invalidating campaign data."""
|
||||||
|
|
||||||
@@ -767,7 +810,10 @@ def unlock_user_locked_campaign_version(
|
|||||||
version.user_locked_at = None
|
version.user_locked_at = None
|
||||||
version.user_locked_by_user_id = None
|
version.user_locked_by_user_id = None
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.commit()
|
if commit:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
@@ -778,6 +824,7 @@ def permanently_lock_campaign_version(
|
|||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
version_id: str,
|
version_id: str,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
"""Apply an irreversible user lock.
|
"""Apply an irreversible user lock.
|
||||||
|
|
||||||
@@ -805,7 +852,10 @@ def permanently_lock_campaign_version(
|
|||||||
# Retain published_at as a compatibility marker for existing integrations.
|
# Retain published_at as a compatibility marker for existing integrations.
|
||||||
version.published_at = version.published_at or now
|
version.published_at = version.published_at or now
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.commit()
|
if commit:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
@@ -816,6 +866,7 @@ def publish_campaign_version(
|
|||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
version_id: str,
|
version_id: str,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
"""Backwards-compatible alias for the permanent user lock."""
|
"""Backwards-compatible alias for the permanent user lock."""
|
||||||
|
|
||||||
@@ -825,6 +876,7 @@ def publish_campaign_version(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
commit=commit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -839,6 +891,7 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
|
|||||||
_validate_partial_basics(collector, _dict_value(raw_json, "campaign"))
|
_validate_partial_basics(collector, _dict_value(raw_json, "campaign"))
|
||||||
recipients = _dict_value(raw_json, "recipients")
|
recipients = _dict_value(raw_json, "recipients")
|
||||||
_validate_partial_sender(collector, recipients)
|
_validate_partial_sender(collector, recipients)
|
||||||
|
_validate_partial_mail_profile(collector, raw_json)
|
||||||
_validate_partial_recipients(collector, _dict_value(raw_json, "entries"))
|
_validate_partial_recipients(collector, _dict_value(raw_json, "entries"))
|
||||||
_validate_partial_template(collector, _dict_value(raw_json, "template"))
|
_validate_partial_template(collector, _dict_value(raw_json, "template"))
|
||||||
_validate_partial_attachments(collector, _dict_value(raw_json, "attachments"))
|
_validate_partial_attachments(collector, _dict_value(raw_json, "attachments"))
|
||||||
@@ -864,6 +917,26 @@ def _validate_partial_sender(collector: _PartialValidationCollector, recipients:
|
|||||||
collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
|
collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_json: dict[str, Any]) -> None:
|
||||||
|
violations = campaign_mail_profile_boundary_violations(raw_json)
|
||||||
|
if violations:
|
||||||
|
collector.issue(
|
||||||
|
"error",
|
||||||
|
"sender",
|
||||||
|
"server",
|
||||||
|
"campaign_local_mail_transport_forbidden",
|
||||||
|
"Campaign-local SMTP/IMAP settings are not supported. Select or migrate to an authorized Mail-module profile.",
|
||||||
|
)
|
||||||
|
elif campaign_mail_profile_id(raw_json) is None:
|
||||||
|
collector.issue(
|
||||||
|
"warning",
|
||||||
|
"sender",
|
||||||
|
"server.mail_profile_id",
|
||||||
|
"missing_mail_profile",
|
||||||
|
"Select an authorized Mail-module profile before validating or delivering this campaign.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
|
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
|
||||||
has_inline = bool(entries.get("inline"))
|
has_inline = bool(entries.get("inline"))
|
||||||
has_source = isinstance(entries.get("source"), dict)
|
has_source = isinstance(entries.get("source"), dict)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -20,13 +21,20 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
SendAttempt,
|
SendAttempt,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
|
||||||
from govoplan_campaign.backend.response_security import public_campaign_payload, public_source_filename
|
from govoplan_campaign.backend.response_security import (
|
||||||
|
public_campaign_payload,
|
||||||
|
public_delivery_result_message,
|
||||||
|
public_source_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CampaignReportError(RuntimeError):
|
class CampaignReportError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _utcnow_iso() -> str:
|
def _utcnow_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
@@ -56,7 +64,11 @@ def _selected_version(
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
|
def _version_info(
|
||||||
|
version: CampaignVersion | None,
|
||||||
|
*,
|
||||||
|
include_diagnostics: bool = False,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
if not version:
|
if not version:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -65,14 +77,25 @@ def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
|
|||||||
"schema_version": version.schema_version,
|
"schema_version": version.schema_version,
|
||||||
"source_filename": public_source_filename(version.source_filename),
|
"source_filename": public_source_filename(version.source_filename),
|
||||||
"created_at": version.created_at.isoformat() if version.created_at else None,
|
"created_at": version.created_at.isoformat() if version.created_at else None,
|
||||||
"validation_summary": public_campaign_payload(version.validation_summary),
|
"validation_summary": public_campaign_payload(
|
||||||
"build_summary": public_campaign_payload(version.build_summary),
|
version.validation_summary,
|
||||||
|
include_diagnostics=include_diagnostics,
|
||||||
|
),
|
||||||
|
"build_summary": public_campaign_payload(
|
||||||
|
version.build_summary,
|
||||||
|
include_diagnostics=include_diagnostics,
|
||||||
|
),
|
||||||
"execution_snapshot_hash": version.execution_snapshot_hash,
|
"execution_snapshot_hash": version.execution_snapshot_hash,
|
||||||
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, Any]:
|
def _load_delivery_info(
|
||||||
|
version: CampaignVersion | None,
|
||||||
|
jobs: list[CampaignJob],
|
||||||
|
*,
|
||||||
|
include_diagnostics: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Read deterministic delivery settings from the immutable execution snapshot."""
|
"""Read deterministic delivery settings from the immutable execution snapshot."""
|
||||||
|
|
||||||
default = {
|
default = {
|
||||||
@@ -82,25 +105,31 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
|
|||||||
"execution_snapshot_hash": None,
|
"execution_snapshot_hash": None,
|
||||||
"execution_snapshot_at": None,
|
"execution_snapshot_at": None,
|
||||||
"snapshot_version": None,
|
"snapshot_version": None,
|
||||||
"build_token": None, # nosec B105 - absent report value, not a credential.
|
|
||||||
"built_at": None,
|
"built_at": None,
|
||||||
"job_manifest_sha256": None,
|
|
||||||
"job_count": 0,
|
"job_count": 0,
|
||||||
"queueable_job_count": 0,
|
"queueable_job_count": 0,
|
||||||
"effective_policy_sha256": None,
|
|
||||||
"smtp_config_fingerprint": None,
|
|
||||||
"imap_config_fingerprint": None,
|
|
||||||
"estimated_remaining_send_seconds": None,
|
"estimated_remaining_send_seconds": None,
|
||||||
"estimated_remaining_send_human": None,
|
"estimated_remaining_send_human": None,
|
||||||
"background_workers_enabled": bool(core_settings.celery_enabled),
|
|
||||||
}
|
}
|
||||||
|
if include_diagnostics:
|
||||||
|
default.update(
|
||||||
|
{
|
||||||
|
"build_token": None, # nosec B105 - absent report value, not a credential.
|
||||||
|
"job_manifest_sha256": None,
|
||||||
|
"effective_policy_sha256": None,
|
||||||
|
"smtp_transport_revision": None,
|
||||||
|
"imap_transport_revision": None,
|
||||||
|
"background_workers_enabled": bool(core_settings.celery_enabled),
|
||||||
|
}
|
||||||
|
)
|
||||||
if not version or not isinstance(version.execution_snapshot, dict):
|
if not version or not isinstance(version.execution_snapshot, dict):
|
||||||
default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery."
|
default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery."
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
||||||
except Exception as exc: # pragma: no cover - reporting should remain available
|
except Exception: # pragma: no cover - reporting should remain available
|
||||||
default["load_error"] = str(exc)
|
logger.warning("Campaign execution snapshot could not be loaded for a report")
|
||||||
|
default["load_error"] = "Execution snapshot is invalid; rebuild the selected version before delivery."
|
||||||
return default
|
return default
|
||||||
|
|
||||||
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
|
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
|
||||||
@@ -109,7 +138,7 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
|
|||||||
if messages_per_minute and pending:
|
if messages_per_minute and pending:
|
||||||
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60))
|
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60))
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"rate_limit": {
|
"rate_limit": {
|
||||||
"messages_per_minute": messages_per_minute,
|
"messages_per_minute": messages_per_minute,
|
||||||
"concurrency": snapshot.delivery.rate_limit.concurrency,
|
"concurrency": snapshot.delivery.rate_limit.concurrency,
|
||||||
@@ -125,18 +154,24 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
|
|||||||
"execution_snapshot_hash": version.execution_snapshot_hash,
|
"execution_snapshot_hash": version.execution_snapshot_hash,
|
||||||
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
||||||
"snapshot_version": snapshot.snapshot_version,
|
"snapshot_version": snapshot.snapshot_version,
|
||||||
"build_token": snapshot.build_token,
|
|
||||||
"built_at": snapshot.built_at,
|
"built_at": snapshot.built_at,
|
||||||
"job_manifest_sha256": snapshot.job_manifest_sha256,
|
|
||||||
"job_count": snapshot.job_count,
|
"job_count": snapshot.job_count,
|
||||||
"queueable_job_count": snapshot.queueable_job_count,
|
"queueable_job_count": snapshot.queueable_job_count,
|
||||||
"effective_policy_sha256": snapshot.effective_policy_sha256,
|
|
||||||
"smtp_config_fingerprint": snapshot.smtp_config_fingerprint,
|
|
||||||
"imap_config_fingerprint": snapshot.imap_config_fingerprint,
|
|
||||||
"estimated_remaining_send_seconds": estimated_seconds,
|
"estimated_remaining_send_seconds": estimated_seconds,
|
||||||
"estimated_remaining_send_human": _human_duration(estimated_seconds),
|
"estimated_remaining_send_human": _human_duration(estimated_seconds),
|
||||||
"background_workers_enabled": bool(core_settings.celery_enabled),
|
|
||||||
}
|
}
|
||||||
|
if include_diagnostics:
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"build_token": snapshot.build_token,
|
||||||
|
"job_manifest_sha256": snapshot.job_manifest_sha256,
|
||||||
|
"effective_policy_sha256": snapshot.effective_policy_sha256,
|
||||||
|
"smtp_transport_revision": snapshot.smtp_transport_revision,
|
||||||
|
"imap_transport_revision": snapshot.imap_transport_revision,
|
||||||
|
"background_workers_enabled": bool(core_settings.celery_enabled),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _human_duration(seconds: int | None) -> str | None:
|
def _human_duration(seconds: int | None) -> str | None:
|
||||||
@@ -223,15 +258,19 @@ def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[s
|
|||||||
"send_status": job.send_status,
|
"send_status": job.send_status,
|
||||||
"imap_status": job.imap_status,
|
"imap_status": job.imap_status,
|
||||||
"attempt_count": job.attempt_count,
|
"attempt_count": job.attempt_count,
|
||||||
"last_error": job.last_error,
|
"last_error": public_delivery_result_message(
|
||||||
|
last_error=job.last_error,
|
||||||
|
send_status=job.send_status,
|
||||||
|
imap_status=job.imap_status,
|
||||||
|
),
|
||||||
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
|
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
|
||||||
}
|
}
|
||||||
for job in failed[:limit]
|
for job in failed[:limit]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _job_row(job: CampaignJob) -> dict[str, Any]:
|
def _job_row(job: CampaignJob, *, include_diagnostics: bool = False) -> dict[str, Any]:
|
||||||
return {
|
row = {
|
||||||
"job_id": job.id,
|
"job_id": job.id,
|
||||||
"entry_index": job.entry_index,
|
"entry_index": job.entry_index,
|
||||||
"entry_id": job.entry_id,
|
"entry_id": job.entry_id,
|
||||||
@@ -244,17 +283,27 @@ def _job_row(job: CampaignJob) -> dict[str, Any]:
|
|||||||
"imap_status": job.imap_status,
|
"imap_status": job.imap_status,
|
||||||
"attempt_count": job.attempt_count,
|
"attempt_count": job.attempt_count,
|
||||||
"queued_at": job.queued_at.isoformat() if job.queued_at else None,
|
"queued_at": job.queued_at.isoformat() if job.queued_at else None,
|
||||||
"claimed_at": job.claimed_at.isoformat() if job.claimed_at else None,
|
|
||||||
"smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None,
|
|
||||||
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None,
|
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None,
|
||||||
"sent_at": job.sent_at.isoformat() if job.sent_at else None,
|
"sent_at": job.sent_at.isoformat() if job.sent_at else None,
|
||||||
"last_error": job.last_error,
|
"last_error": public_delivery_result_message(
|
||||||
|
last_error=job.last_error,
|
||||||
|
send_status=job.send_status,
|
||||||
|
imap_status=job.imap_status,
|
||||||
|
),
|
||||||
"eml_size_bytes": job.eml_size_bytes,
|
"eml_size_bytes": job.eml_size_bytes,
|
||||||
"eml_sha256": job.eml_sha256,
|
"eml_sha256": job.eml_sha256,
|
||||||
"issues_count": len(job.issues_snapshot or []),
|
"issues_count": len(job.issues_snapshot or []),
|
||||||
"attachment_config_count": len(job.resolved_attachments or []),
|
"attachment_config_count": len(job.resolved_attachments or []),
|
||||||
"matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)),
|
"matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)),
|
||||||
}
|
}
|
||||||
|
if include_diagnostics:
|
||||||
|
row.update(
|
||||||
|
{
|
||||||
|
"claimed_at": job.claimed_at.isoformat() if job.claimed_at else None,
|
||||||
|
"smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
def _address_summary(value: Any) -> str:
|
def _address_summary(value: Any) -> str:
|
||||||
@@ -317,8 +366,9 @@ def _job_evidence_row(
|
|||||||
*,
|
*,
|
||||||
latest_smtp: SendAttempt | None = None,
|
latest_smtp: SendAttempt | None = None,
|
||||||
latest_imap: ImapAppendAttempt | None = None,
|
latest_imap: ImapAppendAttempt | None = None,
|
||||||
|
include_diagnostics: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
row = _job_row(job)
|
row = _job_row(job, include_diagnostics=include_diagnostics)
|
||||||
recipients = job.resolved_recipients or {}
|
recipients = job.resolved_recipients or {}
|
||||||
row.update({
|
row.update({
|
||||||
"campaign_id": job.campaign_id,
|
"campaign_id": job.campaign_id,
|
||||||
@@ -333,15 +383,11 @@ def _job_evidence_row(
|
|||||||
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
|
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
|
||||||
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
|
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
|
||||||
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None,
|
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None,
|
||||||
"latest_smtp_response": latest_smtp.smtp_response if latest_smtp else None,
|
|
||||||
"latest_smtp_error_type": latest_smtp.error_type if latest_smtp else None,
|
|
||||||
"latest_smtp_error_message": latest_smtp.error_message if latest_smtp else None,
|
|
||||||
"latest_smtp_started_at": latest_smtp.started_at.isoformat() if latest_smtp and latest_smtp.started_at else None,
|
"latest_smtp_started_at": latest_smtp.started_at.isoformat() if latest_smtp and latest_smtp.started_at else None,
|
||||||
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None,
|
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None,
|
||||||
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap else None,
|
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap else None,
|
||||||
"latest_imap_status": latest_imap.status if latest_imap else None,
|
"latest_imap_status": latest_imap.status if latest_imap else None,
|
||||||
"latest_imap_folder": latest_imap.folder if latest_imap else None,
|
"latest_imap_folder": latest_imap.folder if latest_imap else None,
|
||||||
"latest_imap_error_message": latest_imap.error_message if latest_imap else None,
|
|
||||||
"latest_imap_created_at": latest_imap.created_at.isoformat() if latest_imap and latest_imap.created_at else None,
|
"latest_imap_created_at": latest_imap.created_at.isoformat() if latest_imap and latest_imap.created_at else None,
|
||||||
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None,
|
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None,
|
||||||
})
|
})
|
||||||
@@ -355,7 +401,8 @@ def generate_campaign_report(
|
|||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
version_id: str | None = None,
|
version_id: str | None = None,
|
||||||
include_jobs: bool = False,
|
include_jobs: bool = False,
|
||||||
include_recent_failures: bool = True,
|
include_recent_failures: bool = False,
|
||||||
|
include_diagnostics: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Generate a dashboard/report payload for one campaign.
|
"""Generate a dashboard/report payload for one campaign.
|
||||||
|
|
||||||
@@ -374,9 +421,13 @@ def generate_campaign_report(
|
|||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
include_recent_failures=include_recent_failures,
|
include_recent_failures=include_recent_failures,
|
||||||
|
include_diagnostics=include_diagnostics,
|
||||||
)
|
)
|
||||||
if include_jobs:
|
if include_jobs:
|
||||||
report["jobs"] = [_job_row(job) for job in jobs]
|
report["jobs"] = [
|
||||||
|
_job_row(job, include_diagnostics=include_diagnostics)
|
||||||
|
for job in jobs
|
||||||
|
]
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
@@ -406,12 +457,13 @@ def _campaign_report_payload(
|
|||||||
jobs: list[CampaignJob],
|
jobs: list[CampaignJob],
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
include_recent_failures: bool,
|
include_recent_failures: bool,
|
||||||
|
include_diagnostics: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
job_ids = [job.id for job in jobs]
|
job_ids = [job.id for job in jobs]
|
||||||
report = {
|
report = {
|
||||||
"generated_at": _utcnow_iso(),
|
"generated_at": _utcnow_iso(),
|
||||||
"campaign": _campaign_report_campaign_payload(campaign),
|
"campaign": _campaign_report_campaign_payload(campaign),
|
||||||
"current_version": _version_info(version),
|
"current_version": _version_info(version, include_diagnostics=include_diagnostics),
|
||||||
"selected_version_id": version.id if version else None,
|
"selected_version_id": version.id if version else None,
|
||||||
"cards": _campaign_report_cards(version, jobs),
|
"cards": _campaign_report_cards(version, jobs),
|
||||||
"status_counts": _campaign_report_status_counts(version, jobs),
|
"status_counts": _campaign_report_status_counts(version, jobs),
|
||||||
@@ -426,7 +478,11 @@ def _campaign_report_payload(
|
|||||||
},
|
},
|
||||||
"attachments": _attachment_summary(jobs),
|
"attachments": _attachment_summary(jobs),
|
||||||
"attempts": _campaign_report_attempt_counts(session, job_ids),
|
"attempts": _campaign_report_attempt_counts(session, job_ids),
|
||||||
"delivery": _load_delivery_info(version, jobs),
|
"delivery": _load_delivery_info(
|
||||||
|
version,
|
||||||
|
jobs,
|
||||||
|
include_diagnostics=include_diagnostics,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if include_recent_failures:
|
if include_recent_failures:
|
||||||
report["recent_failures"] = _recent_failures(jobs)
|
report["recent_failures"] = _recent_failures(jobs)
|
||||||
@@ -531,6 +587,7 @@ def generate_jobs_csv(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
version_id: str | None = None,
|
version_id: str | None = None,
|
||||||
|
include_diagnostics: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||||
version = _selected_version(session, campaign, version_id)
|
version = _selected_version(session, campaign, version_id)
|
||||||
@@ -571,6 +628,7 @@ def generate_jobs_csv(
|
|||||||
job,
|
job,
|
||||||
latest_smtp=latest_smtp.get(job.id),
|
latest_smtp=latest_smtp.get(job.id),
|
||||||
latest_imap=latest_imap.get(job.id),
|
latest_imap=latest_imap.get(job.id),
|
||||||
|
include_diagnostics=include_diagnostics,
|
||||||
)
|
)
|
||||||
for job in jobs
|
for job in jobs
|
||||||
]
|
]
|
||||||
@@ -595,8 +653,6 @@ def generate_jobs_csv(
|
|||||||
"imap_status",
|
"imap_status",
|
||||||
"attempt_count",
|
"attempt_count",
|
||||||
"queued_at",
|
"queued_at",
|
||||||
"claimed_at",
|
|
||||||
"smtp_started_at",
|
|
||||||
"outcome_unknown_at",
|
"outcome_unknown_at",
|
||||||
"sent_at",
|
"sent_at",
|
||||||
"last_error",
|
"last_error",
|
||||||
@@ -609,18 +665,17 @@ def generate_jobs_csv(
|
|||||||
"latest_smtp_attempt_number",
|
"latest_smtp_attempt_number",
|
||||||
"latest_smtp_status",
|
"latest_smtp_status",
|
||||||
"latest_smtp_status_code",
|
"latest_smtp_status_code",
|
||||||
"latest_smtp_response",
|
|
||||||
"latest_smtp_error_type",
|
|
||||||
"latest_smtp_error_message",
|
|
||||||
"latest_smtp_started_at",
|
"latest_smtp_started_at",
|
||||||
"latest_smtp_finished_at",
|
"latest_smtp_finished_at",
|
||||||
"latest_imap_attempt_number",
|
"latest_imap_attempt_number",
|
||||||
"latest_imap_status",
|
"latest_imap_status",
|
||||||
"latest_imap_folder",
|
"latest_imap_folder",
|
||||||
"latest_imap_error_message",
|
|
||||||
"latest_imap_created_at",
|
"latest_imap_created_at",
|
||||||
"latest_imap_updated_at",
|
"latest_imap_updated_at",
|
||||||
]
|
]
|
||||||
|
if include_diagnostics:
|
||||||
|
sent_at_index = fieldnames.index("outcome_unknown_at")
|
||||||
|
fieldnames[sent_at_index:sent_at_index] = ["claimed_at", "smtp_started_at"]
|
||||||
buffer = io.StringIO()
|
buffer = io.StringIO()
|
||||||
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
|
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ from typing import Any
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
||||||
from govoplan_campaign.backend.campaign.loader import load_campaign_config
|
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||||
from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
|
||||||
from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot
|
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||||
from govoplan_campaign.backend.integrations import SmtpConfigurationError, mail_integration
|
from govoplan_campaign.backend.integrations import SmtpConfigurationError
|
||||||
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
|
||||||
|
|
||||||
|
|
||||||
class CampaignReportEmailError(RuntimeError):
|
class CampaignReportEmailError(RuntimeError):
|
||||||
@@ -30,8 +31,6 @@ class CampaignReportEmailResult:
|
|||||||
sent: bool
|
sent: bool
|
||||||
attached_jobs_csv: bool
|
attached_jobs_csv: bool
|
||||||
attached_report_json: bool
|
attached_report_json: bool
|
||||||
smtp_host: str | None = None
|
|
||||||
smtp_port: int | None = None
|
|
||||||
accepted_count: int | None = None
|
accepted_count: int | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
@@ -44,8 +43,6 @@ class CampaignReportEmailResult:
|
|||||||
"sent": self.sent,
|
"sent": self.sent,
|
||||||
"attached_jobs_csv": self.attached_jobs_csv,
|
"attached_jobs_csv": self.attached_jobs_csv,
|
||||||
"attached_report_json": self.attached_report_json,
|
"attached_report_json": self.attached_report_json,
|
||||||
"smtp_host": self.smtp_host,
|
|
||||||
"smtp_port": self.smtp_port,
|
|
||||||
"accepted_count": self.accepted_count,
|
"accepted_count": self.accepted_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,18 +59,15 @@ def _selected_version(
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
def _load_config(version: CampaignVersion) -> CampaignConfig:
|
def _load_config(session: Session, version: CampaignVersion) -> CampaignConfig:
|
||||||
snapshot_path = _write_campaign_snapshot(version)
|
_campaign, _version, config = load_version_config(session, version.id)
|
||||||
return load_campaign_config(snapshot_path)
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
|
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
|
||||||
if config.recipients.from_:
|
if config.recipients.from_:
|
||||||
return config.recipients.from_[0].email, config.recipients.from_[0].name
|
return config.recipients.from_[0].email, config.recipients.from_[0].name
|
||||||
smtp_config = config.server.runtime_smtp_config()
|
raise SmtpConfigurationError("Report email requires a Campaign-owned recipients.from address")
|
||||||
if smtp_config and smtp_config.username and "@" in smtp_config.username:
|
|
||||||
return smtp_config.username, None
|
|
||||||
raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address")
|
|
||||||
|
|
||||||
|
|
||||||
def _text_summary(report: dict[str, Any]) -> str:
|
def _text_summary(report: dict[str, Any]) -> str:
|
||||||
@@ -150,7 +144,7 @@ def send_campaign_report_email(
|
|||||||
version_id: str | None = None,
|
version_id: str | None = None,
|
||||||
to: list[str],
|
to: list[str],
|
||||||
include_jobs: bool = False,
|
include_jobs: bool = False,
|
||||||
attach_jobs_csv: bool = True,
|
attach_jobs_csv: bool = False,
|
||||||
attach_report_json: bool = False,
|
attach_report_json: bool = False,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
) -> CampaignReportEmailResult:
|
) -> CampaignReportEmailResult:
|
||||||
@@ -161,10 +155,28 @@ def send_campaign_report_email(
|
|||||||
raise CampaignReportEmailError("At least one report recipient is required")
|
raise CampaignReportEmailError("At least one report recipient is required")
|
||||||
|
|
||||||
version = _selected_version(session, campaign, version_id)
|
version = _selected_version(session, campaign, version_id)
|
||||||
config = _load_config(version)
|
config = _load_config(session, version)
|
||||||
smtp_config: SmtpConfig | None = config.server.runtime_smtp_config()
|
if not config.server.profile_capabilities.smtp_available:
|
||||||
if smtp_config is None:
|
|
||||||
raise SmtpConfigurationError("Campaign has no SMTP configuration")
|
raise SmtpConfigurationError("Campaign has no SMTP configuration")
|
||||||
|
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||||
|
if not profile_id:
|
||||||
|
raise SmtpConfigurationError("Campaign has no Mail profile reference")
|
||||||
|
if not isinstance(version.execution_snapshot, dict):
|
||||||
|
raise CampaignReportEmailError(
|
||||||
|
"Report email requires a validated and built campaign version with stored Mail-profile evidence."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
snapshot = ensure_execution_snapshot(session, version)
|
||||||
|
except ExecutionSnapshotError as exc:
|
||||||
|
raise CampaignReportEmailError(
|
||||||
|
"Report email requires a validated and built campaign version with current Mail-profile evidence."
|
||||||
|
) from exc
|
||||||
|
if not snapshot.smtp_transport_revision:
|
||||||
|
raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision")
|
||||||
|
if not dry_run:
|
||||||
|
raise CampaignReportEmailError(
|
||||||
|
"Report email delivery is disabled until it uses a durable, idempotent Mail-owned outbox with unknown-outcome reconciliation."
|
||||||
|
)
|
||||||
|
|
||||||
report = generate_campaign_report(
|
report = generate_campaign_report(
|
||||||
session,
|
session,
|
||||||
@@ -172,6 +184,7 @@ def send_campaign_report_email(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version.id,
|
version_id=version.id,
|
||||||
include_jobs=include_jobs,
|
include_jobs=include_jobs,
|
||||||
|
include_recent_failures=include_jobs,
|
||||||
)
|
)
|
||||||
jobs_csv = (
|
jobs_csv = (
|
||||||
generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id)
|
generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id)
|
||||||
@@ -187,38 +200,13 @@ def send_campaign_report_email(
|
|||||||
jobs_csv=jobs_csv,
|
jobs_csv=jobs_csv,
|
||||||
report_json=report_json,
|
report_json=report_json,
|
||||||
)
|
)
|
||||||
envelope_from, _ = _effective_from(config)
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
return CampaignReportEmailResult(
|
|
||||||
campaign_id=campaign.id,
|
|
||||||
version_id=version.id,
|
|
||||||
to=to,
|
|
||||||
subject=str(message["Subject"]),
|
|
||||||
dry_run=True,
|
|
||||||
sent=False,
|
|
||||||
attached_jobs_csv=jobs_csv is not None,
|
|
||||||
attached_report_json=report_json is not None,
|
|
||||||
smtp_host=smtp_config.host,
|
|
||||||
smtp_port=smtp_config.port,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = mail_integration().send_email_message(
|
|
||||||
message,
|
|
||||||
smtp_config=smtp_config,
|
|
||||||
envelope_from=envelope_from,
|
|
||||||
envelope_recipients=to,
|
|
||||||
)
|
|
||||||
return CampaignReportEmailResult(
|
return CampaignReportEmailResult(
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
version_id=version.id,
|
version_id=version.id,
|
||||||
to=to,
|
to=to,
|
||||||
subject=str(message["Subject"]),
|
subject=str(message["Subject"]),
|
||||||
dry_run=False,
|
dry_run=True,
|
||||||
sent=True,
|
sent=False,
|
||||||
attached_jobs_csv=jobs_csv is not None,
|
attached_jobs_csv=jobs_csv is not None,
|
||||||
attached_report_json=report_json is not None,
|
attached_report_json=report_json is not None,
|
||||||
smtp_host=result.host,
|
|
||||||
smtp_port=result.port,
|
|
||||||
accepted_count=result.accepted_count,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import copy
|
|||||||
from pathlib import Path, PureWindowsPath
|
from pathlib import Path, PureWindowsPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import public_campaign_mail_server
|
||||||
|
|
||||||
|
|
||||||
# These fields locate process-local or storage-backend resources, or authorize
|
# These fields locate process-local or storage-backend resources, or authorize
|
||||||
# a worker claim. They are useful for tightly controlled diagnostics but are
|
# a worker claim. They are useful for tightly controlled diagnostics but are
|
||||||
@@ -22,23 +24,65 @@ CAMPAIGN_INTERNAL_RESPONSE_KEYS = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"background_workers_enabled",
|
||||||
|
"build_token",
|
||||||
|
"celery_enabled",
|
||||||
|
"claimed_at",
|
||||||
|
"effective_policy_sha256",
|
||||||
|
"execution_input_sha256",
|
||||||
|
"imap_claimed_at",
|
||||||
|
"imap_transport_revision",
|
||||||
|
"job_manifest_sha256",
|
||||||
|
"smtp_started_at",
|
||||||
|
"smtp_transport_revision",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def public_campaign_payload(value: Any) -> Any:
|
|
||||||
|
def public_campaign_payload(value: Any, *, include_diagnostics: bool = False) -> Any:
|
||||||
"""Return a detached payload without infrastructure-only locators."""
|
"""Return a detached payload without infrastructure-only locators."""
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
|
blocked_keys = CAMPAIGN_INTERNAL_RESPONSE_KEYS
|
||||||
|
if not include_diagnostics:
|
||||||
|
blocked_keys = blocked_keys | CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS
|
||||||
return {
|
return {
|
||||||
key: public_campaign_payload(item)
|
key: public_campaign_payload(item, include_diagnostics=include_diagnostics)
|
||||||
for key, item in value.items()
|
for key, item in value.items()
|
||||||
if key not in CAMPAIGN_INTERNAL_RESPONSE_KEYS
|
if key not in blocked_keys
|
||||||
}
|
}
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [public_campaign_payload(item) for item in value]
|
return [public_campaign_payload(item, include_diagnostics=include_diagnostics) for item in value]
|
||||||
if isinstance(value, tuple):
|
if isinstance(value, tuple):
|
||||||
return tuple(public_campaign_payload(item) for item in value)
|
return tuple(public_campaign_payload(item, include_diagnostics=include_diagnostics) for item in value)
|
||||||
return copy.deepcopy(value)
|
return copy.deepcopy(value)
|
||||||
|
|
||||||
|
|
||||||
|
def public_delivery_result_message(
|
||||||
|
*,
|
||||||
|
last_error: Any,
|
||||||
|
send_status: Any,
|
||||||
|
imap_status: Any,
|
||||||
|
) -> str | None:
|
||||||
|
"""Map persisted provider text to a stable business-safe explanation."""
|
||||||
|
|
||||||
|
if not last_error:
|
||||||
|
return None
|
||||||
|
clean_send_status = str(send_status or "")
|
||||||
|
clean_imap_status = str(imap_status or "")
|
||||||
|
if clean_send_status == "outcome_unknown":
|
||||||
|
return "SMTP delivery outcome requires operator reconciliation."
|
||||||
|
if clean_send_status in {"failed_temporary", "failed_permanent"}:
|
||||||
|
return "SMTP delivery failed; an operator can inspect restricted diagnostics."
|
||||||
|
if clean_imap_status in {"outcome_unknown", "appending"}:
|
||||||
|
return "Sent-folder append outcome requires operator reconciliation."
|
||||||
|
if clean_imap_status in {"failed", "skipped"}:
|
||||||
|
return "Sent-folder append did not complete; an operator can inspect restricted diagnostics."
|
||||||
|
return "Delivery recorded a warning; an operator can inspect restricted diagnostics."
|
||||||
|
|
||||||
|
|
||||||
def public_campaign_configuration(value: Any) -> Any:
|
def public_campaign_configuration(value: Any) -> Any:
|
||||||
"""Return campaign JSON without infrastructure locators or mail secrets.
|
"""Return campaign JSON without infrastructure locators or mail secrets.
|
||||||
|
|
||||||
@@ -49,18 +93,8 @@ def public_campaign_configuration(value: Any) -> Any:
|
|||||||
payload = public_campaign_payload(value)
|
payload = public_campaign_payload(value)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return payload
|
return payload
|
||||||
server = payload.get("server")
|
if "server" in payload:
|
||||||
if isinstance(server, dict):
|
payload["server"] = public_campaign_mail_server(payload)
|
||||||
for protocol in ("smtp", "imap"):
|
|
||||||
config = server.get(protocol)
|
|
||||||
if isinstance(config, dict):
|
|
||||||
config.pop("password", None)
|
|
||||||
credentials = server.get("credentials")
|
|
||||||
if isinstance(credentials, dict):
|
|
||||||
for protocol in ("smtp", "imap"):
|
|
||||||
config = credentials.get(protocol)
|
|
||||||
if isinstance(config, dict):
|
|
||||||
config.pop("password", None)
|
|
||||||
_sanitize_configuration_paths(payload)
|
_sanitize_configuration_paths(payload)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,11 @@ def _apply_eml_retention(
|
|||||||
if job.queue_status in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value} or job.send_status not in FINAL_EML_SEND_STATUSES:
|
if job.queue_status in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value} or job.send_status not in FINAL_EML_SEND_STATUSES:
|
||||||
result["skipped_not_final"] += 1
|
result["skipped_not_final"] += 1
|
||||||
continue
|
continue
|
||||||
if job.imap_status == JobImapStatus.PENDING.value:
|
if job.imap_status in {
|
||||||
|
JobImapStatus.PENDING.value,
|
||||||
|
JobImapStatus.APPENDING.value,
|
||||||
|
JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
}:
|
||||||
result["skipped_not_final"] += 1
|
result["skipped_not_final"] += 1
|
||||||
continue
|
continue
|
||||||
result["eligible"] += 1
|
result["eligible"] += 1
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -94,12 +95,18 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
CampaignVersionWorkflowState,
|
CampaignVersionWorkflowState,
|
||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
|
JobImapStatus,
|
||||||
|
JobQueueStatus,
|
||||||
|
JobSendStatus,
|
||||||
RecipientImportMappingProfile,
|
RecipientImportMappingProfile,
|
||||||
SendAttempt,
|
SendAttempt,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||||
from govoplan_campaign.backend.response_security import public_campaign_payload
|
from govoplan_campaign.backend.response_security import (
|
||||||
|
public_campaign_payload,
|
||||||
|
public_delivery_result_message,
|
||||||
|
)
|
||||||
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.persistence.campaigns import (
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
CampaignPersistenceError,
|
CampaignPersistenceError,
|
||||||
@@ -108,9 +115,15 @@ from govoplan_campaign.backend.persistence.campaigns import (
|
|||||||
load_campaign_config_from_json,
|
load_campaign_config_from_json,
|
||||||
validate_campaign_version,
|
validate_campaign_version,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.integrations import files_integration
|
from govoplan_campaign.backend.integrations import (
|
||||||
|
MailProfileError,
|
||||||
|
SmtpConfigurationError,
|
||||||
|
SmtpSendError,
|
||||||
|
files_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
|
||||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
|
||||||
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
|
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
|
||||||
from govoplan_core.security.time import utc_now
|
from govoplan_core.security.time import utc_now
|
||||||
from govoplan_campaign.backend.persistence.versions import (
|
from govoplan_campaign.backend.persistence.versions import (
|
||||||
@@ -130,6 +143,7 @@ from govoplan_campaign.backend.persistence.versions import (
|
|||||||
update_campaign_review_state,
|
update_campaign_review_state,
|
||||||
validate_campaign_partial,
|
validate_campaign_partial,
|
||||||
)
|
)
|
||||||
|
|
||||||
from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
|
from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
|
||||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
|
||||||
from govoplan_campaign.backend.sending.jobs import (
|
from govoplan_campaign.backend.sending.jobs import (
|
||||||
@@ -147,6 +161,7 @@ from govoplan_campaign.backend.sending.jobs import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs"
|
CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs"
|
||||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||||
@@ -341,14 +356,16 @@ def _recipient_sections_changed(current: dict[str, object] | None, proposed: dic
|
|||||||
|
|
||||||
|
|
||||||
def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None:
|
def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None:
|
||||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
return campaign_mail_profile_id(raw_json)
|
||||||
value = server.get("mail_profile_id") if isinstance(server, dict) else None
|
|
||||||
return str(value) if value else None
|
|
||||||
|
|
||||||
|
|
||||||
def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[str, object] | None) -> None:
|
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"):
|
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_servers: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(
|
def _campaign_version_detail_response(
|
||||||
@@ -373,14 +390,22 @@ def _campaign_version_detail_response(
|
|||||||
details=audit_details,
|
details=audit_details,
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return CampaignVersionDetailResponse.model_validate(version)
|
_write_current_version_snapshot_if_available(version)
|
||||||
|
return CampaignVersionDetailResponse.model_validate(
|
||||||
|
version,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
)
|
||||||
except LockedCampaignVersionError as exc:
|
except LockedCampaignVersionError as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
except CampaignPathSecurityError as exc:
|
except CampaignPathSecurityError as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
session.rollback()
|
||||||
if validation_error_status is None:
|
if validation_error_status is None:
|
||||||
raise
|
raise
|
||||||
raise HTTPException(status_code=validation_error_status, detail=str(exc)) from exc
|
raise HTTPException(status_code=validation_error_status, detail=str(exc)) from exc
|
||||||
@@ -419,12 +444,15 @@ def _update_campaign_version_detail_response(
|
|||||||
source_filename=payload.source_filename,
|
source_filename=payload.source_filename,
|
||||||
source_base_path=payload.source_base_path,
|
source_base_path=payload.source_base_path,
|
||||||
autosave=autosave,
|
autosave=autosave,
|
||||||
|
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
|
||||||
|
commit=False,
|
||||||
),
|
),
|
||||||
audit_action=audit_action,
|
audit_action=audit_action,
|
||||||
details=lambda version: {
|
details=lambda version: {
|
||||||
"campaign_id": campaign_id,
|
"campaign_id": campaign_id,
|
||||||
"current_flow": version.current_flow,
|
"current_flow": version.current_flow,
|
||||||
"current_step": version.current_step,
|
"current_step": version.current_step,
|
||||||
|
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||||
},
|
},
|
||||||
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
)
|
)
|
||||||
@@ -446,6 +474,23 @@ def _require_campaign_profile_use_if_needed(
|
|||||||
_require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {})
|
_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(
|
def _get_version_for_principal(
|
||||||
session: Session,
|
session: Session,
|
||||||
version_id: str,
|
version_id: str,
|
||||||
@@ -575,6 +620,7 @@ def create_campaign(
|
|||||||
raw_json=payload.config,
|
raw_json=payload.config,
|
||||||
source_filename=payload.source_filename,
|
source_filename=payload.source_filename,
|
||||||
source_base_path=payload.source_base_path,
|
source_base_path=payload.source_base_path,
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
@@ -585,11 +631,20 @@ def create_campaign(
|
|||||||
details={"version_id": version.id, "external_id": campaign.external_id},
|
details={"version_id": version.id, "external_id": campaign.external_id},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
|
_write_current_version_snapshot_if_available(version)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
session.rollback()
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version))
|
return CampaignCreateResponse(
|
||||||
|
campaign=CampaignResponse.model_validate(campaign),
|
||||||
|
version=CampaignVersionResponse.model_validate(
|
||||||
|
version,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/new", response_model=CampaignCreateResponse)
|
@router.post("/new", response_model=CampaignCreateResponse)
|
||||||
@@ -615,6 +670,7 @@ def create_minimal_campaign_endpoint(
|
|||||||
description=payload.description,
|
description=payload.description,
|
||||||
current_flow=payload.current_flow,
|
current_flow=payload.current_flow,
|
||||||
current_step=payload.current_step,
|
current_step=payload.current_step,
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
@@ -625,9 +681,20 @@ def create_minimal_campaign_endpoint(
|
|||||||
details={"version_id": version.id, "external_id": campaign.external_id},
|
details={"version_id": version.id, "external_id": campaign.external_id},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version))
|
_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 CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1009,14 +1076,30 @@ def _campaign_workspace_response(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=selected_version_id,
|
version_id=selected_version_id,
|
||||||
include_jobs=False,
|
include_jobs=False,
|
||||||
|
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||||
)
|
)
|
||||||
except CampaignReportError:
|
except CampaignReportError:
|
||||||
summary_payload = None
|
summary_payload = None
|
||||||
|
|
||||||
return CampaignWorkspaceResponse(
|
return CampaignWorkspaceResponse(
|
||||||
campaign=CampaignResponse.model_validate(campaign),
|
campaign=CampaignResponse.model_validate(campaign),
|
||||||
versions=[CampaignVersionResponse.model_validate(item) for item in versions] if include_versions else [],
|
versions=[
|
||||||
current_version=CampaignVersionDetailResponse.model_validate(current_version) if current_version is not None else None,
|
CampaignVersionResponse.model_validate(
|
||||||
|
item,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
)
|
||||||
|
for item in versions
|
||||||
|
]
|
||||||
|
if include_versions
|
||||||
|
else [],
|
||||||
|
current_version=(
|
||||||
|
CampaignVersionDetailResponse.model_validate(
|
||||||
|
current_version,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
)
|
||||||
|
if current_version is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
summary=summary_payload,
|
summary=summary_payload,
|
||||||
selected_version_id=selected_version_id,
|
selected_version_id=selected_version_id,
|
||||||
)
|
)
|
||||||
@@ -1131,7 +1214,13 @@ def _campaign_workspace_delta_response(
|
|||||||
)
|
)
|
||||||
return CampaignWorkspaceDeltaResponse(
|
return CampaignWorkspaceDeltaResponse(
|
||||||
campaign=CampaignResponse.model_validate(campaign) if delta.changed_campaign else None,
|
campaign=CampaignResponse.model_validate(campaign) if delta.changed_campaign else None,
|
||||||
versions=_campaign_workspace_delta_versions(session, campaign, delta, include_versions=include_versions),
|
versions=_campaign_workspace_delta_versions(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
campaign,
|
||||||
|
delta,
|
||||||
|
include_versions=include_versions,
|
||||||
|
),
|
||||||
current_version=_campaign_workspace_delta_current_version(
|
current_version=_campaign_workspace_delta_current_version(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -1204,6 +1293,7 @@ def _campaign_workspace_delta_deleted(entries: list[object]) -> list[dict[str, o
|
|||||||
|
|
||||||
def _campaign_workspace_delta_versions(
|
def _campaign_workspace_delta_versions(
|
||||||
session: Session,
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
campaign: Campaign,
|
campaign: Campaign,
|
||||||
delta: _WorkspaceDeltaState,
|
delta: _WorkspaceDeltaState,
|
||||||
*,
|
*,
|
||||||
@@ -1217,7 +1307,13 @@ def _campaign_workspace_delta_versions(
|
|||||||
.order_by(CampaignVersion.version_number.desc())
|
.order_by(CampaignVersion.version_number.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [CampaignVersionResponse.model_validate(item) for item in versions]
|
return [
|
||||||
|
CampaignVersionResponse.model_validate(
|
||||||
|
item,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
)
|
||||||
|
for item in versions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _campaign_workspace_delta_current_version(
|
def _campaign_workspace_delta_current_version(
|
||||||
@@ -1238,7 +1334,14 @@ def _campaign_workspace_delta_current_version(
|
|||||||
.filter(CampaignVersion.id == delta.selected_version_id, CampaignVersion.campaign_id == campaign.id)
|
.filter(CampaignVersion.id == delta.selected_version_id, CampaignVersion.campaign_id == campaign.id)
|
||||||
.one_or_none()
|
.one_or_none()
|
||||||
)
|
)
|
||||||
return CampaignVersionDetailResponse.model_validate(current_version) if current_version is not None else None
|
return (
|
||||||
|
CampaignVersionDetailResponse.model_validate(
|
||||||
|
current_version,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
)
|
||||||
|
if current_version is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _campaign_workspace_delta_summary(
|
def _campaign_workspace_delta_summary(
|
||||||
@@ -1260,6 +1363,7 @@ def _campaign_workspace_delta_summary(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=delta.selected_version_id,
|
version_id=delta.selected_version_id,
|
||||||
include_jobs=False,
|
include_jobs=False,
|
||||||
|
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||||
)
|
)
|
||||||
except CampaignReportError:
|
except CampaignReportError:
|
||||||
return None
|
return None
|
||||||
@@ -1342,17 +1446,25 @@ def update_campaign_metadata_endpoint(
|
|||||||
|
|
||||||
_sync_campaign_metadata_to_current_version(session, campaign)
|
_sync_campaign_metadata_to_current_version(session, campaign)
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.commit()
|
session.flush()
|
||||||
|
try:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.metadata_updated",
|
||||||
|
object_type="campaign",
|
||||||
|
object_id=campaign.id,
|
||||||
|
details={"external_id": campaign.external_id, "name": campaign.name},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
session.refresh(campaign)
|
session.refresh(campaign)
|
||||||
audit_from_principal(
|
if campaign.current_version_id:
|
||||||
session,
|
current_version = session.get(CampaignVersion, campaign.current_version_id)
|
||||||
principal,
|
if current_version is not None:
|
||||||
action="campaign.metadata_updated",
|
_write_current_version_snapshot_if_available(current_version)
|
||||||
object_type="campaign",
|
|
||||||
object_id=campaign.id,
|
|
||||||
details={"external_id": campaign.external_id, "name": campaign.name},
|
|
||||||
commit=True,
|
|
||||||
)
|
|
||||||
return CampaignResponse.model_validate(campaign)
|
return CampaignResponse.model_validate(campaign)
|
||||||
|
|
||||||
|
|
||||||
@@ -1422,7 +1534,13 @@ def list_versions(
|
|||||||
.order_by(CampaignVersion.version_number.desc())
|
.order_by(CampaignVersion.version_number.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [CampaignVersionResponse.model_validate(item) for item in versions]
|
return [
|
||||||
|
CampaignVersionResponse.model_validate(
|
||||||
|
item,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
)
|
||||||
|
for item in versions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
|
@router.get("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
|
||||||
@@ -1438,7 +1556,10 @@ def get_version_detail(
|
|||||||
version = get_campaign_version_for_tenant(
|
version = get_campaign_version_for_tenant(
|
||||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id
|
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id
|
||||||
)
|
)
|
||||||
return CampaignVersionDetailResponse.model_validate(version)
|
return CampaignVersionDetailResponse.model_validate(
|
||||||
|
version,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
)
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
@@ -1461,7 +1582,18 @@ def fork_version_for_edit(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
payload = payload or CampaignVersionUpdateRequest()
|
payload = payload or CampaignVersionUpdateRequest()
|
||||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
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:
|
try:
|
||||||
version = fork_campaign_version_for_edit(
|
version = fork_campaign_version_for_edit(
|
||||||
session,
|
session,
|
||||||
@@ -1475,6 +1607,8 @@ def fork_version_for_edit(
|
|||||||
source_filename=payload.source_filename,
|
source_filename=payload.source_filename,
|
||||||
source_base_path=payload.source_base_path,
|
source_base_path=payload.source_base_path,
|
||||||
autosave=True,
|
autosave=True,
|
||||||
|
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
@@ -1483,19 +1617,34 @@ def fork_version_for_edit(
|
|||||||
action="campaign.version_forked_for_edit",
|
action="campaign.version_forked_for_edit",
|
||||||
object_type="campaign_version",
|
object_type="campaign_version",
|
||||||
object_id=version.id,
|
object_id=version.id,
|
||||||
details={"campaign_id": campaign_id, "source_version_id": version_id, "version_number": version.version_number},
|
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,
|
commit=True,
|
||||||
)
|
)
|
||||||
|
_write_current_version_snapshot_if_available(version)
|
||||||
return CampaignCreateResponse(
|
return CampaignCreateResponse(
|
||||||
campaign=CampaignResponse.model_validate(campaign),
|
campaign=CampaignResponse.model_validate(campaign),
|
||||||
version=CampaignVersionResponse.model_validate(version),
|
version=CampaignVersionResponse.model_validate(
|
||||||
|
version,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except LockedCampaignVersionError as exc:
|
except LockedCampaignVersionError as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
except CampaignPathSecurityError as exc:
|
except CampaignPathSecurityError as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
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)
|
@router.post("/{campaign_id}/versions/{version_id}/unlock-validation", response_model=CampaignVersionDetailResponse)
|
||||||
@@ -1521,6 +1670,7 @@ def unlock_version_validation(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
|
commit=False,
|
||||||
),
|
),
|
||||||
audit_action="campaign.version_validation_unlocked",
|
audit_action="campaign.version_validation_unlocked",
|
||||||
)
|
)
|
||||||
@@ -1544,6 +1694,7 @@ def lock_version_temporarily(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
user_id=principal.user.id,
|
user_id=principal.user.id,
|
||||||
|
commit=False,
|
||||||
),
|
),
|
||||||
audit_action="campaign.version_user_locked_temporarily",
|
audit_action="campaign.version_user_locked_temporarily",
|
||||||
)
|
)
|
||||||
@@ -1566,6 +1717,7 @@ def unlock_version_user_lock(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
|
commit=False,
|
||||||
),
|
),
|
||||||
audit_action="campaign.version_user_lock_removed",
|
audit_action="campaign.version_user_lock_removed",
|
||||||
)
|
)
|
||||||
@@ -1589,6 +1741,7 @@ def lock_version_permanently(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
user_id=principal.user.id,
|
user_id=principal.user.id,
|
||||||
|
commit=False,
|
||||||
),
|
),
|
||||||
audit_action="campaign.version_user_locked_permanently",
|
audit_action="campaign.version_user_locked_permanently",
|
||||||
)
|
)
|
||||||
@@ -1641,8 +1794,11 @@ def set_version_step(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||||
):
|
):
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
try:
|
return _campaign_version_detail_response(
|
||||||
version = update_campaign_version(
|
session,
|
||||||
|
principal,
|
||||||
|
campaign_id,
|
||||||
|
lambda: update_campaign_version(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
@@ -1650,12 +1806,15 @@ def set_version_step(
|
|||||||
current_flow=payload.current_flow,
|
current_flow=payload.current_flow,
|
||||||
current_step=payload.current_step,
|
current_step=payload.current_step,
|
||||||
autosave=True,
|
autosave=True,
|
||||||
)
|
commit=False,
|
||||||
return CampaignVersionDetailResponse.model_validate(version)
|
),
|
||||||
except LockedCampaignVersionError as exc:
|
audit_action="campaign.version_step_updated",
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
details={
|
||||||
except CampaignPersistenceError as exc:
|
"campaign_id": campaign_id,
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
"current_flow": payload.current_flow,
|
||||||
|
"current_step": payload.current_step,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/versions/{version_id}/review-state", response_model=CampaignVersionDetailResponse)
|
@router.post("/{campaign_id}/versions/{version_id}/review-state", response_model=CampaignVersionDetailResponse)
|
||||||
@@ -1676,6 +1835,7 @@ def set_version_review_state(
|
|||||||
inspection_complete=payload.inspection_complete,
|
inspection_complete=payload.inspection_complete,
|
||||||
reviewed_message_keys=payload.reviewed_message_keys,
|
reviewed_message_keys=payload.reviewed_message_keys,
|
||||||
user_id=principal.user.id,
|
user_id=principal.user.id,
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
@@ -1690,11 +1850,19 @@ def set_version_review_state(
|
|||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return CampaignVersionDetailResponse.model_validate(version)
|
return CampaignVersionDetailResponse.model_validate(
|
||||||
|
version,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
)
|
||||||
except LockedCampaignVersionError as exc:
|
except LockedCampaignVersionError as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse)
|
@router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse)
|
||||||
@@ -1744,6 +1912,7 @@ def publish_version(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
user_id=principal.user.id,
|
user_id=principal.user.id,
|
||||||
|
commit=False,
|
||||||
),
|
),
|
||||||
audit_action="campaign.version_user_locked_permanently",
|
audit_action="campaign.version_user_locked_permanently",
|
||||||
)
|
)
|
||||||
@@ -1814,7 +1983,10 @@ def validate_version(
|
|||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return public_campaign_payload(result)
|
return public_campaign_payload(
|
||||||
|
result,
|
||||||
|
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
@@ -1849,7 +2021,10 @@ def build_version(
|
|||||||
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
|
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return public_campaign_payload(result)
|
return public_campaign_payload(
|
||||||
|
result,
|
||||||
|
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||||
|
)
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1882,10 +2057,12 @@ def _job_summary_payload(
|
|||||||
"eml_size_bytes": job.eml_size_bytes,
|
"eml_size_bytes": job.eml_size_bytes,
|
||||||
"eml_sha256": job.eml_sha256,
|
"eml_sha256": job.eml_sha256,
|
||||||
"attempt_count": job.attempt_count,
|
"attempt_count": job.attempt_count,
|
||||||
"last_error": job.last_error,
|
"last_error": public_delivery_result_message(
|
||||||
|
last_error=job.last_error,
|
||||||
|
send_status=job.send_status,
|
||||||
|
imap_status=job.imap_status,
|
||||||
|
),
|
||||||
"queued_at": job.queued_at,
|
"queued_at": job.queued_at,
|
||||||
"claimed_at": job.claimed_at,
|
|
||||||
"smtp_started_at": job.smtp_started_at,
|
|
||||||
"outcome_unknown_at": job.outcome_unknown_at,
|
"outcome_unknown_at": job.outcome_unknown_at,
|
||||||
"sent_at": job.sent_at,
|
"sent_at": job.sent_at,
|
||||||
"created_at": job.created_at,
|
"created_at": job.created_at,
|
||||||
@@ -1925,14 +2102,14 @@ def _job_attempts_payload(
|
|||||||
"attempt_number": attempt.attempt_number,
|
"attempt_number": attempt.attempt_number,
|
||||||
"status": attempt.status,
|
"status": attempt.status,
|
||||||
"smtp_status_code": attempt.smtp_status_code,
|
"smtp_status_code": attempt.smtp_status_code,
|
||||||
"smtp_response": attempt.smtp_response,
|
|
||||||
"error_type": attempt.error_type,
|
|
||||||
"error_message": attempt.error_message,
|
|
||||||
"started_at": attempt.started_at,
|
"started_at": attempt.started_at,
|
||||||
"finished_at": attempt.finished_at,
|
"finished_at": attempt.finished_at,
|
||||||
}
|
}
|
||||||
if include_diagnostics:
|
if include_diagnostics:
|
||||||
payload["claim_token"] = attempt.claim_token
|
payload["claim_token"] = attempt.claim_token
|
||||||
|
payload["smtp_response"] = attempt.smtp_response
|
||||||
|
payload["error_type"] = attempt.error_type
|
||||||
|
payload["error_message"] = attempt.error_message
|
||||||
smtp_payloads.append(payload)
|
smtp_payloads.append(payload)
|
||||||
|
|
||||||
imap_payloads: list[dict[str, object]] = []
|
imap_payloads: list[dict[str, object]] = []
|
||||||
@@ -1942,12 +2119,12 @@ def _job_attempts_payload(
|
|||||||
"attempt_number": attempt.attempt_number,
|
"attempt_number": attempt.attempt_number,
|
||||||
"status": attempt.status,
|
"status": attempt.status,
|
||||||
"folder": attempt.folder,
|
"folder": attempt.folder,
|
||||||
"error_message": attempt.error_message,
|
|
||||||
"created_at": attempt.created_at,
|
"created_at": attempt.created_at,
|
||||||
"updated_at": attempt.updated_at,
|
"updated_at": attempt.updated_at,
|
||||||
}
|
}
|
||||||
if include_diagnostics:
|
if include_diagnostics:
|
||||||
payload["claim_token"] = attempt.claim_token
|
payload["claim_token"] = attempt.claim_token
|
||||||
|
payload["error_message"] = attempt.error_message
|
||||||
imap_payloads.append(payload)
|
imap_payloads.append(payload)
|
||||||
return {"smtp": smtp_payloads, "imap": imap_payloads}
|
return {"smtp": smtp_payloads, "imap": imap_payloads}
|
||||||
|
|
||||||
@@ -1972,6 +2149,7 @@ def _job_diagnostics_payload(
|
|||||||
"claimed_at": job.claimed_at,
|
"claimed_at": job.claimed_at,
|
||||||
"smtp_started_at": job.smtp_started_at,
|
"smtp_started_at": job.smtp_started_at,
|
||||||
"outcome_unknown_at": job.outcome_unknown_at,
|
"outcome_unknown_at": job.outcome_unknown_at,
|
||||||
|
"last_error": job.last_error,
|
||||||
},
|
},
|
||||||
attempts=_job_attempts_payload(
|
attempts=_job_attempts_payload(
|
||||||
send_attempts,
|
send_attempts,
|
||||||
@@ -2587,6 +2765,8 @@ def campaign_summary(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
include_jobs=include_jobs,
|
include_jobs=include_jobs,
|
||||||
|
include_recent_failures=include_jobs,
|
||||||
|
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||||
)
|
)
|
||||||
except CampaignReportError as exc:
|
except CampaignReportError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
@@ -2612,6 +2792,8 @@ def campaign_report(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
include_jobs=include_jobs,
|
include_jobs=include_jobs,
|
||||||
|
include_recent_failures=include_jobs,
|
||||||
|
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||||
)
|
)
|
||||||
except CampaignReportError as exc:
|
except CampaignReportError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
@@ -2630,7 +2812,11 @@ def campaign_jobs_csv(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
csv_text = generate_jobs_csv(
|
csv_text = generate_jobs_csv(
|
||||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id
|
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:
|
except CampaignReportError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
@@ -2650,8 +2836,15 @@ def email_campaign_report(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")),
|
principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")),
|
||||||
):
|
):
|
||||||
_get_campaign_for_principal(session, campaign_id, principal)
|
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||||
if payload.include_jobs:
|
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 {},
|
||||||
|
)
|
||||||
|
if payload.include_jobs or payload.attach_jobs_csv:
|
||||||
_require_permission(principal, "campaigns:recipient:export")
|
_require_permission(principal, "campaigns:recipient:export")
|
||||||
"""Generate a campaign report and send it to one or more email addresses."""
|
"""Generate a campaign report and send it to one or more email addresses."""
|
||||||
|
|
||||||
@@ -2679,8 +2872,14 @@ def email_campaign_report(
|
|||||||
return ReportEmailResponse(result=result.as_dict())
|
return ReportEmailResponse(result=result.as_dict())
|
||||||
except CampaignReportError as exc:
|
except CampaignReportError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
except (CampaignReportEmailError, Exception) as exc:
|
except (CampaignReportEmailError, MailProfileError, SmtpConfigurationError, SmtpSendError) as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2984,6 +3183,7 @@ def resolve_campaign_job_outcome(
|
|||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
decision=payload.decision,
|
decision=payload.decision,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
@@ -2996,7 +3196,11 @@ def resolve_campaign_job_outcome(
|
|||||||
)
|
)
|
||||||
return CampaignActionResponse(result=result)
|
return CampaignActionResponse(result=result)
|
||||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||||
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)
|
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)
|
||||||
@@ -3060,10 +3264,11 @@ def send_campaign_now_endpoint(
|
|||||||
):
|
):
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
_require_permission(principal, "campaigns:recipient:read")
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
"""Validate/build/queue and synchronously send a small campaign version.
|
"""Validate/build/queue and synchronously send every eligible job.
|
||||||
|
|
||||||
This endpoint is intentionally conservative and suitable for a first small
|
No server-side job-count bound is currently enforced. Ordinary batches
|
||||||
test campaign. Larger campaigns should use the queue/Celery flow.
|
must use the queue/Celery flow; this endpoint is for deliberately small
|
||||||
|
interactive runs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
payload = payload or SendCampaignNowRequest()
|
payload = payload or SendCampaignNowRequest()
|
||||||
@@ -3146,6 +3351,18 @@ def resume_campaign(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||||
):
|
):
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_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:
|
try:
|
||||||
result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
|
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)
|
audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True)
|
||||||
@@ -3178,7 +3395,23 @@ def append_sent(
|
|||||||
):
|
):
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
payload = payload or AppendSentRequest()
|
payload = payload or AppendSentRequest()
|
||||||
_require_campaign_profile_use_if_needed(session, principal, campaign_id)
|
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:
|
try:
|
||||||
result = enqueue_pending_imap_appends(
|
result = enqueue_pending_imap_appends(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -90,125 +90,9 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"mail_profile_id": {
|
"mail_profile_id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
},
|
"minLength": 1,
|
||||||
"inherit_smtp_credentials": {
|
"description": "Stable reference to an authorized profile owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials."
|
||||||
"type": "boolean",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"inherit_imap_credentials": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"smtp": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"host": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 65535
|
|
||||||
},
|
|
||||||
"username": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"plain",
|
|
||||||
"tls",
|
|
||||||
"starttls"
|
|
||||||
],
|
|
||||||
"default": "starttls"
|
|
||||||
},
|
|
||||||
"timeout_seconds": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"default": 30
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"imap": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false
|
|
||||||
},
|
|
||||||
"host": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 65535
|
|
||||||
},
|
|
||||||
"username": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"plain",
|
|
||||||
"tls",
|
|
||||||
"starttls"
|
|
||||||
],
|
|
||||||
"default": "tls"
|
|
||||||
},
|
|
||||||
"sent_folder": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "auto"
|
|
||||||
},
|
|
||||||
"timeout_seconds": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"default": 30
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"credentials": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"smtp": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"username": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"imap": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"username": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false,
|
|
||||||
"default": {
|
|
||||||
"smtp": {},
|
|
||||||
"imap": {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, 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_core.mail.config import ImapConfig, SmtpConfig
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
|
public_campaign_editor_state,
|
||||||
|
validate_campaign_editor_state,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.response_security import (
|
from govoplan_campaign.backend.response_security import (
|
||||||
public_campaign_configuration,
|
public_campaign_configuration,
|
||||||
public_campaign_payload,
|
public_campaign_payload,
|
||||||
@@ -55,6 +57,12 @@ class CampaignVersionUpdateRequest(BaseModel):
|
|||||||
editor_state: dict[str, Any] | None = None
|
editor_state: dict[str, Any] | None = None
|
||||||
source_filename: str | None = None
|
source_filename: str | None = None
|
||||||
source_base_path: str | None = None
|
source_base_path: str | None = None
|
||||||
|
migrate_legacy_mail_settings: bool = False
|
||||||
|
|
||||||
|
@field_validator("editor_state")
|
||||||
|
@classmethod
|
||||||
|
def validate_editor_state(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
return validate_campaign_editor_state(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
class CampaignVersionSetStepRequest(BaseModel):
|
class CampaignVersionSetStepRequest(BaseModel):
|
||||||
@@ -105,6 +113,14 @@ class CampaignVersionResponse(BaseModel):
|
|||||||
execution_snapshot_hash: str | None = None
|
execution_snapshot_hash: str | None = None
|
||||||
execution_snapshot_at: datetime | None = None
|
execution_snapshot_at: datetime | None = None
|
||||||
|
|
||||||
|
@field_validator("editor_state", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def remove_unsupported_editor_state(cls, value: Any, info: ValidationInfo) -> dict[str, Any]:
|
||||||
|
return public_campaign_editor_state(
|
||||||
|
value,
|
||||||
|
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("source_filename", mode="before")
|
@field_validator("source_filename", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def remove_source_directory(cls, value: Any) -> str | None:
|
def remove_source_directory(cls, value: Any) -> str | None:
|
||||||
@@ -112,12 +128,16 @@ class CampaignVersionResponse(BaseModel):
|
|||||||
|
|
||||||
@field_validator("validation_summary", "build_summary", mode="before")
|
@field_validator("validation_summary", "build_summary", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def remove_internal_summary_fields(cls, value: Any) -> Any:
|
def remove_internal_summary_fields(cls, value: Any, info: ValidationInfo) -> Any:
|
||||||
return public_campaign_payload(value)
|
return public_campaign_payload(
|
||||||
|
value,
|
||||||
|
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CampaignVersionDetailResponse(CampaignVersionResponse):
|
class CampaignVersionDetailResponse(CampaignVersionResponse):
|
||||||
raw_json: dict[str, Any]
|
raw_json: dict[str, Any]
|
||||||
|
mail_profile_migration_required: bool = False
|
||||||
|
|
||||||
@field_validator("raw_json", mode="before")
|
@field_validator("raw_json", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -412,8 +432,20 @@ class CampaignSendJobRequest(BaseModel):
|
|||||||
class CampaignResolveOutcomeRequest(BaseModel):
|
class CampaignResolveOutcomeRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
decision: Literal["smtp_accepted", "not_sent"]
|
decision: Literal[
|
||||||
note: str | None = None
|
"smtp_accepted",
|
||||||
|
"not_sent",
|
||||||
|
"imap_appended",
|
||||||
|
"imap_not_appended",
|
||||||
|
]
|
||||||
|
note: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest":
|
||||||
|
self.note = (self.note or "").strip()
|
||||||
|
if not self.note:
|
||||||
|
raise ValueError("Reconciliation requires an evidence note")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ValidateCampaignRequest(BaseModel):
|
class ValidateCampaignRequest(BaseModel):
|
||||||
@@ -429,125 +461,6 @@ class BuildCampaignRequest(BaseModel):
|
|||||||
write_eml: bool = True
|
write_eml: bool = True
|
||||||
|
|
||||||
|
|
||||||
class MailSmtpTestRequest(SmtpConfig):
|
|
||||||
"""SMTP settings supplied directly from the WebUI mail settings form."""
|
|
||||||
|
|
||||||
|
|
||||||
class MailImapTestRequest(ImapConfig):
|
|
||||||
"""IMAP settings supplied directly from the WebUI mail settings form."""
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
|
|
||||||
|
|
||||||
|
|
||||||
class MailCredentialPolicyPayload(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
inherit: bool | None = None
|
|
||||||
allow_override: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MailProfilePolicyPayload(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
allowed_profile_ids: list[str] = Field(default_factory=list)
|
|
||||||
allow_user_profiles: bool | None = None
|
|
||||||
allow_group_profiles: bool | None = None
|
|
||||||
allow_campaign_profiles: bool | None = None
|
|
||||||
smtp_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
|
||||||
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
|
||||||
whitelist: dict[str, list[str]] = Field(default_factory=dict)
|
|
||||||
blacklist: dict[str, list[str]] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class MailProfilePolicyUpdateRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
|
|
||||||
|
|
||||||
|
|
||||||
class MailProfilePolicyResponse(BaseModel):
|
|
||||||
scope_type: MailProfileScope
|
|
||||||
scope_id: str | None = None
|
|
||||||
policy: dict[str, Any]
|
|
||||||
effective_policy: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MailServerProfileCreateRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
name: str = Field(min_length=1, max_length=255)
|
|
||||||
slug: str | None = Field(default=None, max_length=100)
|
|
||||||
description: str | None = None
|
|
||||||
is_active: bool = True
|
|
||||||
scope_type: MailProfileScope = "tenant"
|
|
||||||
scope_id: str | None = None
|
|
||||||
smtp: SmtpConfig
|
|
||||||
imap: ImapConfig | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MailServerProfileUpdateRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, max_length=255)
|
|
||||||
slug: str | None = Field(default=None, max_length=100)
|
|
||||||
description: str | None = None
|
|
||||||
is_active: bool | None = None
|
|
||||||
smtp: SmtpConfig | None = None
|
|
||||||
imap: ImapConfig | None = None
|
|
||||||
clear_imap: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class MailServerProfileResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
tenant_id: str | None = None
|
|
||||||
scope_type: MailProfileScope = "tenant"
|
|
||||||
scope_id: str | None = None
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
description: str | None = None
|
|
||||||
is_active: bool
|
|
||||||
smtp: dict[str, Any]
|
|
||||||
imap: dict[str, Any] | None = None
|
|
||||||
smtp_password_configured: bool = False
|
|
||||||
imap_password_configured: bool = False
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class MailServerProfileListResponse(BaseModel):
|
|
||||||
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class MailConnectionTestResponse(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
protocol: Literal["smtp", "imap"]
|
|
||||||
host: str | None = None
|
|
||||||
port: int | None = None
|
|
||||||
security: str | None = None
|
|
||||||
message: str
|
|
||||||
details: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class MailImapFolderResponse(BaseModel):
|
|
||||||
name: str
|
|
||||||
flags: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class MailImapFolderListResponse(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
protocol: Literal["imap"] = "imap"
|
|
||||||
host: str | None = None
|
|
||||||
port: int | None = None
|
|
||||||
security: str | None = None
|
|
||||||
message: str
|
|
||||||
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
|
||||||
detected_sent_folder: str | None = None
|
|
||||||
details: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyCreateRequest(BaseModel):
|
class ApiKeyCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -630,13 +543,60 @@ class CampaignActionResponse(BaseModel):
|
|||||||
class ReportEmailRequest(BaseModel):
|
class ReportEmailRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
to: list[str]
|
to: list[str] = Field(min_length=1, max_length=50)
|
||||||
version_id: str | None = None
|
version_id: str | None = None
|
||||||
include_jobs: bool = False
|
include_jobs: bool = False
|
||||||
attach_jobs_csv: bool = True
|
attach_jobs_csv: bool = False
|
||||||
attach_report_json: bool = False
|
attach_report_json: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
||||||
|
@field_validator("to", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_and_validate_recipients(cls, value: Any) -> Any:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if not 1 <= len(value) <= 50:
|
||||||
|
raise ValueError("report email requires between 1 and 50 recipients")
|
||||||
|
recipients: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
raise ValueError("report recipients must be email-address strings")
|
||||||
|
recipient = item.strip()
|
||||||
|
if len(recipient) > 320:
|
||||||
|
raise ValueError("report recipient addresses must be at most 320 characters")
|
||||||
|
if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
|
||||||
|
raise ValueError("report recipient addresses must not contain control characters")
|
||||||
|
if recipient.count("@") != 1:
|
||||||
|
raise ValueError("report recipients must be email addresses")
|
||||||
|
local, domain = recipient.split("@", 1)
|
||||||
|
if (
|
||||||
|
not local
|
||||||
|
or not domain
|
||||||
|
or any(character.isspace() for character in recipient)
|
||||||
|
or any(character in ',;:<>[]()\\"' for character in recipient)
|
||||||
|
or local.startswith(".")
|
||||||
|
or local.endswith(".")
|
||||||
|
or ".." in local
|
||||||
|
or domain.startswith(".")
|
||||||
|
or domain.endswith(".")
|
||||||
|
or ".." in domain
|
||||||
|
or any(
|
||||||
|
not label
|
||||||
|
or label.startswith("-")
|
||||||
|
or label.endswith("-")
|
||||||
|
or not all(character.isalnum() or character == "-" for character in label)
|
||||||
|
for label in domain.split(".")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("report recipients must be email addresses")
|
||||||
|
key = recipient.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
recipients.append(recipient)
|
||||||
|
return recipients
|
||||||
|
|
||||||
|
|
||||||
class ReportEmailResponse(BaseModel):
|
class ReportEmailResponse(BaseModel):
|
||||||
result: dict[str, Any]
|
result: dict[str, Any]
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
||||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
|
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
|
CampaignMailProfileBoundaryError,
|
||||||
|
assert_campaign_uses_mail_profile_reference,
|
||||||
|
campaign_mail_profile_id,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
|
from govoplan_campaign.backend.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 = "3"
|
SNAPSHOT_VERSION = "5"
|
||||||
|
|
||||||
|
|
||||||
class ExecutionSnapshotError(RuntimeError):
|
class ExecutionSnapshotError(RuntimeError):
|
||||||
@@ -25,9 +30,9 @@ class ExecutionSnapshot(BaseModel):
|
|||||||
|
|
||||||
Rendered messages and attachment evidence remain normalized in
|
Rendered messages and attachment evidence remain normalized in
|
||||||
``CampaignJob`` and ``CampaignAttachmentUse``. This record freezes the
|
``CampaignJob`` and ``CampaignAttachmentUse``. This record freezes the
|
||||||
mutable transport/runtime configuration and cryptographically binds it to
|
Mail-profile reference, delivery policy, and opaque transport revisions and
|
||||||
the build and the exact set of persisted jobs without duplicating all
|
cryptographically binds them to the build and exact persisted jobs. Mail
|
||||||
recipient data into one large JSON value.
|
owns the resolved transport configuration and credentials.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
@@ -35,6 +40,7 @@ class ExecutionSnapshot(BaseModel):
|
|||||||
snapshot_version: str = SNAPSHOT_VERSION
|
snapshot_version: str = SNAPSHOT_VERSION
|
||||||
campaign_version_id: str
|
campaign_version_id: str
|
||||||
campaign_json_sha256: str
|
campaign_json_sha256: str
|
||||||
|
mail_profile_id: str
|
||||||
created_at: str
|
created_at: str
|
||||||
build_token: str | None = None
|
build_token: str | None = None
|
||||||
built_at: str | None = None
|
built_at: str | None = None
|
||||||
@@ -42,10 +48,8 @@ class ExecutionSnapshot(BaseModel):
|
|||||||
queueable_job_count: int = 0
|
queueable_job_count: int = 0
|
||||||
job_manifest_sha256: str | None = None
|
job_manifest_sha256: str | None = None
|
||||||
effective_policy_sha256: str | None = None
|
effective_policy_sha256: str | None = None
|
||||||
smtp_config_fingerprint: str | None = None
|
smtp_transport_revision: str | None = None
|
||||||
imap_config_fingerprint: str | None = None
|
imap_transport_revision: str | None = None
|
||||||
smtp: SmtpConfig
|
|
||||||
imap: ImapConfig | None = None
|
|
||||||
delivery: DeliveryConfig
|
delivery: DeliveryConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -61,110 +65,50 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
|
|||||||
return _sha256(payload)
|
return _sha256(payload)
|
||||||
|
|
||||||
|
|
||||||
def _transport_fingerprint(config: SmtpConfig | ImapConfig | None) -> str | None:
|
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
|
||||||
if config is None:
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
return None
|
_assert_version_mail_profile_boundary(raw_json)
|
||||||
payload = config.model_dump(mode="json")
|
|
||||||
# The fingerprint is safe to expose in reports. It identifies the effective
|
|
||||||
# account/transport settings without incorporating or revealing the secret.
|
|
||||||
if "password" in payload:
|
|
||||||
payload["password"] = "<configured>" if payload.get("password") else None
|
|
||||||
return _sha256(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpConfig | ImapConfig | None:
|
|
||||||
if config is None:
|
|
||||||
return None
|
|
||||||
payload = config.model_dump(mode="json")
|
|
||||||
payload["password"] = None
|
|
||||||
if isinstance(config, SmtpConfig):
|
|
||||||
return SmtpConfig.model_validate(payload)
|
|
||||||
return ImapConfig.model_validate(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None:
|
|
||||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
|
||||||
credentials = server.get("credentials") if isinstance(server, dict) and isinstance(server.get("credentials"), dict) else None
|
|
||||||
config = credentials.get(name) if isinstance(credentials, dict) and isinstance(credentials.get(name), dict) else None
|
|
||||||
password = config.get("password") if isinstance(config, dict) else None
|
|
||||||
if password is None:
|
|
||||||
legacy_config = server.get(name) if isinstance(server, dict) else None
|
|
||||||
password = legacy_config.get("password") if isinstance(legacy_config, dict) else None
|
|
||||||
if password is None:
|
|
||||||
return None
|
|
||||||
return str(password)
|
|
||||||
|
|
||||||
|
|
||||||
def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any]:
|
|
||||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
|
||||||
return server if isinstance(server, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_for_version(session: Session, version: CampaignVersion):
|
|
||||||
mail = mail_integration()
|
mail = mail_integration()
|
||||||
profile_id = mail.mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
|
profile_id = campaign_mail_profile_id(raw_json)
|
||||||
if not profile_id:
|
if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
|
||||||
return None
|
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if campaign is None:
|
if campaign is None:
|
||||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||||
try:
|
try:
|
||||||
return mail.ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
|
return mail.campaign_profile_delivery_summary(
|
||||||
|
session,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
raise ExecutionSnapshotError(str(exc)) from exc
|
raise ExecutionSnapshotError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> SmtpConfig:
|
def profile_transport_revisions(session: Session, version: CampaignVersion) -> dict[str, str | None]:
|
||||||
payload = snapshot.smtp.model_dump(mode="json")
|
summary = profile_delivery_summary(session, version)
|
||||||
if not payload.get("password"):
|
return {
|
||||||
server = _server_from_campaign_json(version.raw_json)
|
"smtp": summary.get("smtp_transport_revision"),
|
||||||
profile = _profile_for_version(session, version)
|
"imap": summary.get("imap_transport_revision"),
|
||||||
if profile is not None:
|
}
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
|
||||||
if campaign is None:
|
|
||||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
|
||||||
mail = mail_integration()
|
|
||||||
profile_payload = mail.smtp_config_from_profile(profile).model_dump(mode="json")
|
|
||||||
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
|
|
||||||
return SmtpConfig.model_validate(profile_payload)
|
|
||||||
return SmtpConfig.model_validate(mail.apply_campaign_credentials(profile_payload, server, "smtp"))
|
|
||||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp")
|
|
||||||
return SmtpConfig.model_validate(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> ImapConfig | None:
|
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
|
||||||
server = _server_from_campaign_json(version.raw_json)
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
if snapshot.imap is None:
|
_assert_version_mail_profile_boundary(raw_json)
|
||||||
profile = _profile_for_version(session, version)
|
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
|
||||||
if profile is None:
|
raise ExecutionSnapshotError(
|
||||||
return None
|
"The campaign's Mail profile reference differs from the built execution snapshot. "
|
||||||
mail = mail_integration()
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
imap = mail.imap_config_from_profile(profile)
|
)
|
||||||
if imap is None:
|
|
||||||
return None
|
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None:
|
||||||
if campaign is None:
|
try:
|
||||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True)
|
||||||
imap_payload = imap.model_dump(mode="json")
|
except CampaignMailProfileBoundaryError as exc:
|
||||||
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
raise ExecutionSnapshotError(str(exc)) from exc
|
||||||
return ImapConfig.model_validate(imap_payload)
|
|
||||||
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
|
|
||||||
payload = snapshot.imap.model_dump(mode="json")
|
|
||||||
if not payload.get("password"):
|
|
||||||
profile = _profile_for_version(session, version)
|
|
||||||
if profile is not None:
|
|
||||||
mail = mail_integration()
|
|
||||||
imap = mail.imap_config_from_profile(profile)
|
|
||||||
if imap is not None:
|
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
|
||||||
if campaign is None:
|
|
||||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
|
||||||
imap_payload = imap.model_dump(mode="json")
|
|
||||||
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
|
||||||
return ImapConfig.model_validate(imap_payload)
|
|
||||||
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
|
|
||||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap")
|
|
||||||
return ImapConfig.model_validate(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
|
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
|
||||||
@@ -180,70 +124,143 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"job_id": job.id,
|
||||||
|
"entry_index": job.entry_index,
|
||||||
|
"entry_id": job.entry_id,
|
||||||
|
"recipient_email": job.recipient_email,
|
||||||
|
"subject": job.subject,
|
||||||
|
"message_id_header": job.message_id_header,
|
||||||
|
"eml_size_bytes": job.eml_size_bytes,
|
||||||
|
"eml_sha256": job.eml_sha256,
|
||||||
|
"build_status": job.build_status,
|
||||||
|
"validation_status": job.validation_status,
|
||||||
|
"resolved_recipients_sha256": _sha256(job.resolved_recipients or {}),
|
||||||
|
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
|
||||||
|
"issues_sha256": _sha256(job.issues_snapshot or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def job_execution_input_hash(job: CampaignJob) -> str:
|
||||||
|
return _sha256(_job_execution_input_payload(job))
|
||||||
|
|
||||||
|
|
||||||
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
|
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
|
||||||
"""Hash the immutable per-message execution records in stable order."""
|
"""Hash the immutable per-message execution records in stable order."""
|
||||||
|
|
||||||
payload: list[dict[str, Any]] = []
|
payload = [
|
||||||
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)):
|
_job_execution_input_payload(job)
|
||||||
payload.append(
|
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
|
||||||
{
|
]
|
||||||
"job_id": job.id,
|
|
||||||
"entry_index": job.entry_index,
|
|
||||||
"entry_id": job.entry_id,
|
|
||||||
"recipient_email": job.recipient_email,
|
|
||||||
"subject": job.subject,
|
|
||||||
"message_id_header": job.message_id_header,
|
|
||||||
"eml_size_bytes": job.eml_size_bytes,
|
|
||||||
"eml_sha256": job.eml_sha256,
|
|
||||||
"build_status": job.build_status,
|
|
||||||
"validation_status": job.validation_status,
|
|
||||||
"resolved_recipients_sha256": _sha256(job.resolved_recipients or {}),
|
|
||||||
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
|
|
||||||
"issues_sha256": _sha256(job.issues_snapshot or []),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return _sha256(payload)
|
return _sha256(payload)
|
||||||
|
|
||||||
|
|
||||||
def create_execution_snapshot(
|
def create_execution_snapshot(
|
||||||
version: CampaignVersion,
|
version: CampaignVersion,
|
||||||
*,
|
*,
|
||||||
smtp: SmtpConfig,
|
mail_profile_id: str,
|
||||||
imap: ImapConfig | None,
|
smtp_transport_revision: str,
|
||||||
|
imap_transport_revision: str | None,
|
||||||
delivery: DeliveryConfig,
|
delivery: DeliveryConfig,
|
||||||
jobs: Iterable[CampaignJob] = (),
|
jobs: Iterable[CampaignJob] = (),
|
||||||
build_summary: dict[str, Any] | None = None,
|
build_summary: dict[str, Any] | None = None,
|
||||||
) -> tuple[dict[str, Any], str]:
|
) -> tuple[dict[str, Any], str]:
|
||||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
job_list = list(jobs)
|
job_list = list(jobs)
|
||||||
|
for job in job_list:
|
||||||
|
job.execution_input_sha256 = job_execution_input_hash(job)
|
||||||
summary = build_summary if isinstance(build_summary, dict) else {}
|
summary = build_summary if isinstance(build_summary, dict) else {}
|
||||||
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
||||||
redacted_smtp = _redacted_transport_config(smtp)
|
|
||||||
redacted_imap = _redacted_transport_config(imap)
|
|
||||||
if not isinstance(redacted_smtp, SmtpConfig):
|
|
||||||
raise ExecutionSnapshotError("Redacted SMTP configuration is invalid.")
|
|
||||||
if redacted_imap is not None and not isinstance(redacted_imap, ImapConfig):
|
|
||||||
raise ExecutionSnapshotError("Redacted IMAP configuration is invalid.")
|
|
||||||
payload = ExecutionSnapshot(
|
payload = ExecutionSnapshot(
|
||||||
campaign_version_id=version.id,
|
campaign_version_id=version.id,
|
||||||
campaign_json_sha256=_sha256(raw_json),
|
campaign_json_sha256=_sha256(raw_json),
|
||||||
|
mail_profile_id=mail_profile_id,
|
||||||
build_token=str(summary.get("build_token") or "") or None,
|
build_token=str(summary.get("build_token") or "") or None,
|
||||||
built_at=str(summary.get("built_at") or "") or None,
|
built_at=str(summary.get("built_at") or "") or None,
|
||||||
job_count=len(job_list),
|
job_count=len(job_list),
|
||||||
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
|
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
|
||||||
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
|
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
|
||||||
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
|
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
|
||||||
smtp_config_fingerprint=_transport_fingerprint(smtp),
|
smtp_transport_revision=smtp_transport_revision,
|
||||||
imap_config_fingerprint=_transport_fingerprint(imap),
|
imap_transport_revision=imap_transport_revision,
|
||||||
created_at=datetime.now(timezone.utc).isoformat(),
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
smtp=redacted_smtp,
|
|
||||||
imap=redacted_imap,
|
|
||||||
delivery=delivery,
|
delivery=delivery,
|
||||||
).model_dump(mode="json")
|
).model_dump(mode="json")
|
||||||
return payload, snapshot_hash(payload)
|
return payload, snapshot_hash(payload)
|
||||||
|
|
||||||
|
|
||||||
def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> ExecutionSnapshot:
|
def _assert_snapshot_matches_persisted_inputs(
|
||||||
|
session: Session,
|
||||||
|
version: CampaignVersion,
|
||||||
|
snapshot: ExecutionSnapshot,
|
||||||
|
*,
|
||||||
|
effect_job: CampaignJob | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fail closed when any build-bound input drifted after snapshot creation."""
|
||||||
|
|
||||||
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
|
if snapshot.campaign_version_id != version.id:
|
||||||
|
raise ExecutionSnapshotError("Execution snapshot campaign version mismatch")
|
||||||
|
if snapshot.campaign_json_sha256 != _sha256(raw_json):
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"Campaign inputs changed after this execution snapshot was built. "
|
||||||
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
|
)
|
||||||
|
if not snapshot.smtp_transport_revision:
|
||||||
|
raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision")
|
||||||
|
if not snapshot.job_manifest_sha256:
|
||||||
|
raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum")
|
||||||
|
if not snapshot.effective_policy_sha256:
|
||||||
|
raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum")
|
||||||
|
if snapshot.effective_policy_sha256 != _policy_fingerprint(raw_json, snapshot.delivery):
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"Campaign delivery policy changed after the execution snapshot was created. "
|
||||||
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
|
)
|
||||||
|
|
||||||
|
if effect_job is not None:
|
||||||
|
if effect_job.campaign_version_id != version.id:
|
||||||
|
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
|
||||||
|
if not getattr(effect_job, "execution_input_sha256", None):
|
||||||
|
raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery")
|
||||||
|
if effect_job.execution_input_sha256 != job_execution_input_hash(effect_job):
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"Built campaign job inputs changed after the execution snapshot was created. "
|
||||||
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
jobs = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(CampaignJob.campaign_version_id == version.id)
|
||||||
|
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
||||||
|
if snapshot.job_count != len(jobs):
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"Built campaign jobs changed after the execution snapshot was created. "
|
||||||
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
|
)
|
||||||
|
queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses)
|
||||||
|
if (
|
||||||
|
snapshot.queueable_job_count != queueable_count
|
||||||
|
or snapshot.job_manifest_sha256 != job_manifest_hash(jobs)
|
||||||
|
or any(getattr(job, "execution_input_sha256", None) != job_execution_input_hash(job) for job in jobs)
|
||||||
|
):
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"Built campaign job inputs changed after the execution snapshot was created. "
|
||||||
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_execution_snapshot(
|
||||||
|
session: Session,
|
||||||
|
version: CampaignVersion,
|
||||||
|
*,
|
||||||
|
effect_job: CampaignJob | None = None,
|
||||||
|
) -> ExecutionSnapshot:
|
||||||
"""Return a validated snapshot, creating one for pre-migration builds.
|
"""Return a validated snapshot, creating one for pre-migration builds.
|
||||||
|
|
||||||
New builds create the snapshot after persisting their jobs. The fallback is
|
New builds create the snapshot after persisting their jobs. The fallback is
|
||||||
@@ -251,27 +268,46 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
|
|||||||
without a manual data migration.
|
without a manual data migration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
try:
|
try:
|
||||||
assert_server_safe_campaign_paths(
|
assert_server_safe_campaign_paths(
|
||||||
version.raw_json if isinstance(version.raw_json, dict) else {},
|
raw_json,
|
||||||
managed_files_available=files_integration().available,
|
managed_files_available=files_integration().available,
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
||||||
if isinstance(version.execution_snapshot, dict):
|
if isinstance(version.execution_snapshot, dict):
|
||||||
|
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION:
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. "
|
||||||
|
"It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
|
||||||
|
"and rebuild a new campaign version."
|
||||||
|
)
|
||||||
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
||||||
expected = snapshot_hash(snapshot.model_dump(mode="json"))
|
expected = snapshot_hash(snapshot.model_dump(mode="json"))
|
||||||
if version.execution_snapshot_hash and version.execution_snapshot_hash != expected:
|
if not version.execution_snapshot_hash:
|
||||||
|
raise ExecutionSnapshotError("Execution snapshot checksum is missing")
|
||||||
|
if version.execution_snapshot_hash != expected:
|
||||||
raise ExecutionSnapshotError("Execution snapshot checksum mismatch")
|
raise ExecutionSnapshotError("Execution snapshot checksum mismatch")
|
||||||
|
_assert_snapshot_profile_matches_version(version, snapshot)
|
||||||
|
_assert_snapshot_matches_persisted_inputs(
|
||||||
|
session,
|
||||||
|
version,
|
||||||
|
snapshot,
|
||||||
|
effect_job=effect_job,
|
||||||
|
)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||||
|
|
||||||
_, _, config = load_version_config(session, version.id)
|
_, _, config = load_version_config(session, version.id)
|
||||||
runtime_smtp = config.server.runtime_smtp_config()
|
profile_id = campaign_mail_profile_id(raw_json)
|
||||||
if not runtime_smtp:
|
if not config.server.profile_capabilities.smtp_available:
|
||||||
raise ExecutionSnapshotError("Campaign has no SMTP configuration")
|
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)
|
||||||
@@ -280,10 +316,14 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
|
|||||||
)
|
)
|
||||||
if not jobs:
|
if not jobs:
|
||||||
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
||||||
|
revisions = profile_transport_revisions(session, version)
|
||||||
|
if not revisions["smtp"]:
|
||||||
|
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
|
||||||
payload, digest = create_execution_snapshot(
|
payload, digest = create_execution_snapshot(
|
||||||
version,
|
version,
|
||||||
smtp=runtime_smtp,
|
mail_profile_id=profile_id,
|
||||||
imap=config.server.runtime_imap_config(),
|
smtp_transport_revision=revisions["smtp"],
|
||||||
|
imap_transport_revision=revisions["imap"],
|
||||||
delivery=config.delivery,
|
delivery=config.delivery,
|
||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
SendAttempt,
|
SendAttempt,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot
|
||||||
from govoplan_campaign.backend.runtime import get_registry
|
from govoplan_campaign.backend.runtime import get_registry
|
||||||
from govoplan_campaign.backend.integrations import (
|
from govoplan_campaign.backend.integrations import (
|
||||||
ImapAppendError,
|
ImapAppendError,
|
||||||
@@ -544,10 +544,11 @@ def send_campaign_now(
|
|||||||
use_rate_limit: bool = True,
|
use_rate_limit: bool = True,
|
||||||
enqueue_imap_task: bool = False,
|
enqueue_imap_task: bool = False,
|
||||||
) -> SendCampaignNowResult:
|
) -> SendCampaignNowResult:
|
||||||
"""Queue and send a small campaign synchronously.
|
"""Queue and send all eligible jobs for one campaign synchronously.
|
||||||
|
|
||||||
This is intended for WebUI test/small-campaign flows. Large campaigns can
|
The service currently has no server-side job-count bound. Callers must use
|
||||||
still use queue_campaign_jobs with Celery workers.
|
the worker queue for ordinary batches and reserve this path for deliberately
|
||||||
|
small interactive runs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||||
@@ -1040,23 +1041,33 @@ def reconcile_job_outcome(
|
|||||||
job_id: str,
|
job_id: str,
|
||||||
decision: str,
|
decision: str,
|
||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
|
commit: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Record an operator decision for an uncertain/incomplete worker state.
|
"""Record an operator decision for an uncertain/incomplete worker state.
|
||||||
|
|
||||||
``smtp_accepted`` protects the message from retry and enables IMAP append.
|
``smtp_accepted`` protects the message from retry and enables IMAP append.
|
||||||
``not_sent`` converts it into a failed-temporary candidate; retry remains a
|
``not_sent`` converts it into a failed-temporary candidate; retry remains a
|
||||||
separate explicit action.
|
separate explicit action. ``imap_appended`` completes an uncertain append;
|
||||||
|
only the evidence-backed ``imap_not_appended`` decision makes it retryable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||||
job = session.get(CampaignJob, job_id)
|
job = session.get(CampaignJob, job_id)
|
||||||
if not job or job.tenant_id != tenant_id or job.campaign_id != campaign.id:
|
if not job or job.tenant_id != tenant_id or job.campaign_id != campaign.id:
|
||||||
raise QueueingError("Campaign job not found or not accessible")
|
raise QueueingError("Campaign job not found or not accessible")
|
||||||
if job.send_status not in {
|
if decision in {"imap_appended", "imap_not_appended"}:
|
||||||
JobSendStatus.OUTCOME_UNKNOWN.value,
|
return _reconcile_imap_append_outcome(
|
||||||
JobSendStatus.SENDING.value,
|
session,
|
||||||
JobSendStatus.CLAIMED.value,
|
campaign=campaign,
|
||||||
}:
|
job=job,
|
||||||
|
decision=decision,
|
||||||
|
note=note,
|
||||||
|
commit=commit,
|
||||||
|
)
|
||||||
|
evidence_note = (note or "").strip()
|
||||||
|
if not evidence_note:
|
||||||
|
raise QueueingError("SMTP reconciliation requires an evidence note")
|
||||||
|
if job.send_status != JobSendStatus.OUTCOME_UNKNOWN.value:
|
||||||
raise QueueingError(f"Job status {job.send_status} does not require reconciliation")
|
raise QueueingError(f"Job status {job.send_status} does not require reconciliation")
|
||||||
version = _get_version_for_campaign(session, campaign, version_id=job.campaign_version_id)
|
version = _get_version_for_campaign(session, campaign, version_id=job.campaign_version_id)
|
||||||
snapshot = ensure_execution_snapshot(session, version)
|
snapshot = ensure_execution_snapshot(session, version)
|
||||||
@@ -1068,7 +1079,7 @@ def reconcile_job_outcome(
|
|||||||
job.sent_at = job.sent_at or now
|
job.sent_at = job.sent_at or now
|
||||||
job.outcome_unknown_at = None
|
job.outcome_unknown_at = None
|
||||||
job.claim_token = None
|
job.claim_token = None
|
||||||
job.last_error = note or "Operator confirmed SMTP acceptance after an uncertain outcome."
|
job.last_error = evidence_note
|
||||||
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
|
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
|
||||||
files_integration().mark_job_attachment_uses_sent(session, job)
|
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||||
attempt_status = "reconciled_smtp_accepted"
|
attempt_status = "reconciled_smtp_accepted"
|
||||||
@@ -1077,7 +1088,7 @@ def reconcile_job_outcome(
|
|||||||
job.queue_status = JobQueueStatus.DRAFT.value
|
job.queue_status = JobQueueStatus.DRAFT.value
|
||||||
job.outcome_unknown_at = None
|
job.outcome_unknown_at = None
|
||||||
job.claim_token = None
|
job.claim_token = None
|
||||||
job.last_error = note or "Operator confirmed that SMTP did not accept this message."
|
job.last_error = evidence_note
|
||||||
attempt_status = "reconciled_not_sent"
|
attempt_status = "reconciled_not_sent"
|
||||||
else:
|
else:
|
||||||
raise QueueingError("decision must be 'smtp_accepted' or 'not_sent'")
|
raise QueueingError("decision must be 'smtp_accepted' or 'not_sent'")
|
||||||
@@ -1089,7 +1100,10 @@ def reconcile_job_outcome(
|
|||||||
session.add(attempt)
|
session.add(attempt)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
_update_campaign_after_job(session, campaign.id, version.id)
|
_update_campaign_after_job(session, campaign.id, version.id)
|
||||||
session.commit()
|
if commit:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
return {
|
return {
|
||||||
"campaign_id": campaign.id,
|
"campaign_id": campaign.id,
|
||||||
"version_id": version.id,
|
"version_id": version.id,
|
||||||
@@ -1101,6 +1115,67 @@ def reconcile_job_outcome(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_imap_append_outcome(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
job: CampaignJob,
|
||||||
|
decision: str,
|
||||||
|
note: str | None,
|
||||||
|
commit: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Resolve an uncertain append while retaining its immutable attempt row."""
|
||||||
|
|
||||||
|
evidence_note = (note or "").strip()
|
||||||
|
if not evidence_note:
|
||||||
|
raise QueueingError("IMAP reconciliation requires an evidence note")
|
||||||
|
if job.send_status not in SMTP_ACCEPTED_STATUSES:
|
||||||
|
raise QueueingError("Only an SMTP-accepted job can reconcile a Sent-folder append")
|
||||||
|
if job.imap_status != JobImapStatus.OUTCOME_UNKNOWN.value:
|
||||||
|
raise QueueingError(f"IMAP status {job.imap_status} does not require reconciliation")
|
||||||
|
|
||||||
|
attempt = (
|
||||||
|
session.query(ImapAppendAttempt)
|
||||||
|
.filter(ImapAppendAttempt.job_id == job.id)
|
||||||
|
.order_by(ImapAppendAttempt.attempt_number.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if decision == "imap_appended":
|
||||||
|
job.imap_status = JobImapStatus.APPENDED.value
|
||||||
|
attempt_status = "reconciled_imap_appended"
|
||||||
|
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||||
|
elif decision == "imap_not_appended":
|
||||||
|
# FAILED is deliberately retryable only after this explicit,
|
||||||
|
# evidence-backed operator decision.
|
||||||
|
job.imap_status = JobImapStatus.FAILED.value
|
||||||
|
attempt_status = "reconciled_imap_not_appended"
|
||||||
|
else: # Kept defensive for non-HTTP callers.
|
||||||
|
raise QueueingError("IMAP decision must be 'imap_appended' or 'imap_not_appended'")
|
||||||
|
|
||||||
|
job.imap_claimed_at = None
|
||||||
|
job.imap_claim_token = None
|
||||||
|
job.last_error = evidence_note
|
||||||
|
if attempt is not None:
|
||||||
|
attempt.status = attempt_status
|
||||||
|
attempt.error_message = evidence_note
|
||||||
|
session.add(attempt)
|
||||||
|
session.add(job)
|
||||||
|
if commit:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
|
return {
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"version_id": job.campaign_version_id,
|
||||||
|
"job_id": job.id,
|
||||||
|
"channel": "imap",
|
||||||
|
"decision": decision,
|
||||||
|
"send_status": job.send_status,
|
||||||
|
"imap_status": job.imap_status,
|
||||||
|
"note": evidence_note,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _verify_eml_evidence(job: CampaignJob, payload: bytes) -> None:
|
def _verify_eml_evidence(job: CampaignJob, payload: bytes) -> None:
|
||||||
if job.eml_size_bytes is not None and len(payload) != job.eml_size_bytes:
|
if job.eml_size_bytes is not None and len(payload) != job.eml_size_bytes:
|
||||||
raise SendJobError(
|
raise SendJobError(
|
||||||
@@ -1143,7 +1218,7 @@ def _addresses_from_job(job: CampaignJob, field: str) -> list[str]:
|
|||||||
return [item.get("email") for item in values if isinstance(item, dict) and item.get("email")]
|
return [item.get("email") for item in values if isinstance(item, dict) and item.get("email")]
|
||||||
|
|
||||||
|
|
||||||
def _sender_from_job(job: CampaignJob, snapshot: ExecutionSnapshot) -> str:
|
def _sender_from_job(job: CampaignJob) -> str:
|
||||||
data = job.resolved_recipients or {}
|
data = job.resolved_recipients or {}
|
||||||
bounce_to = _addresses_from_job(job, "bounce_to")
|
bounce_to = _addresses_from_job(job, "bounce_to")
|
||||||
if bounce_to:
|
if bounce_to:
|
||||||
@@ -1151,17 +1226,17 @@ def _sender_from_job(job: CampaignJob, snapshot: ExecutionSnapshot) -> str:
|
|||||||
from_data = data.get("from") if isinstance(data, dict) else None
|
from_data = data.get("from") if isinstance(data, dict) else None
|
||||||
if isinstance(from_data, dict) and from_data.get("email"):
|
if isinstance(from_data, dict) and from_data.get("email"):
|
||||||
return from_data["email"]
|
return from_data["email"]
|
||||||
if snapshot.smtp.username:
|
raise SmtpConfigurationError(
|
||||||
return snapshot.smtp.username
|
"No envelope sender could be determined from Campaign-owned recipient data; configure recipients.from or bounce_to before building"
|
||||||
raise SmtpConfigurationError("No envelope sender could be determined")
|
)
|
||||||
|
|
||||||
|
|
||||||
def _from_header_from_job(job: CampaignJob, snapshot: ExecutionSnapshot) -> str | None:
|
def _from_header_from_job(job: CampaignJob) -> str | None:
|
||||||
data = job.resolved_recipients or {}
|
data = job.resolved_recipients or {}
|
||||||
from_data = data.get("from") if isinstance(data, dict) else None
|
from_data = data.get("from") if isinstance(data, dict) else None
|
||||||
if isinstance(from_data, dict) and from_data.get("email"):
|
if isinstance(from_data, dict) and from_data.get("email"):
|
||||||
return str(from_data["email"])
|
return str(from_data["email"])
|
||||||
return snapshot.smtp.username
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _recipients_from_job(job: CampaignJob) -> list[str]:
|
def _recipients_from_job(job: CampaignJob) -> list[str]:
|
||||||
@@ -1438,12 +1513,12 @@ def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDe
|
|||||||
if not version:
|
if not version:
|
||||||
raise SendJobError("Campaign version not found")
|
raise SendJobError("Campaign version not found")
|
||||||
try:
|
try:
|
||||||
snapshot = ensure_execution_snapshot(session, version)
|
snapshot = ensure_execution_snapshot(session, version, effect_job=job)
|
||||||
except ExecutionSnapshotError as exc:
|
except ExecutionSnapshotError as exc:
|
||||||
raise SendJobError(str(exc)) from exc
|
raise SendJobError(str(exc)) from exc
|
||||||
|
|
||||||
message_bytes = _load_eml_bytes_for_job(job)
|
message_bytes = _load_eml_bytes_for_job(job)
|
||||||
envelope_from = _sender_from_job(job, snapshot)
|
envelope_from = _sender_from_job(job)
|
||||||
envelope_recipients = _recipients_from_job(job)
|
envelope_recipients = _recipients_from_job(job)
|
||||||
if not envelope_recipients:
|
if not envelope_recipients:
|
||||||
raise SmtpConfigurationError("No envelope recipients could be determined")
|
raise SmtpConfigurationError("No envelope recipients could be determined")
|
||||||
@@ -1504,30 +1579,19 @@ def _send_claimed_campaign_job(
|
|||||||
)
|
)
|
||||||
attempt = _record_attempt_start(session, job, claim_token)
|
attempt = _record_attempt_start(session, job, claim_token)
|
||||||
try:
|
try:
|
||||||
smtp_config = runtime_smtp_config(session, context.version, context.snapshot)
|
result = mail_integration().send_campaign_email_bytes(
|
||||||
mail_integration().assert_mail_policy_allows_send(
|
|
||||||
session,
|
session,
|
||||||
tenant_id=job.tenant_id,
|
tenant_id=job.tenant_id,
|
||||||
campaign_id=job.campaign_id,
|
campaign_id=job.campaign_id,
|
||||||
smtp=smtp_config,
|
profile_id=context.snapshot.mail_profile_id,
|
||||||
envelope_sender=context.envelope_from,
|
message_bytes=context.message_bytes,
|
||||||
from_header=_from_header_from_job(job, context.snapshot),
|
|
||||||
recipients=context.envelope_recipients,
|
|
||||||
)
|
|
||||||
result = mail_integration().send_email_bytes(
|
|
||||||
context.message_bytes,
|
|
||||||
smtp_config=smtp_config,
|
|
||||||
envelope_from=context.envelope_from,
|
envelope_from=context.envelope_from,
|
||||||
envelope_recipients=context.envelope_recipients,
|
envelope_recipients=context.envelope_recipients,
|
||||||
|
from_header=_from_header_from_job(job),
|
||||||
|
expected_smtp_transport_revision=context.snapshot.smtp_transport_revision or "",
|
||||||
)
|
)
|
||||||
return _record_smtp_send_success(
|
if result.accepted_count <= 0:
|
||||||
session,
|
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
|
||||||
job=job,
|
|
||||||
attempt=attempt,
|
|
||||||
snapshot=context.snapshot,
|
|
||||||
result=result,
|
|
||||||
enqueue_imap_task=enqueue_imap_task,
|
|
||||||
)
|
|
||||||
except SmtpSendError as exc:
|
except SmtpSendError as exc:
|
||||||
outcome_unknown = _record_smtp_send_error(session, job=job, attempt=attempt, exc=exc)
|
outcome_unknown = _record_smtp_send_error(session, job=job, attempt=attempt, exc=exc)
|
||||||
if outcome_unknown is not None:
|
if outcome_unknown is not None:
|
||||||
@@ -1536,6 +1600,41 @@ def _send_claimed_campaign_job(
|
|||||||
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
|
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
|
||||||
_record_permanent_send_error(session, job=job, attempt=attempt, exc=exc)
|
_record_permanent_send_error(session, job=job, attempt=attempt, exc=exc)
|
||||||
raise
|
raise
|
||||||
|
try:
|
||||||
|
success = _record_smtp_send_success(
|
||||||
|
session,
|
||||||
|
job=job,
|
||||||
|
attempt=attempt,
|
||||||
|
snapshot=context.snapshot,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
current = session.get(CampaignJob, job.id)
|
||||||
|
if current is None:
|
||||||
|
raise SendJobError("SMTP accepted the message, but its durable Campaign job record is unavailable.") from None
|
||||||
|
return mark_job_outcome_unknown(
|
||||||
|
session,
|
||||||
|
current,
|
||||||
|
reason=(
|
||||||
|
"SMTP accepted the provider effect, but persisting the accepted outcome failed. "
|
||||||
|
"Automatic retry is stopped until an operator reconciles the job."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if enqueue_imap_task and _celery_enabled() and job.imap_status == JobImapStatus.PENDING.value:
|
||||||
|
try:
|
||||||
|
_celery_enqueue_append_sent_job(job.id)
|
||||||
|
except Exception:
|
||||||
|
return SendJobResult(
|
||||||
|
job_id=success.job_id,
|
||||||
|
status=success.status,
|
||||||
|
attempt_number=success.attempt_number,
|
||||||
|
message=(
|
||||||
|
f"{success.message + ' ' if success.message else ''}"
|
||||||
|
"SMTP acceptance was persisted, but the Sent-folder append could not be enqueued; it remains pending."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
def _record_smtp_send_success(
|
def _record_smtp_send_success(
|
||||||
@@ -1545,10 +1644,7 @@ def _record_smtp_send_success(
|
|||||||
attempt: SendAttempt,
|
attempt: SendAttempt,
|
||||||
snapshot: ExecutionSnapshot,
|
snapshot: ExecutionSnapshot,
|
||||||
result: object,
|
result: object,
|
||||||
enqueue_imap_task: bool,
|
|
||||||
) -> SendJobResult:
|
) -> SendJobResult:
|
||||||
if result.accepted_count <= 0:
|
|
||||||
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
|
|
||||||
refused_warning = _refused_recipient_warning(result)
|
refused_warning = _refused_recipient_warning(result)
|
||||||
attempt.finished_at = _utcnow()
|
attempt.finished_at = _utcnow()
|
||||||
attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value
|
attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value
|
||||||
@@ -1565,8 +1661,6 @@ def _record_smtp_send_success(
|
|||||||
session.add(job)
|
session.add(job)
|
||||||
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
||||||
session.commit()
|
session.commit()
|
||||||
if enqueue_imap_task and _celery_enabled() and job.imap_status == JobImapStatus.PENDING.value:
|
|
||||||
_celery_enqueue_append_sent_job(job.id)
|
|
||||||
return SendJobResult(
|
return SendJobResult(
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
status=JobSendStatus.SMTP_ACCEPTED.value,
|
status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||||
@@ -1639,14 +1733,54 @@ def _record_permanent_send_error(
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt:
|
def _imap_attempt_count(session: Session, job_id: str) -> int:
|
||||||
|
return session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job_id).count()
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None:
|
||||||
|
"""Atomically grant one worker permission to invoke the IMAP provider."""
|
||||||
|
|
||||||
|
claim_token = str(uuid4())
|
||||||
|
changed = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.id == job.id,
|
||||||
|
CampaignJob.send_status.in_(list(SMTP_ACCEPTED_STATUSES)),
|
||||||
|
CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]),
|
||||||
|
CampaignJob.imap_claim_token.is_(None),
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
CampaignJob.imap_status: JobImapStatus.APPENDING.value,
|
||||||
|
CampaignJob.imap_claimed_at: _utcnow(),
|
||||||
|
CampaignJob.imap_claim_token: claim_token,
|
||||||
|
CampaignJob.last_error: None,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
return claim_token if changed == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_imap_attempt_start(
|
||||||
|
session: Session,
|
||||||
|
job: CampaignJob,
|
||||||
|
claim_token: str,
|
||||||
|
) -> ImapAppendAttempt:
|
||||||
|
if (
|
||||||
|
job.imap_status != JobImapStatus.APPENDING.value
|
||||||
|
or job.imap_claim_token != claim_token
|
||||||
|
):
|
||||||
|
raise SendJobError("The IMAP append claim was lost before the provider effect started")
|
||||||
existing_count = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
existing_count = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||||
attempt = ImapAppendAttempt(
|
attempt = ImapAppendAttempt(
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
attempt_number=existing_count + 1,
|
attempt_number=existing_count + 1,
|
||||||
status="running",
|
status="running",
|
||||||
|
claim_token=claim_token,
|
||||||
)
|
)
|
||||||
job.imap_status = JobImapStatus.PENDING.value
|
|
||||||
job.last_error = None
|
job.last_error = None
|
||||||
session.add(attempt)
|
session.add(attempt)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
@@ -1654,13 +1788,241 @@ def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppend
|
|||||||
return attempt
|
return attempt
|
||||||
|
|
||||||
|
|
||||||
def _effective_imap_folder(snapshot: ExecutionSnapshot) -> str:
|
def _imap_blocked_result(
|
||||||
delivery_folder = snapshot.delivery.imap_append_sent.folder
|
session: Session,
|
||||||
if delivery_folder and delivery_folder != "auto":
|
job: CampaignJob,
|
||||||
return delivery_folder
|
*,
|
||||||
if snapshot.imap and snapshot.imap.sent_folder and snapshot.imap.sent_folder != "auto":
|
dry_run: bool,
|
||||||
return snapshot.imap.sent_folder
|
) -> AppendSentResult | None:
|
||||||
return "auto"
|
if job.imap_status not in {
|
||||||
|
JobImapStatus.NOT_REQUESTED.value,
|
||||||
|
JobImapStatus.APPENDED.value,
|
||||||
|
JobImapStatus.SKIPPED.value,
|
||||||
|
JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
JobImapStatus.APPENDING.value,
|
||||||
|
}:
|
||||||
|
return None
|
||||||
|
attempts = _imap_attempt_count(session, job.id)
|
||||||
|
if job.imap_status == JobImapStatus.NOT_REQUESTED.value:
|
||||||
|
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=attempts, dry_run=dry_run)
|
||||||
|
if job.imap_status == JobImapStatus.APPENDED.value:
|
||||||
|
return AppendSentResult(job_id=job.id, status="already_appended", attempt_number=attempts, dry_run=dry_run)
|
||||||
|
if job.imap_status == JobImapStatus.SKIPPED.value:
|
||||||
|
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=attempts, dry_run=dry_run)
|
||||||
|
if job.imap_status == JobImapStatus.OUTCOME_UNKNOWN.value:
|
||||||
|
return AppendSentResult(
|
||||||
|
job_id=job.id,
|
||||||
|
status=JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
attempt_number=attempts,
|
||||||
|
dry_run=dry_run,
|
||||||
|
message="The prior IMAP append outcome is unresolved; inspect and reconcile the mailbox before retrying.",
|
||||||
|
)
|
||||||
|
if job.imap_status == JobImapStatus.APPENDING.value:
|
||||||
|
return AppendSentResult(
|
||||||
|
job_id=job.id,
|
||||||
|
status="append_in_progress",
|
||||||
|
attempt_number=attempts,
|
||||||
|
dry_run=dry_run,
|
||||||
|
message="Another worker owns the IMAP append claim; automatic duplicate append was stopped.",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _imap_result_after_lost_claim(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
job_id: str,
|
||||||
|
attempt: ImapAppendAttempt,
|
||||||
|
provider_succeeded: bool,
|
||||||
|
) -> AppendSentResult:
|
||||||
|
session.rollback()
|
||||||
|
current_attempt = session.get(ImapAppendAttempt, attempt.id)
|
||||||
|
if current_attempt is not None:
|
||||||
|
current_attempt.status = "late_ack_ignored" if provider_succeeded else "claim_lost"
|
||||||
|
current_attempt.error_message = (
|
||||||
|
"The provider returned after this attempt no longer owned the durable IMAP claim."
|
||||||
|
)
|
||||||
|
session.add(current_attempt)
|
||||||
|
if provider_succeeded:
|
||||||
|
# If no other worker is active, freeze any retryable state. A provider
|
||||||
|
# success received after claim loss must never be followed by an
|
||||||
|
# automatic duplicate append.
|
||||||
|
session.query(CampaignJob).filter(
|
||||||
|
CampaignJob.id == job_id,
|
||||||
|
CampaignJob.imap_status.in_(
|
||||||
|
[
|
||||||
|
JobImapStatus.PENDING.value,
|
||||||
|
JobImapStatus.FAILED.value,
|
||||||
|
JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
CampaignJob.imap_claim_token.is_(None),
|
||||||
|
).update(
|
||||||
|
{
|
||||||
|
CampaignJob.imap_status: JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
CampaignJob.last_error: (
|
||||||
|
"An IMAP provider acknowledgement arrived after the durable claim was lost; operator reconciliation is required."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
current = session.get(CampaignJob, job_id)
|
||||||
|
if current is None:
|
||||||
|
raise SendJobError("Campaign job disappeared while reconciling a lost IMAP claim")
|
||||||
|
blocked = _imap_blocked_result(session, current, dry_run=False)
|
||||||
|
if blocked is not None:
|
||||||
|
return blocked
|
||||||
|
return AppendSentResult(
|
||||||
|
job_id=current.id,
|
||||||
|
status="claim_lost",
|
||||||
|
attempt_number=_imap_attempt_count(session, current.id),
|
||||||
|
message="The IMAP append claim changed while the provider operation was in progress.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_imap_append_failure(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
job: CampaignJob,
|
||||||
|
attempt: ImapAppendAttempt,
|
||||||
|
claim_token: str,
|
||||||
|
folder: str,
|
||||||
|
message: str,
|
||||||
|
outcome_unknown: bool,
|
||||||
|
) -> bool:
|
||||||
|
next_status = (
|
||||||
|
JobImapStatus.OUTCOME_UNKNOWN.value
|
||||||
|
if outcome_unknown
|
||||||
|
else JobImapStatus.FAILED.value
|
||||||
|
)
|
||||||
|
changed = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.id == job.id,
|
||||||
|
CampaignJob.imap_status == JobImapStatus.APPENDING.value,
|
||||||
|
CampaignJob.imap_claim_token == claim_token,
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
CampaignJob.imap_status: next_status,
|
||||||
|
CampaignJob.imap_claimed_at: None,
|
||||||
|
CampaignJob.imap_claim_token: None,
|
||||||
|
CampaignJob.last_error: message,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
attempt.status = next_status if outcome_unknown else "failed"
|
||||||
|
attempt.folder = None if folder == "auto" else folder
|
||||||
|
attempt.error_message = message
|
||||||
|
session.add(attempt)
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
return changed == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _record_imap_append_success(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
job: CampaignJob,
|
||||||
|
attempt: ImapAppendAttempt,
|
||||||
|
claim_token: str,
|
||||||
|
folder: str,
|
||||||
|
) -> AppendSentResult:
|
||||||
|
changed = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.id == job.id,
|
||||||
|
CampaignJob.imap_status == JobImapStatus.APPENDING.value,
|
||||||
|
CampaignJob.imap_claim_token == claim_token,
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
CampaignJob.imap_status: JobImapStatus.APPENDED.value,
|
||||||
|
CampaignJob.imap_claimed_at: None,
|
||||||
|
CampaignJob.imap_claim_token: None,
|
||||||
|
CampaignJob.last_error: None,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if changed != 1:
|
||||||
|
return _imap_result_after_lost_claim(
|
||||||
|
session,
|
||||||
|
job_id=job.id,
|
||||||
|
attempt=attempt,
|
||||||
|
provider_succeeded=True,
|
||||||
|
)
|
||||||
|
attempt.status = "appended"
|
||||||
|
attempt.folder = folder
|
||||||
|
attempt.error_message = None
|
||||||
|
session.add(attempt)
|
||||||
|
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
return AppendSentResult(
|
||||||
|
job_id=job.id,
|
||||||
|
status="appended",
|
||||||
|
attempt_number=attempt.attempt_number,
|
||||||
|
folder=folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_imap_append_outcome_unknown_after_effect(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
job_id: str,
|
||||||
|
attempt_id: str,
|
||||||
|
claim_token: str,
|
||||||
|
reason: str,
|
||||||
|
) -> AppendSentResult:
|
||||||
|
"""Freeze retry after a provider effect whose local persistence failed."""
|
||||||
|
|
||||||
|
session.rollback()
|
||||||
|
current_attempt = session.get(ImapAppendAttempt, attempt_id)
|
||||||
|
if current_attempt is not None:
|
||||||
|
current_attempt.status = JobImapStatus.OUTCOME_UNKNOWN.value
|
||||||
|
current_attempt.error_message = reason
|
||||||
|
session.add(current_attempt)
|
||||||
|
changed = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.id == job_id,
|
||||||
|
CampaignJob.imap_status == JobImapStatus.APPENDING.value,
|
||||||
|
CampaignJob.imap_claim_token == claim_token,
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
CampaignJob.imap_status: JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
CampaignJob.imap_claimed_at: None,
|
||||||
|
CampaignJob.imap_claim_token: None,
|
||||||
|
CampaignJob.last_error: reason,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if changed != 1:
|
||||||
|
if current_attempt is not None:
|
||||||
|
session.rollback()
|
||||||
|
current_attempt = session.get(ImapAppendAttempt, attempt_id)
|
||||||
|
if current_attempt is None:
|
||||||
|
raise SendJobError("IMAP append outcome is unknown and its attempt record is unavailable")
|
||||||
|
return _imap_result_after_lost_claim(
|
||||||
|
session,
|
||||||
|
job_id=job_id,
|
||||||
|
attempt=current_attempt,
|
||||||
|
provider_succeeded=True,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
return AppendSentResult(
|
||||||
|
job_id=job_id,
|
||||||
|
status=JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
attempt_number=_imap_attempt_count(session, job_id),
|
||||||
|
message=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult:
|
def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult:
|
||||||
@@ -1671,20 +2033,15 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
|||||||
raise SendJobError(f"Job not found: {job_id}")
|
raise SendJobError(f"Job not found: {job_id}")
|
||||||
if job.send_status not in SMTP_ACCEPTED_STATUSES:
|
if job.send_status not in SMTP_ACCEPTED_STATUSES:
|
||||||
return AppendSentResult(job_id=job_id, status="not_sent", attempt_number=0, dry_run=dry_run, message="SMTP has not accepted this job")
|
return AppendSentResult(job_id=job_id, status="not_sent", attempt_number=0, dry_run=dry_run, message="SMTP has not accepted this job")
|
||||||
if job.imap_status == JobImapStatus.NOT_REQUESTED.value:
|
blocked = _imap_blocked_result(session, job, dry_run=dry_run)
|
||||||
return AppendSentResult(job_id=job_id, status="not_requested", attempt_number=0, dry_run=dry_run)
|
if blocked is not None:
|
||||||
if job.imap_status == JobImapStatus.APPENDED.value:
|
return blocked
|
||||||
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
|
||||||
return AppendSentResult(job_id=job_id, status="already_appended", attempt_number=attempts, dry_run=dry_run)
|
|
||||||
if job.imap_status == JobImapStatus.SKIPPED.value:
|
|
||||||
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
|
||||||
return AppendSentResult(job_id=job_id, status="skipped", attempt_number=attempts, dry_run=dry_run)
|
|
||||||
|
|
||||||
version = session.get(CampaignVersion, job.campaign_version_id)
|
version = session.get(CampaignVersion, job.campaign_version_id)
|
||||||
if not version:
|
if not version:
|
||||||
raise SendJobError("Campaign version not found")
|
raise SendJobError("Campaign version not found")
|
||||||
try:
|
try:
|
||||||
snapshot = ensure_execution_snapshot(session, version)
|
snapshot = ensure_execution_snapshot(session, version, effect_job=job)
|
||||||
except ExecutionSnapshotError as exc:
|
except ExecutionSnapshotError as exc:
|
||||||
raise SendJobError(str(exc)) from exc
|
raise SendJobError(str(exc)) from exc
|
||||||
if not snapshot.delivery.imap_append_sent.enabled:
|
if not snapshot.delivery.imap_append_sent.enabled:
|
||||||
@@ -1692,16 +2049,15 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
|||||||
session.add(job)
|
session.add(job)
|
||||||
session.commit()
|
session.commit()
|
||||||
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run)
|
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run)
|
||||||
imap_config = runtime_imap_config(session, version, snapshot)
|
if not snapshot.imap_transport_revision:
|
||||||
if not imap_config:
|
|
||||||
job.imap_status = JobImapStatus.SKIPPED.value
|
job.imap_status = JobImapStatus.SKIPPED.value
|
||||||
job.last_error = "IMAP append requested, but the execution snapshot has no IMAP configuration"
|
job.last_error = "IMAP append requested, but the selected Mail profile has no IMAP configuration"
|
||||||
session.add(job)
|
session.add(job)
|
||||||
session.commit()
|
session.commit()
|
||||||
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
|
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
|
||||||
|
|
||||||
message_bytes = _load_eml_bytes_for_job(job)
|
message_bytes = _load_eml_bytes_for_job(job)
|
||||||
folder = _effective_imap_folder(snapshot)
|
folder = snapshot.delivery.imap_append_sent.folder or "auto"
|
||||||
if dry_run:
|
if dry_run:
|
||||||
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||||
return AppendSentResult(
|
return AppendSentResult(
|
||||||
@@ -1713,39 +2069,95 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
|||||||
message=f"Would append {len(message_bytes)} bytes to IMAP folder {folder!r}",
|
message=f"Would append {len(message_bytes)} bytes to IMAP folder {folder!r}",
|
||||||
)
|
)
|
||||||
|
|
||||||
attempt = _record_imap_attempt_start(session, job)
|
claim_token = _claim_job_for_imap_append(session, job)
|
||||||
|
if claim_token is None:
|
||||||
|
current = session.get(CampaignJob, job.id)
|
||||||
|
if current is None:
|
||||||
|
raise SendJobError(f"Job disappeared while claiming IMAP append: {job.id}")
|
||||||
|
blocked = _imap_blocked_result(session, current, dry_run=False)
|
||||||
|
if blocked is not None:
|
||||||
|
return blocked
|
||||||
|
return AppendSentResult(
|
||||||
|
job_id=current.id,
|
||||||
|
status="not_claimed",
|
||||||
|
attempt_number=_imap_attempt_count(session, current.id),
|
||||||
|
message=f"Job is no longer eligible for IMAP append: {current.imap_status}",
|
||||||
|
)
|
||||||
|
job = session.get(CampaignJob, job.id)
|
||||||
|
if job is None:
|
||||||
|
raise SendJobError("Claimed campaign job disappeared before IMAP append")
|
||||||
|
attempt = _record_imap_attempt_start(session, job, claim_token)
|
||||||
try:
|
try:
|
||||||
mail_integration().assert_mail_policy_allows_send(
|
result = mail_integration().append_campaign_message_to_sent(
|
||||||
session,
|
session,
|
||||||
tenant_id=job.tenant_id,
|
tenant_id=job.tenant_id,
|
||||||
campaign_id=job.campaign_id,
|
campaign_id=job.campaign_id,
|
||||||
smtp=snapshot.smtp,
|
profile_id=snapshot.mail_profile_id,
|
||||||
imap=imap_config,
|
message_bytes=message_bytes,
|
||||||
)
|
|
||||||
result = mail_integration().append_message_to_sent(
|
|
||||||
message_bytes,
|
|
||||||
imap_config=imap_config,
|
|
||||||
folder=None if folder == "auto" else folder,
|
folder=None if folder == "auto" else folder,
|
||||||
|
expected_smtp_transport_revision=snapshot.smtp_transport_revision or "",
|
||||||
|
expected_imap_transport_revision=snapshot.imap_transport_revision,
|
||||||
)
|
)
|
||||||
attempt.status = "appended"
|
except (MailProfileError, ImapConfigurationError, ImapAppendError) as exc:
|
||||||
attempt.folder = result.folder
|
outcome_unknown = bool(getattr(exc, "outcome_unknown", False))
|
||||||
job.imap_status = JobImapStatus.APPENDED.value
|
owned = _record_imap_append_failure(
|
||||||
job.last_error = None
|
session,
|
||||||
files_integration().mark_job_attachment_uses_sent(session, job)
|
job=job,
|
||||||
session.add(attempt)
|
attempt=attempt,
|
||||||
session.add(job)
|
claim_token=claim_token,
|
||||||
session.commit()
|
folder=folder,
|
||||||
return AppendSentResult(job_id=job.id, status="appended", attempt_number=attempt.attempt_number, folder=result.folder)
|
message=str(exc),
|
||||||
except (MailProfileError, ImapConfigurationError, ImapAppendError, SendJobError, OSError) as exc:
|
outcome_unknown=outcome_unknown,
|
||||||
attempt.status = "failed"
|
)
|
||||||
attempt.folder = None if folder == "auto" else folder
|
if not owned:
|
||||||
attempt.error_message = str(exc)
|
_imap_result_after_lost_claim(
|
||||||
job.imap_status = JobImapStatus.FAILED.value
|
session,
|
||||||
job.last_error = str(exc)
|
job_id=job.id,
|
||||||
session.add(attempt)
|
attempt=attempt,
|
||||||
session.add(job)
|
provider_succeeded=outcome_unknown,
|
||||||
session.commit()
|
)
|
||||||
raise
|
raise
|
||||||
|
except Exception:
|
||||||
|
reason = (
|
||||||
|
"The Sent-folder append outcome is unknown after an unexpected provider failure; "
|
||||||
|
"inspect and reconcile the mailbox before retrying."
|
||||||
|
)
|
||||||
|
owned = _record_imap_append_failure(
|
||||||
|
session,
|
||||||
|
job=job,
|
||||||
|
attempt=attempt,
|
||||||
|
claim_token=claim_token,
|
||||||
|
folder=folder,
|
||||||
|
message=reason,
|
||||||
|
outcome_unknown=True,
|
||||||
|
)
|
||||||
|
if not owned:
|
||||||
|
_imap_result_after_lost_claim(
|
||||||
|
session,
|
||||||
|
job_id=job.id,
|
||||||
|
attempt=attempt,
|
||||||
|
provider_succeeded=True,
|
||||||
|
)
|
||||||
|
raise ImapAppendError(reason, outcome_unknown=True) from None
|
||||||
|
try:
|
||||||
|
return _record_imap_append_success(
|
||||||
|
session,
|
||||||
|
job=job,
|
||||||
|
attempt=attempt,
|
||||||
|
claim_token=claim_token,
|
||||||
|
folder=result.folder,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return _mark_imap_append_outcome_unknown_after_effect(
|
||||||
|
session,
|
||||||
|
job_id=job.id,
|
||||||
|
attempt_id=attempt.id,
|
||||||
|
claim_token=claim_token,
|
||||||
|
reason=(
|
||||||
|
"The IMAP provider accepted the append, but persisting the outcome failed. "
|
||||||
|
"Automatic retry is stopped until an operator reconciles the mailbox."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def enqueue_pending_imap_appends(
|
def enqueue_pending_imap_appends(
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
|||||||
"campaign": {"id": f"no-attachment-{behavior or legacy_allow}", "name": "No attachment policy", "mode": "test"},
|
"campaign": {"id": f"no-attachment-{behavior or legacy_allow}", "name": "No attachment policy", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"profile_capabilities": {"smtp_available": True},
|
||||||
|
},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "Subject", "text": "Body"},
|
"template": {"subject": "Subject", "text": "Body"},
|
||||||
"attachments": attachments,
|
"attachments": attachments,
|
||||||
@@ -140,7 +143,10 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
|||||||
"campaign": {"id": "zip-missing-pattern", "name": "ZIP missing pattern", "mode": "test"},
|
"campaign": {"id": "zip-missing-pattern", "name": "ZIP missing pattern", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"profile_capabilities": {"smtp_available": True},
|
||||||
|
},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "Subject", "text": "Body"},
|
"template": {"subject": "Subject", "text": "Body"},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_campaign.backend import router
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _principal() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
api_key=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
principal = _principal()
|
||||||
|
version = SimpleNamespace(
|
||||||
|
id="version-1",
|
||||||
|
raw_json={},
|
||||||
|
current_flow="manual",
|
||||||
|
current_step="recipients",
|
||||||
|
)
|
||||||
|
|
||||||
|
def mutate(*_args, **kwargs):
|
||||||
|
assert kwargs["commit"] is False
|
||||||
|
session.flush()
|
||||||
|
return version
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(router, "_get_campaign_for_principal"),
|
||||||
|
patch.object(router, "_get_version_for_tenant", return_value=version),
|
||||||
|
patch.object(router, "update_campaign_version", side_effect=mutate),
|
||||||
|
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException, match="audit unavailable") as captured:
|
||||||
|
router._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
|
||||||
|
session,
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
"campaign-1",
|
||||||
|
"version-1",
|
||||||
|
CampaignVersionUpdateRequest(current_step="recipients"),
|
||||||
|
autosave=True,
|
||||||
|
audit_action="campaign.version_autosaved",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured.value.status_code == 422
|
||||||
|
session.commit.assert_not_called()
|
||||||
|
session.rollback.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_fork_rolls_back_when_its_audit_record_cannot_be_written() -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
principal = _principal()
|
||||||
|
campaign = SimpleNamespace(id="campaign-1")
|
||||||
|
source = SimpleNamespace(id="version-1", campaign_id="campaign-1", raw_json={})
|
||||||
|
forked = SimpleNamespace(id="version-2", campaign_id="campaign-1", version_number=2)
|
||||||
|
|
||||||
|
def mutate(*_args, **kwargs):
|
||||||
|
assert kwargs["commit"] is False
|
||||||
|
session.flush()
|
||||||
|
return forked
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||||
|
patch.object(router, "_require_permission"),
|
||||||
|
patch.object(router, "_get_version_for_tenant", return_value=source),
|
||||||
|
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
|
||||||
|
patch.object(router, "fork_campaign_version_for_edit", side_effect=mutate),
|
||||||
|
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||||
|
router.fork_version_for_edit(
|
||||||
|
"campaign-1",
|
||||||
|
"version-1",
|
||||||
|
CampaignVersionUpdateRequest(),
|
||||||
|
session=session,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.commit.assert_not_called()
|
||||||
|
session.rollback.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_update_rolls_back_when_its_audit_record_cannot_be_written() -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
principal = _principal()
|
||||||
|
campaign = SimpleNamespace(
|
||||||
|
id="campaign-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
external_id="C-1",
|
||||||
|
name="Old name",
|
||||||
|
description=None,
|
||||||
|
status="draft",
|
||||||
|
current_version_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||||
|
patch.object(router, "_sync_campaign_metadata_to_current_version"),
|
||||||
|
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||||
|
router.update_campaign_metadata_endpoint(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignUpdateRequest(name="New name"),
|
||||||
|
session=session,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.flush.assert_called_once_with()
|
||||||
|
session.commit.assert_not_called()
|
||||||
|
session.rollback.assert_called_once_with()
|
||||||
@@ -3,8 +3,14 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
|
from govoplan_campaign.backend.reports.campaigns import (
|
||||||
|
_job_evidence_row,
|
||||||
|
_latest_by_job_id,
|
||||||
|
_load_delivery_info,
|
||||||
|
generate_campaign_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _dt() -> datetime:
|
def _dt() -> datetime:
|
||||||
@@ -87,9 +93,12 @@ def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
|
|||||||
assert "bundle.zip" in row["attachment_names"]
|
assert "bundle.zip" in row["attachment_names"]
|
||||||
assert "notice.pdf" in row["attachment_names"]
|
assert "notice.pdf" in row["attachment_names"]
|
||||||
assert row["latest_smtp_status_code"] == 250
|
assert row["latest_smtp_status_code"] == 250
|
||||||
assert row["latest_smtp_response"] == "2.0.0 queued"
|
assert "latest_smtp_response" not in row
|
||||||
|
assert "latest_smtp_error_type" not in row
|
||||||
|
assert "latest_smtp_error_message" not in row
|
||||||
assert row["latest_imap_status"] == "appended"
|
assert row["latest_imap_status"] == "appended"
|
||||||
assert row["latest_imap_folder"] == "Sent"
|
assert row["latest_imap_folder"] == "Sent"
|
||||||
|
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
|
||||||
|
|
||||||
@@ -107,6 +116,51 @@ def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
|
|||||||
assert latest["job-2"].attempt_number == 2
|
assert latest["job-2"].attempt_number == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_legacy_snapshot_never_echoes_validation_details() -> None:
|
||||||
|
version = SimpleNamespace(
|
||||||
|
execution_snapshot={
|
||||||
|
"snapshot_version": "legacy",
|
||||||
|
"smtp": {"host": "smtp.internal.example", "password": "provider-secret"},
|
||||||
|
},
|
||||||
|
execution_snapshot_hash=None,
|
||||||
|
execution_snapshot_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = _load_delivery_info(version, [])
|
||||||
|
|
||||||
|
assert delivery["load_error"] == "Execution snapshot is invalid; rebuild the selected version before delivery."
|
||||||
|
assert "provider-secret" not in repr(delivery)
|
||||||
|
assert "smtp.internal.example" not in repr(delivery)
|
||||||
|
assert "background_workers_enabled" not in delivery
|
||||||
|
assert "smtp_transport_revision" not in delivery
|
||||||
|
|
||||||
|
operator_delivery = _load_delivery_info(version, [], include_diagnostics=True)
|
||||||
|
assert "background_workers_enabled" in operator_delivery
|
||||||
|
assert "smtp_transport_revision" in operator_delivery
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_report_omits_recipient_level_failures_by_default() -> None:
|
||||||
|
campaign = SimpleNamespace(id="campaign-1")
|
||||||
|
version = SimpleNamespace(id="version-1")
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.reports.campaigns._get_campaign", return_value=campaign),
|
||||||
|
patch("govoplan_campaign.backend.reports.campaigns._selected_version", return_value=version),
|
||||||
|
patch("govoplan_campaign.backend.reports.campaigns._report_jobs", return_value=[]),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.reports.campaigns._campaign_report_payload",
|
||||||
|
return_value={"cards": {}},
|
||||||
|
) as payload,
|
||||||
|
):
|
||||||
|
report = generate_campaign_report(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report == {"cards": {}}
|
||||||
|
assert payload.call_args.kwargs["include_recent_failures"] is False
|
||||||
|
|
||||||
|
|
||||||
class CampaignReportProjectionTests(unittest.TestCase):
|
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()
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
|
||||||
|
from govoplan_core.core.modules import DocumentationContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Principal:
|
||||||
|
scopes: frozenset[str]
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
namespace, resource, _action = scope.split(":", 2)
|
||||||
|
return scope in self.scopes or f"{namespace}:{resource}:*" in self.scopes or f"{namespace}:*" in self.scopes or "*:*" in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, integrations: set[str], *, interface_only: set[str] | None = None, capability_only: set[str] | None = None) -> None:
|
||||||
|
interfaces = integrations | (interface_only or set())
|
||||||
|
self._capabilities = integrations | (capability_only or set())
|
||||||
|
self._manifests = (
|
||||||
|
SimpleNamespace(
|
||||||
|
provides_interfaces=tuple(SimpleNamespace(name=name) for name in sorted(interfaces)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return self._manifests
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name in self._capabilities
|
||||||
|
|
||||||
|
|
||||||
|
def _topics(scopes: set[str], integrations: set[str] | None = None, *, documentation_type: str = "user"):
|
||||||
|
return documentation_topics(
|
||||||
|
DocumentationContext(
|
||||||
|
registry=_Registry(integrations or set()),
|
||||||
|
principal=_Principal(frozenset(scopes)),
|
||||||
|
documentation_type=documentation_type, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_runtime_documentation_provider_is_registered() -> None:
|
||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
assert documentation_topics in get_manifest().documentation_providers
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
|
||||||
|
assert _topics({"docs:documentation:read"}) == ()
|
||||||
|
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_documentation_reflects_actor_authority_without_exposing_scopes() -> None:
|
||||||
|
topic = _topics(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:campaign:build",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
}
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
configuration = topic.metadata["current_configuration"]
|
||||||
|
assert "Role authorization: Create new campaigns." in configuration
|
||||||
|
assert "Role authorization: Build exact recipient messages for review." in configuration
|
||||||
|
assert not any("queue" in item.lower() for item in configuration)
|
||||||
|
assert not any("campaigns:" in item or "files:" in item or "mail:" in item for item in configuration)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_documentation_requires_both_interface_and_capability() -> None:
|
||||||
|
integration = "mail.campaign_delivery"
|
||||||
|
scopes = {"campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"}
|
||||||
|
|
||||||
|
for registry in (
|
||||||
|
_Registry(set(), interface_only={integration}),
|
||||||
|
_Registry(set(), capability_only={integration}),
|
||||||
|
):
|
||||||
|
topic = documentation_topics(
|
||||||
|
DocumentationContext(registry=registry, principal=_Principal(frozenset(scopes)), documentation_type="user")
|
||||||
|
)[0]
|
||||||
|
assert not any("profile picker" in item for item in topic.metadata["current_configuration"])
|
||||||
|
|
||||||
|
topic = _topics(scopes, {integration})[0]
|
||||||
|
assert any("Mail's actor-filtered profile picker" in item for item in topic.metadata["current_configuration"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mail_delivery_actions_are_not_presented_without_the_mail_contract() -> None:
|
||||||
|
scopes = {
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:queue",
|
||||||
|
"campaigns:campaign:send",
|
||||||
|
"campaigns:campaign:retry",
|
||||||
|
}
|
||||||
|
|
||||||
|
without_mail = _topics(scopes)[0]
|
||||||
|
with_mail = _topics(scopes, {"mail.campaign_delivery"})[0]
|
||||||
|
|
||||||
|
assert not any("Queue an eligible" in item for item in without_mail.metadata["current_configuration"])
|
||||||
|
assert any("Queue an eligible" in item for item in with_mail.metadata["current_configuration"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("integrations", "scopes", "expected"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"files.campaign_attachments"},
|
||||||
|
{"campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"},
|
||||||
|
"Managed campaign attachments can be previewed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"addresses.lookup"},
|
||||||
|
{"campaigns:campaign:read", "campaigns:recipient:read"},
|
||||||
|
"Address records can be looked up",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"addresses.recipient_source"},
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
},
|
||||||
|
"traceable recipient snapshot",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"notifications.dispatch"},
|
||||||
|
{"campaigns:campaign:read"},
|
||||||
|
"status changes",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_runtime_documentation_reflects_optional_composition_permutations(
|
||||||
|
integrations: set[str],
|
||||||
|
scopes: set[str],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
topic = _topics(scopes, integrations)[0]
|
||||||
|
assert any(expected in item for item in topic.metadata["current_configuration"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_documentation_full_composition_uses_only_user_facing_names() -> None:
|
||||||
|
integrations = {
|
||||||
|
"mail.campaign_delivery",
|
||||||
|
"files.campaign_attachments",
|
||||||
|
"addresses.lookup",
|
||||||
|
"addresses.recipient_source",
|
||||||
|
"notifications.dispatch",
|
||||||
|
}
|
||||||
|
topic = _topics({"*:*"}, integrations)[0]
|
||||||
|
rendered = "\n".join(
|
||||||
|
(
|
||||||
|
topic.title,
|
||||||
|
topic.summary,
|
||||||
|
topic.body,
|
||||||
|
*topic.metadata["current_configuration"],
|
||||||
|
*topic.metadata["limitations"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Mail's actor-filtered profile picker" in rendered
|
||||||
|
assert "Managed file versions" in rendered
|
||||||
|
assert "Address records" in rendered
|
||||||
|
assert "In-app notifications" in rendered
|
||||||
|
assert topic.metadata["help_contexts"] == ["campaigns.list", "campaign.overview"]
|
||||||
|
for technical_name in integrations:
|
||||||
|
assert technical_name not in rendered
|
||||||
|
assert "0.1." not in rendered
|
||||||
|
assert "0.2." not in rendered
|
||||||
|
assert "hostname" not in rendered.lower()
|
||||||
|
assert "secret" not in rendered.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_static_topics(
|
||||||
|
scopes: set[str],
|
||||||
|
*,
|
||||||
|
modules: set[str] | None = None,
|
||||||
|
capabilities: set[str] | None = None,
|
||||||
|
) -> set[str]:
|
||||||
|
installed = modules or {"campaigns"}
|
||||||
|
available_capabilities = capabilities or set()
|
||||||
|
principal = _Principal(frozenset(scopes))
|
||||||
|
visible: set[str] = set()
|
||||||
|
for topic in CAMPAIGN_USER_DOCUMENTATION:
|
||||||
|
condition = topic.conditions[0]
|
||||||
|
if not set(condition.required_modules).issubset(installed):
|
||||||
|
continue
|
||||||
|
if not set(condition.required_capabilities).issubset(available_capabilities):
|
||||||
|
continue
|
||||||
|
if not all(principal.has(scope) for scope in condition.required_scopes):
|
||||||
|
continue
|
||||||
|
visible.add(topic.id)
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_manager_sees_only_authoring_tasks_from_the_static_handbook() -> None:
|
||||||
|
visible = _visible_static_topics(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:copy",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:campaign:build",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
"campaigns:report:read",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {
|
||||||
|
"campaigns.workflow.create-campaign",
|
||||||
|
"campaigns.workflow.create-editable-successor",
|
||||||
|
"campaigns.workflow.import-recipients",
|
||||||
|
"campaigns.workflow.view-delivery-report",
|
||||||
|
}.issubset(visible)
|
||||||
|
assert "campaigns.workflow.queue-delivery" not in visible
|
||||||
|
assert "campaigns.workflow.send-small-controlled-run" not in visible
|
||||||
|
assert "campaigns.workflow.export-delivery-report" not in visible
|
||||||
|
assert "campaigns.workflow.share-campaign" not in visible
|
||||||
|
assert "campaigns.workflow.archive-campaign" not in visible
|
||||||
|
assert "campaigns.workflow.delete-untouched-draft" not in visible
|
||||||
|
assert "campaigns.workflow.create-campaign" not in _visible_static_topics({"campaigns:campaign:create"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_authority() -> None:
|
||||||
|
scopes = {
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:queue",
|
||||||
|
"campaigns:campaign:send",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
}
|
||||||
|
|
||||||
|
without_mail = _visible_static_topics(scopes)
|
||||||
|
with_mail = _visible_static_topics(
|
||||||
|
scopes,
|
||||||
|
modules={"campaigns", "mail"},
|
||||||
|
capabilities={"mail.campaign_delivery"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "campaigns.workflow.queue-delivery" not in without_mail
|
||||||
|
assert "campaigns.workflow.send-small-controlled-run" not in without_mail
|
||||||
|
assert "campaigns.workflow.queue-delivery" in with_mail
|
||||||
|
assert "campaigns.workflow.send-small-controlled-run" in with_mail
|
||||||
|
|
||||||
|
|
||||||
|
def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None:
|
||||||
|
attachment_scopes = {
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:share",
|
||||||
|
}
|
||||||
|
source_scopes = {
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
}
|
||||||
|
|
||||||
|
files_visible = _visible_static_topics(
|
||||||
|
attachment_scopes,
|
||||||
|
modules={"campaigns", "files"},
|
||||||
|
capabilities={"files.campaign_attachments"},
|
||||||
|
)
|
||||||
|
addresses_visible = _visible_static_topics(
|
||||||
|
source_scopes,
|
||||||
|
modules={"campaigns", "addresses"},
|
||||||
|
capabilities={"addresses.recipient_source"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "campaigns.workflow.use-managed-attachments" in files_visible
|
||||||
|
assert "campaigns.workflow.import-address-source" in addresses_visible
|
||||||
|
assert "campaigns.workflow.use-managed-attachments" not in _visible_static_topics(attachment_scopes)
|
||||||
|
assert "campaigns.workflow.import-address-source" not in _visible_static_topics(source_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_export_and_lifecycle_tasks_are_independently_permission_gated() -> None:
|
||||||
|
exporter = _visible_static_topics(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"campaigns:report:export",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:export",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
custodian = _visible_static_topics(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:share",
|
||||||
|
"campaigns:campaign:archive",
|
||||||
|
"campaigns:campaign:delete",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "campaigns.workflow.export-delivery-report" in exporter
|
||||||
|
assert "campaigns.workflow.view-delivery-report" in exporter
|
||||||
|
assert "campaigns.workflow.share-campaign" in custodian
|
||||||
|
assert "campaigns.workflow.archive-campaign" in custodian
|
||||||
|
assert "campaigns.workflow.delete-untouched-draft" in custodian
|
||||||
|
assert "campaigns.workflow.export-delivery-report" not in custodian
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_resend_claim() -> None:
|
||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
topics = get_manifest().documentation
|
||||||
|
ids = [topic.id for topic in topics]
|
||||||
|
rendered_static = "\n".join(
|
||||||
|
(topic.title + "\n" + topic.summary + "\n" + topic.body).lower()
|
||||||
|
for topic in CAMPAIGN_USER_DOCUMENTATION
|
||||||
|
)
|
||||||
|
known_help_contexts = {
|
||||||
|
"campaigns.list",
|
||||||
|
"campaign.overview",
|
||||||
|
"campaign.settings",
|
||||||
|
"campaign.fields",
|
||||||
|
"campaign.template",
|
||||||
|
"campaign.attachments",
|
||||||
|
"campaign.recipients",
|
||||||
|
"campaign.recipient-data",
|
||||||
|
"campaign.server-settings",
|
||||||
|
"campaign.global-settings",
|
||||||
|
"campaign.review-send",
|
||||||
|
"campaign.report",
|
||||||
|
"campaign.audit",
|
||||||
|
"campaign.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
assert "single resend" not in rendered_static
|
||||||
|
for topic in CAMPAIGN_USER_DOCUMENTATION:
|
||||||
|
assert topic.metadata["kind"] == "workflow"
|
||||||
|
assert topic.metadata["prerequisites"]
|
||||||
|
assert topic.metadata["steps"]
|
||||||
|
assert topic.metadata["outcome"]
|
||||||
|
assert topic.metadata["verification"]
|
||||||
|
assert set(topic.metadata["help_contexts"]).issubset(known_help_contexts)
|
||||||
|
|
||||||
|
existing_user_workflows = {
|
||||||
|
topic.id: topic
|
||||||
|
for topic in topics
|
||||||
|
if topic.id
|
||||||
|
in {
|
||||||
|
"campaigns.mail-profile-user-journey",
|
||||||
|
"campaigns.workflow.prepare-validate-and-build",
|
||||||
|
"campaigns.workflow.complete-review",
|
||||||
|
"campaigns.workflow.retry-and-reconcile",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert set(existing_user_workflows) == {
|
||||||
|
"campaigns.mail-profile-user-journey",
|
||||||
|
"campaigns.workflow.prepare-validate-and-build",
|
||||||
|
"campaigns.workflow.complete-review",
|
||||||
|
"campaigns.workflow.retry-and-reconcile",
|
||||||
|
}
|
||||||
|
for topic in existing_user_workflows.values():
|
||||||
|
assert topic.metadata["help_contexts"]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
|
campaign_editor_state_for_edit,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import (
|
||||||
|
CampaignVersionResponse,
|
||||||
|
CampaignVersionUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"editor_state",
|
||||||
|
[
|
||||||
|
{"smtp": {"host": "smtp.example.test", "password": "secret"}},
|
||||||
|
{"transport": {"imap_password": "secret"}},
|
||||||
|
{
|
||||||
|
"review_send": {
|
||||||
|
"build_token": "forged",
|
||||||
|
"inspection_complete": True,
|
||||||
|
"reviewed_message_keys": [],
|
||||||
|
"updated_at": "2026-07-21T00:00:00+00:00",
|
||||||
|
"updated_by_user_id": "user-1",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_version_updates_reject_arbitrary_or_server_owned_editor_state(
|
||||||
|
editor_state: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValidationError, match="unsupported or transport-owned"):
|
||||||
|
CampaignVersionUpdateRequest(editor_state=editor_state)
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_response_omits_legacy_secret_bearing_editor_state() -> None:
|
||||||
|
response = CampaignVersionResponse.model_validate(
|
||||||
|
{
|
||||||
|
"id": "version-1",
|
||||||
|
"campaign_id": "campaign-1",
|
||||||
|
"version_number": 1,
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"editor_state": {
|
||||||
|
"opt_ins": {"inline_guidance": True},
|
||||||
|
"smtp": {"host": "smtp.internal.example", "password": "provider-secret"},
|
||||||
|
},
|
||||||
|
"created_at": datetime.now(UTC),
|
||||||
|
"updated_at": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.editor_state == {"opt_ins": {"inline_guidance": True}}
|
||||||
|
assert "provider-secret" not in repr(response)
|
||||||
|
assert "internal.example" not in repr(response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_build_token_is_visible_only_in_operator_diagnostics() -> None:
|
||||||
|
value = {
|
||||||
|
"id": "version-1",
|
||||||
|
"campaign_id": "campaign-1",
|
||||||
|
"version_number": 1,
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"editor_state": {
|
||||||
|
"review_send": {
|
||||||
|
"build_token": "internal-build-token",
|
||||||
|
"inspection_complete": True,
|
||||||
|
"reviewed_message_keys": ["entry-1"],
|
||||||
|
"updated_at": "2026-07-21T00:00:00+00:00",
|
||||||
|
"updated_by_user_id": "reviewer-1",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at": datetime.now(UTC),
|
||||||
|
"updated_at": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
public = CampaignVersionResponse.model_validate(value)
|
||||||
|
operator = CampaignVersionResponse.model_validate(
|
||||||
|
value,
|
||||||
|
context={"include_diagnostics": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "build_token" not in public.editor_state["review_send"]
|
||||||
|
assert operator.editor_state["review_send"]["build_token"] == "internal-build-token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_copy_keeps_only_client_owned_bounded_metadata() -> None:
|
||||||
|
copied = campaign_editor_state_for_edit(
|
||||||
|
{
|
||||||
|
"created_from": "minimal_campaign",
|
||||||
|
"field_overrides": {"department": False},
|
||||||
|
"review_send": {
|
||||||
|
"build_token": "build-1",
|
||||||
|
"inspection_complete": True,
|
||||||
|
"reviewed_message_keys": ["entry-1"],
|
||||||
|
"updated_at": "2026-07-21T00:00:00+00:00",
|
||||||
|
"updated_by_user_id": "reviewer-1",
|
||||||
|
},
|
||||||
|
"credentials": {"password": "legacy-secret"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert copied == {
|
||||||
|
"created_from": "minimal_campaign",
|
||||||
|
"field_overrides": {"department": False},
|
||||||
|
}
|
||||||
|
assert "legacy-secret" not in repr(copied)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||||
|
from govoplan_campaign.backend.sending.execution import (
|
||||||
|
ExecutionSnapshotError,
|
||||||
|
create_execution_snapshot,
|
||||||
|
ensure_execution_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_campaign() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
|
||||||
|
"server": {"mail_profile_id": "profile-1"},
|
||||||
|
"recipients": {"from": [{"email": "sender@example.test"}]},
|
||||||
|
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
|
||||||
|
"entries": {"inline": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _job() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
entry_index=0,
|
||||||
|
entry_id="entry-1",
|
||||||
|
recipient_email="recipient@example.test",
|
||||||
|
subject="Subject",
|
||||||
|
message_id_header="<message@example.test>",
|
||||||
|
eml_size_bytes=123,
|
||||||
|
eml_sha256="eml-digest",
|
||||||
|
build_status="built",
|
||||||
|
validation_status="ready",
|
||||||
|
resolved_recipients={"to": ["recipient@example.test"]},
|
||||||
|
resolved_attachments=[],
|
||||||
|
issues_snapshot=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Query:
|
||||||
|
def __init__(self, jobs: list[SimpleNamespace]):
|
||||||
|
self.jobs = jobs
|
||||||
|
|
||||||
|
def filter(self, *_args):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def order_by(self, *_args):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return list(self.jobs)
|
||||||
|
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self, jobs: list[SimpleNamespace]):
|
||||||
|
self.jobs = jobs
|
||||||
|
|
||||||
|
def query(self, _model):
|
||||||
|
return _Query(self.jobs)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshotted_version(job: SimpleNamespace):
|
||||||
|
version = SimpleNamespace(
|
||||||
|
id="version-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
raw_json=_raw_campaign(),
|
||||||
|
build_summary={"build_token": "build-1", "built_at": "2026-07-21T00:00:00+00:00"},
|
||||||
|
)
|
||||||
|
payload, digest = create_execution_snapshot(
|
||||||
|
version, # type: ignore[arg-type]
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_transport_revision="smtp-revision",
|
||||||
|
imap_transport_revision="imap-revision",
|
||||||
|
delivery=DeliveryConfig(),
|
||||||
|
jobs=[job], # type: ignore[list-item]
|
||||||
|
build_summary=version.build_summary,
|
||||||
|
)
|
||||||
|
version.execution_snapshot = payload
|
||||||
|
version.execution_snapshot_hash = digest
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure(session: _Session, version) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||||
|
return_value=SimpleNamespace(available=False),
|
||||||
|
):
|
||||||
|
ensure_execution_snapshot(session, version) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v5_snapshot_requires_its_persisted_checksum() -> None:
|
||||||
|
job = _job()
|
||||||
|
version = _snapshotted_version(job)
|
||||||
|
version.execution_snapshot_hash = None
|
||||||
|
|
||||||
|
with pytest.raises(ExecutionSnapshotError, match="checksum is missing"):
|
||||||
|
_ensure(_Session([job]), version)
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_json_drift_is_rejected_before_delivery() -> None:
|
||||||
|
job = _job()
|
||||||
|
version = _snapshotted_version(job)
|
||||||
|
version.raw_json["template"] = {"subject": "Changed", "text": "Body"}
|
||||||
|
|
||||||
|
with pytest.raises(ExecutionSnapshotError, match="Campaign inputs changed"):
|
||||||
|
_ensure(_Session([job]), version)
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_build_recipient_drift_cannot_redirect_delivery() -> None:
|
||||||
|
job = _job()
|
||||||
|
version = _snapshotted_version(job)
|
||||||
|
job.resolved_recipients = {"to": ["attacker@example.test"]}
|
||||||
|
|
||||||
|
with pytest.raises(ExecutionSnapshotError, match="job inputs changed"):
|
||||||
|
_ensure(_Session([job]), version)
|
||||||
|
|
||||||
|
|
||||||
|
def test_effect_check_verifies_only_the_claimed_job_in_constant_time() -> None:
|
||||||
|
job = _job()
|
||||||
|
version = _snapshotted_version(job)
|
||||||
|
session = SimpleNamespace(
|
||||||
|
query=lambda *_args: pytest.fail("per-effect validation must not rescan every campaign job")
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||||
|
return_value=SimpleNamespace(available=False),
|
||||||
|
):
|
||||||
|
ensure_execution_snapshot(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
version,
|
||||||
|
effect_job=job, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
|
||||||
|
from govoplan_campaign.backend import router
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
CampaignJob,
|
||||||
|
ImapAppendAttempt,
|
||||||
|
JobImapStatus,
|
||||||
|
JobSendStatus,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.retention import _apply_eml_retention
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignResolveOutcomeRequest
|
||||||
|
from govoplan_campaign.backend.sending.jobs import (
|
||||||
|
AppendSentResult,
|
||||||
|
QueueingError,
|
||||||
|
_claim_job_for_imap_append,
|
||||||
|
_record_imap_append_success,
|
||||||
|
append_sent_for_job,
|
||||||
|
reconcile_job_outcome,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("imap_status", "expected_status"),
|
||||||
|
[
|
||||||
|
(JobImapStatus.APPENDING.value, "append_in_progress"),
|
||||||
|
(JobImapStatus.OUTCOME_UNKNOWN.value, JobImapStatus.OUTCOME_UNKNOWN.value),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_in_progress_and_unknown_jobs_never_reinvoke_mail_provider(
|
||||||
|
imap_status: str,
|
||||||
|
expected_status: str,
|
||||||
|
) -> None:
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
send_status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||||
|
imap_status=imap_status,
|
||||||
|
)
|
||||||
|
session = SimpleNamespace(get=lambda _model, _id: job)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._imap_attempt_count", return_value=1),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.mail_integration") as mail,
|
||||||
|
):
|
||||||
|
result = append_sent_for_job(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
job_id="job-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == expected_status
|
||||||
|
mail.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_imap_claim_is_a_single_atomic_state_transition() -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
query = session.query.return_value
|
||||||
|
query.filter.return_value.update.return_value = 1
|
||||||
|
job = SimpleNamespace(id="job-1")
|
||||||
|
|
||||||
|
claim_token = _claim_job_for_imap_append(
|
||||||
|
session,
|
||||||
|
job, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert claim_token
|
||||||
|
changes = query.filter.return_value.update.call_args.args[0]
|
||||||
|
assert changes[CampaignJob.imap_status] == JobImapStatus.APPENDING.value
|
||||||
|
assert changes[CampaignJob.imap_claim_token] == claim_token
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
session.expire_all.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_provider_acknowledgement_cannot_overwrite_a_changed_claim() -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
session.query.return_value.filter.return_value.update.return_value = 0
|
||||||
|
job = SimpleNamespace(id="job-1")
|
||||||
|
attempt = SimpleNamespace(id="attempt-1", attempt_number=1)
|
||||||
|
expected = AppendSentResult(
|
||||||
|
job_id="job-1",
|
||||||
|
status=JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
attempt_number=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.jobs._imap_result_after_lost_claim",
|
||||||
|
return_value=expected,
|
||||||
|
) as lost_claim,
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.files_integration") as files,
|
||||||
|
):
|
||||||
|
result = _record_imap_append_success(
|
||||||
|
session,
|
||||||
|
job=job, # type: ignore[arg-type]
|
||||||
|
attempt=attempt, # type: ignore[arg-type]
|
||||||
|
claim_token="claim-1",
|
||||||
|
folder="Sent",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is expected
|
||||||
|
lost_claim.assert_called_once_with(
|
||||||
|
session,
|
||||||
|
job_id="job-1",
|
||||||
|
attempt=attempt,
|
||||||
|
provider_succeeded=True,
|
||||||
|
)
|
||||||
|
files.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_provider_persistence_failure_freezes_imap_retry() -> None:
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
send_status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||||
|
imap_status=JobImapStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
version = SimpleNamespace(id="version-1")
|
||||||
|
snapshot = SimpleNamespace(
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_transport_revision="smtp-revision",
|
||||||
|
imap_transport_revision="imap-revision",
|
||||||
|
delivery=SimpleNamespace(
|
||||||
|
imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
attempt = SimpleNamespace(id="attempt-1", attempt_number=1)
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def get(self, model, _object_id):
|
||||||
|
return version if model.__name__ == "CampaignVersion" else job
|
||||||
|
|
||||||
|
class Mail:
|
||||||
|
def append_campaign_message_to_sent(self, *_args, **_kwargs):
|
||||||
|
return SimpleNamespace(folder="Sent")
|
||||||
|
|
||||||
|
expected = AppendSentResult(
|
||||||
|
job_id="job-1",
|
||||||
|
status=JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
attempt_number=1,
|
||||||
|
)
|
||||||
|
session = Session()
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.ensure_execution_snapshot", return_value=snapshot),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._load_eml_bytes_for_job", return_value=b"message"),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._claim_job_for_imap_append", return_value="claim-1"),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._record_imap_attempt_start", return_value=attempt),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.jobs._record_imap_append_success",
|
||||||
|
side_effect=OSError("database unavailable"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.jobs._mark_imap_append_outcome_unknown_after_effect",
|
||||||
|
return_value=expected,
|
||||||
|
) as mark_unknown,
|
||||||
|
):
|
||||||
|
result = append_sent_for_job(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
job_id="job-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is expected
|
||||||
|
assert "Automatic retry is stopped" in mark_unknown.call_args.kwargs["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_attempt_numbers_are_unique_per_job_in_the_model_contract() -> None:
|
||||||
|
constraints = {
|
||||||
|
constraint.name
|
||||||
|
for constraint in ImapAppendAttempt.__table__.constraints
|
||||||
|
}
|
||||||
|
assert "uq_imap_append_attempts_job_attempt" in constraints
|
||||||
|
|
||||||
|
|
||||||
|
def test_imap_claim_migration_preserves_and_renumbers_duplicate_attempts() -> None:
|
||||||
|
migration = importlib.import_module(
|
||||||
|
"govoplan_campaign.backend.migrations.versions.3c4d5e6f8192_v019_imap_append_claim"
|
||||||
|
)
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE imap_append_attempts ("
|
||||||
|
"id VARCHAR(36) PRIMARY KEY, job_id VARCHAR(36) NOT NULL, "
|
||||||
|
"attempt_number INTEGER NOT NULL, created_at DATETIME NOT NULL)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO imap_append_attempts "
|
||||||
|
"(id, job_id, attempt_number, created_at) VALUES "
|
||||||
|
"('a2', 'job-1', 1, '2026-01-02'), "
|
||||||
|
"('a1', 'job-1', 1, '2026-01-01'), "
|
||||||
|
"('b1', 'job-2', 7, '2026-01-01')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with patch.object(migration.op, "get_bind", return_value=connection):
|
||||||
|
migration._renumber_attempts()
|
||||||
|
rows = connection.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, job_id, attempt_number FROM imap_append_attempts "
|
||||||
|
"ORDER BY job_id, attempt_number"
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
assert rows == [
|
||||||
|
("a1", "job-1", 1),
|
||||||
|
("a2", "job-1", 2),
|
||||||
|
("b1", "job-2", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("decision", ["smtp_accepted", "not_sent", "imap_appended", "imap_not_appended"])
|
||||||
|
def test_reconciliation_requires_an_evidence_note(decision: str) -> None:
|
||||||
|
with pytest.raises(ValueError, match="evidence note"):
|
||||||
|
CampaignResolveOutcomeRequest(
|
||||||
|
decision=decision, # type: ignore[arg-type]
|
||||||
|
note=" ",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("decision", "expected_status", "attempt_status"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"imap_appended",
|
||||||
|
JobImapStatus.APPENDED.value,
|
||||||
|
"reconciled_imap_appended",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"imap_not_appended",
|
||||||
|
JobImapStatus.FAILED.value,
|
||||||
|
"reconciled_imap_not_appended",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryable(
|
||||||
|
decision: str,
|
||||||
|
expected_status: str,
|
||||||
|
attempt_status: str,
|
||||||
|
) -> None:
|
||||||
|
campaign = SimpleNamespace(id="campaign-1")
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
send_status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||||
|
imap_status=JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
imap_claimed_at=datetime.now(timezone.utc),
|
||||||
|
imap_claim_token="claim-1",
|
||||||
|
last_error="unknown",
|
||||||
|
)
|
||||||
|
attempt = SimpleNamespace(
|
||||||
|
id="attempt-1",
|
||||||
|
status=JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
error_message="unknown",
|
||||||
|
)
|
||||||
|
session = MagicMock()
|
||||||
|
session.get.return_value = job
|
||||||
|
session.query.return_value.filter.return_value.order_by.return_value.first.return_value = attempt
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant",
|
||||||
|
return_value=campaign,
|
||||||
|
),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.files_integration") as files,
|
||||||
|
):
|
||||||
|
result = reconcile_job_outcome(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
job_id="job-1",
|
||||||
|
decision=decision,
|
||||||
|
note="Mailbox UID evidence checked by operator 42.",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["channel"] == "imap"
|
||||||
|
assert job.imap_status == expected_status
|
||||||
|
assert job.imap_claimed_at is None
|
||||||
|
assert job.imap_claim_token is None
|
||||||
|
assert attempt.status == attempt_status
|
||||||
|
assert attempt.error_message == "Mailbox UID evidence checked by operator 42."
|
||||||
|
assert attempt in session.add.call_args_list[0].args
|
||||||
|
session.flush.assert_called_once_with()
|
||||||
|
session.commit.assert_not_called()
|
||||||
|
if decision == "imap_appended":
|
||||||
|
files().mark_job_attachment_uses_sent.assert_called_once_with(session, job)
|
||||||
|
else:
|
||||||
|
files().mark_job_attachment_uses_sent.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"imap_status",
|
||||||
|
[JobImapStatus.APPENDING.value, JobImapStatus.FAILED.value, JobImapStatus.APPENDED.value],
|
||||||
|
)
|
||||||
|
def test_imap_reconciliation_rejects_live_retryable_or_completed_state(imap_status: str) -> None:
|
||||||
|
campaign = SimpleNamespace(id="campaign-1")
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
send_status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||||
|
imap_status=imap_status,
|
||||||
|
)
|
||||||
|
session = MagicMock()
|
||||||
|
session.get.return_value = job
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant",
|
||||||
|
return_value=campaign,
|
||||||
|
):
|
||||||
|
with pytest.raises(QueueingError, match="does not require reconciliation"):
|
||||||
|
reconcile_job_outcome(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
job_id="job-1",
|
||||||
|
decision="imap_not_appended",
|
||||||
|
note="Checked mailbox.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("send_status", [JobSendStatus.CLAIMED.value, JobSendStatus.SENDING.value])
|
||||||
|
def test_smtp_reconciliation_rejects_a_live_worker_state(send_status: str) -> None:
|
||||||
|
campaign = SimpleNamespace(id="campaign-1")
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
send_status=send_status,
|
||||||
|
)
|
||||||
|
session = MagicMock()
|
||||||
|
session.get.return_value = job
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant",
|
||||||
|
return_value=campaign,
|
||||||
|
):
|
||||||
|
with pytest.raises(QueueingError, match="does not require reconciliation"):
|
||||||
|
reconcile_job_outcome(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
job_id="job-1",
|
||||||
|
decision="not_sent",
|
||||||
|
note="Checked provider logs.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("decision", "note"),
|
||||||
|
[
|
||||||
|
("smtp_accepted", "SMTP provider evidence checked."),
|
||||||
|
("imap_appended", "Mailbox evidence checked."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_reconciliation_rolls_back_state_when_audit_fails(
|
||||||
|
decision: str,
|
||||||
|
note: str | None,
|
||||||
|
) -> None:
|
||||||
|
payload = CampaignResolveOutcomeRequest(decision=decision, note=note) # type: ignore[arg-type]
|
||||||
|
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
def mutate_without_commit(*_args, **kwargs):
|
||||||
|
assert kwargs["commit"] is False
|
||||||
|
session.flush()
|
||||||
|
return {"decision": decision, "job_id": "job-1"}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.router._get_campaign_for_principal"),
|
||||||
|
patch("govoplan_campaign.backend.router._require_permission"),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.router.reconcile_job_outcome",
|
||||||
|
side_effect=mutate_without_commit,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.router.audit_from_principal",
|
||||||
|
side_effect=RuntimeError("audit unavailable"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||||
|
router.resolve_campaign_job_outcome(
|
||||||
|
"campaign-1",
|
||||||
|
"job-1",
|
||||||
|
payload,
|
||||||
|
session=session,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.commit.assert_not_called()
|
||||||
|
session.rollback.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_retention_preserves_eml_for_unknown_imap_outcome(tmp_path: Path) -> None:
|
||||||
|
eml_path = tmp_path / "message.eml"
|
||||||
|
eml_path.write_bytes(b"message")
|
||||||
|
job = SimpleNamespace(
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
updated_at=datetime.now(timezone.utc) - timedelta(days=10),
|
||||||
|
queue_status="draft",
|
||||||
|
send_status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||||
|
imap_status=JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
eml_local_path=str(eml_path),
|
||||||
|
eml_storage_key=None,
|
||||||
|
)
|
||||||
|
query = MagicMock()
|
||||||
|
query.filter.return_value.order_by.return_value.all.return_value = [job]
|
||||||
|
session = MagicMock()
|
||||||
|
session.query.return_value = query
|
||||||
|
policy = SimpleNamespace(generated_eml_retention_days=1)
|
||||||
|
|
||||||
|
result = _apply_eml_retention(
|
||||||
|
session,
|
||||||
|
dry_run=False,
|
||||||
|
now=datetime.now(timezone.utc),
|
||||||
|
policy_for_campaign_id=lambda _campaign_id: policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["skipped_not_final"] == 1
|
||||||
|
assert eml_path.exists()
|
||||||
|
session.add.assert_not_called()
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_campaign.backend import router
|
||||||
|
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
|
CampaignMailProfileBoundaryError,
|
||||||
|
assert_campaign_uses_mail_profile_reference,
|
||||||
|
campaign_mail_profile_boundary_violations,
|
||||||
|
campaign_mail_profile_id,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||||
|
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError, load_campaign_config_from_json
|
||||||
|
from govoplan_campaign.backend.persistence.versions import update_campaign_version
|
||||||
|
from govoplan_campaign.backend.integrations import MailCampaignIntegration
|
||||||
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot, ensure_execution_snapshot
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignVersionUpdateRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_json(server: dict[str, object] | None = None) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
|
||||||
|
"server": server or {},
|
||||||
|
"recipients": {"from": [{"email": "sender@example.test"}]},
|
||||||
|
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
|
||||||
|
"entries": {"inline": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_mail_contract_accepts_only_a_stable_profile_reference() -> None:
|
||||||
|
raw = _campaign_json({"mail_profile_id": " profile-1 "})
|
||||||
|
|
||||||
|
assert_campaign_uses_mail_profile_reference(raw)
|
||||||
|
|
||||||
|
assert campaign_mail_profile_id(raw) == "profile-1"
|
||||||
|
assert campaign_mail_profile_boundary_violations(raw) == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mail_profile_documentation_is_classified_for_adaptive_views() -> None:
|
||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
manifest = get_manifest()
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
|
||||||
|
workflow = topics["campaigns.mail-profile-user-journey"]
|
||||||
|
assert workflow.metadata["kind"] == "workflow"
|
||||||
|
assert workflow.metadata["route"] == "/campaigns/{campaign_id}/mail-settings"
|
||||||
|
assert workflow.conditions[0].required_scopes == (
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"mail:profile:use",
|
||||||
|
)
|
||||||
|
assert workflow.metadata["prerequisites"]
|
||||||
|
assert workflow.metadata["steps"]
|
||||||
|
assert workflow.metadata["outcome"]
|
||||||
|
assert workflow.metadata["verification"]
|
||||||
|
assert "campaigns.mail-profile-governance" in workflow.metadata["related_topic_ids"]
|
||||||
|
|
||||||
|
assert topics["campaigns.mail-profile-governance"].metadata["kind"] == "reference"
|
||||||
|
assert topics["campaigns.mail-profile-operations"].metadata["kind"] == "reference"
|
||||||
|
assert topics["campaigns.workflow.prepare-validate-and-build"].metadata["kind"] == "workflow"
|
||||||
|
assert topics["campaigns.workflow.complete-review"].metadata["kind"] == "workflow"
|
||||||
|
assert topics["campaigns.workflow.retry-and-reconcile"].metadata["kind"] == "workflow"
|
||||||
|
assert topics["campaigns.reference.composition-assurance"].metadata["kind"] == "reference"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("legacy_key", ["smtp", "imap", "credentials", "inherit_smtp_credentials", "profile_id"])
|
||||||
|
def test_campaign_mail_contract_rejects_every_legacy_server_field(legacy_key: str) -> None:
|
||||||
|
raw = _campaign_json({"mail_profile_id": "profile-1", legacy_key: {}})
|
||||||
|
|
||||||
|
with pytest.raises(CampaignMailProfileBoundaryError, match="select an authorized Mail profile"):
|
||||||
|
assert_campaign_uses_mail_profile_reference(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_persisted_schema_rejects_inline_transport_even_without_a_secret() -> None:
|
||||||
|
with pytest.raises(CampaignSchemaError, match="Additional properties are not allowed"):
|
||||||
|
validate_against_schema(_campaign_json({"smtp": {"host": "smtp.example.test"}}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_rejects_inline_transport_before_optional_mail_summary() -> None:
|
||||||
|
integration = SimpleNamespace(campaign_profile_delivery_summary=lambda *_args, **_kwargs: pytest.fail("must not resolve"))
|
||||||
|
with patch("govoplan_campaign.backend.persistence.campaigns.mail_integration", return_value=integration):
|
||||||
|
with pytest.raises(CampaignMailProfileBoundaryError, match="remove campaign-local SMTP/IMAP settings"):
|
||||||
|
load_campaign_config_from_json(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
raw_json=_campaign_json({"smtp": {"password": "secret"}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_uses_only_non_secret_mail_profile_capabilities() -> None:
|
||||||
|
raw = _campaign_json({"mail_profile_id": "profile-1"})
|
||||||
|
|
||||||
|
def summary(_session, **kwargs):
|
||||||
|
assert kwargs["profile_id"] == "profile-1"
|
||||||
|
return {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"smtp_available": True,
|
||||||
|
"imap_available": False,
|
||||||
|
"smtp_transport_revision": "opaque-smtp",
|
||||||
|
"imap_transport_revision": None,
|
||||||
|
# Even a broken/malicious provider cannot inject extra material into
|
||||||
|
# Campaign's strict in-memory ServerConfig.
|
||||||
|
"host": "smtp.example.test",
|
||||||
|
"password": "secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
integration = SimpleNamespace(campaign_profile_delivery_summary=summary)
|
||||||
|
with patch("govoplan_campaign.backend.persistence.campaigns.mail_integration", return_value=integration):
|
||||||
|
config = load_campaign_config_from_json(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
raw_json=raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert raw["server"] == {"mail_profile_id": "profile-1"}
|
||||||
|
assert config.server.mail_profile_id == "profile-1"
|
||||||
|
assert config.server.profile_capabilities.smtp_available is True
|
||||||
|
assert config.server.profile_capabilities.imap_available is False
|
||||||
|
assert "smtp.example.test" not in repr(config.server)
|
||||||
|
assert "secret" not in repr(config.server)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_material() -> None:
|
||||||
|
raw = _campaign_json({"mail_profile_id": "profile-1"})
|
||||||
|
version = SimpleNamespace(id="version-1", raw_json=raw)
|
||||||
|
|
||||||
|
payload, _digest = create_execution_snapshot(
|
||||||
|
version, # type: ignore[arg-type]
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_transport_revision="opaque-smtp-evidence",
|
||||||
|
imap_transport_revision="opaque-imap-evidence",
|
||||||
|
delivery=DeliveryConfig(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["snapshot_version"] == "5"
|
||||||
|
assert payload["mail_profile_id"] == "profile-1"
|
||||||
|
assert "smtp" not in payload
|
||||||
|
assert "imap" not in payload
|
||||||
|
assert payload["smtp_transport_revision"] == "opaque-smtp-evidence"
|
||||||
|
assert payload["imap_transport_revision"] == "opaque-imap-evidence"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_execution_snapshot_is_preserved_but_fails_closed() -> None:
|
||||||
|
version = SimpleNamespace(
|
||||||
|
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
|
||||||
|
execution_snapshot={"snapshot_version": "3", "smtp": {"host": "legacy.example.test"}},
|
||||||
|
execution_snapshot_hash=None,
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||||
|
return_value=SimpleNamespace(available=False),
|
||||||
|
):
|
||||||
|
with pytest.raises(ExecutionSnapshotError, match="preserved for audit only"):
|
||||||
|
ensure_execution_snapshot(object(), version) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert version.execution_snapshot["smtp"]["host"] == "legacy.example.test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_mail_adapter_does_not_expose_raw_transport_helpers() -> None:
|
||||||
|
integration = MailCampaignIntegration(SimpleNamespace())
|
||||||
|
|
||||||
|
for name in (
|
||||||
|
"smtp_config_from_profile",
|
||||||
|
"imap_config_from_profile",
|
||||||
|
"send_email_bytes",
|
||||||
|
"send_email_message",
|
||||||
|
"materialize_campaign_mail_profile_config",
|
||||||
|
):
|
||||||
|
assert not hasattr(integration, name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_a_legacy_record_requires_an_explicit_profile_migration() -> None:
|
||||||
|
legacy_raw = _campaign_json({"smtp": {"host": "smtp.example.test", "password": "secret"}})
|
||||||
|
version = SimpleNamespace(id="version-1", campaign_id="campaign-1", raw_json=legacy_raw)
|
||||||
|
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.persistence.versions.get_campaign_version_for_tenant", return_value=version),
|
||||||
|
patch("govoplan_campaign.backend.persistence.versions._require_campaign", return_value=campaign),
|
||||||
|
patch("govoplan_campaign.backend.persistence.versions.ensure_current_working_version"),
|
||||||
|
patch("govoplan_campaign.backend.persistence.versions.is_version_locked", return_value=False),
|
||||||
|
):
|
||||||
|
with pytest.raises(CampaignPersistenceError, match="explicitly save the migration"):
|
||||||
|
update_campaign_version(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
version_id="version-1",
|
||||||
|
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert version.raw_json is legacy_raw
|
||||||
|
assert legacy_raw["server"]["smtp"]["password"] == "secret" # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
|
||||||
|
principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
campaign = SimpleNamespace(id="campaign-1")
|
||||||
|
source = SimpleNamespace(
|
||||||
|
id="version-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
raw_json={"server": {"mail_profile_id": "profile-1"}},
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||||
|
patch.object(router, "_require_permission"),
|
||||||
|
patch.object(router, "_get_version_for_tenant", return_value=source),
|
||||||
|
patch.object(router, "has_scope", return_value=False),
|
||||||
|
patch.object(router, "fork_campaign_version_for_edit") as fork,
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as captured:
|
||||||
|
router.fork_version_for_edit(
|
||||||
|
"campaign-1",
|
||||||
|
"version-1",
|
||||||
|
CampaignVersionUpdateRequest(),
|
||||||
|
session=object(), # type: ignore[arg-type]
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured.value.status_code == 403
|
||||||
|
fork.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_wide_effect_authorizes_every_affected_version() -> None:
|
||||||
|
session = object()
|
||||||
|
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||||
|
|
||||||
|
with patch.object(router, "_require_campaign_profile_use_if_needed") as require_profile:
|
||||||
|
router._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
"campaign-1",
|
||||||
|
{"version-2", "version-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert require_profile.call_args_list == [
|
||||||
|
call(session, principal, "campaign-1", "version-1"),
|
||||||
|
call(session, principal, "campaign-1", "version-2"),
|
||||||
|
]
|
||||||
@@ -80,6 +80,30 @@ class CampaignPartialValidationTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class CampaignSemanticValidationTests(unittest.TestCase):
|
class CampaignSemanticValidationTests(unittest.TestCase):
|
||||||
|
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
|
||||||
|
config = CampaignConfig.model_validate({
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
|
||||||
|
"server": {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"profile_capabilities": {"smtp_available": True},
|
||||||
|
},
|
||||||
|
"recipients": {"allow_individual_from": True},
|
||||||
|
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{"id": "ready", "from": [{"email": "sender@example.local"}]},
|
||||||
|
{"id": "missing"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
report = validate_campaign_config(config)
|
||||||
|
|
||||||
|
missing_sender = [issue for issue in report.issues if issue.code == "missing_sender"]
|
||||||
|
self.assertEqual(1, len(missing_sender))
|
||||||
|
self.assertEqual("/entries/inline/1/from", missing_sender[0].path)
|
||||||
|
|
||||||
def test_semantic_validation_reports_zip_delivery_and_external_mapping_issues(self) -> None:
|
def test_semantic_validation_reports_zip_delivery_and_external_mapping_issues(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
@@ -139,8 +163,7 @@ class CampaignSemanticValidationTests(unittest.TestCase):
|
|||||||
self.assertIn("unknown_global_value", codes)
|
self.assertIn("unknown_global_value", codes)
|
||||||
self.assertIn("zip_global_password_value_missing", codes)
|
self.assertIn("zip_global_password_value_missing", codes)
|
||||||
self.assertIn("zip_archive_unknown", codes)
|
self.assertIn("zip_archive_unknown", codes)
|
||||||
self.assertIn("delivery_imap_enabled_without_server_imap", codes)
|
self.assertIn("missing_mail_profile", codes)
|
||||||
self.assertIn("missing_smtp_config", codes)
|
|
||||||
self.assertIn("unknown_mapping_target", codes)
|
self.assertIn("unknown_mapping_target", codes)
|
||||||
self.assertIn("mapping_target_not_overridable", codes)
|
self.assertIn("mapping_target_not_overridable", codes)
|
||||||
self.assertIn("mapping_columns_missing", codes)
|
self.assertIn("mapping_columns_missing", codes)
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from govoplan_campaign.backend import router
|
||||||
|
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
||||||
|
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_email_recipient_schema_normalizes_and_has_safe_attachment_default() -> None:
|
||||||
|
request = ReportEmailRequest.model_validate({
|
||||||
|
"to": [" First@Example.test ", "first@example.test", "second@example.test"],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert request.to == ["First@Example.test", "second@example.test"]
|
||||||
|
assert request.attach_jobs_csv is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"recipients",
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
[f"recipient-{index}@example.test" for index in range(51)],
|
||||||
|
["a" * 310 + "@example.test"],
|
||||||
|
["victim@example.test\r\nBcc: attacker@example.test"],
|
||||||
|
["missing-at.example.test"],
|
||||||
|
["@example.test"],
|
||||||
|
["local@"],
|
||||||
|
["local @example.test"],
|
||||||
|
["one@two@example.test"],
|
||||||
|
["victim@example.test,Bcc:attacker"],
|
||||||
|
["Display<a@example.test>"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_report_email_recipient_schema_rejects_unsafe_addresses(recipients: list[str]) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ReportEmailRequest.model_validate({"to": recipients})
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> None:
|
||||||
|
payload = ReportEmailRequest(to=["recipient@example.test"], attach_jobs_csv=True)
|
||||||
|
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
router,
|
||||||
|
"_get_campaign_for_principal",
|
||||||
|
return_value=SimpleNamespace(id="campaign-1", current_version_id=None),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
router,
|
||||||
|
"_require_permission",
|
||||||
|
side_effect=HTTPException(status_code=403, detail="missing export"),
|
||||||
|
) as require_permission,
|
||||||
|
patch.object(router, "send_campaign_report_email") as send_report,
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as captured:
|
||||||
|
router.email_campaign_report(
|
||||||
|
"campaign-1",
|
||||||
|
payload,
|
||||||
|
session=object(), # type: ignore[arg-type]
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured.value.status_code == 403
|
||||||
|
require_permission.assert_called_once_with(principal, "campaigns:recipient:export")
|
||||||
|
send_report.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_email_requires_permission_to_use_selected_mail_profile() -> None:
|
||||||
|
payload = ReportEmailRequest(to=["recipient@example.test"])
|
||||||
|
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||||
|
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||||
|
version = SimpleNamespace(
|
||||||
|
id="version-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
raw_json={"server": {"mail_profile_id": "profile-1"}},
|
||||||
|
)
|
||||||
|
session = SimpleNamespace(get=lambda _model, _id: version)
|
||||||
|
with (
|
||||||
|
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||||
|
patch.object(
|
||||||
|
router,
|
||||||
|
"_require_mail_profile_use_if_needed",
|
||||||
|
side_effect=HTTPException(status_code=403, detail="missing profile use"),
|
||||||
|
) as require_profile_use,
|
||||||
|
patch.object(router, "send_campaign_report_email") as send_report,
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as captured:
|
||||||
|
router.email_campaign_report(
|
||||||
|
"campaign-1",
|
||||||
|
payload,
|
||||||
|
session=session, # type: ignore[arg-type]
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured.value.status_code == 403
|
||||||
|
require_profile_use.assert_called_once_with(principal, version.raw_json)
|
||||||
|
send_report.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_report_failure_does_not_leak_internal_details() -> None:
|
||||||
|
payload = ReportEmailRequest(to=["recipient@example.test"])
|
||||||
|
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
router,
|
||||||
|
"_get_campaign_for_principal",
|
||||||
|
return_value=SimpleNamespace(id="campaign-1", current_version_id=None),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
router,
|
||||||
|
"send_campaign_report_email",
|
||||||
|
side_effect=RuntimeError("smtp.internal.example provider-secret"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as captured:
|
||||||
|
router.email_campaign_report(
|
||||||
|
"campaign-1",
|
||||||
|
payload,
|
||||||
|
session=object(), # type: ignore[arg-type]
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured.value.status_code == 500
|
||||||
|
assert captured.value.detail == "Campaign report email could not be completed."
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_send_fails_closed_until_durable_mail_outbox_exists() -> None:
|
||||||
|
campaign = SimpleNamespace(
|
||||||
|
id="campaign-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
name="Campaign",
|
||||||
|
external_id="campaign-1",
|
||||||
|
)
|
||||||
|
version = SimpleNamespace(
|
||||||
|
id="version-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
raw_json={"server": {"mail_profile_id": "profile-1"}},
|
||||||
|
execution_snapshot={"snapshot_version": "5"},
|
||||||
|
)
|
||||||
|
config = SimpleNamespace(
|
||||||
|
server=SimpleNamespace(profile_capabilities=SimpleNamespace(smtp_available=True)),
|
||||||
|
)
|
||||||
|
snapshot = SimpleNamespace(smtp_transport_revision="frozen-build-revision")
|
||||||
|
class Session:
|
||||||
|
def get(self, _model, _id):
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.reports.emailing._selected_version", return_value=version),
|
||||||
|
patch("govoplan_campaign.backend.reports.emailing._load_config", return_value=config),
|
||||||
|
patch("govoplan_campaign.backend.reports.emailing.ensure_execution_snapshot", return_value=snapshot),
|
||||||
|
patch("govoplan_campaign.backend.reports.emailing.generate_campaign_report") as generate_report,
|
||||||
|
):
|
||||||
|
with pytest.raises(CampaignReportEmailError, match="durable, idempotent Mail-owned outbox"):
|
||||||
|
send_campaign_report_email(
|
||||||
|
Session(), # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
to=["recipient@example.test"],
|
||||||
|
)
|
||||||
|
|
||||||
|
generate_report.assert_not_called()
|
||||||
@@ -40,7 +40,7 @@ def _job() -> SimpleNamespace:
|
|||||||
eml_storage_key="campaign/job-1.eml",
|
eml_storage_key="campaign/job-1.eml",
|
||||||
claim_token="job-claim-secret",
|
claim_token="job-claim-secret",
|
||||||
attempt_count=1,
|
attempt_count=1,
|
||||||
last_error=None,
|
last_error="smtp.internal.example /srv/private provider-secret",
|
||||||
queued_at=_now(),
|
queued_at=_now(),
|
||||||
claimed_at=_now(),
|
claimed_at=_now(),
|
||||||
smtp_started_at=_now(),
|
smtp_started_at=_now(),
|
||||||
@@ -67,9 +67,9 @@ def _smtp_attempt() -> SimpleNamespace:
|
|||||||
status="started",
|
status="started",
|
||||||
claim_token="attempt-claim-secret",
|
claim_token="attempt-claim-secret",
|
||||||
smtp_status_code=None,
|
smtp_status_code=None,
|
||||||
smtp_response=None,
|
smtp_response="smtp.internal.example provider-secret",
|
||||||
error_type=None,
|
error_type="/srv/private/ProviderError",
|
||||||
error_message=None,
|
error_message="credential=provider-secret",
|
||||||
started_at=_now(),
|
started_at=_now(),
|
||||||
finished_at=None,
|
finished_at=None,
|
||||||
)
|
)
|
||||||
@@ -82,7 +82,7 @@ def _imap_attempt() -> SimpleNamespace:
|
|||||||
status="claimed",
|
status="claimed",
|
||||||
claim_token="imap-claim-secret",
|
claim_token="imap-claim-secret",
|
||||||
folder="Sent",
|
folder="Sent",
|
||||||
error_message=None,
|
error_message="imap.internal.example /srv/private provider-secret",
|
||||||
created_at=_now(),
|
created_at=_now(),
|
||||||
updated_at=_now(),
|
updated_at=_now(),
|
||||||
)
|
)
|
||||||
@@ -129,7 +129,11 @@ def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> No
|
|||||||
created_at=_now(),
|
created_at=_now(),
|
||||||
updated_at=_now(),
|
updated_at=_now(),
|
||||||
validation_summary={"campaign_file": "/tmp/campaign.json", "ok": True},
|
validation_summary={"campaign_file": "/tmp/campaign.json", "ok": True},
|
||||||
build_summary={"messages": [{"eml_path": "/tmp/message.eml", "subject": "Public"}]},
|
build_summary={
|
||||||
|
"build_token": "internal-build-token",
|
||||||
|
"smtp_transport_revision": "internal-smtp-revision",
|
||||||
|
"messages": [{"eml_path": "/tmp/message.eml", "subject": "Public"}],
|
||||||
|
},
|
||||||
execution_snapshot_hash=None,
|
execution_snapshot_hash=None,
|
||||||
execution_snapshot_at=None,
|
execution_snapshot_at=None,
|
||||||
)
|
)
|
||||||
@@ -141,6 +145,13 @@ def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> No
|
|||||||
assert payload["validation_summary"] == {"ok": True}
|
assert payload["validation_summary"] == {"ok": True}
|
||||||
assert payload["build_summary"] == {"messages": [{"subject": "Public"}]}
|
assert payload["build_summary"] == {"messages": [{"subject": "Public"}]}
|
||||||
|
|
||||||
|
operator_payload = CampaignVersionResponse.model_validate(
|
||||||
|
version,
|
||||||
|
context={"include_diagnostics": True},
|
||||||
|
).model_dump()
|
||||||
|
assert operator_payload["build_summary"]["build_token"] == "internal-build-token"
|
||||||
|
assert operator_payload["build_summary"]["smtp_transport_revision"] == "internal-smtp-revision"
|
||||||
|
|
||||||
detail = CampaignVersionDetailResponse.model_validate(
|
detail = CampaignVersionDetailResponse.model_validate(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
**version.__dict__,
|
**version.__dict__,
|
||||||
@@ -148,6 +159,7 @@ def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> No
|
|||||||
"campaign": {"title": "Public"},
|
"campaign": {"title": "Public"},
|
||||||
"files": [{"storage_key": "private/object", "filename": "public.pdf"}],
|
"files": [{"storage_key": "private/object", "filename": "public.pdf"}],
|
||||||
"server": {
|
"server": {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
"smtp": {"host": "smtp.example.invalid", "password": "smtp-secret"},
|
"smtp": {"host": "smtp.example.invalid", "password": "smtp-secret"},
|
||||||
"imap": {"host": "imap.example.invalid", "password": "imap-secret"},
|
"imap": {"host": "imap.example.invalid", "password": "imap-secret"},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
@@ -176,14 +188,7 @@ def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> No
|
|||||||
assert detail["raw_json"] == {
|
assert detail["raw_json"] == {
|
||||||
"campaign": {"title": "Public"},
|
"campaign": {"title": "Public"},
|
||||||
"files": [{"filename": "public.pdf"}],
|
"files": [{"filename": "public.pdf"}],
|
||||||
"server": {
|
"server": {"mail_profile_id": "profile-1"},
|
||||||
"smtp": {"host": "smtp.example.invalid"},
|
|
||||||
"imap": {"host": "imap.example.invalid"},
|
|
||||||
"credentials": {
|
|
||||||
"smtp": {"username": "sender"},
|
|
||||||
"imap": {"username": "archive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"archives": [{"password": "business-zip-password"}],
|
"archives": [{"password": "business-zip-password"}],
|
||||||
"template": {
|
"template": {
|
||||||
"source": {
|
"source": {
|
||||||
@@ -206,9 +211,19 @@ def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
|
|||||||
|
|
||||||
assert "eml_local_path" not in job_payload
|
assert "eml_local_path" not in job_payload
|
||||||
assert "eml_storage_key" not in job_payload
|
assert "eml_storage_key" not in job_payload
|
||||||
|
assert "claimed_at" not in job_payload
|
||||||
|
assert "smtp_started_at" not in job_payload
|
||||||
assert job_payload["attachments"] == [{"filename": "public.pdf"}]
|
assert job_payload["attachments"] == [{"filename": "public.pdf"}]
|
||||||
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 "error_type" not in attempts["smtp"][0]
|
||||||
|
assert "error_message" not in attempts["smtp"][0]
|
||||||
|
assert "error_message" not in attempts["imap"][0]
|
||||||
|
ordinary = repr({"job": job_payload, "attempts": attempts})
|
||||||
|
assert "internal.example" not in ordinary
|
||||||
|
assert "/srv/private" not in ordinary
|
||||||
|
assert "provider-secret" not in ordinary
|
||||||
|
|
||||||
|
|
||||||
def test_operator_diagnostics_include_claim_and_storage_details() -> None:
|
def test_operator_diagnostics_include_claim_and_storage_details() -> None:
|
||||||
@@ -223,6 +238,9 @@ def test_operator_diagnostics_include_claim_and_storage_details() -> None:
|
|||||||
assert diagnostics["worker_claim"]["claim_token"] == "job-claim-secret"
|
assert diagnostics["worker_claim"]["claim_token"] == "job-claim-secret"
|
||||||
assert diagnostics["attempts"]["smtp"][0]["claim_token"] == "attempt-claim-secret"
|
assert diagnostics["attempts"]["smtp"][0]["claim_token"] == "attempt-claim-secret"
|
||||||
assert diagnostics["attempts"]["imap"][0]["claim_token"] == "imap-claim-secret"
|
assert diagnostics["attempts"]["imap"][0]["claim_token"] == "imap-claim-secret"
|
||||||
|
assert "smtp.internal.example" in diagnostics["attempts"]["smtp"][0]["smtp_response"]
|
||||||
|
assert "imap.internal.example" in diagnostics["attempts"]["imap"][0]["error_message"]
|
||||||
|
assert "provider-secret" in diagnostics["worker_claim"]["last_error"]
|
||||||
|
|
||||||
|
|
||||||
def test_diagnostics_permission_is_operator_only_by_default() -> None:
|
def test_diagnostics_permission_is_operator_only_by_default() -> None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
JobBuildStatus,
|
JobBuildStatus,
|
||||||
@@ -10,8 +11,10 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
JobValidationStatus,
|
JobValidationStatus,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.sending.jobs import (
|
from govoplan_campaign.backend.sending.jobs import (
|
||||||
|
SendJobResult,
|
||||||
_queue_validation_statuses,
|
_queue_validation_statuses,
|
||||||
_select_campaign_jobs_for_queue,
|
_select_campaign_jobs_for_queue,
|
||||||
|
_send_claimed_campaign_job,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +118,78 @@ class CampaignQueueSelectionTests(unittest.TestCase):
|
|||||||
self.assertEqual(warning.send_status, JobSendStatus.NOT_QUEUED.value)
|
self.assertEqual(warning.send_status, JobSendStatus.NOT_QUEUED.value)
|
||||||
self.assertEqual(session.added, [])
|
self.assertEqual(session.added, [])
|
||||||
|
|
||||||
|
def test_post_smtp_persistence_failure_is_outcome_unknown_not_retryable_failure(self):
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
imap_status="not_requested",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||||
|
)
|
||||||
|
snapshot = SimpleNamespace(
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_transport_revision="frozen",
|
||||||
|
delivery=SimpleNamespace(
|
||||||
|
rate_limit=SimpleNamespace(messages_per_minute=60),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
snapshot=snapshot,
|
||||||
|
message_bytes=b"message",
|
||||||
|
envelope_from="sender@example.test",
|
||||||
|
envelope_recipients=["recipient@example.test"],
|
||||||
|
)
|
||||||
|
current = SimpleNamespace(id="job-1")
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.rolled_back = False
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
self.rolled_back = True
|
||||||
|
|
||||||
|
def get(self, _model, _id):
|
||||||
|
return current
|
||||||
|
|
||||||
|
class Mail:
|
||||||
|
def wait_for_rate_limit(self, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send_campaign_email_bytes(self, *_args, **_kwargs):
|
||||||
|
return SimpleNamespace(accepted_count=1)
|
||||||
|
|
||||||
|
session = Session()
|
||||||
|
expected = SendJobResult(
|
||||||
|
job_id="job-1",
|
||||||
|
status=JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
attempt_number=1,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._record_attempt_start", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.jobs._record_smtp_send_success",
|
||||||
|
side_effect=OSError("storage unavailable"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.jobs.mark_job_outcome_unknown",
|
||||||
|
return_value=expected,
|
||||||
|
) as mark_unknown,
|
||||||
|
):
|
||||||
|
result = _send_claimed_campaign_job(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
job=job, # type: ignore[arg-type]
|
||||||
|
claim_token="claim-1",
|
||||||
|
context=context, # type: ignore[arg-type]
|
||||||
|
use_rate_limit=False,
|
||||||
|
enqueue_imap_task=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(result, expected)
|
||||||
|
self.assertTrue(session.rolled_back)
|
||||||
|
self.assertIn("Automatic retry is stopped", mark_unknown.call_args.kwargs["reason"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export type CampaignVersionListItem = {
|
|||||||
export type CampaignVersionDetail = CampaignVersionListItem & {
|
export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||||
raw_json: Record<string, unknown>;
|
raw_json: Record<string, unknown>;
|
||||||
campaign_json?: Record<string, unknown>;
|
campaign_json?: Record<string, unknown>;
|
||||||
|
mail_profile_migration_required?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignWorkspaceResponse = {
|
export type CampaignWorkspaceResponse = {
|
||||||
@@ -209,6 +210,7 @@ export type CampaignVersionUpdatePayload = {
|
|||||||
editor_state?: Record<string, unknown> | null;
|
editor_state?: Record<string, unknown> | null;
|
||||||
source_filename?: string | null;
|
source_filename?: string | null;
|
||||||
source_base_path?: string | null;
|
source_base_path?: string | null;
|
||||||
|
migrate_legacy_mail_settings?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignPartialValidationPayload = {
|
export type CampaignPartialValidationPayload = {
|
||||||
@@ -894,7 +896,7 @@ export async function resolveCampaignJobOutcome(
|
|||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
jobId: string,
|
jobId: string,
|
||||||
decision: "smtp_accepted" | "not_sent",
|
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended",
|
||||||
note?: string)
|
note?: string)
|
||||||
: Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
|
||||||
|
|||||||
+2
-56
@@ -2,17 +2,10 @@ import type {
|
|||||||
ApiSettings,
|
ApiSettings,
|
||||||
MailConnectionTestResponse,
|
MailConnectionTestResponse,
|
||||||
MailImapFolderListResponse,
|
MailImapFolderListResponse,
|
||||||
MailImapTestPayload,
|
|
||||||
MailProfilePolicy,
|
|
||||||
MailProfilePolicyResponse,
|
|
||||||
MailProfileScope,
|
|
||||||
MailSecurity,
|
|
||||||
MailServerProfile,
|
MailServerProfile,
|
||||||
MailServerProfilePayload,
|
|
||||||
MailSmtpTestPayload,
|
|
||||||
MockMailboxMessageResponse
|
MockMailboxMessageResponse
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
|
import { apiFetch, apiGetList, apiPost } from "./client";
|
||||||
|
|
||||||
const profileActionEndpoints = {
|
const profileActionEndpoints = {
|
||||||
smtp: "test-smtp",
|
smtp: "test-smtp",
|
||||||
@@ -20,12 +13,6 @@ const profileActionEndpoints = {
|
|||||||
folders: "list-imap-folders"
|
folders: "list-imap-folders"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const rawSettingsEndpoints = {
|
|
||||||
smtp: "/api/v1/mail/test-smtp",
|
|
||||||
imap: "/api/v1/mail/test-imap",
|
|
||||||
folders: "/api/v1/mail/list-imap-folders"
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function runProfileAction<TResponse>(
|
function runProfileAction<TResponse>(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
@@ -37,14 +24,6 @@ function runProfileAction<TResponse>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runRawSettingsAction<TResponse, TPayload>(
|
|
||||||
settings: ApiSettings,
|
|
||||||
payload: TPayload,
|
|
||||||
action: keyof typeof rawSettingsEndpoints
|
|
||||||
): Promise<TResponse> {
|
|
||||||
return apiPostJson<TResponse>(settings, rawSettingsEndpoints[action], payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||||
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
|
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
|
||||||
include_inactive: includeInactive ? true : undefined,
|
include_inactive: includeInactive ? true : undefined,
|
||||||
@@ -52,25 +31,6 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
|
|
||||||
return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMailProfilePolicy(
|
|
||||||
settings: ApiSettings,
|
|
||||||
scopeType: MailProfileScope,
|
|
||||||
scopeId?: string | null,
|
|
||||||
campaignId?: string | null
|
|
||||||
): Promise<MailProfilePolicyResponse> {
|
|
||||||
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
|
|
||||||
scope_id: scopeId,
|
|
||||||
campaign_id: campaignId
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
|
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
|
||||||
}
|
}
|
||||||
@@ -83,22 +43,8 @@ export async function listMailProfileImapFolders(settings: ApiSettings, profileI
|
|||||||
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
|
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise<MailConnectionTestResponse> {
|
|
||||||
return runRawSettingsAction<MailConnectionTestResponse, MailSmtpTestPayload>(settings, payload, "smtp");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailConnectionTestResponse> {
|
|
||||||
return runRawSettingsAction<MailConnectionTestResponse, MailImapTestPayload>(settings, payload, "imap");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailImapFolderListResponse> {
|
|
||||||
return runRawSettingsAction<MailImapFolderListResponse, MailImapTestPayload>(settings, payload, "folders");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||||
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
|
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
|
export type { MailConnectionTestResponse, MailImapFolderListResponse, MailImapFolderResponse, MailServerProfile, MailServerProfileListResponse, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";
|
||||||
export type { MailConnectionTestResponse, MailCredentialPolicy, MailImapFolderListResponse, MailImapFolderResponse, MailImapTestPayload, MailProfilePatternKey, MailProfilePatternRules, MailProfilePolicy, MailProfilePolicyLimitKey, MailProfilePolicyLimitPermissions, MailProfilePolicyResponse, MailProfileScope, MailSecurity, MailServerProfile, MailServerProfileCredentialsPayload, MailServerProfileListResponse, MailServerProfilePayload, MailSmtpTestPayload, MailTransportCredentialsPayload, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";
|
|
||||||
export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui";
|
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { MailServerFolderLookupResultView, MailServerSettingsPanel, ToggleSwitch, addressesFromValue, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTransportCredentialsPayloadFromRecords, usePlatformModuleInstalled, usePlatformUiCapability, type MailProfilesUiCapability, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
LoadingFrame,
|
||||||
|
MailServerFolderLookupResultView,
|
||||||
|
MetricCard,
|
||||||
|
PageTitle,
|
||||||
|
ToggleSwitch,
|
||||||
|
usePlatformModuleInstalled,
|
||||||
|
usePlatformUiCapability,
|
||||||
|
type MailProfilesUiCapability,
|
||||||
|
type MailServerConnectionTestResult,
|
||||||
|
type MailServerFolderLookupResult
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import { Button } from "@govoplan/core-webui";
|
|
||||||
import { Card } from "@govoplan/core-webui";
|
|
||||||
import { FormField } from "@govoplan/core-webui";
|
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
|
||||||
import VersionLine from "./components/VersionLine";
|
|
||||||
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
|
||||||
import {
|
import {
|
||||||
createMailServerProfile,
|
|
||||||
getMailProfilePolicy,
|
|
||||||
listImapFolders,
|
|
||||||
listMailProfileImapFolders,
|
listMailProfileImapFolders,
|
||||||
listMailServerProfiles,
|
listMailServerProfiles,
|
||||||
testImapSettings,
|
|
||||||
testMailProfileImap,
|
testMailProfileImap,
|
||||||
testMailProfileSmtp,
|
testMailProfileSmtp,
|
||||||
testSmtpSettings,
|
type MailServerProfile
|
||||||
type MailProfilePolicy,
|
} from "../../api/mail";
|
||||||
type MailSecurity,
|
|
||||||
type MailServerProfile } from
|
|
||||||
"../../api/mail";
|
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import { cloneJson, getBool, getNumber, getText } from "./utils/draftEditor";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { campaignMailSettingsPolicyState } from "./policyUi";
|
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
|
import { getBool, getText } from "./utils/draftEditor";
|
||||||
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
|
import { campaignMailProfileReferenceOnly } from "./utils/mailProfileReference";
|
||||||
|
|
||||||
type MailSettingsView = "settings" | "policy";
|
type MailSettingsView = "settings" | "policy";
|
||||||
|
|
||||||
@@ -41,24 +41,22 @@ type MailSettingsPageProps = {
|
|||||||
|
|
||||||
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
|
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
|
||||||
const mailModuleInstalled = usePlatformModuleInstalled("mail");
|
const mailModuleInstalled = usePlatformModuleInstalled("mail");
|
||||||
const isPolicyView = view === "policy";
|
|
||||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||||
const MailProfilePolicyEditor = mailProfilesUi?.MailProfilePolicyEditor ?? null;
|
const MailProfilePolicyEditor = mailProfilesUi?.MailProfilePolicyEditor ?? null;
|
||||||
|
const isPolicyView = view === "policy";
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
|
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
|
||||||
|
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
|
||||||
|
const [profilesLoading, setProfilesLoading] = useState(false);
|
||||||
|
const [profileError, setProfileError] = useState("");
|
||||||
|
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
||||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
|
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
|
||||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
|
||||||
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
|
|
||||||
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
|
|
||||||
const [effectiveMailPolicy, setEffectiveMailPolicy] = useState<MailProfilePolicy | null>(null);
|
|
||||||
const [profilesLoading, setProfilesLoading] = useState(false);
|
|
||||||
const [profileName, setProfileName] = useState("");
|
|
||||||
const [profileMessage, setProfileMessage] = useState("");
|
|
||||||
const [profileError, setProfileError] = useState("");
|
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
|
const migrationRequired = version?.mail_profile_migration_required === true;
|
||||||
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, discardDraft, saveDraft } = useCampaignDraftEditor({
|
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -68,76 +66,29 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
setError,
|
setError,
|
||||||
currentStep: isPolicyView ? "mail-policy" : "mail-settings",
|
currentStep: isPolicyView ? "mail-policy" : "mail-settings",
|
||||||
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_mail_policy_changes.c9327491" : "i18n:govoplan-campaign.unsaved_mail_settings.38e1536b",
|
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_mail_policy_changes.c9327491" : "i18n:govoplan-campaign.unsaved_mail_settings.38e1536b",
|
||||||
unsavedMessage: isPolicyView ?
|
unsavedMessage: isPolicyView
|
||||||
"i18n:govoplan-campaign.mail_policy_changes_have_unsaved_draft_changes_s.5aee7d4e" :
|
? "i18n:govoplan-campaign.mail_policy_changes_have_unsaved_draft_changes_s.5aee7d4e"
|
||||||
"i18n:govoplan-campaign.mail_settings_have_unsaved_changes_save_them_bef.52644559",
|
: "i18n:govoplan-campaign.mail_settings_have_unsaved_changes_save_them_bef.52644559",
|
||||||
transformDraftBeforeSave: normalizeMailSettingsBeforeSave
|
transformDraftBeforeSave: campaignMailProfileReferenceOnly,
|
||||||
|
extraPayload: () => ({
|
||||||
|
migrate_legacy_mail_settings: migrationRequired && Boolean(selectedProfileId)
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = asRecord(displayDraft.server);
|
const server = asRecord(displayDraft.server);
|
||||||
const smtp = asRecord(server.smtp);
|
const selectedProfileId = getText(server, "mail_profile_id");
|
||||||
const imap = asRecord(server.imap);
|
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||||
const credentials = asRecord(server.credentials);
|
|
||||||
const smtpCredentials = asRecord(credentials.smtp);
|
|
||||||
const imapCredentials = asRecord(credentials.imap);
|
|
||||||
const delivery = asRecord(displayDraft.delivery);
|
const delivery = asRecord(displayDraft.delivery);
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
const selectedProfileId = mailModuleInstalled ? getText(server, "mail_profile_id") : "";
|
|
||||||
const selectedProfile = mailModuleInstalled ? mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null : null;
|
|
||||||
const usingMailProfile = mailModuleInstalled && Boolean(selectedProfileId);
|
|
||||||
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
|
|
||||||
const imapUnavailable = usingMailProfile && !selectedProfileHasImap;
|
|
||||||
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
|
|
||||||
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
|
|
||||||
const effectiveMailPolicyForState: MailProfilePolicy | null = mailModuleInstalled ? effectiveMailPolicy : { allow_campaign_profiles: true };
|
|
||||||
const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicyForState, selectedProfileId, locked });
|
|
||||||
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
|
|
||||||
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
|
|
||||||
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
|
|
||||||
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || usingMailProfile && smtpCredentialsInherited;
|
|
||||||
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile;
|
|
||||||
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || usingMailProfile && imapCredentialsInherited;
|
|
||||||
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
|
|
||||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||||
const imapAppendFolder = getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"));
|
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
|
||||||
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
|
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
|
||||||
const inlinePolicyMessages = useMemo(() => {
|
const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId));
|
||||||
const validateMailPolicy = mailProfilesUi?.validateMailPolicy;
|
|
||||||
if (!mailModuleInstalled || usingMailProfile || !validateMailPolicy || !effectiveMailPolicy) return [];
|
|
||||||
const recipients = asRecord(displayDraft.recipients);
|
|
||||||
const fromEmail = firstAddressEmail(recipients.from);
|
|
||||||
return validateMailPolicy(effectiveMailPolicy, {
|
|
||||||
smtpHost: getText(smtp, "host"),
|
|
||||||
imapHost: getText(imap, "host"),
|
|
||||||
envelopeSender: fromEmail || getText(smtpCredentials, "username", getText(smtp, "username")),
|
|
||||||
fromHeader: fromEmail,
|
|
||||||
recipientDomains: collectRecipientDomains(displayDraft)
|
|
||||||
});
|
|
||||||
}, [displayDraft, effectiveMailPolicy, imap, mailModuleInstalled, mailProfilesUi, smtp, smtpCredentials, usingMailProfile]);
|
|
||||||
const displayedSmtp = {
|
|
||||||
host: usingMailProfile && selectedProfile ? stringOrEmpty(selectedProfile.smtp.host) : getText(smtp, "host"),
|
|
||||||
port: usingMailProfile && selectedProfile ? selectedProfile.smtp.port ?? 587 : getNumber(smtp, "port", 587),
|
|
||||||
username: usingMailProfile && smtpCredentialsInherited ? profileUsername(selectedProfile, "smtp") : getText(smtpCredentials, "username", getText(smtp, "username")),
|
|
||||||
password: usingMailProfile && smtpCredentialsInherited ? "" : getText(smtpCredentials, "password", getText(smtp, "password")),
|
|
||||||
security: usingMailProfile && selectedProfile ? selectedProfile.smtp.security ?? "starttls" : getText(smtp, "security", "starttls"),
|
|
||||||
timeout_seconds: usingMailProfile && selectedProfile ? selectedProfile.smtp.timeout_seconds ?? 30 : getNumber(smtp, "timeout_seconds", 30)
|
|
||||||
};
|
|
||||||
const displayedImap = {
|
|
||||||
host: usingMailProfile && selectedProfile?.imap ? stringOrEmpty(selectedProfile.imap.host) : getText(imap, "host"),
|
|
||||||
port: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.port ?? 993 : getNumber(imap, "port", 993),
|
|
||||||
username: usingMailProfile && imapCredentialsInherited ? profileUsername(selectedProfile, "imap") : getText(imapCredentials, "username", getText(imap, "username")),
|
|
||||||
password: usingMailProfile && imapCredentialsInherited ? "" : getText(imapCredentials, "password", getText(imap, "password")),
|
|
||||||
security: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.security ?? "tls" : getText(imap, "security", "tls"),
|
|
||||||
sent_folder: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.sent_folder ?? "auto" : getText(imap, "sent_folder", "auto"),
|
|
||||||
timeout_seconds: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.timeout_seconds ?? 30 : getNumber(imap, "timeout_seconds", 30)
|
|
||||||
};
|
|
||||||
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || selectedProfileHasImap && !imapCredentialsInherited);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mailModuleInstalled) {
|
if (!mailModuleInstalled) {
|
||||||
setMailProfiles([]);
|
setMailProfiles([]);
|
||||||
setPolicyProfiles([]);
|
setPolicyProfiles([]);
|
||||||
setEffectiveMailPolicy({ allow_campaign_profiles: true });
|
|
||||||
setProfileError("");
|
|
||||||
setProfilesLoading(false);
|
setProfilesLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,230 +100,55 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
setProfilesLoading(true);
|
setProfilesLoading(true);
|
||||||
setProfileError("");
|
setProfileError("");
|
||||||
try {
|
try {
|
||||||
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
|
const [allowedProfiles, visibleProfiles] = await Promise.all([
|
||||||
listMailServerProfiles(settings, false, campaignId),
|
listMailServerProfiles(settings, false, campaignId),
|
||||||
listMailServerProfiles(settings, true),
|
listMailServerProfiles(settings, true)
|
||||||
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)]
|
]);
|
||||||
);
|
|
||||||
setMailProfiles(allowedProfiles);
|
setMailProfiles(allowedProfiles);
|
||||||
setPolicyProfiles(visibleProfiles);
|
setPolicyProfiles(visibleProfiles);
|
||||||
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMailProfiles([]);
|
setMailProfiles([]);
|
||||||
setPolicyProfiles([]);
|
setPolicyProfiles([]);
|
||||||
setEffectiveMailPolicy(null);
|
|
||||||
setProfileError(err instanceof Error ? err.message : String(err));
|
setProfileError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setProfilesLoading(false);
|
setProfilesLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMailSettingsBeforeSave(value: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
if (mailModuleInstalled === false) return value;
|
|
||||||
const next = cloneJson(value);
|
|
||||||
const nextServer = { ...asRecord(next.server) };
|
|
||||||
const profileId = getText(nextServer, "mail_profile_id");
|
|
||||||
if (profileId.length === 0) return next;
|
|
||||||
|
|
||||||
const nextCredentials = { ...asRecord(nextServer.credentials) };
|
|
||||||
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "smtp", effectiveMailPolicy?.smtp_credentials?.inherit === false ? false : true);
|
|
||||||
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "imap", effectiveMailPolicy?.imap_credentials?.inherit === false ? false : true);
|
|
||||||
nextServer.credentials = nextCredentials;
|
|
||||||
next.server = nextServer;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProfileCredentialProtocol(
|
|
||||||
serverValue: Record<string, unknown>,
|
|
||||||
credentialsValue: Record<string, unknown>,
|
|
||||||
protocol: "smtp" | "imap",
|
|
||||||
inherit: boolean)
|
|
||||||
{
|
|
||||||
serverValue["inherit_" + protocol + "_credentials"] = inherit;
|
|
||||||
if (inherit === false) return;
|
|
||||||
|
|
||||||
const transport = { ...asRecord(serverValue[protocol]) };
|
|
||||||
delete transport.username;
|
|
||||||
delete transport.password;
|
|
||||||
serverValue[protocol] = transport;
|
|
||||||
credentialsValue[protocol] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectMailProfile(profileId: string) {
|
function selectMailProfile(profileId: string) {
|
||||||
if (!mailModuleInstalled || locked) return;
|
if (!mailModuleInstalled || locked) return;
|
||||||
if (!profileId && !campaignProfilesAllowed) {
|
patch(["server"], profileId ? { mail_profile_id: profileId } : {});
|
||||||
setProfileError(mailPolicyState.inlineBlockedMessage);
|
setSmtpTestResult(null);
|
||||||
return;
|
setImapTestResult(null);
|
||||||
}
|
setFolderResult(null);
|
||||||
patch(["server", "mail_profile_id"], profileId);
|
|
||||||
setProfileMessage("");
|
|
||||||
setProfileError("");
|
setProfileError("");
|
||||||
if (profileId) {
|
|
||||||
patch(["server", "smtp"], {});
|
|
||||||
patch(["server", "imap"], {});
|
|
||||||
patch(["server", "credentials"], {});
|
|
||||||
setSmtpTestResult(null);
|
|
||||||
setImapTestResult(null);
|
|
||||||
setFolderResult(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCurrentSettingsAsProfile() {
|
async function runProfileTest(protocol: "smtp" | "imap") {
|
||||||
if (!mailModuleInstalled || locked || usingMailProfile || !campaignProfilesAllowed) return;
|
if (!selectedProfileId || locked) return;
|
||||||
setProfilesLoading(true);
|
setMailActionState(protocol);
|
||||||
setProfileMessage("");
|
|
||||||
setProfileError("");
|
|
||||||
try {
|
|
||||||
const created = await createMailServerProfile(settings, {
|
|
||||||
name: profileName.trim() || `${data.campaign?.name || "Campaign"} mail profile`,
|
|
||||||
scope_type: "campaign",
|
|
||||||
scope_id: campaignId,
|
|
||||||
smtp: smtpServerPayload(),
|
|
||||||
imap: hasInlineImapSettings() ? imapServerPayload() : null,
|
|
||||||
credentials: mailProfileCredentialsPayload(false),
|
|
||||||
is_active: true
|
|
||||||
});
|
|
||||||
setMailProfiles((current) => [...current.filter((profile) => profile.id !== created.id), created].sort((a, b) => a.name.localeCompare(b.name)));
|
|
||||||
patch(["server", "mail_profile_id"], created.id);
|
|
||||||
patch(["server", "smtp"], {});
|
|
||||||
patch(["server", "imap"], {});
|
|
||||||
patch(["server", "credentials"], {});
|
|
||||||
setProfileName("");
|
|
||||||
setProfileMessage(`Saved profile ${created.name}.`);
|
|
||||||
} catch (err) {
|
|
||||||
setProfileError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setProfilesLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function patchSmtpSettings(patchValue: Partial<MailServerSmtpSettings>) {
|
|
||||||
if (patchValue.host !== undefined) patch(["server", "smtp", "host"], String(patchValue.host ?? ""));
|
|
||||||
if (patchValue.port !== undefined) patch(["server", "smtp", "port"], mailNumberOrNull(patchValue.port));
|
|
||||||
if (patchValue.security !== undefined) patch(["server", "smtp", "security"], String(patchValue.security || "starttls"));
|
|
||||||
if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchImapSettings(patchValue: Partial<MailServerImapSettings>) {
|
|
||||||
if (patchValue.host !== undefined) patch(["server", "imap", "host"], String(patchValue.host ?? ""));
|
|
||||||
if (patchValue.port !== undefined) patch(["server", "imap", "port"], mailNumberOrNull(patchValue.port));
|
|
||||||
if (patchValue.security !== undefined) patch(["server", "imap", "security"], String(patchValue.security || "tls"));
|
|
||||||
if (patchValue.sent_folder !== undefined) patch(["server", "imap", "sent_folder"], String(patchValue.sent_folder ?? ""));
|
|
||||||
if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchSmtpCredentials(patchValue: Partial<MailServerCredentialSettings>) {
|
|
||||||
if (patchValue.username !== undefined) patch(["server", "credentials", "smtp", "username"], String(patchValue.username ?? ""));
|
|
||||||
if (patchValue.password !== undefined) patch(["server", "credentials", "smtp", "password"], String(patchValue.password ?? ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchImapCredentials(patchValue: Partial<MailServerCredentialSettings>) {
|
|
||||||
if (patchValue.username !== undefined) patch(["server", "credentials", "imap", "username"], String(patchValue.username ?? ""));
|
|
||||||
if (patchValue.password !== undefined) patch(["server", "credentials", "imap", "password"], String(patchValue.password ?? ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
function profileScopeLabel(profile: MailServerProfile): string {
|
|
||||||
if (profile.scope_type === "system") return "system";
|
|
||||||
if (profile.scope_type === "tenant") return "tenant";
|
|
||||||
if (profile.scope_type === "user") return "user";
|
|
||||||
if (profile.scope_type === "group") return "group";
|
|
||||||
return "campaign";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function smtpServerPayload() {
|
|
||||||
return mailSmtpSettingsPayload<MailSecurity>(
|
|
||||||
{ host: getText(smtp, "host"), port: getNumber(smtp, "port", 587), security: getText(smtp, "security", "starttls"), timeout_seconds: getNumber(smtp, "timeout_seconds", 30) },
|
|
||||||
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function imapServerPayload() {
|
|
||||||
return mailImapSettingsPayload<MailSecurity>(
|
|
||||||
{ host: getText(imap, "host"), port: getNumber(imap, "port", 993), security: getText(imap, "security", "tls"), sent_folder: getText(imap, "sent_folder", "auto"), timeout_seconds: getNumber(imap, "timeout_seconds", 30) },
|
|
||||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mailProfileCredentialsPayload(preserveBlankPassword: boolean) {
|
|
||||||
return {
|
|
||||||
smtp: mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, preserveBlankPassword),
|
|
||||||
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rawSmtpPayload() {
|
|
||||||
const serverPayload = selectedProfile && !smtpCredentialsInherited ? selectedProfile.smtp : smtpServerPayload();
|
|
||||||
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, false) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function rawImapPayload() {
|
|
||||||
const serverPayload = selectedProfile?.imap && !imapCredentialsInherited ? selectedProfile.imap : imapServerPayload();
|
|
||||||
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, false) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasInlineImapSettings(): boolean {
|
|
||||||
return hasMailImapSettings([getText(imap, "host"), getText(imapCredentials, "username", getText(imap, "username")), getText(imapCredentials, "password", getText(imap, "password"))]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function profileUsername(profile: MailServerProfile | null, protocol: "smtp" | "imap"): string {
|
|
||||||
if (!profile) return "";
|
|
||||||
if (protocol === "smtp") return stringOrEmpty(profile.credentials?.smtp?.username ?? profile.smtp.username);
|
|
||||||
return stringOrEmpty(profile.credentials?.imap?.username ?? profile.imap?.username);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function runSmtpTest() {
|
|
||||||
if (!mailModuleInstalled) {
|
|
||||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_smtp_.a7ce04e1", details: {} });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (locked || inlineMailSettingsBlocked) return;
|
|
||||||
setMailActionState("smtp");
|
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
try {
|
try {
|
||||||
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited ?
|
if (protocol === "smtp") {
|
||||||
await testMailProfileSmtp(settings, selectedProfileId) :
|
setSmtpTestResult(await testMailProfileSmtp(settings, selectedProfileId));
|
||||||
await testSmtpSettings(settings, rawSmtpPayload()));
|
} else {
|
||||||
|
setImapTestResult(await testMailProfileImap(settings, selectedProfileId));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
|
const result = { ok: false, protocol, message: err instanceof Error ? err.message : String(err), details: {} };
|
||||||
} finally {
|
if (protocol === "smtp") setSmtpTestResult(result);
|
||||||
setMailActionState(null);
|
else setImapTestResult(result);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runImapTest() {
|
|
||||||
if (!mailModuleInstalled) {
|
|
||||||
setImapTestResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_imap_.d6537dfd", details: {} });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (imapDisabled) return;
|
|
||||||
setMailActionState("imap");
|
|
||||||
setLocalError("");
|
|
||||||
try {
|
|
||||||
setImapTestResult(selectedProfileId && imapCredentialsInherited ?
|
|
||||||
await testMailProfileImap(settings, selectedProfileId) :
|
|
||||||
await testImapSettings(settings, rawImapPayload()));
|
|
||||||
} catch (err) {
|
|
||||||
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
|
|
||||||
} finally {
|
} finally {
|
||||||
setMailActionState(null);
|
setMailActionState(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runFolderLookup() {
|
async function runFolderLookup() {
|
||||||
if (!mailModuleInstalled) {
|
if (!selectedProfileId || locked || !selectedProfileHasImap) return;
|
||||||
setFolderResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_inspect_im.52535774", folders: [], details: {} });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (appendTargetFolderDisabled) return;
|
|
||||||
setMailActionState("folders");
|
setMailActionState("folders");
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
try {
|
try {
|
||||||
setFolderResult(selectedProfileId && imapCredentialsInherited ?
|
setFolderResult(await listMailProfileImapFolders(settings, selectedProfileId));
|
||||||
await listMailProfileImapFolders(settings, selectedProfileId) :
|
|
||||||
await listImapFolders(settings, rawImapPayload()));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -382,8 +158,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
|
|
||||||
function useDetectedSentFolder() {
|
function useDetectedSentFolder() {
|
||||||
const folder = folderResult?.detected_sent_folder;
|
const folder = folderResult?.detected_sent_folder;
|
||||||
if (!folder || appendTargetFolderDisabled) return;
|
if (folder && !locked) patch(["delivery", "imap_append_sent", "folder"], folder);
|
||||||
patch(["delivery", "imap_append_sent", "folder"], folder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -394,8 +169,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
{!isPolicyView && <Button variant="primary" onClick={() => void saveDraft("manual")} disabled={!canSave}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
|
||||||
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
|
<Button onClick={() => void discardDraft()} disabled={loading}>i18n:govoplan-campaign.discard.36fff63c</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,14 +180,13 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
{!mailModuleInstalled &&
|
{!mailModuleInstalled && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.install_and_enable_the_mail_module_to_select_a_d.01c75fc4</DismissibleAlert>}
|
||||||
<DismissibleAlert tone="info" dismissible={false}>
|
|
||||||
i18n:govoplan-campaign.the_mail_module_is_not_installed_inline_smtp_and.8e0f802e
|
|
||||||
</DismissibleAlert>
|
|
||||||
}
|
|
||||||
|
|
||||||
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor &&
|
{migrationRequired && <DismissibleAlert tone="warning" dismissible={false}>
|
||||||
<MailProfilePolicyEditor
|
i18n:govoplan-campaign.this_version_contains_legacy_campaign_local_mail.44c7a6fd
|
||||||
|
</DismissibleAlert>}
|
||||||
|
|
||||||
|
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && <MailProfilePolicyEditor
|
||||||
settings={settings}
|
settings={settings}
|
||||||
scopeType="campaign"
|
scopeType="campaign"
|
||||||
scopeId={campaignId}
|
scopeId={campaignId}
|
||||||
@@ -423,144 +197,72 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
canWrite={!locked}
|
canWrite={!locked}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
title="i18n:govoplan-campaign.campaign_local_mail_policy.94f59da0"
|
title="i18n:govoplan-campaign.campaign_local_mail_policy.94f59da0"
|
||||||
description="i18n:govoplan-campaign.campaign_local_mail_limits_applied_after_system_.e7f9bb31"
|
description="i18n:govoplan-campaign.mail_policy_limits_which_mail_owned_profiles_thi.824b1b2e"
|
||||||
onSaved={refreshMailProfiles} />
|
onSaved={refreshMailProfiles} />}
|
||||||
|
|
||||||
}
|
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_mail_module_did_not_expose_profile_managemen.2cdb57e1</DismissibleAlert>}
|
||||||
|
|
||||||
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor &&
|
{!isPolicyView && mailModuleInstalled && <Card
|
||||||
<DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_mail_module_did_not_expose_profile_managemen.2cdb57e1</DismissibleAlert>
|
|
||||||
}
|
|
||||||
|
|
||||||
{!isPolicyView && mailModuleInstalled &&
|
|
||||||
<Card
|
|
||||||
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
||||||
actions={
|
actions={<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>}>
|
||||||
<div className="button-row compact-actions">
|
<p className="muted small-note">i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809</p>
|
||||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
|
|
||||||
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save_current_settings_as_profile.7578a50b"}</Button>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
|
|
||||||
<div className="form-grid compact responsive-form-grid">
|
<div className="form-grid compact responsive-form-grid">
|
||||||
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
||||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||||
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>i18n:govoplan-campaign.inline_smtp_imap_settings.cf16c421</option>
|
<option value="">i18n:govoplan-campaign.select_a_mail_profile.76480af0</option>
|
||||||
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-campaign.new_profile_name.393313b6">
|
|
||||||
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? i18nMessage("i18n:govoplan-campaign.value_campaign_local_profile.70cd9d43", { value0: data.campaign.name }) : "i18n:govoplan-campaign.campaign_local_profile.c24cf2d1"} />
|
|
||||||
</FormField>
|
|
||||||
</div>
|
</div>
|
||||||
{!campaignProfilesAllowed && <p className="muted small-note">i18n:govoplan-campaign.campaign_local_mail_settings_are_blocked_by_the_.0b2510aa</p>}
|
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
||||||
{selectedProfile &&
|
{selectedProfile && <div className="metric-grid inside">
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.using.c25de2e8 {selectedProfile.name} ({profileScopeLabel(selectedProfile)}i18n:govoplan-campaign.smtp_credentials.10f75c8a {smtpCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c"}i18n:govoplan-campaign.imap_credentials.7442b238 {selectedProfile.imap ? imapCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c" : "i18n:govoplan-campaign.not_configured.67f2141f"}.</p>
|
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
|
||||||
}
|
<MetricCard label="i18n:govoplan-campaign.scope.4651a34e" value={profileScopeLabel(selectedProfile)} />
|
||||||
{selectedProfileNeedsLocalCredentials &&
|
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value="i18n:govoplan-campaign.configured.668c5fff" tone="good" />
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.the_selected_profile_supplies_the_server_setting.bdc830db</p>
|
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedProfile.imap ? "i18n:govoplan-campaign.configured.668c5fff" : "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedProfile.imap ? "good" : "neutral"} />
|
||||||
}
|
</div>}
|
||||||
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
|
<div className="button-row compact-actions">
|
||||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>}
|
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedProfile || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
|
||||||
</Card>
|
<Button onClick={() => void runProfileTest("imap")} disabled={!selectedProfileHasImap || locked || Boolean(mailActionState)}>{mailActionState === "imap" ? "i18n:govoplan-campaign.testing_imap.13d255cf" : "i18n:govoplan-campaign.test_profile_imap.e1cec0e0"}</Button>
|
||||||
}
|
</div>
|
||||||
|
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
|
||||||
|
{imapTestResult && <DismissibleAlert tone={imapTestResult.ok ? "success" : "danger"} resetKey={`${imapTestResult.protocol}:${imapTestResult.message}`} floating>{imapTestResult.message}</DismissibleAlert>}
|
||||||
|
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
||||||
|
</Card>}
|
||||||
|
|
||||||
{!isPolicyView &&
|
{!isPolicyView && <Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
|
||||||
<Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
|
|
||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8"
|
label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8"
|
||||||
checked={imapAppendEnabled}
|
checked={imapAppendEnabled}
|
||||||
disabled={imapDisabled}
|
disabled={locked || !selectedProfileHasImap}
|
||||||
onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||||
</div>
|
</div>
|
||||||
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help="i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9">
|
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help="i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9">
|
||||||
<div className="field-with-action mail-server-folder-field">
|
<div className="field-with-action mail-server-folder-field">
|
||||||
<input
|
<input
|
||||||
value={imapAppendFolder}
|
value={getText(imapAppend, "folder", "auto")}
|
||||||
disabled={appendTargetFolderDisabled}
|
disabled={locked || !imapAppendEnabled || !selectedProfileHasImap}
|
||||||
onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)}
|
onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)} />
|
||||||
placeholder="i18n:govoplan-core.auto.0d612c12" />
|
<Button type="button" variant="primary" onClick={() => void runFolderLookup()} disabled={locked || !imapAppendEnabled || !selectedProfileHasImap || Boolean(mailActionState)}>
|
||||||
<Button type="button" variant="primary" onClick={() => void runFolderLookup()} disabled={appendTargetFolderDisabled || mailActionState === "folders"}>
|
|
||||||
{mailActionState === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : "i18n:govoplan-core.folders.c603ab65"}
|
{mailActionState === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : "i18n:govoplan-core.folders.c603ab65"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
<MailServerFolderLookupResultView result={folderResult} disabled={appendTargetFolderDisabled} onUseDetected={useDetectedSentFolder} floatingFailures />
|
<MailServerFolderLookupResultView result={folderResult} disabled={locked || !imapAppendEnabled} onUseDetected={useDetectedSentFolder} floatingFailures />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>}
|
||||||
}
|
|
||||||
|
|
||||||
{!isPolicyView &&
|
|
||||||
<Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
|
|
||||||
{inlinePolicyMessages.length > 0 &&
|
|
||||||
<DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
|
|
||||||
<strong>i18n:govoplan-campaign.effective_mail_policy_blocks_the_current_inline_.99c34c6b</strong>
|
|
||||||
<ul>{inlinePolicyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
|
|
||||||
</DismissibleAlert>
|
|
||||||
}
|
|
||||||
<MailServerSettingsPanel
|
|
||||||
smtp={displayedSmtp}
|
|
||||||
imap={displayedImap}
|
|
||||||
onSmtpChange={patchSmtpSettings}
|
|
||||||
onImapChange={patchImapSettings}
|
|
||||||
smtpCredentials={{ username: displayedSmtp.username, password: displayedSmtp.password }}
|
|
||||||
imapCredentials={{ username: displayedImap.username, password: displayedImap.password }}
|
|
||||||
onSmtpCredentialsChange={patchSmtpCredentials}
|
|
||||||
onImapCredentialsChange={patchImapCredentials}
|
|
||||||
smtpDisabled={smtpDisabled}
|
|
||||||
smtpCredentialDisabled={smtpCredentialDisabled}
|
|
||||||
smtpPasswordSaved={Boolean(usingMailProfile && smtpCredentialsInherited && selectedProfile?.smtp_password_configured)}
|
|
||||||
smtpActionDisabled={locked || inlineMailSettingsBlocked || !mailModuleInstalled}
|
|
||||||
imapServerDisabled={imapServerDisabled}
|
|
||||||
imapCredentialDisabled={imapCredentialDisabled}
|
|
||||||
imapPasswordSaved={Boolean(usingMailProfile && imapCredentialsInherited && selectedProfile?.imap_password_configured)}
|
|
||||||
imapActionDisabled={imapDisabled || !mailModuleInstalled}
|
|
||||||
smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
|
|
||||||
imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
|
|
||||||
busyAction={mailActionState}
|
|
||||||
onTestSmtp={runSmtpTest}
|
|
||||||
onTestImap={runImapTest}
|
|
||||||
smtpTestResult={smtpTestResult}
|
|
||||||
imapTestResult={imapTestResult}
|
|
||||||
floatingResults />
|
|
||||||
|
|
||||||
</Card>
|
|
||||||
}
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>);
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileScopeLabel(profile: MailServerProfile): string {
|
||||||
function stringOrEmpty(value: unknown): string {
|
if (profile.scope_type === "system") return "i18n:govoplan-campaign.system.bc0792d8";
|
||||||
return value === null || value === undefined ? "" : String(value);
|
if (profile.scope_type === "tenant") return "i18n:govoplan-campaign.tenant.3ca93c78";
|
||||||
}
|
if (profile.scope_type === "user") return "i18n:govoplan-campaign.user.9f8a2389";
|
||||||
|
if (profile.scope_type === "group") return "i18n:govoplan-campaign.group.171a0606";
|
||||||
function firstAddressEmail(value: unknown): string {
|
return "i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505";
|
||||||
return addressesFromValue(value)[0]?.email ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectRecipientDomains(draft: Record<string, unknown>): string[] {
|
|
||||||
const domains = new Set<string>();
|
|
||||||
const recipients = asRecord(draft.recipients);
|
|
||||||
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, recipients[key]);
|
|
||||||
|
|
||||||
const entries = asArray(asRecord(draft.entries).inline).map(asRecord);
|
|
||||||
for (const entry of entries) {
|
|
||||||
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, entry[key]);
|
|
||||||
addAddressDomains(domains, entry.recipient);
|
|
||||||
addAddressDomains(domains, entry);
|
|
||||||
}
|
|
||||||
return [...domains].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addAddressDomains(domains: Set<string>, value: unknown) {
|
|
||||||
for (const address of addressesFromValue(value)) {
|
|
||||||
const domain = address.email.split("@").pop()?.trim().toLowerCase();
|
|
||||||
if (domain) domains.add(domain);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,12 +136,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
|||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
|
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
|
||||||
const server = asRecord(campaignJson.server);
|
const server = asRecord(campaignJson.server);
|
||||||
const smtpServer = asRecord(server.smtp);
|
const selectedMailProfileId = getText(server, "mail_profile_id");
|
||||||
const imapServer = asRecord(server.imap);
|
|
||||||
const serverCredentials = asRecord(server.credentials);
|
|
||||||
const smtpCredentials = asRecord(serverCredentials.smtp);
|
|
||||||
const imapCredentials = asRecord(serverCredentials.imap);
|
|
||||||
const selectedMailProfileId = getText(server, "mail_profile_id", getText(server, "profile_id"));
|
|
||||||
const inlineEntries = useMemo(
|
const inlineEntries = useMemo(
|
||||||
() => asArray(asRecord(campaignJson.entries).inline).map(asRecord),
|
() => asArray(asRecord(campaignJson.entries).inline).map(asRecord),
|
||||||
[campaignJson]
|
[campaignJson]
|
||||||
@@ -398,7 +393,9 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
|||||||
const imapAppendResultRows = asArray(imapAppendResult?.results).map(asRecord);
|
const imapAppendResultRows = asArray(imapAppendResult?.results).map(asRecord);
|
||||||
const imapDiagnosticRows = imapDiagnostics.jobs.map(asRecord);
|
const imapDiagnosticRows = imapDiagnostics.jobs.map(asRecord);
|
||||||
const imapDiagnosticsPending = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "pending").length;
|
const imapDiagnosticsPending = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "pending").length;
|
||||||
const imapDiagnosticsFailed = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "failed").length;
|
const imapDiagnosticsFailed = imapDiagnosticRows.filter((job) =>
|
||||||
|
["failed", "outcome_unknown"].includes(String(job.imap_status ?? "").toLowerCase()),
|
||||||
|
).length;
|
||||||
const imapPendingForDisplay = Math.max(imapPending, imapDiagnosticsPending);
|
const imapPendingForDisplay = Math.max(imapPending, imapDiagnosticsPending);
|
||||||
const imapFailedForDisplay = Math.max(imapFailed, imapDiagnosticsFailed);
|
const imapFailedForDisplay = Math.max(imapFailed, imapDiagnosticsFailed);
|
||||||
const canAppendPendingImap = Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && !historicalVersion && !userLockedVersion;
|
const canAppendPendingImap = Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && !historicalVersion && !userLockedVersion;
|
||||||
@@ -698,6 +695,12 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
|||||||
reviewJobsRef.current = mergedResult;
|
reviewJobsRef.current = mergedResult;
|
||||||
setReviewJobs(mergedResult);
|
setReviewJobs(mergedResult);
|
||||||
setBuiltReviewRows(jobs);
|
setBuiltReviewRows(jobs);
|
||||||
|
setReviewedMessageKeys(new Set(
|
||||||
|
jobs
|
||||||
|
.filter((row) => row.reviewed === true)
|
||||||
|
.map((row, index) => builtMessageKey(row, index))
|
||||||
|
));
|
||||||
|
setMessageReviewComplete(result.review?.inspection_complete === true);
|
||||||
setReviewPage(1);
|
setReviewPage(1);
|
||||||
setJobsLoadedKey(reviewKey);
|
setJobsLoadedKey(reviewKey);
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
@@ -1032,13 +1035,12 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
|||||||
const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]);
|
const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]);
|
||||||
const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
|
const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
|
||||||
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
|
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
|
||||||
const smtpConfigured = Boolean(selectedMailProfileId || getText(smtpServer, "host") || getText(smtpCredentials, "username"));
|
const mailProfileSelected = Boolean(selectedMailProfileId);
|
||||||
const imapConfigured = Boolean(selectedMailProfileId || getText(imapServer, "host") || getText(imapCredentials, "username"));
|
|
||||||
const deliverabilityPreflightItems: DeliverabilityPreflightItem[] = [
|
const deliverabilityPreflightItems: DeliverabilityPreflightItem[] = [
|
||||||
{
|
{
|
||||||
label: "Transport",
|
label: "Transport",
|
||||||
detail: smtpConfigured ? selectedMailProfileId ? "Reusable mail profile selected." : "Inline SMTP settings are present." : "Select a reusable mail profile or configure SMTP before live delivery.",
|
detail: mailProfileSelected ? "i18n:govoplan-campaign.mail_owned_delivery_profile_selected.4f44778e" : "i18n:govoplan-campaign.select_an_authorized_mail_profile_before_live_de.45c80a42",
|
||||||
state: smtpConfigured ? "ready" : "blocked"
|
state: mailProfileSelected ? "ready" : "blocked"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Policy",
|
label: "Policy",
|
||||||
@@ -1062,8 +1064,8 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Sent copy",
|
label: "Sent copy",
|
||||||
detail: Boolean(imapAppend.enabled) ? imapConfigured ? `IMAP append enabled for ${String(imapAppend.folder ?? "auto")}.` : "IMAP append is enabled, but no IMAP server/profile is visible." : "IMAP append is disabled for this campaign.",
|
detail: Boolean(imapAppend.enabled) ? mailProfileSelected ? i18nMessage("i18n:govoplan-campaign.imap_append_requested_for_value0_validation_chec.51527a20", { value0: String(imapAppend.folder ?? "auto") }) : "i18n:govoplan-campaign.imap_append_is_enabled_but_no_mail_profile_is_se.cf50419e" : "i18n:govoplan-campaign.imap_append_is_disabled_for_this_campaign.7757f7f1",
|
||||||
state: Boolean(imapAppend.enabled) ? imapConfigured ? "ready" : "blocked" : "info"
|
state: Boolean(imapAppend.enabled) ? mailProfileSelected ? "ready" : "blocked" : "info"
|
||||||
}];
|
}];
|
||||||
const canCompleteInspection = blockingReviewCount === 0 &&
|
const canCompleteInspection = blockingReviewCount === 0 &&
|
||||||
reviewRequiredCount > 0 &&
|
reviewRequiredCount > 0 &&
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
|||||||
const permanentUserLock = isPermanentUserLockedVersion(version);
|
const permanentUserLock = isPermanentUserLockedVersion(version);
|
||||||
const finalLock = isFinalLockedVersion(version);
|
const finalLock = isFinalLockedVersion(version);
|
||||||
const canCreateEditableCopy = !historicalVersion && (permanentUserLock || finalLock);
|
const canCreateEditableCopy = !historicalVersion && (permanentUserLock || finalLock);
|
||||||
|
const mailProfileMigrationRequired = Boolean(
|
||||||
|
version && "mail_profile_migration_required" in version && version.mail_profile_migration_required
|
||||||
|
);
|
||||||
const presentation = lockPresentation(version, {
|
const presentation = lockPresentation(version, {
|
||||||
historicalVersion,
|
historicalVersion,
|
||||||
validationLock,
|
validationLock,
|
||||||
@@ -83,7 +86,8 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
|||||||
try {
|
try {
|
||||||
const result = await forkCampaignVersion(settings, campaignId, version.id, {
|
const result = await forkCampaignVersion(settings, campaignId, version.id, {
|
||||||
current_flow: "manual",
|
current_flow: "manual",
|
||||||
current_step: version.current_step ?? null
|
current_step: version.current_step ?? null,
|
||||||
|
migrate_legacy_mail_settings: mailProfileMigrationRequired
|
||||||
});
|
});
|
||||||
setLocalMessage(`Created editable version #${result.version.version_number}.`);
|
setLocalMessage(`Created editable version #${result.version.version_number}.`);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -100,6 +104,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
|||||||
<strong>{presentation.title}</strong>{" "}
|
<strong>{presentation.title}</strong>{" "}
|
||||||
<span>{presentation.description}</span>
|
<span>{presentation.description}</span>
|
||||||
{message && <span className="locked-version-context"> {message}</span>}
|
{message && <span className="locked-version-context"> {message}</span>}
|
||||||
|
{mailProfileMigrationRequired && <span className="locked-version-context"> i18n:govoplan-campaign.the_editable_copy_will_preserve_this_audit_recor.aac956f1</span>}
|
||||||
{presentation.info && <span className="locked-version-reason"> {presentation.info}</span>}
|
{presentation.info && <span className="locked-version-reason"> {presentation.info}</span>}
|
||||||
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
|
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
|
||||||
{localError && <span className="locked-version-error"> {localError}</span>}
|
{localError && <span className="locked-version-error"> {localError}</span>}
|
||||||
@@ -232,4 +237,4 @@ function confirmDialogLabel(action: ConfirmAction): string {
|
|||||||
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock.1526a17e";
|
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock.1526a17e";
|
||||||
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||||
return "i18n:govoplan-campaign.confirm.04a21221";
|
return "i18n:govoplan-campaign.confirm.04a21221";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
export const INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE = "i18n:govoplan-campaign.inline_smtp_imap_settings_are_blocked_by_the_eff.90c94538";
|
|
||||||
|
|
||||||
export type CampaignMailPolicy = {
|
|
||||||
allow_campaign_profiles?: boolean | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CampaignMailSettingsPolicyState = {
|
|
||||||
campaignProfilesAllowed: boolean;
|
|
||||||
usingMailProfile: boolean;
|
|
||||||
inlineMailSettingsBlocked: boolean;
|
|
||||||
inlineOptionDisabled: boolean;
|
|
||||||
canSelectInlineSettings: boolean;
|
|
||||||
inlineBlockedMessage: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function campaignMailSettingsPolicyState({
|
|
||||||
effectivePolicy,
|
|
||||||
selectedProfileId,
|
|
||||||
locked = false
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {effectivePolicy: CampaignMailPolicy | null | undefined;selectedProfileId: string | null | undefined;locked?: boolean;}): CampaignMailSettingsPolicyState {
|
|
||||||
const campaignProfilesAllowed = effectivePolicy?.allow_campaign_profiles === true;
|
|
||||||
const usingMailProfile = Boolean(selectedProfileId);
|
|
||||||
const inlineMailSettingsBlocked = !campaignProfilesAllowed && !usingMailProfile;
|
|
||||||
return {
|
|
||||||
campaignProfilesAllowed,
|
|
||||||
usingMailProfile,
|
|
||||||
inlineMailSettingsBlocked,
|
|
||||||
inlineOptionDisabled: !campaignProfilesAllowed,
|
|
||||||
canSelectInlineSettings: !locked && campaignProfilesAllowed,
|
|
||||||
inlineBlockedMessage: INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const server = isRecord(value.server) ? value.server : {};
|
||||||
|
const rawProfileId = server.mail_profile_id;
|
||||||
|
const profileId = typeof rawProfileId === "string" ? rawProfileId.trim() : "";
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
server: profileId ? { mail_profile_id: profileId } : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -49,8 +49,6 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
|
|||||||
const globalCc = addressesFromValue(recipients.cc);
|
const globalCc = addressesFromValue(recipients.cc);
|
||||||
const globalBcc = addressesFromValue(recipients.bcc);
|
const globalBcc = addressesFromValue(recipients.bcc);
|
||||||
const globalReplyTo = addressesFromValue(recipients.reply_to);
|
const globalReplyTo = addressesFromValue(recipients.reply_to);
|
||||||
const server = asRecord(draft.server);
|
|
||||||
const smtp = asRecord(server.smtp);
|
|
||||||
const delivery = asRecord(draft.delivery);
|
const delivery = asRecord(draft.delivery);
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
return (
|
return (
|
||||||
@@ -106,8 +104,7 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
|
|||||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))} />
|
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))} />
|
||||||
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-campaign.smtp_host.2d4a434b"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
|
<p className="muted small-note">i18n:govoplan-campaign.select_the_delivery_profile_on_the_mail_settings.a89cbb5f</p>
|
||||||
<FormField label="i18n:govoplan-campaign.smtp_port.65b5a108"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
|
|
||||||
<ToggleSwitch label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
<ToggleSwitch label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
@@ -232,4 +229,4 @@ function JsonEditor({ value, onValid }: {value: unknown;onValid: (value: unknown
|
|||||||
{Array.isArray(value) && value.length > 0 && <p className="form-help">i18n:govoplan-campaign.preview.4bf30626 {stringifyPreview(asArray(value)[0], 140)}</p>}
|
{Array.isArray(value) && value.length > 0 && <p className="form-help">i18n:govoplan-campaign.preview.4bf30626 {stringifyPreview(asArray(value)[0], 140)}</p>}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"en": {
|
"en": {
|
||||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||||
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
||||||
|
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Campaign-scoped Mail profile",
|
||||||
|
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign stores only this stable profile reference. The Mail module owns, encrypts, authorizes, tests, and resolves all SMTP/IMAP settings and credentials.",
|
||||||
|
"i18n:govoplan-campaign.configured.668c5fff": "Configured",
|
||||||
|
"i18n:govoplan-campaign.discard.36fff63c": "Discard",
|
||||||
|
"i18n:govoplan-campaign.imap_append_is_disabled_for_this_campaign.7757f7f1": "IMAP append is disabled for this campaign.",
|
||||||
|
"i18n:govoplan-campaign.imap_append_is_enabled_but_no_mail_profile_is_se.cf50419e": "IMAP append is enabled, but no Mail profile is selected.",
|
||||||
|
"i18n:govoplan-campaign.imap_append_requested_for_value0_validation_chec.51527a20": "IMAP append requested for {value0}; validation checks that the selected profile supports it.",
|
||||||
|
"i18n:govoplan-campaign.install_and_enable_the_mail_module_to_select_a_d.01c75fc4": "Install and enable the Mail module to select a delivery profile. Campaign does not store inline SMTP/IMAP settings.",
|
||||||
|
"i18n:govoplan-campaign.mail_owned_delivery_profile_selected.4f44778e": "Mail-owned delivery profile selected.",
|
||||||
|
"i18n:govoplan-campaign.mail_policy_limits_which_mail_owned_profiles_thi.824b1b2e": "Mail policy limits which Mail-owned profiles this campaign may reference.",
|
||||||
|
"i18n:govoplan-campaign.not_configured.811931bb": "Not configured",
|
||||||
|
"i18n:govoplan-campaign.select_a_mail_profile.76480af0": "Select a Mail profile",
|
||||||
|
"i18n:govoplan-campaign.select_an_authorized_mail_profile_before_live_de.45c80a42": "Select an authorized Mail profile before live delivery.",
|
||||||
|
"i18n:govoplan-campaign.select_the_delivery_profile_on_the_mail_settings.a89cbb5f": "Select the delivery profile on the Mail settings page. Campaign stores only the Mail profile reference; SMTP/IMAP settings and credentials remain in Mail.",
|
||||||
|
"i18n:govoplan-campaign.system.bc0792d8": "System",
|
||||||
|
"i18n:govoplan-campaign.testing_imap.13d255cf": "Testing IMAP…",
|
||||||
|
"i18n:govoplan-campaign.testing_smtp.8e9f8247": "Testing SMTP…",
|
||||||
|
"i18n:govoplan-campaign.the_editable_copy_will_preserve_this_audit_recor.aac956f1": "The editable copy will preserve this audit record while removing legacy inline mail transport data; select a Mail profile before validation.",
|
||||||
|
"i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26": "The referenced Mail profile is inactive, unavailable, or no longer authorized for this campaign. Select another profile.",
|
||||||
|
"i18n:govoplan-campaign.this_version_contains_legacy_campaign_local_mail.44c7a6fd": "This version contains legacy campaign-local mail transport data. It is preserved in storage and blocked from delivery. Select a Mail profile and save here to record an audited migration; no inline credentials are returned to the browser.",
|
||||||
"i18n:govoplan-campaign.a_dry_run_checks_the_frozen_queue_and_delivery_c.c42924bb": "A dry run checks the frozen queue and delivery configuration without contacting SMTP or IMAP.",
|
"i18n:govoplan-campaign.a_dry_run_checks_the_frozen_queue_and_delivery_c.c42924bb": "A dry run checks the frozen queue and delivery configuration without contacting SMTP or IMAP.",
|
||||||
"i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e": "A human-readable name shown in lists and reports.",
|
"i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e": "A human-readable name shown in lists and reports.",
|
||||||
"i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3": "A mapping wizard can be added later",
|
"i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3": "A mapping wizard can be added later",
|
||||||
@@ -1156,6 +1176,26 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"de": {
|
"de": {
|
||||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||||
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
||||||
|
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Kampagnenspezifisches Mail-Profil",
|
||||||
|
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign speichert nur diese stabile Profilreferenz. Das Mail-Modul verwaltet, verschlüsselt, autorisiert, testet und löst alle SMTP-/IMAP-Einstellungen und Zugangsdaten auf.",
|
||||||
|
"i18n:govoplan-campaign.configured.668c5fff": "Konfiguriert",
|
||||||
|
"i18n:govoplan-campaign.discard.36fff63c": "Verwerfen",
|
||||||
|
"i18n:govoplan-campaign.imap_append_is_disabled_for_this_campaign.7757f7f1": "Das IMAP-Ablegen ist für diese Kampagne deaktiviert.",
|
||||||
|
"i18n:govoplan-campaign.imap_append_is_enabled_but_no_mail_profile_is_se.cf50419e": "Das IMAP-Ablegen ist aktiviert, aber es ist kein Mail-Profil ausgewählt.",
|
||||||
|
"i18n:govoplan-campaign.imap_append_requested_for_value0_validation_chec.51527a20": "IMAP-Ablegen in {value0} angefordert; die Validierung prüft, ob das ausgewählte Profil dies unterstützt.",
|
||||||
|
"i18n:govoplan-campaign.install_and_enable_the_mail_module_to_select_a_d.01c75fc4": "Installieren und aktivieren Sie das Mail-Modul, um ein Versandprofil auszuwählen. Campaign speichert keine eingebetteten SMTP-/IMAP-Einstellungen.",
|
||||||
|
"i18n:govoplan-campaign.mail_owned_delivery_profile_selected.4f44778e": "Ein vom Mail-Modul verwaltetes Versandprofil ist ausgewählt.",
|
||||||
|
"i18n:govoplan-campaign.mail_policy_limits_which_mail_owned_profiles_thi.824b1b2e": "Die Mail-Richtlinie begrenzt, auf welche vom Mail-Modul verwalteten Profile diese Kampagne verweisen darf.",
|
||||||
|
"i18n:govoplan-campaign.not_configured.811931bb": "Nicht konfiguriert",
|
||||||
|
"i18n:govoplan-campaign.select_a_mail_profile.76480af0": "Mail-Profil auswählen",
|
||||||
|
"i18n:govoplan-campaign.select_an_authorized_mail_profile_before_live_de.45c80a42": "Wählen Sie vor dem Live-Versand ein autorisiertes Mail-Profil aus.",
|
||||||
|
"i18n:govoplan-campaign.select_the_delivery_profile_on_the_mail_settings.a89cbb5f": "Wählen Sie das Versandprofil auf der Seite Mail-Einstellungen aus. Campaign speichert nur die Mail-Profilreferenz; SMTP-/IMAP-Einstellungen und Zugangsdaten verbleiben im Mail-Modul.",
|
||||||
|
"i18n:govoplan-campaign.system.bc0792d8": "System",
|
||||||
|
"i18n:govoplan-campaign.testing_imap.13d255cf": "IMAP wird getestet…",
|
||||||
|
"i18n:govoplan-campaign.testing_smtp.8e9f8247": "SMTP wird getestet…",
|
||||||
|
"i18n:govoplan-campaign.the_editable_copy_will_preserve_this_audit_recor.aac956f1": "Die bearbeitbare Kopie bewahrt diesen Prüfdatensatz und entfernt zugleich alte eingebettete Mail-Transportdaten; wählen Sie vor der Validierung ein Mail-Profil aus.",
|
||||||
|
"i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26": "Das referenzierte Mail-Profil ist inaktiv, nicht verfügbar oder für diese Kampagne nicht mehr autorisiert. Wählen Sie ein anderes Profil aus.",
|
||||||
|
"i18n:govoplan-campaign.this_version_contains_legacy_campaign_local_mail.44c7a6fd": "Diese Version enthält alte kampagnenlokale Mail-Transportdaten. Sie bleiben gespeichert und der Versand ist gesperrt. Wählen Sie ein Mail-Profil und speichern Sie hier, um eine protokollierte Migration festzuhalten; eingebettete Zugangsdaten werden nicht an den Browser zurückgegeben.",
|
||||||
"i18n:govoplan-campaign.a_dry_run_checks_the_frozen_queue_and_delivery_c.c42924bb": "A dry run checks the frozen queue and delivery configuration without contacting SMTP or IMAP.",
|
"i18n:govoplan-campaign.a_dry_run_checks_the_frozen_queue_and_delivery_c.c42924bb": "A dry run checks the frozen queue and delivery configuration without contacting SMTP or IMAP.",
|
||||||
"i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e": "A human-readable name shown in lists and reports.",
|
"i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e": "A human-readable name shown in lists and reports.",
|
||||||
"i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3": "A mapping wizard can be added later",
|
"i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3": "A mapping wizard can be added later",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
export { default } from "./module";
|
export { default } from "./module";
|
||||||
export * from "./module";
|
export * from "./module";
|
||||||
export * from "./api/campaigns";
|
export * from "./api/campaigns";
|
||||||
export * from "./features/campaigns/policyUi";
|
export * from "./features/campaigns/utils/mailProfileReference";
|
||||||
export { default as CampaignListPage } from "./features/campaigns/CampaignListPage";
|
export { default as CampaignListPage } from "./features/campaigns/CampaignListPage";
|
||||||
export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace";
|
export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace";
|
||||||
export { default as OperatorQueuePage } from "./features/operator/OperatorQueuePage";
|
export { default as OperatorQueuePage } from "./features/operator/OperatorQueuePage";
|
||||||
|
|||||||
@@ -1,42 +1,26 @@
|
|||||||
import { campaignMailSettingsPolicyState } from "../src/features/campaigns/policyUi";
|
import { campaignMailProfileReferenceOnly } from "../src/features/campaigns/utils/mailProfileReference";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): void {
|
function assert(condition: unknown, message: string): void {
|
||||||
if (!condition) throw new Error(message);
|
if (!condition) throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockedInline = campaignMailSettingsPolicyState({
|
const source = {
|
||||||
effectivePolicy: { allow_campaign_profiles: false },
|
campaign: { id: "campaign-1" },
|
||||||
selectedProfileId: ""
|
server: {
|
||||||
});
|
mail_profile_id: " profile-1 ",
|
||||||
|
smtp: { host: "smtp.example.test", password: "smtp-secret" },
|
||||||
|
imap: { host: "imap.example.test", password: "imap-secret" },
|
||||||
|
credentials: { smtp: { username: "sender", password: "legacy-secret" } },
|
||||||
|
inherit_smtp_credentials: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
assert(blockedInline.campaignProfilesAllowed === false, "Blocked policy must not allow campaign-local settings.");
|
const normalized = campaignMailProfileReferenceOnly(source);
|
||||||
assert(blockedInline.inlineMailSettingsBlocked === true, "Inline SMTP/IMAP must be blocked when no reusable profile is selected.");
|
const server = normalized.server as Record<string, unknown>;
|
||||||
assert(blockedInline.inlineOptionDisabled === true, "Inline option must be disabled under a blocking policy.");
|
|
||||||
assert(blockedInline.canSelectInlineSettings === false, "Blocked inline option must not be selectable.");
|
|
||||||
|
|
||||||
const blockedWithReusableProfile = campaignMailSettingsPolicyState({
|
assert(server.mail_profile_id === "profile-1", "The stable Mail profile reference must be retained and trimmed.");
|
||||||
effectivePolicy: { allow_campaign_profiles: false },
|
assert(Object.keys(server).length === 1, "All campaign-local transport settings and credentials must be removed.");
|
||||||
selectedProfileId: "tenant-profile"
|
assert((source.server.smtp as Record<string, unknown>).password === "smtp-secret", "Normalization must not mutate the loaded audit representation.");
|
||||||
});
|
|
||||||
|
|
||||||
assert(blockedWithReusableProfile.inlineMailSettingsBlocked === false, "Reusable profiles must remain usable when campaign-local settings are blocked.");
|
const withoutProfile = campaignMailProfileReferenceOnly({ server: { smtp: { host: "legacy" } } });
|
||||||
assert(blockedWithReusableProfile.inlineOptionDisabled === true, "Inline option remains disabled even while a reusable profile is selected.");
|
assert(Object.keys(withoutProfile.server as Record<string, unknown>).length === 0, "Legacy settings without a profile must normalize to an empty reference object.");
|
||||||
|
|
||||||
const allowedInline = campaignMailSettingsPolicyState({
|
|
||||||
effectivePolicy: { allow_campaign_profiles: true },
|
|
||||||
selectedProfileId: ""
|
|
||||||
});
|
|
||||||
|
|
||||||
assert(allowedInline.campaignProfilesAllowed === true, "Allowed policy must allow campaign-local settings.");
|
|
||||||
assert(allowedInline.inlineMailSettingsBlocked === false, "Inline SMTP/IMAP must be available when campaign-local settings are allowed.");
|
|
||||||
assert(allowedInline.inlineOptionDisabled === false, "Inline option must be enabled under an allowing policy.");
|
|
||||||
assert(allowedInline.canSelectInlineSettings === true, "Allowed inline option must be selectable.");
|
|
||||||
|
|
||||||
const lockedInline = campaignMailSettingsPolicyState({
|
|
||||||
effectivePolicy: { allow_campaign_profiles: true },
|
|
||||||
selectedProfileId: "",
|
|
||||||
locked: true
|
|
||||||
});
|
|
||||||
|
|
||||||
assert(lockedInline.inlineOptionDisabled === false, "Policy does not disable inline settings when the policy allows them.");
|
|
||||||
assert(lockedInline.canSelectInlineSettings === false, "A locked version still must not allow selecting inline settings.");
|
|
||||||
|
|||||||
@@ -15,6 +15,6 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"tests/policy-ui.test.ts",
|
"tests/policy-ui.test.ts",
|
||||||
"src/features/campaigns/policyUi.ts"
|
"src/features/campaigns/utils/mailProfileReference.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user