Compare commits
6
Commits
d5b874c469
...
v0.1.19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a21067e44 | ||
|
|
73cfad209a | ||
|
|
2a00d910df | ||
|
|
c846c249b8 | ||
|
|
69e6588a89 | ||
|
|
75e8f864a7 |
@@ -59,6 +59,20 @@ Files, Mail, Distribution Lists, Templates, Postbox, and Calendar are optional m
|
|||||||
|
|
||||||
Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery.
|
Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery.
|
||||||
|
|
||||||
|
Recurring schedules have two immutable modes. Manual mode remains the default
|
||||||
|
and prepares independent drafts without Mail. Autonomous mode is explicit and
|
||||||
|
Mail-only: it seals an already built and explicitly approved execution snapshot,
|
||||||
|
rechecks approval, policy, credential/transport revision, live SMTP health,
|
||||||
|
recipient and attachment evidence before each occurrence, and submits one
|
||||||
|
Mail-owned durable command per frozen message. Occurrence-scoped idempotency is
|
||||||
|
allocated before delivery. Accepted and outcome-unknown effects are never
|
||||||
|
retried automatically; uncertain or systemic failures pause the schedule,
|
||||||
|
notify its accountable operator, and retain non-secret recovery evidence.
|
||||||
|
Generated EML retention excludes source versions while an autonomous schedule
|
||||||
|
has a remaining occurrence, including while it is paused; once the schedule
|
||||||
|
finishes, already accepted Mail commands retain their own encrypted payload and
|
||||||
|
evidence under Mail policy.
|
||||||
|
|
||||||
Public campaign, version, job, and report responses expose business data and
|
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
|
||||||
worker claim tokens. Operational troubleshooting uses the dedicated job
|
worker claim tokens. Operational troubleshooting uses the dedicated job
|
||||||
@@ -78,6 +92,9 @@ services can cooperate without importing campaign internals:
|
|||||||
- `campaigns.policyContext` for retention/policy provenance
|
- `campaigns.policyContext` for retention/policy provenance
|
||||||
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
|
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
|
||||||
- `campaigns.retention` for campaign-owned retention cleanup
|
- `campaigns.retention` for campaign-owned retention cleanup
|
||||||
|
- `privacy.dsar.campaigns` for tenant-scoped recipient, version, delivery,
|
||||||
|
report-projection, and artifact-metadata discovery plus governed erasure
|
||||||
|
planning
|
||||||
|
|
||||||
Keep these capability payloads narrow: stable ids, policy payloads, and task
|
Keep these capability payloads narrow: stable ids, policy payloads, and task
|
||||||
results only.
|
results only.
|
||||||
@@ -120,6 +137,11 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
|||||||
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
|
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
|
||||||
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
|
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
|
||||||
- Immediate delivery is bounded to 25 exact eligible recipient jobs by default. Deployments may set `GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0–500), and tenants may narrow that ceiling through `campaign_delivery_policy.synchronous_send_max_recipients` in tenant settings.
|
- Immediate delivery is bounded to 25 exact eligible recipient jobs by default. Deployments may set `GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0–500), and tenants may narrow that ceiling through `campaign_delivery_policy.synchronous_send_max_recipients` in tenant settings.
|
||||||
|
- Immediate Mail delivery preflights the selected SMTP transport before the
|
||||||
|
first effect and reuses a healthy bounded connection through Mail. Review and
|
||||||
|
send reports the batch state, connection/reconnect counts, and paused count.
|
||||||
|
A systemic authentication, sender, or connectivity failure pauses remaining
|
||||||
|
jobs; correct and test the Mail profile before explicitly resuming them.
|
||||||
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
|
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
|
||||||
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
|
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
|
||||||
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
|
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ Before the first live send for a sender domain or mail-server profile:
|
|||||||
6. If a synchronous request is used, keep Review and send open: it polls the
|
6. If a synchronous request is used, keep Review and send open: it polls the
|
||||||
durable counters while the request runs. A rejection occurs before SMTP and
|
durable counters while the request runs. A rejection occurs before SMTP and
|
||||||
directs oversized runs to workers.
|
directs oversized runs to workers.
|
||||||
|
7. Review the SMTP batch line. `ready` means DNS/connectivity/TLS/auth preflight
|
||||||
|
succeeded. Connection and reconnect counts explain reuse. `paused` means a
|
||||||
|
systemic transport failure stopped the remaining jobs before their SMTP
|
||||||
|
effect; test/correct the Mail profile and explicitly resume the queue.
|
||||||
|
|
||||||
## Outcome Handling
|
## Outcome Handling
|
||||||
|
|
||||||
@@ -88,6 +92,9 @@ unknown provider attempt merely to repair the other layer's state.
|
|||||||
- `failed_temporary`: Retry explicitly after checking the error and retry count.
|
- `failed_temporary`: Retry explicitly after checking the error and retry count.
|
||||||
- `failed_permanent`: Retry only if the operator has corrected the root cause and
|
- `failed_permanent`: Retry only if the operator has corrected the root cause and
|
||||||
intentionally includes permanent failures.
|
intentionally includes permanent failures.
|
||||||
|
- `paused` after a systemic SMTP failure: do not resume until the shared Mail
|
||||||
|
profile passes its connection test. Authentication, sender rejection, and
|
||||||
|
unavailable connectivity affect the batch rather than one recipient.
|
||||||
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
|
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
|
||||||
provider control panels, then reconcile as accepted or not sent.
|
provider control panels, then reconcile as accepted or not sent.
|
||||||
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
||||||
|
|||||||
@@ -557,6 +557,30 @@ purpose, lawful basis, minimization, export control, and retention before the
|
|||||||
campaign starts; do not use Campaign as a substitute consent or address-master
|
campaign starts; do not use Campaign as a substitute consent or address-master
|
||||||
system.
|
system.
|
||||||
|
|
||||||
|
The Core data-subject-request workflow discovers Campaign through the optional
|
||||||
|
`privacy.dsar.campaigns` capability. After the request's email, membership, and
|
||||||
|
namespaced Campaign references have been independently authorized and
|
||||||
|
corroborated, the provider searches only the effective tenant and isolates the
|
||||||
|
matching recipient entries and jobs. Its JSON result includes safe Campaign,
|
||||||
|
version, delivery-attempt, schedule, report-projection, share, import-mapping,
|
||||||
|
attachment, and generated-artifact metadata. Generated EML bytes and paths,
|
||||||
|
storage locators, delivery target snapshots, worker claims, idempotency
|
||||||
|
material, credentials, secret-like values, and unrelated recipients are never
|
||||||
|
embedded in that result. Authorized Campaign and Files review surfaces remain
|
||||||
|
the source for content that cannot safely be copied into the DSAR case.
|
||||||
|
|
||||||
|
Built, locked, published, terminal, delivered, or corrected records are
|
||||||
|
retained with an explicit reason and continue through Campaign's configured
|
||||||
|
retention and redaction process. Draft recipient content and user-owned
|
||||||
|
attachment content require coordinated manual review because copies may span
|
||||||
|
version JSON, jobs, generated messages, and managed files. The provider can
|
||||||
|
idempotently revoke an active share aimed at the subject and delete the
|
||||||
|
subject's personal recipient-import mapping profile. It does not rewrite
|
||||||
|
delivery evidence, delete generated artifacts, or report derived Campaign
|
||||||
|
counts as a separate store. Re-running an approved action is safe: already
|
||||||
|
revoked or absent data is reported as unchanged, and tenant, subject, and row
|
||||||
|
ownership are revalidated immediately before mutation.
|
||||||
|
|
||||||
### Audit and destructive actions
|
### Audit and destructive actions
|
||||||
|
|
||||||
Material authoring, validation, locking, review, queueing, send, retry,
|
Material authoring, validation, locking, review, queueing, send, retry,
|
||||||
@@ -611,7 +635,6 @@ The following are part of the selected reference journey but are not implied by
|
|||||||
the current baseline:
|
the current baseline:
|
||||||
|
|
||||||
- the final audited **test / single send / single resend** semantics;
|
- the final audited **test / single send / single resend** semantics;
|
||||||
- reusable SMTP batch sessions and their measured throughput benefit;
|
|
||||||
- durable, idempotent Campaign report delivery through a Mail-owned outbox
|
- durable, idempotent Campaign report delivery through a Mail-owned outbox
|
||||||
([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17));
|
([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17));
|
||||||
- a fully packaged one-command Campaign reference composition with production
|
- a fully packaged one-command Campaign reference composition with production
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-campaign"
|
name = "govoplan-campaign"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
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"
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.core.policy import (
|
||||||
|
CampaignArchiveEncryptionDecision,
|
||||||
|
CampaignArchiveEncryptionRequest,
|
||||||
|
campaign_archive_encryption_policy,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign
|
||||||
|
from govoplan_campaign.backend.runtime import get_registry
|
||||||
|
|
||||||
|
|
||||||
|
LEGACY_ZIPCRYPTO_SCOPE = "campaigns:archive:use_legacy_zipcrypto"
|
||||||
|
LEGACY_ZIPCRYPTO_LABEL = "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignArchiveEncryptionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EffectiveArchiveEncryptionPolicy:
|
||||||
|
available: bool
|
||||||
|
allowed_password_encryption_methods: frozenset[str]
|
||||||
|
allowed_password_delivery_channels: frozenset[str]
|
||||||
|
policy_hash: str
|
||||||
|
source_path: tuple[Mapping[str, Any], ...]
|
||||||
|
reason: str
|
||||||
|
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"available": self.available,
|
||||||
|
"allowed_password_encryption_methods": sorted(
|
||||||
|
self.allowed_password_encryption_methods
|
||||||
|
),
|
||||||
|
"allowed_password_delivery_channels": sorted(
|
||||||
|
self.allowed_password_delivery_channels
|
||||||
|
),
|
||||||
|
"policy_hash": self.policy_hash,
|
||||||
|
"source_path": [dict(item) for item in self.source_path],
|
||||||
|
"reason": self.reason,
|
||||||
|
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||||
|
"legacy_label": LEGACY_ZIPCRYPTO_LABEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def effective_archive_encryption_policy(
|
||||||
|
session: Session,
|
||||||
|
campaign: Campaign,
|
||||||
|
) -> EffectiveArchiveEncryptionPolicy:
|
||||||
|
provider = campaign_archive_encryption_policy(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
payload = {
|
||||||
|
"available": False,
|
||||||
|
"allowed_password_encryption_methods": ["aes"],
|
||||||
|
"allowed_password_delivery_channels": [
|
||||||
|
"in_person",
|
||||||
|
"letter",
|
||||||
|
"phone",
|
||||||
|
"separate_mail",
|
||||||
|
"sms",
|
||||||
|
],
|
||||||
|
"source_path": [
|
||||||
|
{
|
||||||
|
"scope_type": "system",
|
||||||
|
"scope_id": None,
|
||||||
|
"path": "system",
|
||||||
|
"label": "Secure local fallback",
|
||||||
|
"applied_fields": ["allowed_password_encryption_methods"],
|
||||||
|
"policy": {
|
||||||
|
"allowed_password_encryption_methods": ["aes"],
|
||||||
|
"policy_provider": "unavailable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return EffectiveArchiveEncryptionPolicy(
|
||||||
|
available=False,
|
||||||
|
allowed_password_encryption_methods=frozenset({"aes"}),
|
||||||
|
allowed_password_delivery_channels=frozenset(
|
||||||
|
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||||
|
),
|
||||||
|
policy_hash=_hash(payload),
|
||||||
|
source_path=tuple(payload["source_path"]),
|
||||||
|
reason=(
|
||||||
|
"Policy is unavailable. AES remains available through the secure "
|
||||||
|
"local baseline; legacy ZipCrypto fails closed."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
owner_type: str | None = None
|
||||||
|
owner_id: str | None = None
|
||||||
|
if campaign.owner_group_id:
|
||||||
|
owner_type, owner_id = "group", campaign.owner_group_id
|
||||||
|
elif campaign.owner_user_id:
|
||||||
|
owner_type, owner_id = "user", campaign.owner_user_id
|
||||||
|
decision: CampaignArchiveEncryptionDecision = (
|
||||||
|
provider.resolve_campaign_archive_encryption(
|
||||||
|
session,
|
||||||
|
request=CampaignArchiveEncryptionRequest(
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
owner_type=owner_type, # type: ignore[arg-type]
|
||||||
|
owner_id=owner_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return EffectiveArchiveEncryptionPolicy(
|
||||||
|
available=True,
|
||||||
|
allowed_password_encryption_methods=frozenset(
|
||||||
|
decision.allowed_password_encryption_methods
|
||||||
|
),
|
||||||
|
allowed_password_delivery_channels=frozenset(
|
||||||
|
decision.allowed_password_delivery_channels
|
||||||
|
),
|
||||||
|
policy_hash=decision.policy_hash,
|
||||||
|
source_path=tuple(step.to_dict() for step in decision.source_path),
|
||||||
|
reason=decision.reason or "Effective archive-encryption policy resolved.",
|
||||||
|
diagnostics=decision.diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_archive_encryption_allowed(
|
||||||
|
session: Session,
|
||||||
|
campaign: Campaign,
|
||||||
|
raw_json: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal | None = None,
|
||||||
|
) -> EffectiveArchiveEncryptionPolicy:
|
||||||
|
policy = effective_archive_encryption_policy(session, campaign)
|
||||||
|
for archive in _archive_configs(raw_json):
|
||||||
|
method = str(archive.get("method") or "aes")
|
||||||
|
if method not in policy.allowed_password_encryption_methods:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{_method_label(method)} is blocked. {policy.reason}"
|
||||||
|
)
|
||||||
|
if method == "zip_standard":
|
||||||
|
if not policy.available:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
"Legacy ZipCrypto cannot be used while Policy is unavailable."
|
||||||
|
)
|
||||||
|
if principal is not None and not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||||
|
)
|
||||||
|
if not archive.get("legacy_zipcrypto_acknowledged"):
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{LEGACY_ZIPCRYPTO_LABEL} requires explicit acknowledgement."
|
||||||
|
)
|
||||||
|
if len(str(archive.get("legacy_zipcrypto_reason") or "").strip()) < 10:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{LEGACY_ZIPCRYPTO_LABEL} requires a reason of at least 10 characters."
|
||||||
|
)
|
||||||
|
if not archive.get("legacy_zipcrypto_acknowledged_by") or not archive.get(
|
||||||
|
"legacy_zipcrypto_acknowledged_at"
|
||||||
|
):
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
"Legacy ZipCrypto acknowledgement has no server-recorded actor or time. Save the campaign again."
|
||||||
|
)
|
||||||
|
if archive.get("password_enabled"):
|
||||||
|
# Existing campaign revisions predate the explicit field. Their
|
||||||
|
# model default is the separate-mail channel; apply the same
|
||||||
|
# normalization before policy enforcement so saved revisions do
|
||||||
|
# not become unusable merely because the field was omitted.
|
||||||
|
channel = str(
|
||||||
|
archive.get("password_delivery_channel") or "separate_mail"
|
||||||
|
)
|
||||||
|
if channel not in policy.allowed_password_delivery_channels:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"Password-delivery channel {channel!r} is blocked by the effective policy."
|
||||||
|
)
|
||||||
|
return policy
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_legacy_zipcrypto_acknowledgements(
|
||||||
|
session: Session,
|
||||||
|
campaign: Campaign,
|
||||||
|
current_raw_json: Mapping[str, Any],
|
||||||
|
candidate_raw_json: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
||||||
|
if candidate_raw_json is None:
|
||||||
|
return None, []
|
||||||
|
candidate = copy.deepcopy(candidate_raw_json)
|
||||||
|
current_by_id = {
|
||||||
|
str(item.get("id") or index): item
|
||||||
|
for index, item in enumerate(_archive_configs(current_raw_json))
|
||||||
|
}
|
||||||
|
acknowledgements: list[dict[str, Any]] = []
|
||||||
|
policy = effective_archive_encryption_policy(session, campaign)
|
||||||
|
archives = _archive_configs(candidate)
|
||||||
|
for index, archive in enumerate(archives):
|
||||||
|
if str(archive.get("method") or "aes") != "zip_standard":
|
||||||
|
archive.pop("legacy_zipcrypto_acknowledged_by", None)
|
||||||
|
archive.pop("legacy_zipcrypto_acknowledged_at", None)
|
||||||
|
continue
|
||||||
|
if not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||||
|
)
|
||||||
|
if not policy.available or "zip_standard" not in policy.allowed_password_encryption_methods:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{LEGACY_ZIPCRYPTO_LABEL} is blocked. {policy.reason}"
|
||||||
|
)
|
||||||
|
reason = str(archive.get("legacy_zipcrypto_reason") or "").strip()
|
||||||
|
if not archive.get("legacy_zipcrypto_acknowledged") or len(reason) < 10:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{LEGACY_ZIPCRYPTO_LABEL} requires acknowledgement and a reason of at least 10 characters."
|
||||||
|
)
|
||||||
|
key = str(archive.get("id") or index)
|
||||||
|
previous = current_by_id.get(key, {})
|
||||||
|
unchanged = (
|
||||||
|
previous.get("method") == "zip_standard"
|
||||||
|
and previous.get("legacy_zipcrypto_acknowledged") is True
|
||||||
|
and str(previous.get("legacy_zipcrypto_reason") or "").strip() == reason
|
||||||
|
and previous.get("legacy_zipcrypto_acknowledged_by")
|
||||||
|
and previous.get("legacy_zipcrypto_acknowledged_at")
|
||||||
|
)
|
||||||
|
if unchanged:
|
||||||
|
archive["legacy_zipcrypto_acknowledged_by"] = previous[
|
||||||
|
"legacy_zipcrypto_acknowledged_by"
|
||||||
|
]
|
||||||
|
archive["legacy_zipcrypto_acknowledged_at"] = previous[
|
||||||
|
"legacy_zipcrypto_acknowledged_at"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
archive["legacy_zipcrypto_acknowledged_by"] = principal.user.id
|
||||||
|
archive["legacy_zipcrypto_acknowledged_at"] = datetime.now(UTC).isoformat()
|
||||||
|
acknowledgements.append(
|
||||||
|
{
|
||||||
|
"archive_id": key,
|
||||||
|
"reason": reason,
|
||||||
|
"policy_hash": policy.policy_hash,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return candidate, acknowledgements
|
||||||
|
|
||||||
|
|
||||||
|
def has_password_archives(raw_json: Mapping[str, Any]) -> bool:
|
||||||
|
return any(bool(item.get("password_enabled")) for item in _archive_configs(raw_json))
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_configs(raw_json: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
attachments = raw_json.get("attachments")
|
||||||
|
zip_config = attachments.get("zip") if isinstance(attachments, Mapping) else None
|
||||||
|
archives = zip_config.get("archives") if isinstance(zip_config, Mapping) else None
|
||||||
|
if isinstance(archives, list):
|
||||||
|
return [item for item in archives if isinstance(item, dict)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _method_label(method: str) -> str:
|
||||||
|
return LEGACY_ZIPCRYPTO_LABEL if method == "zip_standard" else method.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(value: object) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CampaignArchiveEncryptionError",
|
||||||
|
"EffectiveArchiveEncryptionPolicy",
|
||||||
|
"LEGACY_ZIPCRYPTO_LABEL",
|
||||||
|
"LEGACY_ZIPCRYPTO_SCOPE",
|
||||||
|
"assert_archive_encryption_allowed",
|
||||||
|
"effective_archive_encryption_policy",
|
||||||
|
"has_password_archives",
|
||||||
|
"stamp_legacy_zipcrypto_acknowledgements",
|
||||||
|
]
|
||||||
@@ -76,6 +76,14 @@ class ZipPasswordScope(StrEnum):
|
|||||||
GLOBAL = "global"
|
GLOBAL = "global"
|
||||||
|
|
||||||
|
|
||||||
|
class ZipPasswordDeliveryChannel(StrEnum):
|
||||||
|
SEPARATE_MAIL = "separate_mail"
|
||||||
|
SMS = "sms"
|
||||||
|
LETTER = "letter"
|
||||||
|
PHONE = "phone"
|
||||||
|
IN_PERSON = "in_person"
|
||||||
|
|
||||||
|
|
||||||
class ZipPasswordMode(StrEnum):
|
class ZipPasswordMode(StrEnum):
|
||||||
NONE = "none"
|
NONE = "none"
|
||||||
DIRECT = "direct"
|
DIRECT = "direct"
|
||||||
@@ -349,6 +357,13 @@ class ZipArchiveConfig(StrictModel):
|
|||||||
password_field: str | None = None
|
password_field: str | None = None
|
||||||
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
|
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
|
||||||
method: ZipMethod = ZipMethod.AES
|
method: ZipMethod = ZipMethod.AES
|
||||||
|
password_delivery_channel: ZipPasswordDeliveryChannel = (
|
||||||
|
ZipPasswordDeliveryChannel.SEPARATE_MAIL
|
||||||
|
)
|
||||||
|
legacy_zipcrypto_acknowledged: bool = False
|
||||||
|
legacy_zipcrypto_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
legacy_zipcrypto_acknowledged_by: str | None = Field(default=None, max_length=255)
|
||||||
|
legacy_zipcrypto_acknowledged_at: str | None = Field(default=None, max_length=80)
|
||||||
|
|
||||||
# Compatibility fields for campaigns created by the first single-archive
|
# Compatibility fields for campaigns created by the first single-archive
|
||||||
# implementation. New WebUI campaigns use password_enabled/field/scope.
|
# implementation. New WebUI campaigns use password_enabled/field/scope.
|
||||||
@@ -376,6 +391,20 @@ class ZipArchiveConfig(StrictModel):
|
|||||||
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
|
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_legacy_zipcrypto_acknowledgement(self) -> "ZipArchiveConfig":
|
||||||
|
if self.method != ZipMethod.ZIP_STANDARD:
|
||||||
|
return self
|
||||||
|
if not self.legacy_zipcrypto_acknowledged:
|
||||||
|
raise ValueError(
|
||||||
|
"Legacy ZipCrypto requires explicit acknowledgement of its weak encryption"
|
||||||
|
)
|
||||||
|
if len((self.legacy_zipcrypto_reason or "").strip()) < 10:
|
||||||
|
raise ValueError(
|
||||||
|
"Legacy ZipCrypto requires an acknowledgement reason of at least 10 characters"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ZipCollectionConfig(StrictModel):
|
class ZipCollectionConfig(StrictModel):
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from email import policy
|
||||||
|
from email.parser import BytesParser
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -13,19 +15,35 @@ from sqlalchemy.orm import Session
|
|||||||
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
|
CampaignJob,
|
||||||
CampaignSchedule,
|
CampaignSchedule,
|
||||||
CampaignScheduleOccurrence,
|
CampaignScheduleOccurrence,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
JobBuildStatus,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.approval_gate import (
|
||||||
|
assert_campaign_approval,
|
||||||
|
campaign_approval_gate,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
|
||||||
|
from govoplan_campaign.backend.integrations import mail_integration
|
||||||
from govoplan_campaign.backend.persistence.campaigns import (
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
create_campaign_version_from_json,
|
create_campaign_version_from_json,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
|
||||||
|
from govoplan_campaign.backend.sending.jobs import (
|
||||||
|
_from_header_from_job,
|
||||||
|
_send_job_delivery_context,
|
||||||
|
_single_job_validation_allowed,
|
||||||
|
_synchronous_smtp_batch_manager,
|
||||||
|
)
|
||||||
from govoplan_core.audit.logging import audit_event
|
from govoplan_core.audit.logging import audit_event
|
||||||
|
|
||||||
|
|
||||||
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
|
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
|
||||||
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
|
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
|
||||||
|
SCHEDULE_DELIVERY_MODES = frozenset({"manual", "autonomous"})
|
||||||
|
|
||||||
|
|
||||||
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
|
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
|
||||||
@@ -96,6 +114,11 @@ def dispatch_due_campaign_schedules(
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
observed_at = _as_utc(now or datetime.now(UTC))
|
observed_at = _as_utc(now or datetime.now(UTC))
|
||||||
|
refreshed = refresh_autonomous_schedule_outcomes(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
now=observed_at,
|
||||||
|
)
|
||||||
query = session.query(CampaignSchedule).filter(
|
query = session.query(CampaignSchedule).filter(
|
||||||
CampaignSchedule.active.is_(True),
|
CampaignSchedule.active.is_(True),
|
||||||
CampaignSchedule.next_fire_at.is_not(None),
|
CampaignSchedule.next_fire_at.is_not(None),
|
||||||
@@ -112,39 +135,95 @@ def dispatch_due_campaign_schedules(
|
|||||||
result: dict[str, object] = {
|
result: dict[str, object] = {
|
||||||
"selected": len(schedules),
|
"selected": len(schedules),
|
||||||
"prepared": 0,
|
"prepared": 0,
|
||||||
|
"autonomous_prepared": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"completed": 0,
|
"completed": 0,
|
||||||
"coalesced": 0,
|
"coalesced": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"deferred": 0,
|
||||||
"campaign_ids": [],
|
"campaign_ids": [],
|
||||||
"operator_actions": [],
|
"operator_actions": [],
|
||||||
|
"refreshed": refreshed,
|
||||||
}
|
}
|
||||||
for schedule in schedules:
|
for schedule in schedules:
|
||||||
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
|
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
|
||||||
|
if schedule.delivery_mode == "autonomous" and _has_open_occurrence(
|
||||||
|
session, schedule_id=schedule.id
|
||||||
|
):
|
||||||
|
result["deferred"] = int(result["deferred"]) + 1
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
with session.begin_nested():
|
with session.begin_nested():
|
||||||
campaign, version, skipped = _prepare_occurrence(
|
if schedule.delivery_mode == "autonomous":
|
||||||
session,
|
_occurrence, skipped = _prepare_autonomous_occurrence(
|
||||||
schedule=schedule,
|
session,
|
||||||
scheduled_for=scheduled_for,
|
schedule=schedule,
|
||||||
observed_at=observed_at,
|
scheduled_for=scheduled_for,
|
||||||
)
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
campaign_id = schedule.campaign_id
|
||||||
|
result["autonomous_prepared"] = (
|
||||||
|
int(result["autonomous_prepared"]) + 1
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
campaign, _version, skipped = _prepare_occurrence(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
campaign_id = campaign.id
|
||||||
result["prepared"] = int(result["prepared"]) + 1
|
result["prepared"] = int(result["prepared"]) + 1
|
||||||
result["coalesced"] = int(result["coalesced"]) + skipped
|
result["coalesced"] = int(result["coalesced"]) + skipped
|
||||||
result["campaign_ids"].append(campaign.id) # type: ignore[union-attr]
|
result["campaign_ids"].append(campaign_id) # type: ignore[union-attr]
|
||||||
if not schedule.active:
|
if not schedule.active:
|
||||||
result["completed"] = int(result["completed"]) + 1
|
result["completed"] = int(result["completed"]) + 1
|
||||||
except Exception as exc: # noqa: BLE001 - persist bounded operator evidence
|
except Exception as exc: # noqa: BLE001 - persist bounded operator evidence
|
||||||
|
session.expire_all()
|
||||||
|
recorded = (
|
||||||
|
session.query(CampaignScheduleOccurrence)
|
||||||
|
.filter(
|
||||||
|
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||||
|
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if recorded is not None:
|
||||||
|
result["duplicates"] = int(result["duplicates"]) + 1
|
||||||
|
if (
|
||||||
|
schedule.active
|
||||||
|
and schedule.next_fire_at is not None
|
||||||
|
and _as_utc(schedule.next_fire_at) == scheduled_for
|
||||||
|
and recorded.status not in {"failed", "uncertain"}
|
||||||
|
):
|
||||||
|
_advance_schedule(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
occurrence=recorded,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
observed_at=observed_at,
|
||||||
|
sequence=schedule.occurrence_count + 1,
|
||||||
|
)
|
||||||
|
continue
|
||||||
session.add(
|
session.add(
|
||||||
CampaignScheduleOccurrence(
|
CampaignScheduleOccurrence(
|
||||||
tenant_id=schedule.tenant_id,
|
tenant_id=schedule.tenant_id,
|
||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
scheduled_for=scheduled_for,
|
scheduled_for=scheduled_for,
|
||||||
status="failed",
|
status="failed",
|
||||||
|
idempotency_key=_occurrence_idempotency_key(
|
||||||
|
schedule.id, scheduled_for
|
||||||
|
),
|
||||||
error=str(exc)[:4000],
|
error=str(exc)[:4000],
|
||||||
|
recovery_state="failed",
|
||||||
|
evidence={"delivery_mode": schedule.delivery_mode},
|
||||||
|
last_checked_at=observed_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
schedule.active = False
|
schedule.active = False
|
||||||
schedule.last_error = str(exc)[:4000]
|
schedule.last_error = str(exc)[:4000]
|
||||||
|
schedule.last_outcome = "failed"
|
||||||
|
schedule.last_recovery_state = "operator_required"
|
||||||
schedule.resource_revision += 1
|
schedule.resource_revision += 1
|
||||||
session.add(schedule)
|
session.add(schedule)
|
||||||
result["failed"] = int(result["failed"]) + 1
|
result["failed"] = int(result["failed"]) + 1
|
||||||
@@ -153,8 +232,14 @@ def dispatch_due_campaign_schedules(
|
|||||||
"schedule_id": schedule.id,
|
"schedule_id": schedule.id,
|
||||||
"campaign_id": schedule.campaign_id,
|
"campaign_id": schedule.campaign_id,
|
||||||
"reason": "draft_preparation_failed",
|
"reason": "draft_preparation_failed",
|
||||||
|
"delivery_mode": schedule.delivery_mode,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
_notify_schedule_operator(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
reason="policy_or_systemic_preflight_failed",
|
||||||
|
)
|
||||||
session.flush()
|
session.flush()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -234,41 +319,26 @@ def _prepare_occurrence(
|
|||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
scheduled_for=scheduled_for,
|
scheduled_for=scheduled_for,
|
||||||
status="prepared",
|
status="prepared",
|
||||||
|
idempotency_key=_occurrence_idempotency_key(schedule.id, scheduled_for),
|
||||||
generated_campaign_id=generated_campaign.id,
|
generated_campaign_id=generated_campaign.id,
|
||||||
generated_version_id=generated_version.id,
|
generated_version_id=generated_version.id,
|
||||||
|
recovery_state="none",
|
||||||
|
evidence={"delivery_mode": "manual"},
|
||||||
|
last_checked_at=observed_at,
|
||||||
)
|
)
|
||||||
session.add(occurrence)
|
session.add(occurrence)
|
||||||
schedule.occurrence_count = sequence
|
session.flush()
|
||||||
schedule.last_fired_at = scheduled_for
|
|
||||||
schedule.last_campaign_id = generated_campaign.id
|
schedule.last_campaign_id = generated_campaign.id
|
||||||
schedule.last_error = None
|
schedule.last_outcome = "prepared"
|
||||||
|
schedule.last_recovery_state = "none"
|
||||||
next_fire = next_schedule_fire(
|
coalesced = _advance_schedule(
|
||||||
scheduled_for,
|
session,
|
||||||
recurrence_kind=schedule.recurrence_kind,
|
schedule=schedule,
|
||||||
interval_count=schedule.interval_count,
|
occurrence=occurrence,
|
||||||
timezone_name=schedule.timezone,
|
scheduled_for=scheduled_for,
|
||||||
|
observed_at=observed_at,
|
||||||
|
sequence=sequence,
|
||||||
)
|
)
|
||||||
coalesced = 0
|
|
||||||
while next_fire is not None and next_fire <= observed_at:
|
|
||||||
next_fire = next_schedule_fire(
|
|
||||||
next_fire,
|
|
||||||
recurrence_kind=schedule.recurrence_kind,
|
|
||||||
interval_count=schedule.interval_count,
|
|
||||||
timezone_name=schedule.timezone,
|
|
||||||
)
|
|
||||||
coalesced += 1
|
|
||||||
if (
|
|
||||||
next_fire is None
|
|
||||||
or sequence >= schedule.max_occurrences
|
|
||||||
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
|
|
||||||
):
|
|
||||||
schedule.active = False
|
|
||||||
schedule.next_fire_at = None
|
|
||||||
else:
|
|
||||||
schedule.next_fire_at = next_fire
|
|
||||||
schedule.resource_revision += 1
|
|
||||||
session.add(schedule)
|
|
||||||
audit_event(
|
audit_event(
|
||||||
session,
|
session,
|
||||||
tenant_id=schedule.tenant_id,
|
tenant_id=schedule.tenant_id,
|
||||||
@@ -291,6 +361,535 @@ def _prepare_occurrence(
|
|||||||
return generated_campaign, generated_version, coalesced
|
return generated_campaign, generated_version, coalesced
|
||||||
|
|
||||||
|
|
||||||
|
def validate_autonomous_schedule_source(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
version: CampaignVersion,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Validate the exact immutable execution that an autonomous schedule reuses."""
|
||||||
|
|
||||||
|
gate = campaign_approval_gate(version)
|
||||||
|
if gate is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires an explicit Approval request for the built source version."
|
||||||
|
)
|
||||||
|
assert_campaign_approval(session, tenant_id=campaign.tenant_id, version=version)
|
||||||
|
snapshot = ensure_execution_snapshot(session, version)
|
||||||
|
snapshot_hash = str(version.execution_snapshot_hash or "")
|
||||||
|
if len(snapshot_hash) != 64:
|
||||||
|
raise RuntimeError("The approved Campaign execution snapshot is incomplete.")
|
||||||
|
jobs = _autonomous_source_jobs(
|
||||||
|
session,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
mail = mail_integration()
|
||||||
|
if not mail.durable_delivery_available:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires Mail's durable delivery-command outbox."
|
||||||
|
)
|
||||||
|
if not snapshot.mail_profile_id or not snapshot.smtp_transport_revision:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The approved Campaign execution has no immutable Mail transport evidence."
|
||||||
|
)
|
||||||
|
summary = mail.campaign_profile_delivery_summary(
|
||||||
|
session,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
profile_id=snapshot.mail_profile_id,
|
||||||
|
smtp_server_id=snapshot.smtp_server_id,
|
||||||
|
smtp_credential_id=snapshot.smtp_credential_id,
|
||||||
|
)
|
||||||
|
if not summary.get("smtp_available"):
|
||||||
|
raise RuntimeError("The approved Campaign Mail transport is unavailable.")
|
||||||
|
if summary.get("smtp_transport_revision") != snapshot.smtp_transport_revision:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The Campaign Mail transport changed after approval; rebuild and approve a new source version."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"execution_snapshot_hash": snapshot_hash,
|
||||||
|
"approval_request_id": str(gate.get("request_id") or ""),
|
||||||
|
"approval_subject_digest": str(gate.get("subject_digest") or ""),
|
||||||
|
"job_count": len(jobs),
|
||||||
|
"job_manifest_sha256": canonical_configuration_hash(
|
||||||
|
{"jobs": [{"id": job.id, "eml_sha256": job.eml_sha256} for job in jobs]}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _autonomous_source_jobs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version: CampaignVersion,
|
||||||
|
) -> list[CampaignJob]:
|
||||||
|
jobs = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.tenant_id == tenant_id,
|
||||||
|
CampaignJob.campaign_id == campaign_id,
|
||||||
|
CampaignJob.campaign_version_id == version.id,
|
||||||
|
)
|
||||||
|
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not jobs:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires a built source version with recipient jobs."
|
||||||
|
)
|
||||||
|
for job in jobs:
|
||||||
|
if job.build_status != JobBuildStatus.BUILT.value:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires every source message to be built."
|
||||||
|
)
|
||||||
|
if not _single_job_validation_allowed(version, job, include_warnings=True):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires every source message to pass its reviewed recipient and attachment gates."
|
||||||
|
)
|
||||||
|
if DeliveryChannelPolicy(job.delivery_channel_policy) != DeliveryChannelPolicy.MAIL:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous schedules currently support Mail-only delivery; use manual mode for hybrid, Postbox, or print delivery."
|
||||||
|
)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_autonomous_occurrence(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
schedule: CampaignSchedule,
|
||||||
|
scheduled_for: datetime,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> tuple[CampaignScheduleOccurrence, int]:
|
||||||
|
existing = (
|
||||||
|
session.query(CampaignScheduleOccurrence)
|
||||||
|
.filter(
|
||||||
|
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||||
|
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
raise RuntimeError("Campaign schedule occurrence was already recorded")
|
||||||
|
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
|
||||||
|
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
|
||||||
|
campaign = session.get(Campaign, schedule.campaign_id)
|
||||||
|
version = session.get(CampaignVersion, schedule.source_version_id)
|
||||||
|
if campaign is None or campaign.tenant_id != schedule.tenant_id:
|
||||||
|
raise RuntimeError("Campaign schedule source is no longer available")
|
||||||
|
if version is None or version.campaign_id != campaign.id:
|
||||||
|
raise RuntimeError("Campaign schedule source version is no longer available")
|
||||||
|
validation = validate_autonomous_schedule_source(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not schedule.approved_execution_snapshot_hash
|
||||||
|
or validation["execution_snapshot_hash"]
|
||||||
|
!= schedule.approved_execution_snapshot_hash
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"The approved Campaign execution changed after the autonomous schedule was created."
|
||||||
|
)
|
||||||
|
jobs = _autonomous_source_jobs(
|
||||||
|
session,
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
occurrence_key = _occurrence_idempotency_key(schedule.id, scheduled_for)
|
||||||
|
occurrence = CampaignScheduleOccurrence(
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
status="preparing",
|
||||||
|
idempotency_key=occurrence_key,
|
||||||
|
recovery_state="prepared",
|
||||||
|
evidence={
|
||||||
|
"delivery_mode": "autonomous",
|
||||||
|
"source_campaign_id": campaign.id,
|
||||||
|
"source_version_id": version.id,
|
||||||
|
"source_snapshot_hash": schedule.source_snapshot_hash,
|
||||||
|
**validation,
|
||||||
|
},
|
||||||
|
last_checked_at=observed_at,
|
||||||
|
)
|
||||||
|
session.add(occurrence)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
contexts = {job.id: _send_job_delivery_context(session, job) for job in jobs}
|
||||||
|
with _synchronous_smtp_batch_manager(session, jobs=jobs, contexts=contexts):
|
||||||
|
pass
|
||||||
|
|
||||||
|
mail = mail_integration()
|
||||||
|
commands: list[dict[str, object]] = []
|
||||||
|
for job in jobs:
|
||||||
|
context = contexts[job.id]
|
||||||
|
if context.envelope_from is None or not context.envelope_recipients:
|
||||||
|
raise RuntimeError("A frozen Campaign message has no delivery envelope.")
|
||||||
|
message = BytesParser(policy=policy.default).parsebytes(context.message_bytes)
|
||||||
|
commands.append(
|
||||||
|
mail.submit_delivery_command(
|
||||||
|
session,
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
command_type="campaign_schedule_occurrence",
|
||||||
|
source_module="campaigns",
|
||||||
|
source_resource_type="campaign",
|
||||||
|
source_resource_id=campaign.id,
|
||||||
|
source_version_id=version.id,
|
||||||
|
idempotency_key=f"{occurrence_key}:{job.id}",
|
||||||
|
profile_id=context.snapshot.mail_profile_id,
|
||||||
|
message_bytes=context.message_bytes,
|
||||||
|
envelope_from=context.envelope_from,
|
||||||
|
envelope_recipients=context.envelope_recipients,
|
||||||
|
from_header=_from_header_from_job(job) or str(message.get("From") or ""),
|
||||||
|
expected_smtp_transport_revision=(
|
||||||
|
context.snapshot.smtp_transport_revision or ""
|
||||||
|
),
|
||||||
|
smtp_server_id=context.snapshot.smtp_server_id,
|
||||||
|
smtp_credential_id=context.snapshot.smtp_credential_id,
|
||||||
|
created_by_user_id=schedule.created_by_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
occurrence.delivery_command_ids = [str(item["id"]) for item in commands]
|
||||||
|
occurrence.status = "prepared"
|
||||||
|
occurrence.recovery_state = "pending"
|
||||||
|
occurrence.evidence = {
|
||||||
|
**occurrence.evidence,
|
||||||
|
"command_count": len(commands),
|
||||||
|
"duplicate_command_count": sum(bool(item.get("duplicate")) for item in commands),
|
||||||
|
"command_status_counts": _status_counts(commands),
|
||||||
|
}
|
||||||
|
occurrence.last_checked_at = observed_at
|
||||||
|
sequence = schedule.occurrence_count + 1
|
||||||
|
schedule.last_campaign_id = campaign.id
|
||||||
|
schedule.last_outcome = "prepared"
|
||||||
|
schedule.last_recovery_state = "pending"
|
||||||
|
coalesced = _advance_schedule(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
occurrence=occurrence,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
observed_at=observed_at,
|
||||||
|
sequence=sequence,
|
||||||
|
)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
user_id=schedule.created_by_user_id,
|
||||||
|
action="campaign.schedule.delivery_prepared",
|
||||||
|
object_type="campaign_schedule_occurrence",
|
||||||
|
object_id=occurrence.id,
|
||||||
|
details={
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"source_version_id": version.id,
|
||||||
|
"scheduled_for": scheduled_for.isoformat(),
|
||||||
|
"occurrence_idempotency_key": occurrence_key,
|
||||||
|
"delivery_command_count": len(commands),
|
||||||
|
"execution_snapshot_hash": validation["execution_snapshot_hash"],
|
||||||
|
"approval_request_id": validation["approval_request_id"],
|
||||||
|
"coalesced_missed_intervals": coalesced,
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
return occurrence, coalesced
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_schedule(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
schedule: CampaignSchedule,
|
||||||
|
occurrence: CampaignScheduleOccurrence,
|
||||||
|
scheduled_for: datetime,
|
||||||
|
observed_at: datetime,
|
||||||
|
sequence: int,
|
||||||
|
) -> int:
|
||||||
|
schedule.occurrence_count = sequence
|
||||||
|
schedule.last_fired_at = scheduled_for
|
||||||
|
schedule.last_error = None
|
||||||
|
next_fire = next_schedule_fire(
|
||||||
|
scheduled_for,
|
||||||
|
recurrence_kind=schedule.recurrence_kind,
|
||||||
|
interval_count=schedule.interval_count,
|
||||||
|
timezone_name=schedule.timezone,
|
||||||
|
)
|
||||||
|
coalesced = 0
|
||||||
|
while next_fire is not None and next_fire <= observed_at:
|
||||||
|
session.add(
|
||||||
|
CampaignScheduleOccurrence(
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=next_fire,
|
||||||
|
status="superseded",
|
||||||
|
idempotency_key=_occurrence_idempotency_key(schedule.id, next_fire),
|
||||||
|
recovery_state="superseded",
|
||||||
|
evidence={
|
||||||
|
"delivery_mode": schedule.delivery_mode,
|
||||||
|
"reason": "coalesced_missed_interval",
|
||||||
|
"superseded_by_occurrence_id": occurrence.id,
|
||||||
|
},
|
||||||
|
last_checked_at=observed_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
next_fire = next_schedule_fire(
|
||||||
|
next_fire,
|
||||||
|
recurrence_kind=schedule.recurrence_kind,
|
||||||
|
interval_count=schedule.interval_count,
|
||||||
|
timezone_name=schedule.timezone,
|
||||||
|
)
|
||||||
|
coalesced += 1
|
||||||
|
if (
|
||||||
|
next_fire is None
|
||||||
|
or sequence >= schedule.max_occurrences
|
||||||
|
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
|
||||||
|
):
|
||||||
|
schedule.active = False
|
||||||
|
schedule.next_fire_at = None
|
||||||
|
else:
|
||||||
|
schedule.next_fire_at = next_fire
|
||||||
|
schedule.resource_revision += 1
|
||||||
|
session.add(schedule)
|
||||||
|
return coalesced
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_autonomous_schedule_outcomes(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
observed_at = _as_utc(now or datetime.now(UTC))
|
||||||
|
query = session.query(CampaignScheduleOccurrence).filter(
|
||||||
|
CampaignScheduleOccurrence.status.in_(("prepared", "uncertain")),
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(CampaignScheduleOccurrence.tenant_id == tenant_id)
|
||||||
|
counts = {
|
||||||
|
"checked": 0,
|
||||||
|
"accepted": 0,
|
||||||
|
"uncertain": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
mail = mail_integration()
|
||||||
|
if not mail.durable_delivery_available:
|
||||||
|
for occurrence in query.order_by(
|
||||||
|
CampaignScheduleOccurrence.created_at
|
||||||
|
).limit(250):
|
||||||
|
if not occurrence.delivery_command_ids:
|
||||||
|
continue
|
||||||
|
counts["checked"] += 1
|
||||||
|
counts["uncertain"] += 1
|
||||||
|
_mark_occurrence_uncertain(
|
||||||
|
session,
|
||||||
|
occurrence=occurrence,
|
||||||
|
observed_at=observed_at,
|
||||||
|
reason="mail_delivery_outbox_unavailable",
|
||||||
|
)
|
||||||
|
return counts
|
||||||
|
for occurrence in query.order_by(CampaignScheduleOccurrence.created_at).limit(250):
|
||||||
|
if not occurrence.delivery_command_ids:
|
||||||
|
continue
|
||||||
|
summaries: list[dict[str, object]] = []
|
||||||
|
try:
|
||||||
|
summaries = [
|
||||||
|
mail.delivery_command_summary(
|
||||||
|
session,
|
||||||
|
tenant_id=occurrence.tenant_id,
|
||||||
|
command_id=command_id,
|
||||||
|
)
|
||||||
|
for command_id in occurrence.delivery_command_ids
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
counts["checked"] += 1
|
||||||
|
counts["uncertain"] += 1
|
||||||
|
_mark_occurrence_uncertain(
|
||||||
|
session,
|
||||||
|
occurrence=occurrence,
|
||||||
|
observed_at=observed_at,
|
||||||
|
reason="mail_delivery_status_unavailable",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
counts["checked"] += 1
|
||||||
|
outcome, recovery_state = _aggregate_command_outcome(summaries)
|
||||||
|
previous_outcome = occurrence.status
|
||||||
|
previous_recovery_state = occurrence.recovery_state
|
||||||
|
occurrence.status = outcome
|
||||||
|
occurrence.recovery_state = recovery_state
|
||||||
|
occurrence.last_checked_at = observed_at
|
||||||
|
occurrence.evidence = {
|
||||||
|
**(occurrence.evidence or {}),
|
||||||
|
"command_status_counts": _status_counts(summaries),
|
||||||
|
"accepted_recipient_count": sum(
|
||||||
|
int(item.get("accepted_count") or 0) for item in summaries
|
||||||
|
),
|
||||||
|
"refused_recipient_count": sum(
|
||||||
|
int(item.get("refused_count") or 0) for item in summaries
|
||||||
|
),
|
||||||
|
"failure_codes": sorted(
|
||||||
|
{
|
||||||
|
str(item["failure_code"])
|
||||||
|
for item in summaries
|
||||||
|
if item.get("failure_code")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
|
||||||
|
if schedule is not None:
|
||||||
|
schedule.last_outcome = outcome
|
||||||
|
schedule.last_recovery_state = recovery_state
|
||||||
|
transitioned_to_operator_required = (
|
||||||
|
outcome in {"uncertain", "failed"}
|
||||||
|
and (
|
||||||
|
previous_outcome != outcome
|
||||||
|
or previous_recovery_state != recovery_state
|
||||||
|
or schedule.active
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if transitioned_to_operator_required:
|
||||||
|
schedule.active = False
|
||||||
|
schedule.last_error = (
|
||||||
|
"Autonomous delivery needs operator review; automatic recurrence is paused."
|
||||||
|
)
|
||||||
|
schedule.resource_revision += 1
|
||||||
|
_notify_schedule_operator(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
reason=f"delivery_{outcome}",
|
||||||
|
)
|
||||||
|
session.add(schedule)
|
||||||
|
session.add(occurrence)
|
||||||
|
if outcome in counts:
|
||||||
|
counts[outcome] += 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_occurrence_uncertain(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
occurrence: CampaignScheduleOccurrence,
|
||||||
|
observed_at: datetime,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
previous_outcome = occurrence.status
|
||||||
|
previous_recovery_state = occurrence.recovery_state
|
||||||
|
occurrence.status = "uncertain"
|
||||||
|
occurrence.recovery_state = "operator_required"
|
||||||
|
occurrence.last_checked_at = observed_at
|
||||||
|
occurrence.evidence = {
|
||||||
|
**(occurrence.evidence or {}),
|
||||||
|
"recovery_reason": reason,
|
||||||
|
}
|
||||||
|
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
|
||||||
|
if schedule is not None:
|
||||||
|
transitioned = (
|
||||||
|
previous_outcome != "uncertain"
|
||||||
|
or previous_recovery_state != "operator_required"
|
||||||
|
or schedule.active
|
||||||
|
)
|
||||||
|
schedule.active = False
|
||||||
|
schedule.last_outcome = "uncertain"
|
||||||
|
schedule.last_recovery_state = "operator_required"
|
||||||
|
schedule.last_error = (
|
||||||
|
"Autonomous delivery status is unavailable; automatic recurrence is paused."
|
||||||
|
)
|
||||||
|
if transitioned:
|
||||||
|
schedule.resource_revision += 1
|
||||||
|
_notify_schedule_operator(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
session.add(schedule)
|
||||||
|
session.add(occurrence)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_open_occurrence(session: Session, *, schedule_id: str) -> bool:
|
||||||
|
rows = (
|
||||||
|
session.query(CampaignScheduleOccurrence.delivery_command_ids)
|
||||||
|
.filter(
|
||||||
|
CampaignScheduleOccurrence.schedule_id == schedule_id,
|
||||||
|
CampaignScheduleOccurrence.status == "prepared",
|
||||||
|
)
|
||||||
|
.limit(1000)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return any(bool(command_ids) for (command_ids,) in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_command_outcome(
|
||||||
|
summaries: list[dict[str, object]],
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
statuses = {str(item.get("status") or "") for item in summaries}
|
||||||
|
if statuses and statuses <= {"accepted", "reconciled_accepted"}:
|
||||||
|
return "accepted", "complete"
|
||||||
|
if statuses and statuses <= {"reconciled_not_accepted"}:
|
||||||
|
return "skipped", "reconciled"
|
||||||
|
if statuses & {"outcome_unknown", "in_progress"}:
|
||||||
|
return "uncertain", "operator_required"
|
||||||
|
if statuses & {"permanent_failure", "partially_refused", "reconciled_not_accepted"}:
|
||||||
|
return "failed", "operator_required"
|
||||||
|
return "prepared", "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def _status_counts(items: list[dict[str, object]]) -> dict[str, int]:
|
||||||
|
result: dict[str, int] = {}
|
||||||
|
for item in items:
|
||||||
|
status = str(item.get("status") or "unknown")
|
||||||
|
result[status] = result.get(status, 0) + 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _occurrence_idempotency_key(schedule_id: str, scheduled_for: datetime) -> str:
|
||||||
|
return f"campaign-schedule:{schedule_id}:{_as_utc(scheduled_for).isoformat()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_schedule_operator(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
schedule: CampaignSchedule,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
from govoplan_core.core.notifications import (
|
||||||
|
NotificationDispatchRequest,
|
||||||
|
notification_dispatch_provider,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.runtime import get_registry
|
||||||
|
|
||||||
|
provider = notification_dispatch_provider(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
source_module="campaigns",
|
||||||
|
source_resource_type="campaign_schedule",
|
||||||
|
source_resource_id=schedule.id,
|
||||||
|
event_kind="campaign.schedule.operator_required",
|
||||||
|
channel="inbox",
|
||||||
|
recipient_type="user" if schedule.created_by_user_id else None,
|
||||||
|
recipient_id=schedule.created_by_user_id,
|
||||||
|
subject=f"Campaign schedule paused: {schedule.name}",
|
||||||
|
body_text=(
|
||||||
|
"Autonomous Campaign delivery was paused before another occurrence. "
|
||||||
|
"Review its recovery evidence before resuming."
|
||||||
|
),
|
||||||
|
action_url=f"/campaigns/{schedule.campaign_id}",
|
||||||
|
priority=2,
|
||||||
|
payload={"schedule_id": schedule.id, "reason": reason},
|
||||||
|
),
|
||||||
|
enqueue_delivery=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _copy_snapshot_shares(
|
def _copy_snapshot_shares(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -360,4 +959,6 @@ __all__ = [
|
|||||||
"canonical_configuration_hash",
|
"canonical_configuration_hash",
|
||||||
"dispatch_due_campaign_schedules",
|
"dispatch_due_campaign_schedules",
|
||||||
"next_schedule_fire",
|
"next_schedule_fire",
|
||||||
|
"refresh_autonomous_schedule_outcomes",
|
||||||
|
"validate_autonomous_schedule_source",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -192,6 +192,12 @@ class CampaignSchedule(Base, TimestampMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
delivery_mode: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default="manual",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
recurrence_kind: Mapped[str] = mapped_column(
|
recurrence_kind: Mapped[str] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
default="once",
|
default="once",
|
||||||
@@ -210,6 +216,9 @@ class CampaignSchedule(Base, TimestampMixin):
|
|||||||
copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
approved_execution_snapshot_hash: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), nullable=True, index=True
|
||||||
|
)
|
||||||
source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_campaign_id: Mapped[str | None] = mapped_column(
|
last_campaign_id: Mapped[str | None] = mapped_column(
|
||||||
@@ -218,6 +227,10 @@ class CampaignSchedule(Base, TimestampMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
last_outcome: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
last_recovery_state: Mapped[str | None] = mapped_column(
|
||||||
|
String(30), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CampaignScheduleOccurrence(Base, TimestampMixin):
|
class CampaignScheduleOccurrence(Base, TimestampMixin):
|
||||||
@@ -240,6 +253,9 @@ class CampaignScheduleOccurrence(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True)
|
status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True)
|
||||||
|
idempotency_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(200), nullable=True, index=True
|
||||||
|
)
|
||||||
generated_campaign_id: Mapped[str | None] = mapped_column(
|
generated_campaign_id: Mapped[str | None] = mapped_column(
|
||||||
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
@@ -251,6 +267,16 @@ class CampaignScheduleOccurrence(Base, TimestampMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
delivery_command_ids: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
)
|
||||||
|
recovery_state: Mapped[str] = mapped_column(
|
||||||
|
String(30), default="none", nullable=False, index=True
|
||||||
|
)
|
||||||
|
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
last_checked_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RecipientImportMappingProfile(Base, TimestampMixin):
|
class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||||
|
|||||||
@@ -236,9 +236,9 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
),
|
),
|
||||||
_workflow_topic(
|
_workflow_topic(
|
||||||
topic_id="campaigns.workflow.schedule-drafts",
|
topic_id="campaigns.workflow.schedule-drafts",
|
||||||
title="Schedule bounded recurring campaign drafts",
|
title="Schedule bounded manual or autonomous campaigns",
|
||||||
summary="Prepare fresh campaign drafts at a future time without bypassing validation, review, approval, or delivery controls.",
|
summary="Prepare fresh drafts or explicitly opt in to governed delivery of an exact approved build.",
|
||||||
body="A Campaign schedule stores an integrity-sealed snapshot of the selected version, campaign policy, Mail profile policy, and optional shares, plus a bounded one-time, daily, weekly, or monthly recurrence. Each due occurrence creates a separately owned draft and an occurrence record. Missed intervals are coalesced instead of producing a catch-up storm. A schedule never validates, approves, queues, retries, or sends a campaign, and a preparation failure pauses it for operator review. Pause and resume reject stale browser state.",
|
body="Every schedule is bounded, timezone-aware, and fixed to either manual or autonomous mode. Manual mode stores an integrity-sealed configuration snapshot and prepares a separately owned draft per due occurrence without requiring Mail. Autonomous mode never rebuilds or silently changes approved content: it requires a built Mail-only source version with an explicit valid Approval request, seals its execution-snapshot hash, and rechecks approval, policy, credential selection, SMTP transport revision and live transport health, recipient gates, attachment evidence, and snapshot integrity before every occurrence. It then creates one Mail-owned durable command per frozen message with occurrence-scoped idempotency before delivery. Mail never automatically retries accepted or outcome-unknown effects. Campaign records prepared, accepted, uncertain, failed, skipped, and superseded recovery evidence; uncertain, policy, configuration, and systemic failures pause the recurrence and notify the accountable operator. Missed intervals are coalesced instead of causing a catch-up storm, and pause/resume rejects stale browser state.",
|
||||||
order=34,
|
order=34,
|
||||||
audience=("campaign_manager", "campaign_author", "operator"),
|
audience=("campaign_manager", "campaign_author", "operator"),
|
||||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
|
||||||
@@ -249,24 +249,26 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
"Choose the exact campaign version whose configuration should seed future drafts.",
|
"Choose the exact campaign version whose configuration should seed future drafts.",
|
||||||
"Recipient data and active shares require their corresponding read or share authority.",
|
"Recipient data and active shares require their corresponding read or share authority.",
|
||||||
"A worker and scheduler process must be running for automatic due-time preparation.",
|
"A worker and scheduler process must be running for automatic due-time preparation.",
|
||||||
|
"Autonomous mode additionally requires campaigns:campaign:queue, campaigns:campaign:send, mail:profile:use, Mail's durable outbox, and an explicitly approved built source version.",
|
||||||
),
|
),
|
||||||
steps=(
|
steps=(
|
||||||
"Open the campaign overview and choose Schedule.",
|
"Open the campaign overview and choose Schedule.",
|
||||||
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
|
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
|
||||||
"Select which configuration domains may be copied and create the schedule.",
|
"Choose manual draft preparation or autonomous approved delivery; mode cannot be changed in place.",
|
||||||
"Review each generated draft independently before validating, building, approving, and sending it.",
|
"For manual mode, select which configuration domains may be copied and review each generated draft independently.",
|
||||||
"Pause the schedule when the approved plan changes; a failed occurrence is paused automatically and remains visible as evidence.",
|
"For autonomous mode, confirm that the selected version is built, Mail-only, and explicitly approved; the API rejects missing or stale evidence.",
|
||||||
|
"Review next occurrence, last outcome, and recovery state. Resolve uncertain or failed Mail commands explicitly before creating or resuming a replacement schedule.",
|
||||||
),
|
),
|
||||||
outcome="A bounded sequence of independent campaign drafts with durable schedule and occurrence evidence.",
|
outcome="A bounded sequence of manual drafts or at-most-once autonomous Mail commands with durable occurrence and recovery evidence.",
|
||||||
verification="The Schedules section shows the next occurrence and generated count; each prepared occurrence links to a distinct draft with no delivery jobs or outcomes.",
|
verification="The Schedules section shows mode, next occurrence, last outcome, recovery state, and any automatic pause; manual occurrences link to distinct drafts while autonomous occurrences retain Mail command identifiers and non-secret outcome totals.",
|
||||||
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
|
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
|
||||||
translations={
|
translations={
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Begrenzte wiederkehrende Kampagnenentwürfe planen",
|
"title": "Begrenzte manuelle oder autonome Kampagnen planen",
|
||||||
"summary": "Künftige Kampagnenentwürfe vorbereiten, ohne Validierung, Prüfung, Freigabe oder Versandkontrollen zu umgehen.",
|
"summary": "Neue Entwürfe vorbereiten oder den Versand eines exakt freigegebenen Builds ausdrücklich autonom ausführen.",
|
||||||
"body": "Ein Kampagnenzeitplan speichert einen integritätsgesicherten Stand der ausgewählten Version, Kampagnenrichtlinie, Mail-Profilrichtlinie und optionalen Freigaben sowie eine begrenzte einmalige, tägliche, wöchentliche oder monatliche Wiederholung. Jede fällige Ausführung erzeugt einen eigenständigen Entwurf und einen Ausführungsnachweis. Verpasste Intervalle werden zusammengefasst, statt unkontrolliert nachgeholt zu werden. Der Zeitplan validiert, genehmigt, startet, wiederholt oder versendet niemals eine Kampagne; ein Fehler pausiert ihn zur betrieblichen Prüfung. Pausieren und Fortsetzen weisen veraltete Browserstände zurück.",
|
"body": "Jeder Zeitplan ist begrenzt, zeitzonenfest und dauerhaft manuell oder autonom. Der manuelle Modus erzeugt eigenständige Entwürfe und funktioniert ohne Mail. Der autonome Modus verlangt eine gebaute, ausschließlich per Mail versendete und ausdrücklich freigegebene Quellversion. Vor jeder Ausführung werden Freigabe, Richtlinie, Zugangsdatenauswahl, Transportrevision und -erreichbarkeit, Empfänger, Anlagen und Snapshot-Integrität erneut geprüft. Pro eingefrorener Nachricht entsteht vor dem Versand ein dauerhafter Mail-Auftrag mit ausführungsspezifischem Idempotenzschlüssel. Angenommene oder unklare Ergebnisse werden nie automatisch wiederholt; unklare oder systemische Fehler pausieren den Zeitplan und benachrichtigen Verantwortliche. Verpasste Intervalle werden zusammengefasst und als Nachweis erhalten.",
|
||||||
"outcome": "Eine begrenzte Folge eigenständiger Kampagnenentwürfe mit dauerhaftem Zeitplan- und Ausführungsnachweis.",
|
"outcome": "Eine begrenzte Folge manueller Entwürfe oder höchstens einmal angenommener autonomer Mail-Aufträge mit dauerhaftem Wiederherstellungsnachweis.",
|
||||||
"verification": "Der Abschnitt Zeitpläne zeigt die nächste Ausführung und die Zahl erzeugter Entwürfe; jede Ausführung verweist auf einen eigenen Entwurf ohne Versandaufträge oder Ergebnisse.",
|
"verification": "Der Abschnitt Zeitpläne zeigt Modus, nächste Ausführung, letztes Ergebnis, Wiederherstellungsstatus und automatische Pausen.",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,11 +74,21 @@ class SmtpConfigurationError(RuntimeError):
|
|||||||
|
|
||||||
class SmtpSendError(RuntimeError):
|
class SmtpSendError(RuntimeError):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, message: str, *, temporary: bool = False, outcome_unknown: bool = False
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
temporary: bool = False,
|
||||||
|
outcome_unknown: bool = False,
|
||||||
|
systemic: bool = False,
|
||||||
|
reason_code: str | None = None,
|
||||||
|
phase: str = "send",
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.temporary = temporary
|
self.temporary = temporary
|
||||||
self.outcome_unknown = outcome_unknown
|
self.outcome_unknown = outcome_unknown
|
||||||
|
self.systemic = systemic
|
||||||
|
self.reason_code = reason_code
|
||||||
|
self.phase = phase
|
||||||
|
|
||||||
|
|
||||||
class ImapConfigurationError(RuntimeError):
|
class ImapConfigurationError(RuntimeError):
|
||||||
@@ -298,6 +308,9 @@ class MailCampaignIntegration:
|
|||||||
str(exc),
|
str(exc),
|
||||||
temporary=bool(getattr(exc, "temporary", False)),
|
temporary=bool(getattr(exc, "temporary", False)),
|
||||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||||
|
systemic=bool(getattr(exc, "systemic", False)),
|
||||||
|
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
|
||||||
|
phase=str(getattr(exc, "phase", "send") or "send"),
|
||||||
) from exc
|
) from exc
|
||||||
except getattr(
|
except getattr(
|
||||||
delegate, "SmtpConfigurationError", SmtpConfigurationError
|
delegate, "SmtpConfigurationError", SmtpConfigurationError
|
||||||
@@ -306,6 +319,30 @@ class MailCampaignIntegration:
|
|||||||
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
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def campaign_smtp_batch(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
|
||||||
|
delegate = self._require()
|
||||||
|
method = getattr(delegate, "campaign_smtp_batch", None)
|
||||||
|
if not callable(method):
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with method(*args, **kwargs) as state:
|
||||||
|
yield state
|
||||||
|
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
|
||||||
|
raise SmtpSendError(
|
||||||
|
str(exc),
|
||||||
|
temporary=bool(getattr(exc, "temporary", False)),
|
||||||
|
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||||
|
systemic=bool(getattr(exc, "systemic", False)),
|
||||||
|
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
|
||||||
|
phase=str(getattr(exc, "phase", "preflight") or "preflight"),
|
||||||
|
) from exc
|
||||||
|
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
|
||||||
|
raise SmtpConfigurationError(str(exc)) from exc
|
||||||
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
def append_campaign_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:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from govoplan_core.core.modules import (
|
|||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
QuickAccessTool,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
||||||
@@ -65,11 +66,19 @@ from govoplan_campaign.backend.documentation import (
|
|||||||
CAMPAIGN_USER_DOCUMENTATION,
|
CAMPAIGN_USER_DOCUMENTATION,
|
||||||
documentation_topics,
|
documentation_topics,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.dsar_provider import CAMPAIGN_DSAR_CAPABILITY
|
||||||
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
||||||
|
|
||||||
register_campaign_change_tracking()
|
register_campaign_change_tracking()
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_campaign.backend.dsar_provider import CampaignDsarProvider
|
||||||
|
|
||||||
|
return CampaignDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _permission(
|
def _permission(
|
||||||
scope: str, label: str, description: str, category: str
|
scope: str, label: str, description: str, category: str
|
||||||
) -> PermissionDefinition:
|
) -> PermissionDefinition:
|
||||||
@@ -113,8 +122,8 @@ PERMISSIONS = (
|
|||||||
),
|
),
|
||||||
_permission(
|
_permission(
|
||||||
"campaigns:campaign:schedule",
|
"campaigns:campaign:schedule",
|
||||||
"Schedule campaign drafts",
|
"Schedule campaigns",
|
||||||
"Prepare fresh campaign drafts at a governed time or bounded recurrence.",
|
"Prepare manual drafts or opt in to approved autonomous Mail delivery at a governed time or bounded recurrence.",
|
||||||
"Campaigns",
|
"Campaigns",
|
||||||
),
|
),
|
||||||
_permission(
|
_permission(
|
||||||
@@ -159,6 +168,12 @@ PERMISSIONS = (
|
|||||||
"Build exact messages and attachment evidence.",
|
"Build exact messages and attachment evidence.",
|
||||||
"Campaigns",
|
"Campaigns",
|
||||||
),
|
),
|
||||||
|
_permission(
|
||||||
|
"campaigns:archive:use_legacy_zipcrypto",
|
||||||
|
"Use legacy ZipCrypto",
|
||||||
|
"Explicitly select weak Windows-compatible ZipCrypto when the effective policy permits it.",
|
||||||
|
"Campaign governance",
|
||||||
|
),
|
||||||
_permission(
|
_permission(
|
||||||
"campaigns:campaign:review",
|
"campaigns:campaign:review",
|
||||||
"Complete campaign review",
|
"Complete campaign review",
|
||||||
@@ -370,7 +385,7 @@ def _campaigns_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.18",
|
version="0.1.19",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -395,7 +410,7 @@ manifest = ModuleManifest(
|
|||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.schedules", version="0.1.0"),
|
ModuleInterfaceProvider(name="campaigns.schedules", version="0.2.0"),
|
||||||
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
||||||
@@ -403,6 +418,7 @@ manifest = ModuleManifest(
|
|||||||
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
|
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceProvider(name=CAMPAIGN_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -602,6 +618,32 @@ manifest = ModuleManifest(
|
|||||||
label="Campaign activity widget",
|
label="Campaign activity widget",
|
||||||
order=50,
|
order=50,
|
||||||
),
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="campaigns.quick_access.campaigns",
|
||||||
|
module_id="campaigns",
|
||||||
|
kind="quick_access",
|
||||||
|
label="Campaign selection",
|
||||||
|
order=55,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
quick_access_tools=(
|
||||||
|
QuickAccessTool(
|
||||||
|
id="campaigns.select",
|
||||||
|
module_id="campaigns",
|
||||||
|
category_id="campaigns",
|
||||||
|
label="Campaigns",
|
||||||
|
description="Select an authorized exact Campaign for the active Case.",
|
||||||
|
surface_id="campaigns.quick_access.campaigns",
|
||||||
|
icon="campaign",
|
||||||
|
full_page_path="/campaigns",
|
||||||
|
required_any=("campaigns:campaign:read",),
|
||||||
|
order=20,
|
||||||
|
modes=("select",),
|
||||||
|
availability="active_object",
|
||||||
|
accepted_reference_kinds=("cases.case",),
|
||||||
|
returned_reference_kinds=("campaigns.campaign",),
|
||||||
|
help_context_id="campaigns.quick_access.campaigns",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
@@ -653,6 +695,107 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
*CAMPAIGN_USER_DOCUMENTATION,
|
*CAMPAIGN_USER_DOCUMENTATION,
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.workflow.link-exact-campaign-to-case",
|
||||||
|
title="Link an exact Campaign reference to an active Case",
|
||||||
|
summary=(
|
||||||
|
"Return an authorized Campaign and current immutable version through "
|
||||||
|
"Quick Access without copying campaign content."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"When a Case is the active object, Campaigns contributes a bounded "
|
||||||
|
"Quick Access selector. The normal Campaign list endpoint applies the "
|
||||||
|
"current actor's tenant, owner, group, share, and administration access "
|
||||||
|
"before candidates appear. Selecting a Campaign returns only its owner "
|
||||||
|
"module, stable Campaign ID, current version ID, display label, tenant, "
|
||||||
|
"and owner route through the versioned result contract. Cases discards "
|
||||||
|
"the label and stores no recipient, message, attachment, delivery, report, "
|
||||||
|
"or campaign configuration content. Opening the reference enters Campaigns "
|
||||||
|
"and rechecks current access. Campaigns disabled, access revoked, or the "
|
||||||
|
"source removed therefore leaves only an unavailable historical Case "
|
||||||
|
"reference; it never turns Case access into Campaign access."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("campaign_manager", "case_manager", "operator", "module_admin"),
|
||||||
|
related_modules=("cases", "quick_access"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns", "cases"),
|
||||||
|
required_scopes=("campaigns:campaign:read",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Campaigns",
|
||||||
|
href="/campaigns",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["campaigns.quick_access.campaigns"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.privacy.data-subject-requests",
|
||||||
|
title="Review Campaign data in a data-subject request",
|
||||||
|
summary="Collect recipient, version, delivery, report, and artifact metadata without rewriting immutable evidence.",
|
||||||
|
body=(
|
||||||
|
"Campaign's DSAR provider searches the effective tenant by normalized recipient email, direct membership references, and namespaced Campaign job, entry, version, or Campaign references. "
|
||||||
|
"It isolates matching inline-recipient fields and job metadata, and reports built versions, delivery attempts, Postbox and print outcomes, message-action corrections, recipient-specific report projections, generated-message digests, and attachment metadata. It does not export EML bytes, object or local paths, provider target snapshots, worker claims, idempotency material, secrets, credentials, or unrelated recipient addresses. "
|
||||||
|
"Built, locked, published, terminal, delivered, or corrected records remain retained with a reason and continue through Campaign's configured retention/redaction process. Draft recipient content and user-owned attachment content require coordinated manual review because the same data may occur in version JSON, jobs, and generated artifacts. The provider can idempotently delete a personal recipient-import mapping profile and revoke an active Campaign share aimed at the subject. It never rewrites delivered evidence or deletes generated artifacts directly. Campaign reports are derived projections rather than a separate personal-data store."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "campaign_manager", "records_manager", "operator"),
|
||||||
|
order=42,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns", "access"),
|
||||||
|
any_scopes=(
|
||||||
|
"access:privacy:read",
|
||||||
|
"access:privacy:manage",
|
||||||
|
"access:privacy:erase",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Data-subject requests",
|
||||||
|
href="/admin?section=tenant-data-subject-requests",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Campaign handbook",
|
||||||
|
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("access", "audit", "files", "mail", "postbox", "reporting"),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/admin?section=tenant-data-subject-requests",
|
||||||
|
"screen": "Data-subject requests",
|
||||||
|
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||||
|
"prerequisites": [
|
||||||
|
"The privacy request and recipient selectors have been independently authorized and corroborated.",
|
||||||
|
"The reviewer understands the effective Campaign retention policy and delivery-evidence obligations.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Run the Campaign provider search and review recipient, version, job, attempt, report-projection, and artifact dispositions.",
|
||||||
|
"Inspect matching draft content manually and keep every evidence retention reason with the case decision.",
|
||||||
|
"Execute only an approved user-owned mapping deletion or subject-targeted share revocation.",
|
||||||
|
"Use Campaign retention and artifact reconciliation for approved content redaction or expiry; do not mutate delivered evidence ad hoc.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Generated EML bytes and attachment content are not embedded in the JSON export; authorized Campaign or Files review paths remain authoritative.",
|
||||||
|
"Draft recipient erasure is manual until a coordinated version/job/artifact rewrite contract can prove that no partial copy remains.",
|
||||||
|
],
|
||||||
|
"outcome": "Campaign personal data receives an explicit retained, review, revoke, or delete disposition without weakening delivery evidence.",
|
||||||
|
"verification": "Confirm matching recipients are isolated, no locator or credential material appears, report counts derive from the same matched jobs, and repeated reversible actions are unchanged.",
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="campaigns.access.child-evidence",
|
id="campaigns.access.child-evidence",
|
||||||
title="Explain access to Campaign child evidence",
|
title="Explain access to Campaign child evidence",
|
||||||
@@ -907,7 +1050,7 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.mail-profile-operations",
|
id="campaigns.mail-profile-operations",
|
||||||
title="Operate profile-backed campaign delivery",
|
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.",
|
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. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Synchronous Mail batches preflight DNS, connectivity, TLS, and authentication before their first effect, reuse a bounded healthy SMTP connection, and reconnect before a later message when the old connection is stale. Review and send shows batch, connection, reconnect, failure, and pause counts. A systemic authentication, sender, or connectivity failure pauses remaining queued jobs with a stable reason code; correct and test the Mail profile before explicitly resuming. A connection loss after DATA begins stays outcome-unknown and is never replayed automatically. Preserve a stopped 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. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
||||||
@@ -1025,6 +1168,33 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.archive-encryption-governance",
|
||||||
|
title="Use governed password-protected ZIP attachments",
|
||||||
|
summary="Use AES by default and select weak Windows-compatible ZipCrypto only with explicit policy, permission, acknowledgement, and evidence.",
|
||||||
|
body=(
|
||||||
|
"Campaign resolves archive encryption through Policy across system, tenant, owner user or group, and campaign scopes. Password-protected archives use AES unless the complete inherited policy permits Legacy ZipCrypto — Windows-compatible, weak encryption and the actor has campaigns:archive:use_legacy_zipcrypto. A legacy selection requires a reasoned acknowledgement. Passwords are never included in Campaign evidence or the campaign message and must be conveyed through the separately selected, policy-allowed channel. Each build freezes the archive and member hashes, implementation version, policy hash and source path, acknowledgement actor, reason and time, and build identity. A more restrictive later policy blocks queueing and sending until the campaign is rebuilt; Campaign never falls back from AES to ZipCrypto after an error. Temporary plaintext and archive material is confined to the bounded build directory and removed after success or failure."
|
||||||
|
),
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("campaign_manager", "campaign_reviewer", "policy_admin"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
any_scopes=(
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:review",
|
||||||
|
"admin:policies:read",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("policy", "audit", "access"),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/campaigns/{campaign_id}/files",
|
||||||
|
"screen": "Campaign attachments",
|
||||||
|
"help_contexts": ["campaign.archive-encryption"],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="campaigns.workflow.complete-review",
|
id="campaigns.workflow.complete-review",
|
||||||
title="Inspect built messages and complete review",
|
title="Inspect built messages and complete review",
|
||||||
@@ -1280,6 +1450,7 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_campaign.backend.reports.provider",
|
"govoplan_campaign.backend.reports.provider",
|
||||||
fromlist=["CampaignAggregateReportProvider"],
|
fromlist=["CampaignAggregateReportProvider"],
|
||||||
).CampaignAggregateReportProvider(),
|
).CampaignAggregateReportProvider(),
|
||||||
|
CAMPAIGN_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
||||||
@@ -1292,6 +1463,13 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "reporting_analyst", "privacy_officer"),
|
audience=("user", "reporting_analyst", "privacy_officer"),
|
||||||
),
|
),
|
||||||
|
CAMPAIGN_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Campaign data-subject request provider",
|
||||||
|
summary="Finds isolated recipient and Campaign evidence metadata and classifies governed erasure actions.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "campaign_manager", "records_manager"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
operational_check_providers=(
|
operational_check_providers=(
|
||||||
OperationalCheckProviderRegistration(
|
OperationalCheckProviderRegistration(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import mimetypes
|
|||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import make_msgid, formatdate
|
from email.utils import make_msgid, formatdate
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -40,7 +40,10 @@ from govoplan_campaign.backend.campaign.models import (
|
|||||||
effective_delivery_channel_policy,
|
effective_delivery_channel_policy,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
from govoplan_campaign.backend.services.zip_service import (
|
||||||
|
create_zip_archive,
|
||||||
|
zip_archive_evidence,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.template_rendering import (
|
from govoplan_campaign.backend.template_rendering import (
|
||||||
find_unresolved_placeholders as _find_unresolved_placeholders,
|
find_unresolved_placeholders as _find_unresolved_placeholders,
|
||||||
render_template as _render_template,
|
render_template as _render_template,
|
||||||
@@ -93,6 +96,7 @@ class _MimeBuildResult:
|
|||||||
build_status: BuildStatus
|
build_status: BuildStatus
|
||||||
validation_status: MessageValidationStatus
|
validation_status: MessageValidationStatus
|
||||||
attachment_count: int
|
attachment_count: int
|
||||||
|
archive_evidence: list[dict[str, object]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -372,8 +376,9 @@ def _attach_files(
|
|||||||
resolution: EntryAttachmentResolution,
|
resolution: EntryAttachmentResolution,
|
||||||
values: dict[str, Any],
|
values: dict[str, Any],
|
||||||
work_dir: Path,
|
work_dir: Path,
|
||||||
) -> int:
|
) -> tuple[int, list[dict[str, object]]]:
|
||||||
attached_count = 0
|
attached_count = 0
|
||||||
|
evidence: list[dict[str, object]] = []
|
||||||
archive_members: dict[str, list[tuple[Path, str]]] = {}
|
archive_members: dict[str, list[tuple[Path, str]]] = {}
|
||||||
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
||||||
used_message_filenames: set[str] = set()
|
used_message_filenames: set[str] = set()
|
||||||
@@ -429,13 +434,38 @@ def _attach_files(
|
|||||||
password,
|
password,
|
||||||
archive.method.value,
|
archive.method.value,
|
||||||
)
|
)
|
||||||
|
archive_record = zip_archive_evidence(
|
||||||
|
archive_path,
|
||||||
|
members,
|
||||||
|
password_protected=bool(password),
|
||||||
|
method=archive.method.value,
|
||||||
|
)
|
||||||
|
archive_record.update(
|
||||||
|
{
|
||||||
|
"archive_id": archive.id,
|
||||||
|
"filename": filename,
|
||||||
|
"password_delivery_channel": (
|
||||||
|
archive.password_delivery_channel.value if password else None
|
||||||
|
),
|
||||||
|
"legacy_acknowledgement": (
|
||||||
|
{
|
||||||
|
"actor_id": archive.legacy_zipcrypto_acknowledged_by,
|
||||||
|
"reason": archive.legacy_zipcrypto_reason,
|
||||||
|
"recorded_at": archive.legacy_zipcrypto_acknowledged_at,
|
||||||
|
}
|
||||||
|
if archive.method.value == "zip_standard"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
evidence.append(archive_record)
|
||||||
data, maintype, subtype = _attachment_bytes(archive_path)
|
data, maintype, subtype = _attachment_bytes(archive_path)
|
||||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||||
attached_count += 1
|
attached_count += 1
|
||||||
for attachment in archive_attachments.get(archive.id, []):
|
for attachment in archive_attachments.get(archive.id, []):
|
||||||
attachment.zip_filename = filename
|
attachment.zip_filename = filename
|
||||||
|
|
||||||
return attached_count
|
return attached_count, evidence
|
||||||
|
|
||||||
def _imap_initial_status(
|
def _imap_initial_status(
|
||||||
config: CampaignConfig,
|
config: CampaignConfig,
|
||||||
@@ -527,6 +557,7 @@ def _message_draft(
|
|||||||
imap_status: ImapStatus | None = None,
|
imap_status: ImapStatus | None = None,
|
||||||
subject: str | None = None,
|
subject: str | None = None,
|
||||||
attachment_count: int = 0,
|
attachment_count: int = 0,
|
||||||
|
archive_evidence: list[dict[str, object]] | None = None,
|
||||||
issues: list[MessageIssue] | None = None,
|
issues: list[MessageIssue] | None = None,
|
||||||
eml_path: str | None = None,
|
eml_path: str | None = None,
|
||||||
eml_size: int | None = None,
|
eml_size: int | None = None,
|
||||||
@@ -566,6 +597,7 @@ def _message_draft(
|
|||||||
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
|
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
|
||||||
attachment_count=attachment_count,
|
attachment_count=attachment_count,
|
||||||
attachments=_attachment_summaries(context.resolution),
|
attachments=_attachment_summaries(context.resolution),
|
||||||
|
archive_evidence=archive_evidence or [],
|
||||||
issues=issues if issues is not None else context.issues,
|
issues=issues if issues is not None else context.issues,
|
||||||
eml_path=eml_path,
|
eml_path=eml_path,
|
||||||
eml_size_bytes=eml_size,
|
eml_size_bytes=eml_size,
|
||||||
@@ -763,7 +795,7 @@ def _build_mime_message(
|
|||||||
_populate_message_body(message, rendered)
|
_populate_message_body(message, rendered)
|
||||||
if work_dir is None:
|
if work_dir is None:
|
||||||
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
|
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
|
||||||
attachment_count = _attach_files(
|
attachment_count, archive_evidence = _attach_files(
|
||||||
message=message,
|
message=message,
|
||||||
config=config,
|
config=config,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
@@ -789,6 +821,7 @@ def _build_mime_message(
|
|||||||
build_status=BuildStatus.BUILT,
|
build_status=BuildStatus.BUILT,
|
||||||
validation_status=context.validation_status,
|
validation_status=context.validation_status,
|
||||||
attachment_count=attachment_count,
|
attachment_count=attachment_count,
|
||||||
|
archive_evidence=archive_evidence,
|
||||||
)
|
)
|
||||||
except ZipBuildError as exc:
|
except ZipBuildError as exc:
|
||||||
context.issues.append(
|
context.issues.append(
|
||||||
@@ -893,6 +926,7 @@ def build_entry_message(
|
|||||||
validation_status=mime_result.validation_status,
|
validation_status=mime_result.validation_status,
|
||||||
subject=rendered.subject,
|
subject=rendered.subject,
|
||||||
attachment_count=mime_result.attachment_count,
|
attachment_count=mime_result.attachment_count,
|
||||||
|
archive_evidence=mime_result.archive_evidence,
|
||||||
eml_path=eml_path,
|
eml_path=eml_path,
|
||||||
eml_size=eml_size,
|
eml_size=eml_size,
|
||||||
)
|
)
|
||||||
@@ -1097,6 +1131,7 @@ def _build_residual_file_message(
|
|||||||
validation_status=mime_result.validation_status,
|
validation_status=mime_result.validation_status,
|
||||||
subject=rendered.subject,
|
subject=rendered.subject,
|
||||||
attachment_count=mime_result.attachment_count,
|
attachment_count=mime_result.attachment_count,
|
||||||
|
archive_evidence=mime_result.archive_evidence,
|
||||||
eml_path=eml_path,
|
eml_path=eml_path,
|
||||||
eml_size=eml_size,
|
eml_size=eml_size,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ class MessageDraft(BaseModel):
|
|||||||
|
|
||||||
attachment_count: int = 0
|
attachment_count: int = 0
|
||||||
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
||||||
|
archive_evidence: list[dict[str, object]] = Field(default_factory=list)
|
||||||
issues: list[MessageIssue] = Field(default_factory=list)
|
issues: list[MessageIssue] = Field(default_factory=list)
|
||||||
|
|
||||||
eml_path: str | None = None
|
eml_path: str | None = None
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
"""add governed autonomous Campaign schedule evidence
|
||||||
|
|
||||||
|
revision = "b6c7d8e9f0a1"
|
||||||
|
down_revision = "a5b6c7d8e9f0"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b6c7d8e9f0a1"
|
||||||
|
down_revision = "a5b6c7d8e9f0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedules",
|
||||||
|
sa.Column("delivery_mode", sa.String(length=20), nullable=False, server_default="manual"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_schedules_delivery_mode",
|
||||||
|
"campaign_schedules",
|
||||||
|
["delivery_mode"],
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedules",
|
||||||
|
sa.Column("approved_execution_snapshot_hash", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_schedules_approved_execution_snapshot_hash",
|
||||||
|
"campaign_schedules",
|
||||||
|
["approved_execution_snapshot_hash"],
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedules",
|
||||||
|
sa.Column("last_outcome", sa.String(length=30), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedules",
|
||||||
|
sa.Column("last_recovery_state", sa.String(length=30), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("idempotency_key", sa.String(length=200), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_schedule_occurrences_idempotency_key",
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
["idempotency_key"],
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("delivery_command_ids", sa.JSON(), nullable=False, server_default="[]"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("recovery_state", sa.String(length=30), nullable=False, server_default="none"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_schedule_occurrences_recovery_state",
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
["recovery_state"],
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False, server_default="{}"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "last_checked_at")
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "evidence")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_campaign_schedule_occurrences_recovery_state",
|
||||||
|
table_name="campaign_schedule_occurrences",
|
||||||
|
)
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "recovery_state")
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "delivery_command_ids")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_campaign_schedule_occurrences_idempotency_key",
|
||||||
|
table_name="campaign_schedule_occurrences",
|
||||||
|
)
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "idempotency_key")
|
||||||
|
op.drop_column("campaign_schedules", "last_recovery_state")
|
||||||
|
op.drop_column("campaign_schedules", "last_outcome")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_campaign_schedules_approved_execution_snapshot_hash",
|
||||||
|
table_name="campaign_schedules",
|
||||||
|
)
|
||||||
|
op.drop_column("campaign_schedules", "approved_execution_snapshot_hash")
|
||||||
|
op.drop_index("ix_campaign_schedules_delivery_mode", table_name="campaign_schedules")
|
||||||
|
op.drop_column("campaign_schedules", "delivery_mode")
|
||||||
@@ -40,6 +40,10 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
JobSendStatus,
|
JobSendStatus,
|
||||||
JobValidationStatus,
|
JobValidationStatus,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
CampaignArchiveEncryptionError,
|
||||||
|
assert_archive_encryption_allowed,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.campaign.loader import (
|
from govoplan_campaign.backend.campaign.loader import (
|
||||||
load_campaign_json,
|
load_campaign_json,
|
||||||
validate_against_schema,
|
validate_against_schema,
|
||||||
@@ -657,6 +661,15 @@ def validate_campaign_version(
|
|||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"Campaign version is not accessible for this tenant"
|
"Campaign version is not accessible for this tenant"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
archive_policy = assert_archive_encryption_allowed(
|
||||||
|
session,
|
||||||
|
campaign,
|
||||||
|
version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
except CampaignArchiveEncryptionError as exc:
|
||||||
|
raise CampaignPersistenceError(str(exc)) from exc
|
||||||
_ensure_current_campaign_version(campaign, version, action="validate")
|
_ensure_current_campaign_version(campaign, version, action="validate")
|
||||||
if _version_is_user_locked(version) or version.workflow_state in {
|
if _version_is_user_locked(version) or version.workflow_state in {
|
||||||
CampaignVersionWorkflowState.QUEUED.value,
|
CampaignVersionWorkflowState.QUEUED.value,
|
||||||
@@ -734,6 +747,7 @@ def validate_campaign_version(
|
|||||||
"warning_count": report.warning_count,
|
"warning_count": report.warning_count,
|
||||||
"validated_at": datetime.now(UTC).isoformat(),
|
"validated_at": datetime.now(UTC).isoformat(),
|
||||||
"validated_by_user_id": user_id,
|
"validated_by_user_id": user_id,
|
||||||
|
"archive_encryption_policy": archive_policy.to_dict(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
version.validation_summary = report_json
|
version.validation_summary = report_json
|
||||||
@@ -1430,6 +1444,11 @@ def _store_execution_snapshot(
|
|||||||
delivery=config.delivery,
|
delivery=config.delivery,
|
||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
build_summary=build_summary,
|
build_summary=build_summary,
|
||||||
|
archive_encryption=(
|
||||||
|
build_summary.get("archive_encryption")
|
||||||
|
if isinstance(build_summary.get("archive_encryption"), dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
version.execution_snapshot = snapshot
|
version.execution_snapshot = snapshot
|
||||||
version.execution_snapshot_hash = snapshot_hash
|
version.execution_snapshot_hash = snapshot_hash
|
||||||
@@ -1615,6 +1634,15 @@ def build_campaign_version(
|
|||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"Campaign version is not accessible for this tenant"
|
"Campaign version is not accessible for this tenant"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
archive_policy = assert_archive_encryption_allowed(
|
||||||
|
session,
|
||||||
|
campaign,
|
||||||
|
version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
except CampaignArchiveEncryptionError as exc:
|
||||||
|
raise CampaignPersistenceError(str(exc)) from exc
|
||||||
_ensure_current_campaign_version(campaign, version, action="build")
|
_ensure_current_campaign_version(campaign, version, action="build")
|
||||||
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
||||||
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
||||||
@@ -1749,6 +1777,25 @@ def build_campaign_version(
|
|||||||
)
|
)
|
||||||
report_json = _campaign_build_report(result, files)
|
report_json = _campaign_build_report(result, files)
|
||||||
report_json["built_by_user_id"] = user_id
|
report_json["built_by_user_id"] = user_id
|
||||||
|
archive_records = [
|
||||||
|
{
|
||||||
|
**archive,
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"campaign_version_id": version.id,
|
||||||
|
"build_token": report_json["build_token"],
|
||||||
|
"built_at": report_json["built_at"],
|
||||||
|
"policy_hash": archive_policy.policy_hash,
|
||||||
|
"policy_source_path": [
|
||||||
|
dict(item) for item in archive_policy.source_path
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for message in result.report.messages
|
||||||
|
for archive in message.archive_evidence
|
||||||
|
]
|
||||||
|
report_json["archive_encryption"] = {
|
||||||
|
"policy": archive_policy.to_dict(),
|
||||||
|
"archives": archive_records,
|
||||||
|
}
|
||||||
if resolved_print_outputs_by_index:
|
if resolved_print_outputs_by_index:
|
||||||
first_output = next(iter(resolved_print_outputs_by_index.values()))
|
first_output = next(iter(resolved_print_outputs_by_index.values()))
|
||||||
report_json["print_output"] = {
|
report_json["print_output"] = {
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ _SEND_NOW_RESULT_KEYS = (
|
|||||||
"failed_count",
|
"failed_count",
|
||||||
"outcome_unknown_count",
|
"outcome_unknown_count",
|
||||||
"skipped_count",
|
"skipped_count",
|
||||||
|
"paused_count",
|
||||||
"preflight_count",
|
"preflight_count",
|
||||||
|
"batch_state",
|
||||||
|
"batch_pause_reason_code",
|
||||||
|
"smtp_connection_count",
|
||||||
|
"smtp_reconnect_count",
|
||||||
"delivery_mode",
|
"delivery_mode",
|
||||||
"dry_run",
|
"dry_run",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ from govoplan_core.core.object_storage import (
|
|||||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_core.settings import settings as core_settings
|
from govoplan_core.settings import settings as core_settings
|
||||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
CampaignJob,
|
||||||
|
CampaignSchedule,
|
||||||
|
CampaignVersion,
|
||||||
|
JobImapStatus,
|
||||||
|
JobQueueStatus,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.runtime import get_settings
|
from govoplan_campaign.backend.runtime import get_settings
|
||||||
|
|
||||||
FINAL_VERSION_STATES = {
|
FINAL_VERSION_STATES = {
|
||||||
@@ -351,6 +357,18 @@ def _apply_eml_retention(
|
|||||||
"delete_failed": 0,
|
"delete_failed": 0,
|
||||||
"recovery_blocked": 0,
|
"recovery_blocked": 0,
|
||||||
"skipped_not_final": 0,
|
"skipped_not_final": 0,
|
||||||
|
"skipped_schedule_source": 0,
|
||||||
|
}
|
||||||
|
protected_source_versions = {
|
||||||
|
str(version_id)
|
||||||
|
for (version_id,) in (
|
||||||
|
session.query(CampaignSchedule.source_version_id)
|
||||||
|
.filter(
|
||||||
|
CampaignSchedule.delivery_mode == "autonomous",
|
||||||
|
CampaignSchedule.next_fire_at.is_not(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
jobs = (
|
jobs = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
@@ -359,6 +377,9 @@ def _apply_eml_retention(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
|
if getattr(job, "campaign_version_id", None) in protected_source_versions:
|
||||||
|
result["skipped_schedule_source"] += 1
|
||||||
|
continue
|
||||||
policy = policy_for_campaign_id(job.campaign_id)
|
policy = policy_for_campaign_id(job.campaign_id)
|
||||||
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
|
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
|
||||||
if not _is_before_cutoff(job.updated_at, cutoff):
|
if not _is_before_cutoff(job.updated_at, cutoff):
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
|||||||
CAMPAIGN_MAIL_SERVER_KEYS,
|
CAMPAIGN_MAIL_SERVER_KEYS,
|
||||||
campaign_mail_profile_id,
|
campaign_mail_profile_id,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
CampaignArchiveEncryptionError,
|
||||||
|
stamp_legacy_zipcrypto_acknowledgements,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
CampaignIssue,
|
CampaignIssue,
|
||||||
@@ -388,7 +392,9 @@ def _update_campaign_version_detail_response(
|
|||||||
autosave: bool,
|
autosave: bool,
|
||||||
audit_action: str,
|
audit_action: str,
|
||||||
) -> CampaignVersionDetailResponse:
|
) -> CampaignVersionDetailResponse:
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
campaign = _get_campaign_for_principal(
|
||||||
|
session, campaign_id, principal, write=True
|
||||||
|
)
|
||||||
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||||
if payload.base_revision is None:
|
if payload.base_revision is None:
|
||||||
error = MissingPreconditionError(
|
error = MissingPreconditionError(
|
||||||
@@ -421,9 +427,40 @@ def _update_campaign_version_detail_response(
|
|||||||
) from exc
|
) from exc
|
||||||
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
||||||
_require_permission(principal, "campaigns:recipient:write")
|
_require_permission(principal, "campaigns:recipient:write")
|
||||||
|
acknowledgements: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
payload.campaign_json, acknowledgements = (
|
||||||
|
stamp_legacy_zipcrypto_acknowledgements(
|
||||||
|
session,
|
||||||
|
campaign,
|
||||||
|
current_version.raw_json
|
||||||
|
if isinstance(current_version.raw_json, dict)
|
||||||
|
else {},
|
||||||
|
payload.campaign_json,
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except CampaignArchiveEncryptionError as exc:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.archive_encryption_denied",
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version_id,
|
||||||
|
details={"campaign_id": campaign_id, "reason": str(exc)},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=(
|
||||||
|
status.HTTP_403_FORBIDDEN
|
||||||
|
if "Missing scope:" in str(exc)
|
||||||
|
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||||
|
),
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||||
try:
|
try:
|
||||||
return _campaign_version_detail_response(
|
result = _campaign_version_detail_response(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
campaign_id,
|
campaign_id,
|
||||||
@@ -462,9 +499,23 @@ def _update_campaign_version_detail_response(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||||
|
"legacy_zipcrypto_acknowledgements": acknowledgements,
|
||||||
},
|
},
|
||||||
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
)
|
)
|
||||||
|
for acknowledgement in acknowledgements:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.legacy_zipcrypto_acknowledged",
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version_id,
|
||||||
|
details={"campaign_id": campaign_id, **acknowledgement},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
if acknowledgements:
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
except RevisionConflictError as exc:
|
except RevisionConflictError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
|
|||||||
@@ -130,9 +130,22 @@ from govoplan_campaign.backend.route_support import (
|
|||||||
_write_current_version_snapshot_if_available,
|
_write_current_version_snapshot_if_available,
|
||||||
bounded_query_rows as _bounded_query_rows,
|
bounded_query_rows as _bounded_query_rows,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
effective_archive_encryption_policy,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{campaign_id}/archive-encryption-policy")
|
||||||
|
def campaign_archive_encryption_policy(
|
||||||
|
campaign_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||||
|
):
|
||||||
|
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||||
|
return effective_archive_encryption_policy(session, campaign).to_dict()
|
||||||
|
|
||||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
from govoplan_campaign.backend.campaign.scheduling import (
|
from govoplan_campaign.backend.campaign.scheduling import (
|
||||||
campaign_schedule_source_snapshot,
|
campaign_schedule_source_snapshot,
|
||||||
canonical_configuration_hash,
|
canonical_configuration_hash,
|
||||||
|
validate_autonomous_schedule_source,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
CampaignSchedule,
|
CampaignSchedule,
|
||||||
@@ -80,6 +81,10 @@ def create_campaign_schedule(
|
|||||||
_require_permission(principal, "campaigns:recipient:read")
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
if payload.include_shares:
|
if payload.include_shares:
|
||||||
_require_permission(principal, "campaigns:campaign:share")
|
_require_permission(principal, "campaigns:campaign:share")
|
||||||
|
if payload.delivery_mode == "autonomous":
|
||||||
|
_require_permission(principal, "campaigns:campaign:queue")
|
||||||
|
_require_permission(principal, "campaigns:campaign:send")
|
||||||
|
_require_permission(principal, "mail:profile:use")
|
||||||
source_version = (
|
source_version = (
|
||||||
session.query(CampaignVersion)
|
session.query(CampaignVersion)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -129,12 +134,27 @@ def create_campaign_schedule(
|
|||||||
for item in source_shares
|
for item in source_shares
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
autonomous_evidence: dict[str, object] | None = None
|
||||||
|
if payload.delivery_mode == "autonomous":
|
||||||
|
try:
|
||||||
|
autonomous_evidence = validate_autonomous_schedule_source(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
version=source_version,
|
||||||
|
)
|
||||||
|
except (RuntimeError, ValueError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
snapshot["autonomous_delivery"] = autonomous_evidence
|
||||||
schedule = CampaignSchedule(
|
schedule = CampaignSchedule(
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
source_version_id=source_version.id,
|
source_version_id=source_version.id,
|
||||||
created_by_user_id=principal.user.id,
|
created_by_user_id=principal.user.id,
|
||||||
name=payload.name.strip(),
|
name=payload.name.strip(),
|
||||||
|
delivery_mode=payload.delivery_mode,
|
||||||
recurrence_kind=payload.recurrence_kind,
|
recurrence_kind=payload.recurrence_kind,
|
||||||
interval_count=payload.interval_count,
|
interval_count=payload.interval_count,
|
||||||
timezone=payload.timezone,
|
timezone=payload.timezone,
|
||||||
@@ -151,6 +171,11 @@ def create_campaign_schedule(
|
|||||||
},
|
},
|
||||||
source_snapshot=snapshot,
|
source_snapshot=snapshot,
|
||||||
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
||||||
|
approved_execution_snapshot_hash=(
|
||||||
|
str(autonomous_evidence["execution_snapshot_hash"])
|
||||||
|
if autonomous_evidence is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
source_base_path=source_version.source_base_path,
|
source_base_path=source_version.source_base_path,
|
||||||
)
|
)
|
||||||
session.add(schedule)
|
session.add(schedule)
|
||||||
@@ -169,7 +194,19 @@ def create_campaign_schedule(
|
|||||||
"starts_at": schedule.starts_at.isoformat(),
|
"starts_at": schedule.starts_at.isoformat(),
|
||||||
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
|
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
|
||||||
"max_occurrences": schedule.max_occurrences,
|
"max_occurrences": schedule.max_occurrences,
|
||||||
|
"delivery_mode": schedule.delivery_mode,
|
||||||
"delivery_started": False,
|
"delivery_started": False,
|
||||||
|
"autonomous_delivery_opted_in": (
|
||||||
|
schedule.delivery_mode == "autonomous"
|
||||||
|
),
|
||||||
|
"approved_execution_snapshot_hash": (
|
||||||
|
schedule.approved_execution_snapshot_hash
|
||||||
|
),
|
||||||
|
"approval_request_id": (
|
||||||
|
autonomous_evidence.get("approval_request_id")
|
||||||
|
if autonomous_evidence is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
@@ -206,6 +243,23 @@ def set_campaign_schedule_state(
|
|||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail="A completed campaign schedule cannot be resumed.",
|
detail="A completed campaign schedule cannot be resumed.",
|
||||||
)
|
)
|
||||||
|
if payload.active:
|
||||||
|
unresolved = (
|
||||||
|
session.query(CampaignScheduleOccurrence.id)
|
||||||
|
.filter(
|
||||||
|
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||||
|
CampaignScheduleOccurrence.status == "uncertain",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if unresolved is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"Reconcile the autonomous delivery outcome in Mail before "
|
||||||
|
"resuming this schedule."
|
||||||
|
),
|
||||||
|
)
|
||||||
schedule.active = payload.active
|
schedule.active = payload.active
|
||||||
schedule.last_error = None if payload.active else schedule.last_error
|
schedule.last_error = None if payload.active else schedule.last_error
|
||||||
schedule.resource_revision += 1
|
schedule.resource_revision += 1
|
||||||
|
|||||||
@@ -770,6 +770,14 @@ def validate_version(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
|
if _is_archive_encryption_denial(exc):
|
||||||
|
_audit_archive_encryption_denial(
|
||||||
|
session, principal, version_id=version_id, error=exc
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||||
) from exc
|
) from exc
|
||||||
@@ -883,6 +891,9 @@ def build_version(
|
|||||||
"attachment_reuse": _attachment_reuse_audit_evidence(
|
"attachment_reuse": _attachment_reuse_audit_evidence(
|
||||||
result.get("attachment_reuse")
|
result.get("attachment_reuse")
|
||||||
),
|
),
|
||||||
|
"archive_encryption": _archive_encryption_audit_evidence(
|
||||||
|
result.get("archive_encryption")
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
@@ -891,6 +902,18 @@ def build_version(
|
|||||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||||
)
|
)
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
|
if _is_archive_encryption_denial(exc):
|
||||||
|
_audit_archive_encryption_denial(
|
||||||
|
session, principal, version_id=version_id, error=exc
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=(
|
||||||
|
status.HTTP_403_FORBIDDEN
|
||||||
|
if "Missing scope:" in str(exc)
|
||||||
|
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||||
|
),
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||||
) from exc
|
) from exc
|
||||||
@@ -915,6 +938,38 @@ def build_version(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _is_archive_encryption_denial(error: Exception) -> bool:
|
||||||
|
message = str(error).casefold()
|
||||||
|
return any(
|
||||||
|
marker in message
|
||||||
|
for marker in (
|
||||||
|
"archive-encryption",
|
||||||
|
"archive encryption",
|
||||||
|
"zipcrypto",
|
||||||
|
"password-delivery channel",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_archive_encryption_denial(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
version_id: str,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
session.rollback()
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.archive_encryption_denied",
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version_id,
|
||||||
|
details={"reason": str(error)},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _residual_file_audit_evidence(value: object) -> dict[str, object]:
|
def _residual_file_audit_evidence(value: object) -> dict[str, object]:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return {}
|
return {}
|
||||||
@@ -945,6 +1000,25 @@ def _attachment_reuse_audit_evidence(value: object) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_encryption_audit_evidence(value: object) -> dict[str, object]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
policy = value.get("policy")
|
||||||
|
archives = [
|
||||||
|
item for item in (value.get("archives") or []) if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"policy_hash": policy.get("policy_hash")
|
||||||
|
if isinstance(policy, dict)
|
||||||
|
else None,
|
||||||
|
"archive_count": len(archives),
|
||||||
|
"legacy_zipcrypto_count": sum(
|
||||||
|
1 for item in archives if item.get("method") == "zip_standard"
|
||||||
|
),
|
||||||
|
"archive_sha256": [item.get("archive_sha256") for item in archives],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _review_decision_audit_evidence(
|
def _review_decision_audit_evidence(
|
||||||
version: CampaignVersion,
|
version: CampaignVersion,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
|||||||
@@ -1625,6 +1625,44 @@
|
|||||||
],
|
],
|
||||||
"default": "aes"
|
"default": "aes"
|
||||||
},
|
},
|
||||||
|
"password_delivery_channel": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"separate_mail",
|
||||||
|
"sms",
|
||||||
|
"letter",
|
||||||
|
"phone",
|
||||||
|
"in_person"
|
||||||
|
],
|
||||||
|
"default": "separate_mail"
|
||||||
|
},
|
||||||
|
"legacy_zipcrypto_acknowledged": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"legacy_zipcrypto_reason": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"maxLength": 1000
|
||||||
|
},
|
||||||
|
"legacy_zipcrypto_acknowledged_by": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"maxLength": 255,
|
||||||
|
"readOnly": true
|
||||||
|
},
|
||||||
|
"legacy_zipcrypto_acknowledged_at": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"maxLength": 80,
|
||||||
|
"readOnly": true
|
||||||
|
},
|
||||||
"password_mode": {
|
"password_mode": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class CampaignScheduleCreateRequest(BaseModel):
|
|||||||
|
|
||||||
source_version_id: str = Field(min_length=1, max_length=36)
|
source_version_id: str = Field(min_length=1, max_length=36)
|
||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
delivery_mode: Literal["manual", "autonomous"] = "manual"
|
||||||
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
|
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
|
||||||
interval_count: int = Field(default=1, ge=1, le=365)
|
interval_count: int = Field(default=1, ge=1, le=365)
|
||||||
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
||||||
@@ -107,9 +108,14 @@ class CampaignScheduleOccurrenceResponse(BaseModel):
|
|||||||
schedule_id: str
|
schedule_id: str
|
||||||
scheduled_for: datetime
|
scheduled_for: datetime
|
||||||
status: str
|
status: str
|
||||||
|
idempotency_key: str | None = None
|
||||||
generated_campaign_id: str | None = None
|
generated_campaign_id: str | None = None
|
||||||
generated_version_id: str | None = None
|
generated_version_id: str | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
delivery_command_ids: list[str] = Field(default_factory=list)
|
||||||
|
recovery_state: str = "none"
|
||||||
|
evidence: dict[str, object] = Field(default_factory=dict)
|
||||||
|
last_checked_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +126,7 @@ class CampaignScheduleResponse(BaseModel):
|
|||||||
campaign_id: str
|
campaign_id: str
|
||||||
source_version_id: str
|
source_version_id: str
|
||||||
name: str
|
name: str
|
||||||
|
delivery_mode: str
|
||||||
recurrence_kind: str
|
recurrence_kind: str
|
||||||
interval_count: int
|
interval_count: int
|
||||||
timezone: str
|
timezone: str
|
||||||
@@ -133,6 +140,8 @@ class CampaignScheduleResponse(BaseModel):
|
|||||||
last_fired_at: datetime | None = None
|
last_fired_at: datetime | None = None
|
||||||
last_campaign_id: str | None = None
|
last_campaign_id: str | None = None
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
|
last_outcome: str | None = None
|
||||||
|
last_recovery_state: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
|
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ 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.archive_encryption import (
|
||||||
|
CampaignArchiveEncryptionError,
|
||||||
|
assert_archive_encryption_allowed,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.campaign.models import (
|
from govoplan_campaign.backend.campaign.models import (
|
||||||
DeliveryChannelPolicy,
|
DeliveryChannelPolicy,
|
||||||
DeliveryConfig,
|
DeliveryConfig,
|
||||||
@@ -22,8 +26,8 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
|||||||
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 = "8"
|
SNAPSHOT_VERSION = "9"
|
||||||
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION}
|
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", "8", SNAPSHOT_VERSION}
|
||||||
|
|
||||||
|
|
||||||
class ExecutionSnapshotError(RuntimeError):
|
class ExecutionSnapshotError(RuntimeError):
|
||||||
@@ -57,6 +61,7 @@ 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
|
||||||
|
archive_encryption: dict[str, Any] | None = None
|
||||||
smtp_transport_revision: str | None = None
|
smtp_transport_revision: str | None = None
|
||||||
imap_transport_revision: str | None = None
|
imap_transport_revision: str | None = None
|
||||||
uses_mail: bool = True
|
uses_mail: bool = True
|
||||||
@@ -263,6 +268,7 @@ def create_execution_snapshot(
|
|||||||
imap_credential_id: str | None = None,
|
imap_credential_id: str | None = None,
|
||||||
jobs: Iterable[CampaignJob] = (),
|
jobs: Iterable[CampaignJob] = (),
|
||||||
build_summary: dict[str, Any] | None = None,
|
build_summary: dict[str, Any] | None = None,
|
||||||
|
archive_encryption: 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)
|
||||||
@@ -311,6 +317,7 @@ def create_execution_snapshot(
|
|||||||
delivery,
|
delivery,
|
||||||
snapshot_version=SNAPSHOT_VERSION,
|
snapshot_version=SNAPSHOT_VERSION,
|
||||||
),
|
),
|
||||||
|
archive_encryption=archive_encryption,
|
||||||
smtp_transport_revision=smtp_transport_revision,
|
smtp_transport_revision=smtp_transport_revision,
|
||||||
imap_transport_revision=imap_transport_revision,
|
imap_transport_revision=imap_transport_revision,
|
||||||
uses_mail=uses_mail,
|
uses_mail=uses_mail,
|
||||||
@@ -355,6 +362,39 @@ def _assert_snapshot_matches_persisted_inputs(
|
|||||||
"Revalidate and rebuild the campaign before delivery."
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
|
if campaign is None:
|
||||||
|
raise ExecutionSnapshotError("Execution snapshot Campaign no longer exists")
|
||||||
|
try:
|
||||||
|
current_archive_policy = assert_archive_encryption_allowed(
|
||||||
|
session,
|
||||||
|
campaign,
|
||||||
|
raw_json,
|
||||||
|
)
|
||||||
|
except CampaignArchiveEncryptionError as exc:
|
||||||
|
raise ExecutionSnapshotError(str(exc)) from exc
|
||||||
|
archive_snapshot = snapshot.archive_encryption
|
||||||
|
configured_archives = (
|
||||||
|
((raw_json.get("attachments") or {}).get("zip") or {}).get("archives")
|
||||||
|
if isinstance(raw_json.get("attachments"), dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if configured_archives and not isinstance(archive_snapshot, dict):
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"Execution snapshot has no governed archive-encryption evidence; rebuild before delivery."
|
||||||
|
)
|
||||||
|
if isinstance(archive_snapshot, dict):
|
||||||
|
frozen_policy = archive_snapshot.get("policy")
|
||||||
|
frozen_hash = (
|
||||||
|
frozen_policy.get("policy_hash")
|
||||||
|
if isinstance(frozen_policy, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if frozen_hash != current_archive_policy.policy_hash:
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"The effective archive-encryption policy changed after build. Revalidate and rebuild before delivery."
|
||||||
|
)
|
||||||
|
|
||||||
if effect_job is not None:
|
if effect_job is not None:
|
||||||
if effect_job.campaign_version_id != version.id:
|
if effect_job.campaign_version_id != version.id:
|
||||||
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
|
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
|
||||||
@@ -494,6 +534,12 @@ def ensure_execution_snapshot(
|
|||||||
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 {},
|
||||||
|
archive_encryption=(
|
||||||
|
version.build_summary.get("archive_encryption")
|
||||||
|
if isinstance(version.build_summary, dict)
|
||||||
|
and isinstance(version.build_summary.get("archive_encryption"), dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
version.execution_snapshot = payload
|
version.execution_snapshot = payload
|
||||||
version.execution_snapshot_hash = digest
|
version.execution_snapshot_hash = digest
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from contextlib import nullcontext
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email import policy
|
from email import policy
|
||||||
@@ -225,6 +226,11 @@ class SendCampaignNowResult:
|
|||||||
failed_count: int
|
failed_count: int
|
||||||
outcome_unknown_count: int
|
outcome_unknown_count: int
|
||||||
skipped_count: int
|
skipped_count: int
|
||||||
|
paused_count: int = 0
|
||||||
|
batch_state: str = "not_started"
|
||||||
|
batch_pause_reason_code: str | None = None
|
||||||
|
smtp_connection_count: int = 0
|
||||||
|
smtp_reconnect_count: int = 0
|
||||||
preflight_count: int = 0
|
preflight_count: int = 0
|
||||||
synchronous_send_policy: dict[str, Any] | None = None
|
synchronous_send_policy: dict[str, Any] | None = None
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
@@ -239,7 +245,12 @@ class SendCampaignNowResult:
|
|||||||
"failed_count": self.failed_count,
|
"failed_count": self.failed_count,
|
||||||
"outcome_unknown_count": self.outcome_unknown_count,
|
"outcome_unknown_count": self.outcome_unknown_count,
|
||||||
"skipped_count": self.skipped_count,
|
"skipped_count": self.skipped_count,
|
||||||
|
"paused_count": self.paused_count,
|
||||||
"preflight_count": self.preflight_count,
|
"preflight_count": self.preflight_count,
|
||||||
|
"batch_state": self.batch_state,
|
||||||
|
"batch_pause_reason_code": self.batch_pause_reason_code,
|
||||||
|
"smtp_connection_count": self.smtp_connection_count,
|
||||||
|
"smtp_reconnect_count": self.smtp_reconnect_count,
|
||||||
"delivery_mode": "synchronous",
|
"delivery_mode": "synchronous",
|
||||||
"synchronous_send_policy": self.synchronous_send_policy or {},
|
"synchronous_send_policy": self.synchronous_send_policy or {},
|
||||||
"dry_run": self.dry_run,
|
"dry_run": self.dry_run,
|
||||||
@@ -1070,48 +1081,91 @@ def send_campaign_now(
|
|||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
policy=synchronous_policy,
|
policy=synchronous_policy,
|
||||||
)
|
)
|
||||||
# Queue state and its inbox notification become durable only after every
|
|
||||||
# message and the selected transport revision have passed preflight. This
|
|
||||||
# preserves late-ack recovery without leaving rejected work eligible for a
|
|
||||||
# background worker.
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
outcome_unknown_count = 0
|
outcome_unknown_count = 0
|
||||||
skipped_after_queue = 0
|
skipped_after_queue = 0
|
||||||
for job in jobs:
|
attempted_count = 0
|
||||||
try:
|
paused_count = 0
|
||||||
result = _deliver_job_with_recovery(
|
pause_reason_code: str | None = None
|
||||||
session,
|
batch_state = "ready"
|
||||||
job=job,
|
batch_manager = _synchronous_smtp_batch_manager(
|
||||||
context=delivery_contexts[job.id],
|
session,
|
||||||
use_rate_limit=use_rate_limit,
|
jobs=jobs,
|
||||||
enqueue_imap_task=enqueue_imap_task,
|
contexts=delivery_contexts,
|
||||||
)
|
)
|
||||||
result_dict = result.as_dict()
|
try:
|
||||||
results.append(result_dict)
|
with batch_manager as smtp_batch:
|
||||||
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
|
# Queue state becomes durable only after local and SMTP
|
||||||
sent_count += 1
|
# DNS/connectivity/TLS/auth preflight succeeds.
|
||||||
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
session.commit()
|
||||||
outcome_unknown_count += 1
|
for index, job in enumerate(jobs):
|
||||||
else:
|
attempted_count += 1
|
||||||
skipped_after_queue += 1
|
try:
|
||||||
except Exception as exc: # keep sending other jobs and return per-job details
|
result = _deliver_job_with_recovery(
|
||||||
failed_count += 1
|
session,
|
||||||
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
|
job=job,
|
||||||
|
context=delivery_contexts[job.id],
|
||||||
|
use_rate_limit=use_rate_limit,
|
||||||
|
enqueue_imap_task=enqueue_imap_task,
|
||||||
|
)
|
||||||
|
result_dict = result.as_dict()
|
||||||
|
results.append(result_dict)
|
||||||
|
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
|
||||||
|
sent_count += 1
|
||||||
|
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
||||||
|
outcome_unknown_count += 1
|
||||||
|
else:
|
||||||
|
skipped_after_queue += 1
|
||||||
|
except Exception as exc:
|
||||||
|
failed_count += 1
|
||||||
|
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
|
||||||
|
if isinstance(exc, SmtpSendError) and exc.systemic:
|
||||||
|
pause_reason_code = exc.reason_code or "smtp_systemic_failure"
|
||||||
|
paused_count = _pause_jobs_after_systemic_smtp_failure(
|
||||||
|
session,
|
||||||
|
campaign_id=job.campaign_id,
|
||||||
|
exclude_job_id=job.id,
|
||||||
|
reason_code=pause_reason_code,
|
||||||
|
)
|
||||||
|
batch_state = "paused"
|
||||||
|
for remaining in jobs[index + 1 :]:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"job_id": remaining.id,
|
||||||
|
"status": "paused",
|
||||||
|
"message": "Batch paused after a systemic SMTP failure.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
break
|
||||||
|
smtp_connection_count = int(getattr(smtp_batch, "connection_count", 0) or 0)
|
||||||
|
smtp_reconnect_count = int(getattr(smtp_batch, "reconnect_count", 0) or 0)
|
||||||
|
except (MailProfileError, SmtpConfigurationError, SmtpSendError, OSError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
reason_code = str(getattr(exc, "reason_code", "") or "smtp_batch_preflight_failed")
|
||||||
|
raise SynchronousSendRejected(
|
||||||
|
"SMTP batch preflight could not validate DNS, connectivity, TLS, and authentication; no message was sent.",
|
||||||
|
reason=reason_code,
|
||||||
|
eligible_count=len(jobs),
|
||||||
|
policy=synchronous_policy,
|
||||||
|
) from exc
|
||||||
|
|
||||||
return SendCampaignNowResult(
|
return SendCampaignNowResult(
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
version_id=version.id,
|
version_id=version.id,
|
||||||
attempted_count=len(jobs),
|
attempted_count=attempted_count,
|
||||||
sent_count=sent_count,
|
sent_count=sent_count,
|
||||||
failed_count=failed_count,
|
failed_count=failed_count,
|
||||||
outcome_unknown_count=outcome_unknown_count,
|
outcome_unknown_count=outcome_unknown_count,
|
||||||
skipped_count=queue_result.skipped_count
|
skipped_count=queue_result.skipped_count
|
||||||
+ queue_result.blocked_count
|
+ queue_result.blocked_count
|
||||||
+ skipped_after_queue,
|
+ skipped_after_queue,
|
||||||
|
paused_count=paused_count,
|
||||||
|
batch_state=batch_state,
|
||||||
|
batch_pause_reason_code=pause_reason_code,
|
||||||
|
smtp_connection_count=smtp_connection_count,
|
||||||
|
smtp_reconnect_count=smtp_reconnect_count,
|
||||||
preflight_count=len(delivery_contexts),
|
preflight_count=len(delivery_contexts),
|
||||||
synchronous_send_policy=synchronous_policy.as_dict(),
|
synchronous_send_policy=synchronous_policy.as_dict(),
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
@@ -1193,6 +1247,100 @@ def _preflight_synchronous_send_batch(
|
|||||||
return contexts
|
return contexts
|
||||||
|
|
||||||
|
|
||||||
|
def _synchronous_smtp_batch_manager(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
jobs: list[CampaignJob],
|
||||||
|
contexts: dict[str, _SendJobDeliveryContext],
|
||||||
|
):
|
||||||
|
mail_items = [
|
||||||
|
(job, contexts[job.id])
|
||||||
|
for job in jobs
|
||||||
|
if DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
|
||||||
|
]
|
||||||
|
if not mail_items:
|
||||||
|
return nullcontext(None)
|
||||||
|
first_job, first_context = mail_items[0]
|
||||||
|
envelope_froms = {str(context.envelope_from or "") for _job, context in mail_items}
|
||||||
|
transport_keys = {
|
||||||
|
(
|
||||||
|
context.snapshot.mail_profile_id,
|
||||||
|
context.snapshot.smtp_transport_revision,
|
||||||
|
context.snapshot.smtp_server_id,
|
||||||
|
context.snapshot.smtp_credential_id,
|
||||||
|
)
|
||||||
|
for _job, context in mail_items
|
||||||
|
}
|
||||||
|
if len(envelope_froms) != 1 or "" in envelope_froms or len(transport_keys) != 1:
|
||||||
|
raise SynchronousSendRejected(
|
||||||
|
"A synchronous SMTP batch requires one frozen sender and transport selection.",
|
||||||
|
reason="smtp_batch_transport_mismatch",
|
||||||
|
eligible_count=len(jobs),
|
||||||
|
)
|
||||||
|
recipients = sorted(
|
||||||
|
{
|
||||||
|
recipient
|
||||||
|
for _job, context in mail_items
|
||||||
|
for recipient in context.envelope_recipients
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return mail_integration().campaign_smtp_batch(
|
||||||
|
session,
|
||||||
|
tenant_id=first_job.tenant_id,
|
||||||
|
campaign_id=first_job.campaign_id,
|
||||||
|
profile_id=first_context.snapshot.mail_profile_id,
|
||||||
|
envelope_from=str(first_context.envelope_from),
|
||||||
|
envelope_recipients=recipients,
|
||||||
|
from_header=_from_header_from_job(first_job),
|
||||||
|
expected_smtp_transport_revision=first_context.snapshot.smtp_transport_revision or "",
|
||||||
|
smtp_server_id=first_context.snapshot.smtp_server_id,
|
||||||
|
smtp_credential_id=first_context.snapshot.smtp_credential_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pause_jobs_after_systemic_smtp_failure(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign_id: str,
|
||||||
|
exclude_job_id: str,
|
||||||
|
reason_code: str,
|
||||||
|
) -> int:
|
||||||
|
reason = f"SMTP batch paused after systemic failure ({reason_code[:80]})."
|
||||||
|
changed = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.campaign_id == campaign_id,
|
||||||
|
CampaignJob.id != exclude_job_id,
|
||||||
|
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
|
||||||
|
CampaignJob.send_status.in_(
|
||||||
|
[JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
CampaignJob.queue_status: JobQueueStatus.PAUSED.value,
|
||||||
|
CampaignJob.last_error: reason,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
campaign = session.get(Campaign, campaign_id)
|
||||||
|
if changed and campaign is not None:
|
||||||
|
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||||
|
session.add(campaign)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=campaign.tenant_id if campaign is not None else None,
|
||||||
|
user_id=None,
|
||||||
|
action="campaign.smtp_batch_paused",
|
||||||
|
object_type="campaign",
|
||||||
|
object_id=campaign_id,
|
||||||
|
details={"reason_code": reason_code[:80], "paused_count": int(changed)},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return int(changed)
|
||||||
|
|
||||||
|
|
||||||
def enqueue_existing_queued_jobs(
|
def enqueue_existing_queued_jobs(
|
||||||
session: Session, *, tenant_id: str, campaign_id: str
|
session: Session, *, tenant_id: str, campaign_id: str
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -3652,6 +3800,13 @@ def _send_claimed_mail_only_job(
|
|||||||
outcome_unknown = _record_smtp_send_error(
|
outcome_unknown = _record_smtp_send_error(
|
||||||
session, job=job, attempt=attempt, exc=exc
|
session, job=job, attempt=attempt, exc=exc
|
||||||
)
|
)
|
||||||
|
if exc.systemic:
|
||||||
|
_pause_jobs_after_systemic_smtp_failure(
|
||||||
|
session,
|
||||||
|
campaign_id=job.campaign_id,
|
||||||
|
exclude_job_id=job.id,
|
||||||
|
reason_code=exc.reason_code or "smtp_systemic_failure",
|
||||||
|
)
|
||||||
if outcome_unknown is not None:
|
if outcome_unknown is not None:
|
||||||
return outcome_unknown
|
return outcome_unknown
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
from importlib import metadata
|
||||||
import secrets
|
import secrets
|
||||||
import stat
|
import stat
|
||||||
import struct
|
import struct
|
||||||
@@ -49,6 +51,8 @@ def create_zip_archive(
|
|||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
members = _normalized_members(files)
|
members = _normalized_members(files)
|
||||||
if password:
|
if password:
|
||||||
|
if method not in {ZIP_METHOD_AES, ZIP_METHOD_STANDARD}:
|
||||||
|
raise ValueError(f"Unsupported password-encryption method: {method}")
|
||||||
if method == ZIP_METHOD_STANDARD:
|
if method == ZIP_METHOD_STANDARD:
|
||||||
_create_zipcrypto_archive(output_path, members, password)
|
_create_zipcrypto_archive(output_path, members, password)
|
||||||
return output_path
|
return output_path
|
||||||
@@ -61,6 +65,51 @@ def create_zip_archive(
|
|||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def zip_archive_evidence(
|
||||||
|
output_path: Path,
|
||||||
|
members: Iterable[Path | ArchiveMember],
|
||||||
|
*,
|
||||||
|
password_protected: bool,
|
||||||
|
method: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return password-free, content-addressed evidence for one built archive."""
|
||||||
|
|
||||||
|
normalized = _normalized_members(members)
|
||||||
|
archive_bytes = output_path.read_bytes()
|
||||||
|
if password_protected and method == ZIP_METHOD_AES:
|
||||||
|
try:
|
||||||
|
implementation_version = metadata.version("pyzipper")
|
||||||
|
except metadata.PackageNotFoundError: # pragma: no cover - guarded by writer
|
||||||
|
implementation_version = "unknown"
|
||||||
|
implementation = "pyzipper"
|
||||||
|
archive_format = "WinZip AES"
|
||||||
|
elif password_protected and method == ZIP_METHOD_STANDARD:
|
||||||
|
implementation = "govoplan-campaign.zipcrypto"
|
||||||
|
implementation_version = "1"
|
||||||
|
archive_format = "Legacy ZipCrypto"
|
||||||
|
else:
|
||||||
|
implementation = "python.zipfile"
|
||||||
|
implementation_version = "stdlib"
|
||||||
|
archive_format = "ZIP (unencrypted)"
|
||||||
|
return {
|
||||||
|
"format": archive_format,
|
||||||
|
"method": method if password_protected else "none",
|
||||||
|
"password_protected": password_protected,
|
||||||
|
"implementation": implementation,
|
||||||
|
"implementation_version": implementation_version,
|
||||||
|
"archive_sha256": hashlib.sha256(archive_bytes).hexdigest(),
|
||||||
|
"archive_size_bytes": len(archive_bytes),
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"name": archive_name,
|
||||||
|
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||||
|
"size_bytes": path.stat().st_size,
|
||||||
|
}
|
||||||
|
for path, archive_name in normalized
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
|
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
|
||||||
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
CampaignArchiveEncryptionError,
|
||||||
|
LEGACY_ZIPCRYPTO_SCOPE,
|
||||||
|
assert_archive_encryption_allowed,
|
||||||
|
effective_archive_encryption_policy,
|
||||||
|
stamp_legacy_zipcrypto_acknowledgements,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.policy import (
|
||||||
|
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
|
||||||
|
CampaignArchiveEncryptionDecision,
|
||||||
|
PolicySourceStep,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _PolicyProvider:
|
||||||
|
def __init__(self, methods: set[str]) -> None:
|
||||||
|
self.methods = methods
|
||||||
|
|
||||||
|
def resolve_campaign_archive_encryption(self, session=None, *, request):
|
||||||
|
del session, request
|
||||||
|
return CampaignArchiveEncryptionDecision(
|
||||||
|
allowed_password_encryption_methods=frozenset(self.methods),
|
||||||
|
allowed_password_delivery_channels=frozenset(
|
||||||
|
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||||
|
),
|
||||||
|
policy_hash="f" * 64,
|
||||||
|
source_path=(
|
||||||
|
PolicySourceStep(
|
||||||
|
scope_type="system",
|
||||||
|
label="System archive-encryption policy",
|
||||||
|
applied_fields=("allowed_password_encryption_methods",),
|
||||||
|
policy={"allowed_password_encryption_methods": sorted(self.methods)},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name == CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION
|
||||||
|
|
||||||
|
def capability(self, name: str):
|
||||||
|
return self.provider if self.has_capability(name) else None
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignArchiveEncryptionGovernanceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.campaign = Campaign(
|
||||||
|
id="campaign-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
external_id="example",
|
||||||
|
name="Example",
|
||||||
|
owner_user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_unavailable_policy_keeps_aes_and_fails_closed_for_legacy(self) -> None:
|
||||||
|
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None):
|
||||||
|
policy = effective_archive_encryption_policy(self.session, self.campaign)
|
||||||
|
self.assertFalse(policy.available)
|
||||||
|
self.assertEqual(frozenset({"aes"}), policy.allowed_password_encryption_methods)
|
||||||
|
assert_archive_encryption_allowed(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
_raw_archive("aes"),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "blocked"):
|
||||||
|
assert_archive_encryption_allowed(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
_raw_archive("zip_standard", stamped=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_existing_password_archive_inherits_separate_mail_channel(self) -> None:
|
||||||
|
raw = _raw_archive("aes")
|
||||||
|
raw["attachments"]["zip"]["archives"][0].pop("password_delivery_channel")
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.archive_encryption.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
decision = assert_archive_encryption_allowed(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"separate_mail",
|
||||||
|
decision.allowed_password_delivery_channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_selection_requires_permission_and_gets_server_stamp(self) -> None:
|
||||||
|
registry = _Registry(_PolicyProvider({"aes", "zip_standard"}))
|
||||||
|
candidate = _raw_archive("zip_standard")
|
||||||
|
candidate["attachments"]["zip"]["archives"][0].update(
|
||||||
|
{
|
||||||
|
"legacy_zipcrypto_acknowledged": True,
|
||||||
|
"legacy_zipcrypto_reason": "Recipient requires built-in Windows extraction",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=registry):
|
||||||
|
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "Missing scope"):
|
||||||
|
stamp_legacy_zipcrypto_acknowledgements(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
{},
|
||||||
|
candidate,
|
||||||
|
principal=_principal(set()),
|
||||||
|
)
|
||||||
|
stamped, evidence = stamp_legacy_zipcrypto_acknowledgements(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
{},
|
||||||
|
candidate,
|
||||||
|
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
||||||
|
)
|
||||||
|
archive = stamped["attachments"]["zip"]["archives"][0]
|
||||||
|
self.assertEqual("user-1", archive["legacy_zipcrypto_acknowledged_by"])
|
||||||
|
self.assertTrue(archive["legacy_zipcrypto_acknowledged_at"])
|
||||||
|
self.assertEqual("f" * 64, evidence[0]["policy_hash"])
|
||||||
|
assert_archive_encryption_allowed(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
stamped,
|
||||||
|
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_archive(method: str, *, stamped: bool = False) -> dict:
|
||||||
|
archive = {
|
||||||
|
"id": "archive-1",
|
||||||
|
"method": method,
|
||||||
|
"password_enabled": True,
|
||||||
|
"password_delivery_channel": "separate_mail",
|
||||||
|
"legacy_zipcrypto_acknowledged": method == "zip_standard",
|
||||||
|
"legacy_zipcrypto_reason": "Windows recipient compatibility required"
|
||||||
|
if method == "zip_standard"
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
if stamped:
|
||||||
|
archive.update(
|
||||||
|
{
|
||||||
|
"legacy_zipcrypto_acknowledged_by": "user-1",
|
||||||
|
"legacy_zipcrypto_acknowledged_at": "2026-08-20T10:00:00+00:00",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"attachments": {"zip": {"enabled": True, "archives": [archive]}}}
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import nullcontext
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from unittest.mock import patch
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from sqlalchemy import Column, String, Table, create_engine
|
from sqlalchemy import Column, String, Table, create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -15,6 +17,7 @@ from govoplan_campaign.backend.campaign.scheduling import (
|
|||||||
)
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
|
CampaignJob,
|
||||||
CampaignSchedule,
|
CampaignSchedule,
|
||||||
CampaignScheduleOccurrence,
|
CampaignScheduleOccurrence,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
@@ -71,6 +74,7 @@ class TestCampaignScheduling:
|
|||||||
groups,
|
groups,
|
||||||
Campaign.__table__,
|
Campaign.__table__,
|
||||||
CampaignVersion.__table__,
|
CampaignVersion.__table__,
|
||||||
|
CampaignJob.__table__,
|
||||||
CampaignShare.__table__,
|
CampaignShare.__table__,
|
||||||
CampaignSchedule.__table__,
|
CampaignSchedule.__table__,
|
||||||
CampaignScheduleOccurrence.__table__,
|
CampaignScheduleOccurrence.__table__,
|
||||||
@@ -238,3 +242,289 @@ class TestCampaignScheduling:
|
|||||||
assert generated.settings == {"retention": "sealed"}
|
assert generated.settings == {"retention": "sealed"}
|
||||||
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
|
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
|
||||||
assert schedule.resource_revision == 2
|
assert schedule.resource_revision == 2
|
||||||
|
|
||||||
|
def test_autonomous_occurrences_allocate_commands_once_and_complete_bound(self):
|
||||||
|
context = SimpleNamespace(
|
||||||
|
snapshot=SimpleNamespace(
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_transport_revision="transport-1",
|
||||||
|
smtp_server_id="smtp-1",
|
||||||
|
smtp_credential_id="credential-1",
|
||||||
|
),
|
||||||
|
message_bytes=b"From: Sender <sender@example.test>\r\nTo: one@example.test\r\n\r\nHello",
|
||||||
|
envelope_from="sender@example.test",
|
||||||
|
envelope_recipients=["one@example.test"],
|
||||||
|
)
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||||
|
)
|
||||||
|
mail = Mock()
|
||||||
|
mail.durable_delivery_available = True
|
||||||
|
mail.delivery_command_summary.return_value = {
|
||||||
|
"id": "command-1",
|
||||||
|
"status": "accepted",
|
||||||
|
"accepted_count": 1,
|
||||||
|
"refused_count": 0,
|
||||||
|
"failure_code": None,
|
||||||
|
}
|
||||||
|
mail.submit_delivery_command.side_effect = [
|
||||||
|
{"id": "command-1", "status": "pending", "duplicate": False},
|
||||||
|
{"id": "command-2", "status": "pending", "duplicate": False},
|
||||||
|
]
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.delivery_mode = "autonomous"
|
||||||
|
schedule.approved_execution_snapshot_hash = "a" * 64
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
validation = {
|
||||||
|
"execution_snapshot_hash": "a" * 64,
|
||||||
|
"approval_request_id": "approval-1",
|
||||||
|
"approval_subject_digest": "b" * 64,
|
||||||
|
"job_count": 1,
|
||||||
|
"job_manifest_sha256": "c" * 64,
|
||||||
|
}
|
||||||
|
patches = (
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.validate_autonomous_schedule_source",
|
||||||
|
return_value=validation,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._autonomous_source_jobs",
|
||||||
|
return_value=[job],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._send_job_delivery_context",
|
||||||
|
return_value=context,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._synchronous_smtp_batch_manager",
|
||||||
|
return_value=nullcontext(None),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||||
|
return_value=mail,
|
||||||
|
),
|
||||||
|
patch("govoplan_campaign.backend.campaign.scheduling.audit_event"),
|
||||||
|
)
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
|
||||||
|
first = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
second = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 8, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert first["autonomous_prepared"] == 1
|
||||||
|
assert second["autonomous_prepared"] == 1
|
||||||
|
assert schedule.active is False
|
||||||
|
occurrences = (
|
||||||
|
session.query(CampaignScheduleOccurrence)
|
||||||
|
.order_by(CampaignScheduleOccurrence.scheduled_for)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert [item.status for item in occurrences] == ["accepted", "prepared"]
|
||||||
|
assert [item.delivery_command_ids for item in occurrences] == [
|
||||||
|
["command-1"],
|
||||||
|
["command-2"],
|
||||||
|
]
|
||||||
|
assert len({item.idempotency_key for item in occurrences}) == 2
|
||||||
|
assert [item.recovery_state for item in occurrences] == [
|
||||||
|
"complete",
|
||||||
|
"pending",
|
||||||
|
]
|
||||||
|
assert occurrences[0].evidence["source_campaign_id"] == "campaign-1"
|
||||||
|
assert occurrences[0].evidence["source_version_id"] == "version-1"
|
||||||
|
assert (
|
||||||
|
occurrences[0].evidence["source_snapshot_hash"]
|
||||||
|
== schedule.source_snapshot_hash
|
||||||
|
)
|
||||||
|
assert mail.submit_delivery_command.call_count == 2
|
||||||
|
|
||||||
|
def test_autonomous_unknown_outcome_pauses_without_resubmission(self):
|
||||||
|
mail = Mock()
|
||||||
|
mail.durable_delivery_available = True
|
||||||
|
mail.delivery_command_summary.return_value = {
|
||||||
|
"id": "command-1",
|
||||||
|
"status": "outcome_unknown",
|
||||||
|
"accepted_count": 0,
|
||||||
|
"refused_count": 0,
|
||||||
|
"failure_code": "smtp_outcome_unknown",
|
||||||
|
}
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.delivery_mode = "autonomous"
|
||||||
|
occurrence = CampaignScheduleOccurrence(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
|
||||||
|
status="prepared",
|
||||||
|
idempotency_key="occurrence-1",
|
||||||
|
delivery_command_ids=["command-1"],
|
||||||
|
recovery_state="pending",
|
||||||
|
)
|
||||||
|
session.add(occurrence)
|
||||||
|
session.commit()
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||||
|
return_value=mail,
|
||||||
|
), patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
|
||||||
|
) as notify:
|
||||||
|
result = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 6, 9, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert result["refreshed"]["uncertain"] == 1
|
||||||
|
assert result["selected"] == 0
|
||||||
|
assert occurrence.status == "uncertain"
|
||||||
|
assert occurrence.recovery_state == "operator_required"
|
||||||
|
assert schedule.active is False
|
||||||
|
assert schedule.last_outcome == "uncertain"
|
||||||
|
notify.assert_called_once()
|
||||||
|
mail.submit_delivery_command.assert_not_called()
|
||||||
|
|
||||||
|
def test_autonomous_pending_occurrence_defers_the_next_delivery(self):
|
||||||
|
mail = Mock()
|
||||||
|
mail.durable_delivery_available = True
|
||||||
|
mail.delivery_command_summary.return_value = {
|
||||||
|
"id": "command-1",
|
||||||
|
"status": "pending",
|
||||||
|
"accepted_count": 0,
|
||||||
|
"refused_count": 0,
|
||||||
|
"failure_code": None,
|
||||||
|
}
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.delivery_mode = "autonomous"
|
||||||
|
session.add(
|
||||||
|
CampaignScheduleOccurrence(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
|
||||||
|
status="prepared",
|
||||||
|
idempotency_key="occurrence-1",
|
||||||
|
delivery_command_ids=["command-1"],
|
||||||
|
recovery_state="pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||||
|
return_value=mail,
|
||||||
|
):
|
||||||
|
result = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["refreshed"]["checked"] == 1
|
||||||
|
assert result["deferred"] == 1
|
||||||
|
assert result["autonomous_prepared"] == 0
|
||||||
|
assert schedule.active is True
|
||||||
|
assert schedule.occurrence_count == 0
|
||||||
|
mail.submit_delivery_command.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_mail_recovery_capability_pauses_an_open_occurrence(self):
|
||||||
|
mail = Mock()
|
||||||
|
mail.durable_delivery_available = False
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.delivery_mode = "autonomous"
|
||||||
|
occurrence = CampaignScheduleOccurrence(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
|
||||||
|
status="prepared",
|
||||||
|
idempotency_key="occurrence-1",
|
||||||
|
delivery_command_ids=["command-1"],
|
||||||
|
recovery_state="pending",
|
||||||
|
)
|
||||||
|
session.add(occurrence)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||||
|
return_value=mail,
|
||||||
|
), patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
|
||||||
|
) as notify:
|
||||||
|
result = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["refreshed"]["uncertain"] == 1
|
||||||
|
assert occurrence.status == "uncertain"
|
||||||
|
assert occurrence.evidence["recovery_reason"] == (
|
||||||
|
"mail_delivery_outbox_unavailable"
|
||||||
|
)
|
||||||
|
assert schedule.active is False
|
||||||
|
notify.assert_called_once()
|
||||||
|
|
||||||
|
def test_autonomous_source_requires_an_explicit_approval(self):
|
||||||
|
with self.SessionLocal() as session, patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.campaign_approval_gate",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
campaign = session.get(Campaign, "campaign-1")
|
||||||
|
version = session.get(CampaignVersion, "version-1")
|
||||||
|
assert campaign is not None and version is not None
|
||||||
|
from govoplan_campaign.backend.campaign.scheduling import (
|
||||||
|
validate_autonomous_schedule_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_autonomous_schedule_source(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert "explicit Approval request" in str(exc)
|
||||||
|
else: # pragma: no cover - defensive assertion
|
||||||
|
raise AssertionError("Autonomous source validation unexpectedly passed")
|
||||||
|
|
||||||
|
def test_duplicate_occurrence_recovers_schedule_without_another_effect(self):
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
recorded = CampaignScheduleOccurrence(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=schedule.next_fire_at,
|
||||||
|
status="prepared",
|
||||||
|
idempotency_key="existing-key",
|
||||||
|
recovery_state="pending",
|
||||||
|
)
|
||||||
|
session.add(recorded)
|
||||||
|
session.commit()
|
||||||
|
with patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
|
||||||
|
result = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
assert result["duplicates"] == 1
|
||||||
|
assert result["failed"] == 0
|
||||||
|
assert schedule.occurrence_count == 1
|
||||||
|
assert schedule.next_fire_at == datetime(2026, 8, 8, 8, tzinfo=UTC)
|
||||||
|
assert session.query(CampaignScheduleOccurrence).count() == 1
|
||||||
|
|||||||
@@ -0,0 +1,705 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
AttachmentBlob,
|
||||||
|
AttachmentInstance,
|
||||||
|
Campaign,
|
||||||
|
CampaignIssue,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignMessageAction,
|
||||||
|
CampaignMessageActionAttempt,
|
||||||
|
CampaignSchedule,
|
||||||
|
CampaignShare,
|
||||||
|
CampaignVersion,
|
||||||
|
ImapAppendAttempt,
|
||||||
|
PostboxDeliveryAttempt,
|
||||||
|
PrintOutputAttempt,
|
||||||
|
RecipientImportMappingProfile,
|
||||||
|
SendAttempt,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.dsar_provider import (
|
||||||
|
CAMPAIGN_DSAR_CAPABILITY,
|
||||||
|
CampaignDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.manifest import manifest
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
DataSubjectRequest,
|
||||||
|
create_data_subject_request,
|
||||||
|
execute_data_subject_erasure,
|
||||||
|
plan_data_subject_erasure,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: CampaignDsarProvider,
|
||||||
|
*,
|
||||||
|
campaign_active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.campaign_active = campaign_active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (CAMPAIGN_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "campaigns"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
campaign_active = self.campaign_active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("campaigns",) if campaign_active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "campaigns"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != CAMPAIGN_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
bind=self.engine,
|
||||||
|
tables=[
|
||||||
|
Account.__table__,
|
||||||
|
User.__table__,
|
||||||
|
Group.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
DataSubjectRequest.__table__,
|
||||||
|
Campaign.__table__,
|
||||||
|
CampaignShare.__table__,
|
||||||
|
CampaignVersion.__table__,
|
||||||
|
CampaignJob.__table__,
|
||||||
|
CampaignIssue.__table__,
|
||||||
|
AttachmentBlob.__table__,
|
||||||
|
AttachmentInstance.__table__,
|
||||||
|
SendAttempt.__table__,
|
||||||
|
CampaignMessageAction.__table__,
|
||||||
|
CampaignMessageActionAttempt.__table__,
|
||||||
|
ImapAppendAttempt.__table__,
|
||||||
|
PostboxDeliveryAttempt.__table__,
|
||||||
|
PrintOutputAttempt.__table__,
|
||||||
|
RecipientImportMappingProfile.__table__,
|
||||||
|
CampaignSchedule.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="subject@example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
other_account = Account(
|
||||||
|
id="account-2",
|
||||||
|
email="other@example.test",
|
||||||
|
normalized_email="other@example.test",
|
||||||
|
display_name="Other",
|
||||||
|
)
|
||||||
|
self.user = User(
|
||||||
|
id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=self.account.id,
|
||||||
|
email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
self.other_user = User(
|
||||||
|
id="membership-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=other_account.id,
|
||||||
|
email="other@example.test",
|
||||||
|
display_name="Other",
|
||||||
|
)
|
||||||
|
self.campaign = Campaign(
|
||||||
|
id="campaign-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
created_by_user_id=self.other_user.id,
|
||||||
|
owner_user_id=self.other_user.id,
|
||||||
|
external_id="privacy-notice",
|
||||||
|
name="Privacy notice",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.version = CampaignVersion(
|
||||||
|
id="version-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
version_number=1,
|
||||||
|
workflow_state="built",
|
||||||
|
execution_snapshot_hash="a" * 64,
|
||||||
|
raw_json={
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": "entry-subject",
|
||||||
|
"to": [
|
||||||
|
{
|
||||||
|
"email": "subject@example.test",
|
||||||
|
"name": "Subject Person",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cc": [
|
||||||
|
{
|
||||||
|
"email": "other@example.test",
|
||||||
|
"name": "Unrelated person",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": "private-rendered-body-do-not-export",
|
||||||
|
"password": "inline-secret-do-not-export",
|
||||||
|
"case_reference": "CASE-SUBJECT-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "entry-other",
|
||||||
|
"to": [{"email": "other@example.test"}],
|
||||||
|
"private_value": "other-recipient-data-do-not-export",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.draft_version = CampaignVersion(
|
||||||
|
id="version-draft",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
version_number=2,
|
||||||
|
workflow_state="editing",
|
||||||
|
raw_json={
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": "entry-draft-subject",
|
||||||
|
"to": [{"email": "subject@example.test"}],
|
||||||
|
"case_reference": "CASE-DRAFT-1",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.job = CampaignJob(
|
||||||
|
id="job-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
campaign_version_id=self.version.id,
|
||||||
|
entry_index=0,
|
||||||
|
entry_id="entry-subject",
|
||||||
|
recipient_email="Subject@Example.Test",
|
||||||
|
subject="Your governed notice",
|
||||||
|
eml_storage_key="private/eml/key-do-not-export",
|
||||||
|
eml_local_path="/private/message-do-not-export.eml",
|
||||||
|
eml_size_bytes=512,
|
||||||
|
eml_sha256="b" * 64,
|
||||||
|
build_status="built",
|
||||||
|
validation_status="ready",
|
||||||
|
queue_status="completed",
|
||||||
|
send_status="smtp_accepted",
|
||||||
|
postbox_status="accepted",
|
||||||
|
print_status="accepted",
|
||||||
|
imap_status="appended",
|
||||||
|
attempt_count=1,
|
||||||
|
queued_at=now,
|
||||||
|
sent_at=now,
|
||||||
|
claim_token="job-claim-do-not-export",
|
||||||
|
resolved_recipients={
|
||||||
|
"from": {"email": "sender@example.test"},
|
||||||
|
"to": [{"email": "subject@example.test", "name": "Subject"}],
|
||||||
|
"cc": [{"email": "other@example.test", "name": "Other"}],
|
||||||
|
"legacy": ["other@example.test", "subject@example.test"],
|
||||||
|
},
|
||||||
|
resolved_attachments=[
|
||||||
|
{"storage_key": "resolved-attachment-key-do-not-export"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
other_job = CampaignJob(
|
||||||
|
id="job-other",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
campaign_version_id=self.version.id,
|
||||||
|
entry_index=1,
|
||||||
|
entry_id="entry-other",
|
||||||
|
recipient_email="other@example.test",
|
||||||
|
subject="Other person's message",
|
||||||
|
build_status="built",
|
||||||
|
validation_status="ready",
|
||||||
|
)
|
||||||
|
tenant_two_campaign = Campaign(
|
||||||
|
id="campaign-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
external_id="other-tenant",
|
||||||
|
name="Other tenant data do not export",
|
||||||
|
)
|
||||||
|
tenant_two_version = CampaignVersion(
|
||||||
|
id="version-tenant-2",
|
||||||
|
campaign_id=tenant_two_campaign.id,
|
||||||
|
version_number=1,
|
||||||
|
raw_json={
|
||||||
|
"entries": {"inline": [{"to": [{"email": "subject@example.test"}]}]}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tenant_two_job = CampaignJob(
|
||||||
|
id="job-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
campaign_id=tenant_two_campaign.id,
|
||||||
|
campaign_version_id=tenant_two_version.id,
|
||||||
|
entry_index=0,
|
||||||
|
recipient_email="subject@example.test",
|
||||||
|
)
|
||||||
|
self.issue = CampaignIssue(
|
||||||
|
id="issue-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
campaign_version_id=self.version.id,
|
||||||
|
job_id=self.job.id,
|
||||||
|
severity="warning",
|
||||||
|
code="delivery_warning",
|
||||||
|
message="issue-detail-do-not-export",
|
||||||
|
source="private-source-do-not-export",
|
||||||
|
behavior="review",
|
||||||
|
)
|
||||||
|
send_attempt = SendAttempt(
|
||||||
|
id="send-attempt-1",
|
||||||
|
job_id=self.job.id,
|
||||||
|
attempt_number=1,
|
||||||
|
status="accepted",
|
||||||
|
claim_token="attempt-claim-do-not-export",
|
||||||
|
smtp_status_code=250,
|
||||||
|
smtp_response="smtp-response-do-not-export",
|
||||||
|
error_message="transport-detail-do-not-export",
|
||||||
|
started_at=now,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
postbox_attempt = PostboxDeliveryAttempt(
|
||||||
|
id="postbox-attempt-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
job_id=self.job.id,
|
||||||
|
target_key="target-key-do-not-export",
|
||||||
|
target_index=0,
|
||||||
|
attempt_number=1,
|
||||||
|
idempotency_key="postbox-idempotency-do-not-export",
|
||||||
|
status="accepted",
|
||||||
|
target_snapshot={"private": "snapshot-do-not-export"},
|
||||||
|
provider_delivery_id="delivery-1",
|
||||||
|
provider_message_id="message-1",
|
||||||
|
postbox_id="postbox-1",
|
||||||
|
address="subject@example.test",
|
||||||
|
evidence={"private": "postbox-evidence-do-not-export"},
|
||||||
|
started_at=now,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
print_attempt = PrintOutputAttempt(
|
||||||
|
id="print-attempt-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
job_id=self.job.id,
|
||||||
|
attempt_number=1,
|
||||||
|
idempotency_key="print-idempotency-do-not-export",
|
||||||
|
status="accepted",
|
||||||
|
render_id="render-1",
|
||||||
|
artifact_sha256="c" * 64,
|
||||||
|
evidence={"private": "print-evidence-do-not-export"},
|
||||||
|
started_at=now,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
self.share = CampaignShare(
|
||||||
|
id="share-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
target_type="user",
|
||||||
|
target_id=self.user.id,
|
||||||
|
permission="read",
|
||||||
|
created_by_user_id=self.other_user.id,
|
||||||
|
)
|
||||||
|
self.profile = RecipientImportMappingProfile(
|
||||||
|
id="mapping-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
owner_user_id=self.user.id,
|
||||||
|
name="Subject mapping",
|
||||||
|
column_count=2,
|
||||||
|
headers=["email", "case_reference"],
|
||||||
|
normalized_headers=["email", "case_reference"],
|
||||||
|
ordered_header_fingerprint="d" * 64,
|
||||||
|
unordered_header_fingerprint="e" * 64,
|
||||||
|
delimiter=";",
|
||||||
|
header_rows=1,
|
||||||
|
quoted=True,
|
||||||
|
value_separators=",;|",
|
||||||
|
mappings=[
|
||||||
|
{"header": "email", "field": "to.0.email"},
|
||||||
|
{"secret": "profile-secret-do-not-export"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
blob = AttachmentBlob(
|
||||||
|
id="blob-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
sha256="f" * 64,
|
||||||
|
size_bytes=42,
|
||||||
|
mime_type="application/pdf",
|
||||||
|
storage_bucket="private-bucket-do-not-export",
|
||||||
|
storage_key="private-attachment-key-do-not-export",
|
||||||
|
)
|
||||||
|
attachment = AttachmentInstance(
|
||||||
|
id="attachment-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
owner_user_id=self.other_user.id,
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
blob_id=blob.id,
|
||||||
|
logical_name="notice",
|
||||||
|
filename="notice.pdf",
|
||||||
|
tags=["notice"],
|
||||||
|
metadata_={"secret": "attachment-secret-do-not-export"},
|
||||||
|
)
|
||||||
|
schedule = CampaignSchedule(
|
||||||
|
id="schedule-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
source_version_id=self.version.id,
|
||||||
|
created_by_user_id=self.other_user.id,
|
||||||
|
name="Recurring privacy notice",
|
||||||
|
delivery_mode="manual",
|
||||||
|
recurrence_kind="monthly",
|
||||||
|
starts_at=now,
|
||||||
|
next_fire_at=now,
|
||||||
|
max_occurrences=12,
|
||||||
|
source_snapshot={"private": "schedule-snapshot-do-not-export"},
|
||||||
|
source_snapshot_hash="1" * 64,
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
self.account,
|
||||||
|
other_account,
|
||||||
|
self.user,
|
||||||
|
self.other_user,
|
||||||
|
self.campaign,
|
||||||
|
self.version,
|
||||||
|
self.draft_version,
|
||||||
|
self.job,
|
||||||
|
other_job,
|
||||||
|
tenant_two_campaign,
|
||||||
|
tenant_two_version,
|
||||||
|
tenant_two_job,
|
||||||
|
self.issue,
|
||||||
|
send_attempt,
|
||||||
|
postbox_attempt,
|
||||||
|
print_attempt,
|
||||||
|
self.share,
|
||||||
|
self.profile,
|
||||||
|
blob,
|
||||||
|
attachment,
|
||||||
|
schedule,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = CampaignDsarProvider()
|
||||||
|
self.subject = DsarSubjectRef(
|
||||||
|
membership_id=self.user.id,
|
||||||
|
email="subject@example.test",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||||
|
provided_names = {item.name for item in manifest.provides_interfaces}
|
||||||
|
self.assertIn(CAMPAIGN_DSAR_CAPABILITY, provided_names)
|
||||||
|
provider = manifest.capability_factories[CAMPAIGN_DSAR_CAPABILITY](None)
|
||||||
|
self.assertIsInstance(provider, DsarProvider)
|
||||||
|
self.assertIn(
|
||||||
|
"campaigns.privacy.data-subject-requests",
|
||||||
|
{topic.id for topic in manifest.documentation},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_is_tenant_scoped_minimized_and_recipient_specific(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
resource_types = {record.resource_type for record in records}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"campaign",
|
||||||
|
"campaign_version",
|
||||||
|
"campaign_recipient_job",
|
||||||
|
"campaign_message_artifact",
|
||||||
|
"campaign_delivery_issue",
|
||||||
|
"campaign_send_attempt",
|
||||||
|
"campaign_postbox_attempt",
|
||||||
|
"campaign_print_attempt",
|
||||||
|
"campaign_report_projection",
|
||||||
|
"campaign_share",
|
||||||
|
"recipient_import_mapping_profile",
|
||||||
|
"campaign_schedule",
|
||||||
|
"campaign_attachment",
|
||||||
|
}.issubset(resource_types)
|
||||||
|
)
|
||||||
|
report = next(
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if record.resource_type == "campaign_report_projection"
|
||||||
|
)
|
||||||
|
self.assertEqual(1, report.data["matched_job_count"])
|
||||||
|
artifact = next(
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if record.resource_type == "campaign_message_artifact"
|
||||||
|
)
|
||||||
|
self.assertEqual("b" * 64, artifact.data["sha256"])
|
||||||
|
self.assertEqual(512, artifact.data["size_bytes"])
|
||||||
|
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
for hidden in (
|
||||||
|
"job-tenant-2",
|
||||||
|
"Other tenant data do not export",
|
||||||
|
"job-other",
|
||||||
|
"other@example.test",
|
||||||
|
"Unrelated person",
|
||||||
|
"other-recipient-data-do-not-export",
|
||||||
|
"private-rendered-body-do-not-export",
|
||||||
|
"inline-secret-do-not-export",
|
||||||
|
"private/eml/key-do-not-export",
|
||||||
|
"/private/message-do-not-export.eml",
|
||||||
|
"resolved-attachment-key-do-not-export",
|
||||||
|
"job-claim-do-not-export",
|
||||||
|
"issue-detail-do-not-export",
|
||||||
|
"private-source-do-not-export",
|
||||||
|
"attempt-claim-do-not-export",
|
||||||
|
"smtp-response-do-not-export",
|
||||||
|
"transport-detail-do-not-export",
|
||||||
|
"target-key-do-not-export",
|
||||||
|
"postbox-idempotency-do-not-export",
|
||||||
|
"snapshot-do-not-export",
|
||||||
|
"postbox-evidence-do-not-export",
|
||||||
|
"print-idempotency-do-not-export",
|
||||||
|
"print-evidence-do-not-export",
|
||||||
|
"profile-secret-do-not-export",
|
||||||
|
"private-bucket-do-not-export",
|
||||||
|
"private-attachment-key-do-not-export",
|
||||||
|
"attachment-secret-do-not-export",
|
||||||
|
"schedule-snapshot-do-not-export",
|
||||||
|
):
|
||||||
|
self.assertNotIn(hidden, serialized)
|
||||||
|
|
||||||
|
def test_conflicting_email_references_fail_closed_for_recipient_data(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
email="subject@example.test",
|
||||||
|
external_references={"campaign.email": "other@example.test"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), records)
|
||||||
|
|
||||||
|
def test_plan_retains_evidence_and_limits_execution_to_reversible_data(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
kinds = {action.kind for action in actions}
|
||||||
|
self.assertTrue({"retain", "manual_review", "revoke", "delete"}.issubset(kinds))
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
action.action_id
|
||||||
|
== "campaigns:retain:campaign_recipient_job:job-subject"
|
||||||
|
for action in actions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
action.action_id == "campaigns:review:campaign_version:version-draft"
|
||||||
|
for action in actions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
executable_ids = {action.action_id for action in actions if action.executable}
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"campaigns:revoke:campaign_share:share-1",
|
||||||
|
"campaigns:delete:recipient_import_mapping_profile:mapping-1",
|
||||||
|
},
|
||||||
|
executable_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_execution_is_revalidated_tenant_bound_and_idempotent(self) -> None:
|
||||||
|
actions = self._executable_actions()
|
||||||
|
|
||||||
|
wrong_tenant = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-wrong-tenant",
|
||||||
|
)
|
||||||
|
self.assertEqual({"blocked"}, {result.status for result in wrong_tenant})
|
||||||
|
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"executed"}, {result.status for result in first})
|
||||||
|
self.session.flush()
|
||||||
|
self.assertIsNotNone(self.share.revoked_at)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.session.get(RecipientImportMappingProfile, self.profile.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
repeated = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"unchanged"}, {result.status for result in repeated})
|
||||||
|
|
||||||
|
def test_execution_blocks_when_mapping_owner_changed_after_planning(self) -> None:
|
||||||
|
delete_action = next(
|
||||||
|
action
|
||||||
|
for action in self._executable_actions()
|
||||||
|
if action.resource_type == "recipient_import_mapping_profile"
|
||||||
|
)
|
||||||
|
self.profile.owner_user_id = self.other_user.id
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
result = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=(delete_action,),
|
||||||
|
request_id="dsar-stale",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", result[0].status)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.session.get(RecipientImportMappingProfile, self.profile.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_discovers_active_provider_and_skips_it_when_disabled(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
request = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-CAMPAIGN-1",
|
||||||
|
request_kind="access_and_erasure",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Respond to an authorized privacy request.",
|
||||||
|
legal_basis="Article 15 and 17 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
registry = _Registry(self.provider)
|
||||||
|
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=registry,
|
||||||
|
row=request,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual("searched", request.status)
|
||||||
|
self.assertEqual(["campaigns"], request.coverage["covered_modules"])
|
||||||
|
self.assertEqual([], request.coverage["modules_without_provider"])
|
||||||
|
plan_data_subject_erasure(
|
||||||
|
self.session,
|
||||||
|
registry=registry,
|
||||||
|
row=request,
|
||||||
|
expected_revision=2,
|
||||||
|
)
|
||||||
|
executable_ids = [
|
||||||
|
action["action_id"]
|
||||||
|
for action in request.erasure_plan["actions"]
|
||||||
|
if action["executable"]
|
||||||
|
]
|
||||||
|
execute_data_subject_erasure(
|
||||||
|
self.session,
|
||||||
|
registry=registry,
|
||||||
|
row=request,
|
||||||
|
expected_revision=3,
|
||||||
|
action_ids=executable_ids,
|
||||||
|
)
|
||||||
|
self.assertEqual("completed", request.status)
|
||||||
|
|
||||||
|
disabled = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-CAMPAIGN-DISABLED",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Verify disabled-module coverage.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, campaign_active=False),
|
||||||
|
row=disabled,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, disabled.search_result["record_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
[CAMPAIGN_DSAR_CAPABILITY],
|
||||||
|
disabled.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _executable_actions(self):
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
return tuple(action for action in actions if action.executable)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -64,6 +64,9 @@ class _Session:
|
|||||||
def query(self, _model):
|
def query(self, _model):
|
||||||
return _Query(self.jobs)
|
return _Query(self.jobs)
|
||||||
|
|
||||||
|
def get(self, _model, _identifier):
|
||||||
|
return SimpleNamespace(id="campaign-1")
|
||||||
|
|
||||||
|
|
||||||
def _snapshotted_version(job: SimpleNamespace):
|
def _snapshotted_version(job: SimpleNamespace):
|
||||||
version = SimpleNamespace(
|
version = SimpleNamespace(
|
||||||
@@ -87,9 +90,15 @@ def _snapshotted_version(job: SimpleNamespace):
|
|||||||
|
|
||||||
|
|
||||||
def _ensure(session: _Session, version) -> None:
|
def _ensure(session: _Session, version) -> None:
|
||||||
with patch(
|
with (
|
||||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
patch(
|
||||||
return_value=SimpleNamespace(available=False),
|
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||||
|
return_value=SimpleNamespace(available=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed",
|
||||||
|
return_value=SimpleNamespace(policy_hash="archive-policy"),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
ensure_execution_snapshot(session, version) # type: ignore[arg-type]
|
ensure_execution_snapshot(session, version) # type: ignore[arg-type]
|
||||||
|
|
||||||
@@ -125,12 +134,19 @@ def test_effect_check_verifies_only_the_claimed_job_in_constant_time() -> None:
|
|||||||
job = _job()
|
job = _job()
|
||||||
version = _snapshotted_version(job)
|
version = _snapshotted_version(job)
|
||||||
session = SimpleNamespace(
|
session = SimpleNamespace(
|
||||||
query=lambda *_args: pytest.fail("per-effect validation must not rescan every campaign job")
|
query=lambda *_args: pytest.fail("per-effect validation must not rescan every campaign job"),
|
||||||
|
get=lambda *_args: SimpleNamespace(id="campaign-1"),
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with (
|
||||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
patch(
|
||||||
return_value=SimpleNamespace(available=False),
|
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||||
|
return_value=SimpleNamespace(available=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed",
|
||||||
|
return_value=SimpleNamespace(policy_hash="archive-policy"),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
ensure_execution_snapshot(
|
ensure_execution_snapshot(
|
||||||
session, # type: ignore[arg-type]
|
session, # type: ignore[arg-type]
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
|
|||||||
delivery=DeliveryConfig(),
|
delivery=DeliveryConfig(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert payload["snapshot_version"] == "8"
|
assert payload["snapshot_version"] == "9"
|
||||||
assert payload["mail_profile_id"] == "profile-1"
|
assert payload["mail_profile_id"] == "profile-1"
|
||||||
assert "smtp" not in payload
|
assert "smtp" not in payload
|
||||||
assert "imap" not in payload
|
assert "imap" not in payload
|
||||||
|
|||||||
@@ -73,3 +73,21 @@ def test_reusable_template_library_is_not_owned_by_campaign() -> None:
|
|||||||
assert "/templates" not in {item.path for item in manifest.nav_items}
|
assert "/templates" not in {item.path for item in manifest.nav_items}
|
||||||
assert "/templates" not in {item.path for item in manifest.frontend.nav_items}
|
assert "/templates" not in {item.path for item in manifest.frontend.nav_items}
|
||||||
assert "/templates" not in {route.path for route in manifest.frontend.routes}
|
assert "/templates" not in {route.path for route in manifest.frontend.routes}
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_access_selects_exact_campaigns_only_for_active_cases() -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
assert manifest.frontend is not None
|
||||||
|
|
||||||
|
tool = next(
|
||||||
|
item
|
||||||
|
for item in manifest.frontend.quick_access_tools
|
||||||
|
if item.id == "campaigns.select"
|
||||||
|
)
|
||||||
|
assert tool.availability == "active_object"
|
||||||
|
assert tool.accepted_reference_kinds == ("cases.case",)
|
||||||
|
assert tool.returned_reference_kinds == ("campaigns.campaign",)
|
||||||
|
assert tool.modes == ("select",)
|
||||||
|
assert "campaigns.workflow.link-exact-campaign-to-case" in {
|
||||||
|
topic.id for topic in manifest.documentation
|
||||||
|
}
|
||||||
|
|||||||
@@ -138,3 +138,39 @@ def test_eml_retention_removes_only_terminal_artifact(tmp_path) -> None:
|
|||||||
assert job.eml_local_path is None
|
assert job.eml_local_path is None
|
||||||
assert job.eml_storage_key is None
|
assert job.eml_storage_key is None
|
||||||
session.add.assert_called_once_with(job)
|
session.add.assert_called_once_with(job)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eml_retention_preserves_unfinished_autonomous_schedule_source(tmp_path) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
eml_path = tmp_path / "approved-source.eml"
|
||||||
|
eml_path.write_bytes(b"approved message")
|
||||||
|
job = SimpleNamespace(
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
updated_at=now - timedelta(days=10),
|
||||||
|
queue_status="draft",
|
||||||
|
send_status="smtp_accepted",
|
||||||
|
imap_status="appended",
|
||||||
|
eml_local_path=str(eml_path),
|
||||||
|
eml_storage_key=None,
|
||||||
|
)
|
||||||
|
schedule_query = MagicMock()
|
||||||
|
schedule_query.filter.return_value.all.return_value = [("version-1",)]
|
||||||
|
job_query = MagicMock()
|
||||||
|
job_query.filter.return_value.order_by.return_value.all.return_value = [job]
|
||||||
|
session = MagicMock()
|
||||||
|
session.query.side_effect = [schedule_query, job_query]
|
||||||
|
policy = SimpleNamespace(generated_eml_retention_days=1)
|
||||||
|
|
||||||
|
result = _apply_eml_retention(
|
||||||
|
session,
|
||||||
|
dry_run=False,
|
||||||
|
now=now,
|
||||||
|
policy_for_campaign_id=lambda _campaign_id: policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["skipped_schedule_source"] == 1
|
||||||
|
assert result["metadata_cleared"] == 0
|
||||||
|
assert eml_path.exists()
|
||||||
|
assert job.eml_local_path == str(eml_path)
|
||||||
|
session.add.assert_not_called()
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
|||||||
actual = _operation_keys(router)
|
actual = _operation_keys(router)
|
||||||
|
|
||||||
assert actual == expected
|
assert actual == expected
|
||||||
assert len(actual) == 80
|
assert len(actual) == 81
|
||||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
|
|||||||
failed_count=1,
|
failed_count=1,
|
||||||
outcome_unknown_count=0,
|
outcome_unknown_count=0,
|
||||||
skipped_count=0,
|
skipped_count=0,
|
||||||
|
paused_count=1,
|
||||||
|
batch_state="paused",
|
||||||
|
batch_pause_reason_code="smtp_authentication_failed",
|
||||||
|
smtp_connection_count=1,
|
||||||
|
smtp_reconnect_count=0,
|
||||||
preflight_count=2,
|
preflight_count=2,
|
||||||
synchronous_send_policy={
|
synchronous_send_policy={
|
||||||
"max_recipient_jobs": 25,
|
"max_recipient_jobs": 25,
|
||||||
@@ -113,7 +118,12 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
|
|||||||
"failed_count",
|
"failed_count",
|
||||||
"outcome_unknown_count",
|
"outcome_unknown_count",
|
||||||
"skipped_count",
|
"skipped_count",
|
||||||
|
"paused_count",
|
||||||
"preflight_count",
|
"preflight_count",
|
||||||
|
"batch_state",
|
||||||
|
"batch_pause_reason_code",
|
||||||
|
"smtp_connection_count",
|
||||||
|
"smtp_reconnect_count",
|
||||||
"delivery_mode",
|
"delivery_mode",
|
||||||
"dry_run",
|
"dry_run",
|
||||||
"synchronous_send_policy",
|
"synchronous_send_policy",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import ANY, patch
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
JobBuildStatus,
|
JobBuildStatus,
|
||||||
@@ -17,6 +17,7 @@ from govoplan_campaign.backend.sending.jobs import (
|
|||||||
_select_campaign_jobs_for_queue,
|
_select_campaign_jobs_for_queue,
|
||||||
_send_claimed_campaign_job,
|
_send_claimed_campaign_job,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.integrations import SmtpSendError
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
@@ -220,6 +221,62 @@ class CampaignQueueSelectionTests(unittest.TestCase):
|
|||||||
self.assertTrue(session.rolled_back)
|
self.assertTrue(session.rolled_back)
|
||||||
self.assertIn("Automatic retry is stopped", mark_unknown.call_args.kwargs["reason"])
|
self.assertIn("Automatic retry is stopped", mark_unknown.call_args.kwargs["reason"])
|
||||||
|
|
||||||
|
def test_systemic_smtp_failure_pauses_remaining_campaign_jobs(self):
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
delivery_channel_policy="mail",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
snapshot=SimpleNamespace(
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_server_id=None,
|
||||||
|
smtp_credential_id=None,
|
||||||
|
smtp_transport_revision="revision-1",
|
||||||
|
delivery=SimpleNamespace(rate_limit=SimpleNamespace(messages_per_minute=60)),
|
||||||
|
),
|
||||||
|
message_bytes=b"message",
|
||||||
|
envelope_from="sender@example.test",
|
||||||
|
envelope_recipients=["recipient@example.test"],
|
||||||
|
)
|
||||||
|
|
||||||
|
class Mail:
|
||||||
|
def wait_for_rate_limit(self, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send_campaign_email_bytes(self, *_args, **_kwargs):
|
||||||
|
raise SmtpSendError(
|
||||||
|
"SMTP authentication failed.",
|
||||||
|
systemic=True,
|
||||||
|
reason_code="smtp_authentication_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._record_attempt_start", return_value=SimpleNamespace(attempt_number=1)),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._record_smtp_send_error", return_value=None),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._pause_jobs_after_systemic_smtp_failure", return_value=4) as pause,
|
||||||
|
self.assertRaises(SmtpSendError),
|
||||||
|
):
|
||||||
|
_send_claimed_campaign_job(
|
||||||
|
object(), # 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
pause.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
exclude_job_id="job-1",
|
||||||
|
reason_code="smtp_authentication_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
@@ -23,7 +24,9 @@ from govoplan_campaign.backend.sending.jobs import (
|
|||||||
QueueCampaignResult,
|
QueueCampaignResult,
|
||||||
SynchronousSendRejected,
|
SynchronousSendRejected,
|
||||||
_ensure_synchronous_send_count_allowed,
|
_ensure_synchronous_send_count_allowed,
|
||||||
|
_pause_jobs_after_systemic_smtp_failure,
|
||||||
_preflight_synchronous_send_batch,
|
_preflight_synchronous_send_batch,
|
||||||
|
_synchronous_smtp_batch_manager,
|
||||||
queue_campaign_jobs,
|
queue_campaign_jobs,
|
||||||
send_campaign_now,
|
send_campaign_now,
|
||||||
synchronous_send_candidate_jobs,
|
synchronous_send_candidate_jobs,
|
||||||
@@ -364,6 +367,72 @@ def test_batch_preflight_checks_every_message_before_provider_effects() -> None:
|
|||||||
provider.send_campaign_email_bytes.assert_not_called()
|
provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_batch_manager_preflights_combined_recipients_and_frozen_transport() -> None:
|
||||||
|
snapshot = SimpleNamespace(
|
||||||
|
uses_mail=True,
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_transport_revision="revision-1",
|
||||||
|
smtp_server_id="server-1",
|
||||||
|
smtp_credential_id="credential-1",
|
||||||
|
)
|
||||||
|
jobs = [
|
||||||
|
SimpleNamespace(
|
||||||
|
id="one",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
delivery_channel_policy="mail",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
id="two",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
delivery_channel_policy="mail",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
contexts = {
|
||||||
|
"one": SimpleNamespace(snapshot=snapshot, envelope_from="sender@example.test", envelope_recipients=["one@example.test"]),
|
||||||
|
"two": SimpleNamespace(snapshot=snapshot, envelope_from="sender@example.test", envelope_recipients=["two@example.test", "one@example.test"]),
|
||||||
|
}
|
||||||
|
state = SimpleNamespace(connection_count=1, reconnect_count=0)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def batch(_session, **kwargs):
|
||||||
|
batch.kwargs = kwargs
|
||||||
|
yield state
|
||||||
|
|
||||||
|
provider = SimpleNamespace(campaign_smtp_batch=batch)
|
||||||
|
with patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=provider):
|
||||||
|
with _synchronous_smtp_batch_manager(object(), jobs=jobs, contexts=contexts) as opened: # type: ignore[arg-type]
|
||||||
|
assert opened is state
|
||||||
|
|
||||||
|
assert batch.kwargs["envelope_recipients"] == ["one@example.test", "two@example.test"]
|
||||||
|
assert batch.kwargs["expected_smtp_transport_revision"] == "revision-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_systemic_failure_pauses_only_remaining_queued_jobs() -> None:
|
||||||
|
session = Mock()
|
||||||
|
session.query.return_value.filter.return_value.update.return_value = 3
|
||||||
|
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1", status="sending")
|
||||||
|
session.get.return_value = campaign
|
||||||
|
with patch("govoplan_campaign.backend.sending.jobs.audit_event") as audit:
|
||||||
|
paused = _pause_jobs_after_systemic_smtp_failure(
|
||||||
|
session,
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
exclude_job_id="failed-job",
|
||||||
|
reason_code="smtp_authentication_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert paused == 3
|
||||||
|
assert campaign.status == "ready_to_queue"
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
assert audit.call_args.kwargs["details"] == {
|
||||||
|
"reason_code": "smtp_authentication_failed",
|
||||||
|
"paused_count": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_rejected_synchronous_preflight_rolls_back_staged_queue_before_audit() -> None:
|
def test_rejected_synchronous_preflight_rolls_back_staged_queue_before_audit() -> None:
|
||||||
session = Mock()
|
session = Mock()
|
||||||
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ try:
|
|||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
pyzipper = None
|
pyzipper = None
|
||||||
|
|
||||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
from govoplan_campaign.backend.services.zip_service import (
|
||||||
|
create_zip_archive,
|
||||||
|
zip_archive_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ZipServiceTests(unittest.TestCase):
|
class ZipServiceTests(unittest.TestCase):
|
||||||
@@ -28,6 +31,20 @@ class ZipServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
|
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
|
||||||
self.assertTrue(info.flag_bits & 0x1)
|
self.assertTrue(info.flag_bits & 0x1)
|
||||||
self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP")
|
self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP")
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
archive.read("message.txt", pwd=b"wrong-password")
|
||||||
|
|
||||||
|
evidence = zip_archive_evidence(
|
||||||
|
output,
|
||||||
|
[(source, "message.txt")],
|
||||||
|
password_protected=True,
|
||||||
|
method="zip_standard",
|
||||||
|
)
|
||||||
|
self.assertEqual("Legacy ZipCrypto", evidence["format"])
|
||||||
|
self.assertEqual("govoplan-campaign.zipcrypto", evidence["implementation"])
|
||||||
|
self.assertNotIn("password", evidence)
|
||||||
|
self.assertEqual(64, len(str(evidence["archive_sha256"])))
|
||||||
|
self.assertEqual(64, len(str(evidence["members"][0]["sha256"])))
|
||||||
|
|
||||||
@unittest.skipIf(pyzipper is None, "pyzipper is not installed")
|
@unittest.skipIf(pyzipper is None, "pyzipper is not installed")
|
||||||
def test_aes_password_zip_keeps_aes_encryption(self) -> None:
|
def test_aes_password_zip_keeps_aes_encryption(self) -> None:
|
||||||
@@ -63,6 +80,18 @@ class ZipServiceTests(unittest.TestCase):
|
|||||||
self.assertFalse(info.flag_bits & 0x1)
|
self.assertFalse(info.flag_bits & 0x1)
|
||||||
self.assertEqual(archive.read("message.txt"), b"Plain ZIP")
|
self.assertEqual(archive.read("message.txt"), b"Plain ZIP")
|
||||||
|
|
||||||
|
def test_unknown_password_method_fails_without_downgrade_or_output(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
source = root / "source.txt"
|
||||||
|
source.write_text("No downgrade", encoding="utf-8")
|
||||||
|
output = root / "unknown.zip"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "Unsupported"):
|
||||||
|
create_zip_archive(output, [source], "secret", "unknown")
|
||||||
|
|
||||||
|
self.assertFalse(output.exists())
|
||||||
|
|
||||||
|
|
||||||
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.18",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -38,6 +38,24 @@ export type CampaignShare = {
|
|||||||
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
||||||
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
||||||
|
|
||||||
|
export type CampaignArchiveEncryptionPolicy = {
|
||||||
|
available: boolean;
|
||||||
|
allowed_password_encryption_methods: Array<"aes" | "zip_standard">;
|
||||||
|
allowed_password_delivery_channels: Array<"separate_mail" | "sms" | "letter" | "phone" | "in_person">;
|
||||||
|
policy_hash: string;
|
||||||
|
source_path: Array<{
|
||||||
|
scope_type: string;
|
||||||
|
scope_id?: string | null;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
applied_fields: string[];
|
||||||
|
policy: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
reason: string;
|
||||||
|
diagnostics: Array<Record<string, unknown>>;
|
||||||
|
legacy_label: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignUpdatePayload = {
|
export type CampaignUpdatePayload = {
|
||||||
external_id?: string | null;
|
external_id?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -120,10 +138,15 @@ export type CampaignScheduleOccurrence = {
|
|||||||
id: string;
|
id: string;
|
||||||
schedule_id: string;
|
schedule_id: string;
|
||||||
scheduled_for: string;
|
scheduled_for: string;
|
||||||
status: "prepared" | "failed" | string;
|
status: "prepared" | "accepted" | "uncertain" | "failed" | "skipped" | "superseded" | string;
|
||||||
|
idempotency_key?: string | null;
|
||||||
generated_campaign_id?: string | null;
|
generated_campaign_id?: string | null;
|
||||||
generated_version_id?: string | null;
|
generated_version_id?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
delivery_command_ids: string[];
|
||||||
|
recovery_state: string;
|
||||||
|
evidence: Record<string, unknown>;
|
||||||
|
last_checked_at?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,6 +155,7 @@ export type CampaignSchedule = {
|
|||||||
campaign_id: string;
|
campaign_id: string;
|
||||||
source_version_id: string;
|
source_version_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
delivery_mode: "manual" | "autonomous";
|
||||||
recurrence_kind: "once" | "daily" | "weekly" | "monthly";
|
recurrence_kind: "once" | "daily" | "weekly" | "monthly";
|
||||||
interval_count: number;
|
interval_count: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
@@ -145,6 +169,8 @@ export type CampaignSchedule = {
|
|||||||
last_fired_at?: string | null;
|
last_fired_at?: string | null;
|
||||||
last_campaign_id?: string | null;
|
last_campaign_id?: string | null;
|
||||||
last_error?: string | null;
|
last_error?: string | null;
|
||||||
|
last_outcome?: string | null;
|
||||||
|
last_recovery_state?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
occurrences: CampaignScheduleOccurrence[];
|
occurrences: CampaignScheduleOccurrence[];
|
||||||
@@ -153,6 +179,7 @@ export type CampaignSchedule = {
|
|||||||
export type CampaignScheduleCreate = {
|
export type CampaignScheduleCreate = {
|
||||||
source_version_id: string;
|
source_version_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
delivery_mode: CampaignSchedule["delivery_mode"];
|
||||||
recurrence_kind: CampaignSchedule["recurrence_kind"];
|
recurrence_kind: CampaignSchedule["recurrence_kind"];
|
||||||
interval_count: number;
|
interval_count: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
@@ -1141,6 +1168,16 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
|
|||||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCampaignArchiveEncryptionPolicy(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string
|
||||||
|
): Promise<CampaignArchiveEncryptionPolicy> {
|
||||||
|
return apiFetch<CampaignArchiveEncryptionPolicy>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/archive-encryption-policy`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateCampaignMetadata(
|
export async function updateCampaignMetadata(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { MetricGrid } from "@govoplan/core-webui";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
|
import {
|
||||||
|
getCampaignArchiveEncryptionPolicy,
|
||||||
|
type CampaignArchiveEncryptionPolicy
|
||||||
|
} from "../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
@@ -22,14 +26,25 @@ import { updateNested } from "./utils/draftEditor";
|
|||||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||||
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
||||||
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
|
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
|
||||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
import { hasScope, insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
||||||
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||||
|
|
||||||
type PathChooserState = {index: number;};
|
type PathChooserState = {index: number;};
|
||||||
type IndividualDisableState = {index: number;usageCount: number;};
|
type IndividualDisableState = {index: number;usageCount: number;};
|
||||||
|
|
||||||
export default function AttachmentsDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = {
|
||||||
|
available: false,
|
||||||
|
allowed_password_encryption_methods: ["aes"],
|
||||||
|
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
|
||||||
|
policy_hash: "",
|
||||||
|
source_path: [],
|
||||||
|
reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.",
|
||||||
|
diagnostics: [],
|
||||||
|
legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AttachmentsDataPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||||
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||||
@@ -41,6 +56,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
|
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
|
||||||
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
||||||
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
||||||
|
const [archivePolicy, setArchivePolicy] = useState<CampaignArchiveEncryptionPolicy>(UNAVAILABLE_ARCHIVE_POLICY);
|
||||||
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 { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
@@ -70,7 +86,15 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
() => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION,
|
() => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION,
|
||||||
[zipConfig.archives, zipConfig.enabled]
|
[zipConfig.archives, zipConfig.enabled]
|
||||||
);
|
);
|
||||||
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
|
const canUseLegacyZipCrypto = hasScope(auth, "campaigns:archive:use_legacy_zipcrypto");
|
||||||
|
const legacyZipCryptoAllowed = archivePolicy.available && archivePolicy.allowed_password_encryption_methods.includes("zip_standard");
|
||||||
|
const legacyConfigurationInvalid = zipConfig.archives.some((archive) =>
|
||||||
|
archive.method === "zip_standard" && (
|
||||||
|
!legacyZipCryptoAllowed || !canUseLegacyZipCrypto ||
|
||||||
|
!archive.legacy_zipcrypto_acknowledged || archive.legacy_zipcrypto_reason.trim().length < 10
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message && !legacyConfigurationInvalid;
|
||||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||||
const attachmentPreviewEntry = useMemo(
|
const attachmentPreviewEntry = useMemo(
|
||||||
@@ -94,6 +118,22 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
return () => {cancelled = true;};
|
return () => {cancelled = true;};
|
||||||
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setArchivePolicy(UNAVAILABLE_ARCHIVE_POLICY);
|
||||||
|
void getCampaignArchiveEncryptionPolicy(settings, campaignId)
|
||||||
|
.then((policy) => { if (!cancelled) setArchivePolicy(policy); })
|
||||||
|
.catch((cause) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setArchivePolicy({
|
||||||
|
...UNAVAILABLE_ARCHIVE_POLICY,
|
||||||
|
reason: cause instanceof Error ? cause.message : String(cause)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
const normalized = ensureAttachmentBasePaths(paths);
|
const normalized = ensureAttachmentBasePaths(paths);
|
||||||
@@ -385,6 +425,11 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||||
|
<DismissibleAlert tone={legacyZipCryptoAllowed ? "warning" : "info"} dismissible={false} compact>
|
||||||
|
<strong>{archivePolicy.legacy_label}</strong>: {archivePolicy.reason}
|
||||||
|
{archivePolicy.source_path.length > 0 && <> Source: {archivePolicy.source_path.map((step) => step.label).join(" → ")}.</>}
|
||||||
|
{!canUseLegacyZipCrypto && <> Your account does not have the dedicated legacy-encryption permission.</>}
|
||||||
|
</DismissibleAlert>
|
||||||
<div className="attachment-zip-master-toggle">
|
<div className="attachment-zip-master-toggle">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
||||||
@@ -403,6 +448,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
|
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
|
||||||
onEditName: setZipNameEditorIndex,
|
onEditName: setZipNameEditorIndex,
|
||||||
passwordFields,
|
passwordFields,
|
||||||
|
legacyAllowed: legacyZipCryptoAllowed,
|
||||||
|
canUseLegacy: canUseLegacyZipCrypto,
|
||||||
|
allowedDeliveryChannels: archivePolicy.allowed_password_delivery_channels,
|
||||||
patchArchive: patchZipArchive,
|
patchArchive: patchZipArchive,
|
||||||
setStandard: setStandardZipArchive,
|
setStandard: setStandardZipArchive,
|
||||||
addArchive: addZipArchive,
|
addArchive: addZipArchive,
|
||||||
@@ -517,6 +565,9 @@ type ZipArchiveColumnContext = {
|
|||||||
invalidNameIndexes: ReadonlySet<number>;
|
invalidNameIndexes: ReadonlySet<number>;
|
||||||
onEditName: (index: number) => void;
|
onEditName: (index: number) => void;
|
||||||
passwordFields: ReturnType<typeof getDraftFields>;
|
passwordFields: ReturnType<typeof getDraftFields>;
|
||||||
|
legacyAllowed: boolean;
|
||||||
|
canUseLegacy: boolean;
|
||||||
|
allowedDeliveryChannels: CampaignArchiveEncryptionPolicy["allowed_password_delivery_channels"];
|
||||||
patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void;
|
patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void;
|
||||||
setStandard: (index: number) => void;
|
setStandard: (index: number) => void;
|
||||||
addArchive: (afterIndex?: number) => void;
|
addArchive: (afterIndex?: number) => void;
|
||||||
@@ -524,7 +575,7 @@ type ZipArchiveColumnContext = {
|
|||||||
removeArchive: (index: number) => void;
|
removeArchive: (index: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, legacyAllowed, canUseLegacy, allowedDeliveryChannels, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start",
|
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start",
|
||||||
@@ -557,18 +608,65 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
|
|||||||
value: (archive) => archive.password_enabled ? "protected" : "none"
|
value: (archive) => archive.password_enabled ? "protected" : "none"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "method", header: "ZIP mode", width: 280, sortable: true, filterable: true,
|
id: "method", header: "Encryption", width: 360, sortable: true, filterable: true,
|
||||||
columnType: "from-list", list: { options: [{ value: "aes", label: "AES" }, { value: "zip_standard", label: "Win-compatible" }] },
|
columnType: "from-list", list: { options: [{ value: "aes", label: "AES (strong, default)" }, { value: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption" }] },
|
||||||
render: (archive, index) =>
|
render: (archive, index) =>
|
||||||
<ToggleSwitch
|
<select
|
||||||
label="Win-compatible"
|
value={archive.method}
|
||||||
checked={archive.method === "zip_standard"}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
help="Win-compatible ZIP uses the legacy ZipCrypto format so password-protected archives can be opened with Windows Explorer. Use AES when recipients can use 7-Zip, NanaZip, WinRAR, or another AES-capable ZIP tool."
|
aria-label="Archive password encryption"
|
||||||
onChange={(checked) => patchArchive(index, { method: checked ? "zip_standard" : "aes" })} />,
|
onChange={(event) => {
|
||||||
|
const method = event.target.value === "zip_standard" ? "zip_standard" : "aes";
|
||||||
|
patchArchive(index, method === "zip_standard" ? {
|
||||||
|
method,
|
||||||
|
legacy_zipcrypto_acknowledged: false,
|
||||||
|
legacy_zipcrypto_reason: ""
|
||||||
|
} : {
|
||||||
|
method,
|
||||||
|
legacy_zipcrypto_acknowledged: false,
|
||||||
|
legacy_zipcrypto_reason: ""
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<option value="aes">AES (strong, default)</option>
|
||||||
|
<option value="zip_standard" disabled={!legacyAllowed || !canUseLegacy}>Legacy ZipCrypto — Windows-compatible, weak encryption</option>
|
||||||
|
</select>,
|
||||||
|
|
||||||
value: (archive) => archive.method
|
value: (archive) => archive.method
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "password_delivery_channel", header: "Password delivery", width: 230, sortable: true, filterable: true,
|
||||||
|
render: (archive, index) =>
|
||||||
|
<select
|
||||||
|
value={archive.password_delivery_channel}
|
||||||
|
disabled={disabled || !archive.password_enabled}
|
||||||
|
aria-label="Separate password-delivery channel"
|
||||||
|
onChange={(event) => patchArchive(index, { password_delivery_channel: event.target.value as AttachmentZipArchive["password_delivery_channel"] })}>
|
||||||
|
{(["separate_mail", "sms", "letter", "phone", "in_person"] as const).map((channel) =>
|
||||||
|
<option key={channel} value={channel} disabled={!allowedDeliveryChannels.includes(channel)}>{passwordDeliveryChannelLabel(channel)}</option>
|
||||||
|
)}
|
||||||
|
</select>,
|
||||||
|
value: (archive) => archive.password_delivery_channel
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "legacy_acknowledgement", header: "Legacy acknowledgement", width: 380,
|
||||||
|
render: (archive, index) => archive.method === "zip_standard" ?
|
||||||
|
<div className="campaign-legacy-zipcrypto-acknowledgement">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="I acknowledge that ZipCrypto encryption is weak"
|
||||||
|
checked={archive.legacy_zipcrypto_acknowledged}
|
||||||
|
disabled={disabled || !legacyAllowed || !canUseLegacy}
|
||||||
|
onChange={(checked) => patchArchive(index, { legacy_zipcrypto_acknowledged: checked })} />
|
||||||
|
<input
|
||||||
|
value={archive.legacy_zipcrypto_reason}
|
||||||
|
disabled={disabled || !archive.legacy_zipcrypto_acknowledged}
|
||||||
|
minLength={10}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="Operational reason (at least 10 characters)"
|
||||||
|
aria-label="Reason for weak legacy encryption"
|
||||||
|
onChange={(event) => patchArchive(index, { legacy_zipcrypto_reason: event.target.value })} />
|
||||||
|
</div> : <span>Not required for AES</span>,
|
||||||
|
value: (archive) => archive.legacy_zipcrypto_reason
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
||||||
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
||||||
@@ -685,6 +783,16 @@ function uniqueStrings(values: string[]): string[] {
|
|||||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function passwordDeliveryChannelLabel(channel: AttachmentZipArchive["password_delivery_channel"]): string {
|
||||||
|
return {
|
||||||
|
separate_mail: "Separate email (never this campaign message)",
|
||||||
|
sms: "SMS",
|
||||||
|
letter: "Letter",
|
||||||
|
phone: "Telephone",
|
||||||
|
in_person: "In person"
|
||||||
|
}[channel];
|
||||||
|
}
|
||||||
|
|
||||||
type AttachmentSourceColumnContext = {
|
type AttachmentSourceColumnContext = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function defaultScheduleDraft(): CampaignScheduleCreate {
|
|||||||
return {
|
return {
|
||||||
source_version_id: "",
|
source_version_id: "",
|
||||||
name: "",
|
name: "",
|
||||||
|
delivery_mode: "manual",
|
||||||
recurrence_kind: "once",
|
recurrence_kind: "once",
|
||||||
interval_count: 1,
|
interval_count: 1,
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
@@ -113,6 +114,10 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
||||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
|
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
|
||||||
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
|
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
|
||||||
|
const canAutonomousSchedule = canSchedule
|
||||||
|
&& hasScope(auth, "campaigns:campaign:queue")
|
||||||
|
&& hasScope(auth, "campaigns:campaign:send")
|
||||||
|
&& hasScope(auth, "mail:profile:use");
|
||||||
|
|
||||||
function openSection(section: string, fragment = "") {
|
function openSection(section: string, fragment = "") {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -317,7 +322,9 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
});
|
});
|
||||||
setSchedules((current) => [created, ...current]);
|
setSchedules((current) => [created, ...current]);
|
||||||
setScheduleDialogOpen(false);
|
setScheduleDialogOpen(false);
|
||||||
setMessage("Campaign schedule created. Each occurrence prepares a fresh draft for review; it does not send automatically.");
|
setMessage(created.delivery_mode === "autonomous"
|
||||||
|
? "Autonomous schedule created from the exact approved source execution."
|
||||||
|
: "Manual schedule created. Each occurrence prepares a fresh draft for review.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -433,17 +440,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
<CalendarClock size={16} aria-hidden="true" />
|
<CalendarClock size={16} aria-hidden="true" />
|
||||||
Add schedule
|
Add schedule
|
||||||
</Button> : undefined}>
|
</Button> : undefined}>
|
||||||
<p className="muted small-note">Due occurrences prepare independent campaign drafts. Validation, review, approval, and delivery are never started by the schedule.</p>
|
<p className="muted small-note">Manual schedules prepare independent drafts. Opt-in autonomous schedules reuse the exact approved build through Mail's durable delivery outbox.</p>
|
||||||
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
|
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
|
||||||
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
|
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
|
||||||
<div className="campaign-schedule-main">
|
<div className="campaign-schedule-main">
|
||||||
<strong>{schedule.name}</strong>
|
<strong>{schedule.name}</strong>
|
||||||
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} prepared</span>
|
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} occurrences</span>
|
||||||
|
<span><StatusBadge status={schedule.delivery_mode} /> {schedule.delivery_mode === "autonomous" ? "Approved source delivery" : "Draft preparation"}</span>
|
||||||
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
|
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
|
||||||
|
{schedule.last_outcome && <span>Last outcome: <StatusBadge status={schedule.last_outcome} /> · recovery {schedule.last_recovery_state ?? "none"}</span>}
|
||||||
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
|
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>Open latest draft</Link>}
|
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>{schedule.delivery_mode === "autonomous" ? "Open approved source" : "Open latest draft"}</Link>}
|
||||||
{schedule.next_fire_at && <Button
|
{schedule.next_fire_at && <Button
|
||||||
iconOnly
|
iconOnly
|
||||||
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
|
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
|
||||||
@@ -573,7 +582,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={scheduleDialogOpen}
|
open={scheduleDialogOpen}
|
||||||
title="Schedule campaign drafts"
|
title="Schedule campaign"
|
||||||
className="campaign-schedule-dialog"
|
className="campaign-schedule-dialog"
|
||||||
helpContextId="campaigns.action.schedule-drafts"
|
helpContextId="campaigns.action.schedule-drafts"
|
||||||
closeDisabled={scheduleBusy}
|
closeDisabled={scheduleBusy}
|
||||||
@@ -584,11 +593,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
|
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
|
||||||
</Button>
|
</Button>
|
||||||
</>}>
|
</>}>
|
||||||
<DismissibleAlert tone="info" resetKey="campaign-schedule-safety">
|
<DismissibleAlert tone="info" resetKey={`campaign-schedule-safety-${scheduleDraft.delivery_mode}`}>
|
||||||
A schedule creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically.
|
{scheduleDraft.delivery_mode === "autonomous"
|
||||||
|
? "Autonomous delivery is opt-in. It requires a built, explicitly approved Mail-only source version and rechecks approval, policy, credentials, transport health, recipients, attachments, and snapshot integrity before each occurrence. Unknown outcomes pause the schedule and are never retried automatically."
|
||||||
|
: "Manual mode creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically and remains available without Mail."}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
<div className="campaign-schedule-form">
|
<div className="campaign-schedule-form">
|
||||||
<FormField label="Draft name">
|
<FormField label="Execution mode" help="Mode is fixed for this schedule. Create a new schedule when the approved delivery plan changes.">
|
||||||
|
<select value={scheduleDraft.delivery_mode} onChange={(event) => setScheduleDraft((current) => ({ ...current, delivery_mode: event.target.value as CampaignSchedule["delivery_mode"] }))}>
|
||||||
|
<option value="manual">Manual draft preparation</option>
|
||||||
|
<option value="autonomous" disabled={!canAutonomousSchedule}>Autonomous approved delivery</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Schedule name">
|
||||||
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
|
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="First occurrence">
|
<FormField label="First occurrence">
|
||||||
@@ -618,13 +635,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
|
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="campaign-copy-options">
|
{scheduleDraft.delivery_mode === "manual" && <div className="campaign-copy-options">
|
||||||
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
|
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
|
||||||
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
|
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
|
||||||
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
|
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
|
||||||
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
|
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
|
||||||
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
|
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
|
||||||
</div>
|
</div>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Megaphone, X } from "lucide-react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DashboardWidgetList,
|
||||||
|
DismissibleAlert,
|
||||||
|
LoadingFrame,
|
||||||
|
useDashboardWidgetData,
|
||||||
|
type QuickAccessToolRenderContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { listCampaigns } from "../../api/campaigns";
|
||||||
|
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
QuickAccessToolRenderContext,
|
||||||
|
"settings" | "launchContext" | "complete" | "cancel"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function CampaignQuickAccess({
|
||||||
|
settings,
|
||||||
|
launchContext,
|
||||||
|
complete,
|
||||||
|
cancel
|
||||||
|
}: Props) {
|
||||||
|
const load = useCallback(
|
||||||
|
async () => (await listCampaigns(settings))
|
||||||
|
.filter((campaign) => Boolean(campaign.current_version_id))
|
||||||
|
.slice(0, 12),
|
||||||
|
[settings]
|
||||||
|
);
|
||||||
|
const { data, loading, error } = useDashboardWidgetData(load, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingFrame loading={loading} label="Loading authorized Campaigns">
|
||||||
|
<p className="muted small-note">
|
||||||
|
Select an exact Campaign version for {launchContext.activeObject?.label}.
|
||||||
|
Campaign content remains in Campaigns and access is checked again when opened.
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
<DashboardWidgetList
|
||||||
|
emptyText="No authorized versioned Campaign is available."
|
||||||
|
items={(data ?? []).map((campaign) => ({
|
||||||
|
id: campaign.id,
|
||||||
|
title: campaign.name,
|
||||||
|
detail: campaign.description || campaign.status,
|
||||||
|
meta: campaign.status,
|
||||||
|
leading: <Megaphone size={17} aria-hidden="true" />,
|
||||||
|
onClick: () => complete({
|
||||||
|
contractVersion: "1",
|
||||||
|
outcome: "completed",
|
||||||
|
action: "selected",
|
||||||
|
reference: {
|
||||||
|
ownerModule: "campaigns",
|
||||||
|
kind: "campaign",
|
||||||
|
objectId: campaign.id,
|
||||||
|
tenantId: launchContext.tenantId,
|
||||||
|
label: campaign.name,
|
||||||
|
version: campaign.current_version_id!,
|
||||||
|
path: `/campaigns/${encodeURIComponent(campaign.id)}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className="dashboard-contribution-footer">
|
||||||
|
<Button onClick={() => cancel("user")}>
|
||||||
|
<X size={15} aria-hidden="true" /> Cancel selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</LoadingFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="recipient-data" element={<Navigate to="../recipients" replace />} />
|
<Route path="recipient-data" element={<Navigate to="../recipients" replace />} />
|
||||||
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="files" element={<AttachmentsDataPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||||
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
||||||
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} />
|
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} />
|
||||||
<Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} />
|
<Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} />
|
||||||
|
|||||||
@@ -932,10 +932,11 @@ export default function ReviewSendPage({
|
|||||||
const sent = result.sent_count ?? 0;
|
const sent = result.sent_count ?? 0;
|
||||||
const failed = result.failed_count ?? 0;
|
const failed = result.failed_count ?? 0;
|
||||||
const unknown = result.outcome_unknown_count ?? 0;
|
const unknown = result.outcome_unknown_count ?? 0;
|
||||||
|
const paused = result.paused_count ?? 0;
|
||||||
setMessage(
|
setMessage(
|
||||||
effectiveDryRun ?
|
effectiveDryRun ?
|
||||||
"i18n:govoplan-campaign.dry_run_finished_no_message_was_sent.c026c6cd" :
|
"i18n:govoplan-campaign.dry_run_finished_no_message_was_sent.c026c6cd" :
|
||||||
`Send finished. SMTP accepted ${String(sent)} message(s), failed ${String(failed)}, outcome unknown ${String(unknown)}.`
|
`Send finished. SMTP accepted ${String(sent)} message(s), failed ${String(failed)}, outcome unknown ${String(unknown)}, paused ${String(paused)}.`
|
||||||
);
|
);
|
||||||
setSendConfirmOpen(false);
|
setSendConfirmOpen(false);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -1765,6 +1766,13 @@ export default function ReviewSendPage({
|
|||||||
{sendResult &&
|
{sendResult &&
|
||||||
<div className="review-flow-data-section">
|
<div className="review-flow-data-section">
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.attempted.a9eb9c90 {String(sendResult.attempted_count ?? "—")}i18n:govoplan-campaign.smtp_accepted.a5d0dccc {String(sendResult.sent_count ?? "—")}i18n:govoplan-campaign.failed.fac9f871 {String(sendResult.failed_count ?? "—")}i18n:govoplan-campaign.outcome_unknown.4383023a {String(sendResult.outcome_unknown_count ?? 0)}i18n:govoplan-campaign.skipped.6b98496c {String(sendResult.skipped_count ?? "—")}.</p>
|
<p className="muted small-note">i18n:govoplan-campaign.attempted.a9eb9c90 {String(sendResult.attempted_count ?? "—")}i18n:govoplan-campaign.smtp_accepted.a5d0dccc {String(sendResult.sent_count ?? "—")}i18n:govoplan-campaign.failed.fac9f871 {String(sendResult.failed_count ?? "—")}i18n:govoplan-campaign.outcome_unknown.4383023a {String(sendResult.outcome_unknown_count ?? 0)}i18n:govoplan-campaign.skipped.6b98496c {String(sendResult.skipped_count ?? "—")}.</p>
|
||||||
|
<p className="muted small-note">
|
||||||
|
SMTP batch: {humanize(String(sendResult.batch_state ?? "not_started"))} · connections {String(sendResult.smtp_connection_count ?? 0)} · reconnects {String(sendResult.smtp_reconnect_count ?? 0)} · paused {String(sendResult.paused_count ?? 0)}.
|
||||||
|
</p>
|
||||||
|
{sendResult.batch_state === "paused" &&
|
||||||
|
<DismissibleAlert tone="warning" resetKey={String(sendResult.batch_pause_reason_code ?? "smtp_systemic_failure")}>
|
||||||
|
Remaining messages were paused before SMTP after a systemic transport failure ({String(sendResult.batch_pause_reason_code ?? "smtp_systemic_failure")}). Review the Mail profile, then resume the campaign queue.
|
||||||
|
</DismissibleAlert>}
|
||||||
{sendResultRows.length > 0 &&
|
{sendResultRows.length > 0 &&
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-workflow-send-results`}
|
id={`campaign-${campaignId}-workflow-send-results`}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export type AttachmentZipArchive = {
|
|||||||
password_field: string;
|
password_field: string;
|
||||||
password_scope: AttachmentZipPasswordScope;
|
password_scope: AttachmentZipPasswordScope;
|
||||||
method: "aes" | "zip_standard";
|
method: "aes" | "zip_standard";
|
||||||
|
password_delivery_channel: "separate_mail" | "sms" | "letter" | "phone" | "in_person";
|
||||||
|
legacy_zipcrypto_acknowledged: boolean;
|
||||||
|
legacy_zipcrypto_reason: string;
|
||||||
|
legacy_zipcrypto_acknowledged_by?: string;
|
||||||
|
legacy_zipcrypto_acknowledged_at?: string;
|
||||||
// Read-only compatibility values retained when normalizing older campaigns.
|
// Read-only compatibility values retained when normalizing older campaigns.
|
||||||
password_mode?: "none" | "direct" | "field" | "template";
|
password_mode?: "none" | "direct" | "field" | "template";
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -32,7 +37,10 @@ export function createAttachmentZipArchive(name = "attachments.zip", standard =
|
|||||||
password_enabled: false,
|
password_enabled: false,
|
||||||
password_field: "",
|
password_field: "",
|
||||||
password_scope: "local",
|
password_scope: "local",
|
||||||
method: "aes"
|
method: "aes",
|
||||||
|
password_delivery_channel: "separate_mail",
|
||||||
|
legacy_zipcrypto_acknowledged: false,
|
||||||
|
legacy_zipcrypto_reason: ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +60,11 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
|
|||||||
password_field: getText(archive, "password_field"),
|
password_field: getText(archive, "password_field"),
|
||||||
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
|
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
|
||||||
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
||||||
|
password_delivery_channel: normalizePasswordDeliveryChannel(getText(archive, "password_delivery_channel", "separate_mail")),
|
||||||
|
legacy_zipcrypto_acknowledged: getBool(archive, "legacy_zipcrypto_acknowledged"),
|
||||||
|
legacy_zipcrypto_reason: getText(archive, "legacy_zipcrypto_reason"),
|
||||||
|
...(getText(archive, "legacy_zipcrypto_acknowledged_by") ? { legacy_zipcrypto_acknowledged_by: getText(archive, "legacy_zipcrypto_acknowledged_by") } : {}),
|
||||||
|
...(getText(archive, "legacy_zipcrypto_acknowledged_at") ? { legacy_zipcrypto_acknowledged_at: getText(archive, "legacy_zipcrypto_acknowledged_at") } : {}),
|
||||||
...(legacyMode ? { password_mode: legacyMode } : {}),
|
...(legacyMode ? { password_mode: legacyMode } : {}),
|
||||||
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
|
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
|
||||||
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
|
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
|
||||||
@@ -76,6 +89,9 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
|
|||||||
password_field: getText(zip, "password_field"),
|
password_field: getText(zip, "password_field"),
|
||||||
password_scope: "local",
|
password_scope: "local",
|
||||||
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
||||||
|
password_delivery_channel: normalizePasswordDeliveryChannel(getText(zip, "password_delivery_channel", "separate_mail")),
|
||||||
|
legacy_zipcrypto_acknowledged: getBool(zip, "legacy_zipcrypto_acknowledged"),
|
||||||
|
legacy_zipcrypto_reason: getText(zip, "legacy_zipcrypto_reason"),
|
||||||
...(legacyMode ? { password_mode: legacyMode } : {}),
|
...(legacyMode ? { password_mode: legacyMode } : {}),
|
||||||
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
|
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
|
||||||
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
|
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
|
||||||
@@ -83,6 +99,13 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePasswordDeliveryChannel(value: string): AttachmentZipArchive["password_delivery_channel"] {
|
||||||
|
if (["sms", "letter", "phone", "in_person"].includes(value)) {
|
||||||
|
return value as AttachmentZipArchive["password_delivery_channel"];
|
||||||
|
}
|
||||||
|
return "separate_mail";
|
||||||
|
}
|
||||||
|
|
||||||
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
|
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
|
||||||
const zip = asRecord(rule.zip);
|
const zip = asRecord(rule.zip);
|
||||||
const archiveId = getText(zip, "archive_id");
|
const archiveId = getText(zip, "archive_id");
|
||||||
|
|||||||
+12
-1
@@ -6,10 +6,12 @@ import {
|
|||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
type DashboardWidgetsUiCapability,
|
type DashboardWidgetsUiCapability,
|
||||||
type PlatformWebModule
|
type PlatformWebModule,
|
||||||
|
type QuickAccessToolsUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { getCampaign } from "./api/campaigns";
|
import { getCampaign } from "./api/campaigns";
|
||||||
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
|
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
|
||||||
|
import CampaignQuickAccess from "./features/campaigns/CampaignQuickAccess";
|
||||||
import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess";
|
import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import { campaignWizardDirectories } from "./features/campaigns/wizard/directory";
|
import { campaignWizardDirectories } from "./features/campaigns/wizard/directory";
|
||||||
@@ -72,6 +74,14 @@ const campaignDashboardWidgets: DashboardWidgetsUiCapability = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
const campaignQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "campaigns.select",
|
||||||
|
render: (context) => createElement(CampaignQuickAccess, context)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
export const campaignModule: PlatformWebModule = {
|
export const campaignModule: PlatformWebModule = {
|
||||||
id: "campaigns",
|
id: "campaigns",
|
||||||
@@ -100,6 +110,7 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
|
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"dashboard.widgets": campaignDashboardWidgets,
|
"dashboard.widgets": campaignDashboardWidgets,
|
||||||
|
"quickAccess.tools": campaignQuickAccessTools,
|
||||||
"wizard.directories": campaignWizardDirectories
|
"wizard.directories": campaignWizardDirectories
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2025,6 +2025,16 @@
|
|||||||
padding-bottom: 7px;
|
padding-bottom: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.campaign-legacy-zipcrypto-acknowledgement {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-legacy-zipcrypto-acknowledgement input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.attachment-zip-name-button {
|
.attachment-zip-name-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ assert.match(overview, /include_files/);
|
|||||||
assert.match(overview, /include_shares/);
|
assert.match(overview, /include_shares/);
|
||||||
assert.match(overview, /include_policies/);
|
assert.match(overview, /include_policies/);
|
||||||
assert.match(overview, /include_mail_profile/);
|
assert.match(overview, /include_mail_profile/);
|
||||||
|
assert.match(overview, /delivery_mode: "manual"/);
|
||||||
|
assert.match(overview, /Autonomous approved delivery/);
|
||||||
|
assert.match(overview, /campaigns:campaign:send/);
|
||||||
|
assert.match(overview, /mail:profile:use/);
|
||||||
|
assert.match(overview, /schedule\.last_outcome/);
|
||||||
|
assert.match(overview, /schedule\.last_recovery_state/);
|
||||||
|
assert.match(overview, /Unknown outcomes pause the schedule and are never retried automatically/);
|
||||||
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
|
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
|
||||||
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
||||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
|
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
|
||||||
|
|||||||
Reference in New Issue
Block a user