Add institutional provenance and approval gates

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent cec3d17bff
commit 2afdd38128
16 changed files with 2472 additions and 509 deletions
+8
View File
@@ -145,6 +145,14 @@ approve/reject chains, delegation, substitutions, escalation, and signatures
belong to the optional Approvals capability. Campaign must not claim an
approval merely because validation, building, or message review completed.
When a campaign has an Approval request reference, mock and real delivery
resolve `approvals.requests` and require an approved request for the exact
`campaign_version` subject and current version id. A missing Approvals module,
unknown request, pending/rejected chain, or approval for an older version blocks
delivery with an explicit requirement. Campaign stores the approval reference,
not Approval tables; changing the campaign version requires a new exact-subject
approval.
Normal readers and reviewers see business state and safe evidence. Process-local
paths, storage keys, worker claim tokens, and raw provider diagnostics require
the dedicated diagnostic permission and must not leak through ordinary campaign,
@@ -0,0 +1,265 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Mapping
from sqlalchemy.orm import Session
from govoplan_core.core.approvals import (
ApprovalRequestCreateCommand,
ApprovalRequestRef,
ApprovalStepDefinition,
)
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_campaign.backend.integrations import (
ApprovalGateUnavailable,
approvals_integration,
)
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
APPROVAL_GATE_KEY = "approval_gate"
SUBJECT_MODULE = "campaigns"
SUBJECT_TYPE = "campaign_execution"
class CampaignApprovalGateError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class _TenantPrincipal:
tenant_id: str
account_id: str | None = None
def campaign_approval_gate(version: CampaignVersion) -> dict[str, object] | None:
state = version.editor_state if isinstance(version.editor_state, dict) else {}
gate = state.get(APPROVAL_GATE_KEY)
return dict(gate) if isinstance(gate, dict) else None
def request_campaign_approval(
session: Session,
principal: object,
*,
campaign: Campaign,
version: CampaignVersion,
title: str,
description: str | None,
steps: tuple[ApprovalStepDefinition, ...],
idempotency_key: str,
template_id: str | None = None,
template_revision: int | None = None,
unique_actors_across_steps: bool = True,
expires_at: datetime | None = None,
policy_refs: tuple[str, ...] = (),
) -> ApprovalRequestRef:
if campaign.tenant_id != str(getattr(principal, "tenant_id", "") or ""):
raise CampaignApprovalGateError("Campaign approval tenant mismatch.")
if version.campaign_id != campaign.id:
raise CampaignApprovalGateError("Campaign approval version mismatch.")
snapshot = ensure_execution_snapshot(session, version)
digest = str(version.execution_snapshot_hash or "")
if len(digest) != 64:
raise CampaignApprovalGateError(
"Build a valid Campaign execution snapshot before requesting approval."
)
subject_version = _subject_version(version, snapshot.build_token)
evidence_actors = _campaign_evidence_actors(campaign, version)
try:
request = approvals_integration().create_request(
session,
principal,
command=ApprovalRequestCreateCommand(
title=title,
description=description,
subject_module=SUBJECT_MODULE,
subject_type=SUBJECT_TYPE,
subject_id=version.id,
subject_version=subject_version,
subject_digest=digest,
steps=steps,
separation_of_duties=True,
unique_actors_across_steps=unique_actors_across_steps,
expires_at=expires_at,
policy_refs=policy_refs,
evidence_actors=evidence_actors,
template_id=template_id,
template_revision=template_revision,
metadata={
"campaign_id": campaign.id,
"campaign_version_number": version.version_number,
"execution_snapshot_version": snapshot.snapshot_version,
},
),
idempotency_key=idempotency_key,
)
except ApprovalGateUnavailable as exc:
raise CampaignApprovalGateError(str(exc)) from exc
state = copy.deepcopy(version.editor_state or {})
state[APPROVAL_GATE_KEY] = {
"request_id": request.id,
"request_revision": request.revision,
"subject_version": subject_version,
"subject_digest": digest,
"requested_at": datetime.now(UTC).isoformat(),
"requested_by_user_id": _actor_id(principal),
}
version.editor_state = state
session.add(version)
session.flush()
return request
def assert_campaign_approval(
session: Session,
*,
tenant_id: str,
version: CampaignVersion,
) -> None:
gate = campaign_approval_gate(version)
if gate is None:
return
snapshot = ensure_execution_snapshot(session, version)
digest = str(version.execution_snapshot_hash or "")
subject_version = _subject_version(version, snapshot.build_token)
if (
gate.get("subject_digest") != digest
or gate.get("subject_version") != subject_version
):
raise CampaignApprovalGateError(
"Campaign execution changed after approval was requested. Request approval for the current build."
)
request_id = str(gate.get("request_id") or "").strip()
if not request_id:
raise CampaignApprovalGateError(
"Campaign approval gate has no request reference."
)
try:
check = approvals_integration().check_approved(
session,
_TenantPrincipal(tenant_id=tenant_id),
request_id=request_id,
subject_module=SUBJECT_MODULE,
subject_type=SUBJECT_TYPE,
subject_id=version.id,
subject_version=subject_version,
subject_digest=digest,
)
except (ApprovalGateUnavailable, LookupError, ValueError) as exc:
raise CampaignApprovalGateError(str(exc)) from exc
if not check.approved:
raise CampaignApprovalGateError(
f"Campaign delivery requires Approval request {request_id}, which is {check.state}."
)
def campaign_approval_status(
session: Session,
*,
tenant_id: str,
version: CampaignVersion,
) -> dict[str, object]:
gate = campaign_approval_gate(version)
integration = approvals_integration()
if gate is None:
return {
"configured": False,
"available": integration.available,
"approved": False,
"state": "not_required",
"explanation": None,
}
try:
assert_campaign_approval(session, tenant_id=tenant_id, version=version)
except CampaignApprovalGateError as exc:
return {
**gate,
"configured": True,
"available": integration.available,
"approved": False,
"state": "unavailable" if not integration.available else "pending",
"explanation": str(exc),
}
return {
**gate,
"configured": True,
"available": True,
"approved": True,
"state": "approved",
"explanation": None,
}
def clear_campaign_approval_gate(version: CampaignVersion) -> None:
state = copy.deepcopy(version.editor_state or {})
state.pop(APPROVAL_GATE_KEY, None)
version.editor_state = state
def _campaign_evidence_actors(
campaign: Campaign,
version: CampaignVersion,
) -> Mapping[str, tuple[str, ...]]:
validation = (
version.validation_summary
if isinstance(version.validation_summary, dict)
else {}
)
build = version.build_summary if isinstance(version.build_summary, dict) else {}
editor = version.editor_state if isinstance(version.editor_state, dict) else {}
review = (
editor.get("review_send") if isinstance(editor.get("review_send"), dict) else {}
)
review_decisions = (
review.get("issue_decisions")
if isinstance(review.get("issue_decisions"), list)
else []
)
values = {
"author": (campaign.created_by_user_id,),
"owner": (campaign.owner_user_id,),
"validator": (
validation.get("validated_by_user_id"),
version.locked_by_user_id,
),
"builder": (build.get("built_by_user_id"),),
"reviewer": (
review.get("updated_by_user_id"),
*(
item.get("actor_user_id")
for item in review_decisions
if isinstance(item, dict)
),
),
}
return {
role: tuple(dict.fromkeys(str(item) for item in actors if item))
for role, actors in values.items()
if any(actors)
}
def _subject_version(version: CampaignVersion, build_token: str | None) -> str:
return str(build_token or f"campaign-version-{version.version_number}")[:120]
def _actor_id(principal: object) -> str | None:
for name in ("account_id", "user_id", "identity_id", "membership_id"):
value = str(getattr(principal, name, "") or "").strip()
if value:
return value
return None
__all__ = [
"CampaignApprovalGateError",
"assert_campaign_approval",
"campaign_approval_gate",
"campaign_approval_status",
"clear_campaign_approval_gate",
"request_campaign_approval",
]
@@ -0,0 +1,78 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.core.approvals import ApprovalActorSelector, ApprovalStepDefinition
class CampaignApprovalSelectorInput(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["account", "group", "role", "function_assignment", "any_account"]
value: str = Field(min_length=1, max_length=255)
label: str | None = Field(default=None, max_length=255)
class CampaignApprovalStepInput(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=255)
selectors: list[CampaignApprovalSelectorInput] = Field(min_length=1, max_length=500)
required_approvals: int = Field(default=1, ge=1, le=500)
rejection_policy: Literal["fail_fast", "collect"] = "fail_fast"
due_at: datetime | None = None
signature_required: bool = False
forbidden_evidence_roles: list[
Literal["author", "owner", "validator", "builder", "reviewer"]
] = Field(default_factory=list, max_length=5)
def to_definition(self) -> ApprovalStepDefinition:
return ApprovalStepDefinition(
key=self.key,
label=self.label,
selectors=tuple(
ApprovalActorSelector(item.kind, item.value, item.label)
for item in self.selectors
),
required_approvals=self.required_approvals,
rejection_policy=self.rejection_policy,
due_at=self.due_at,
signature_required=self.signature_required,
forbidden_evidence_roles=tuple(self.forbidden_evidence_roles),
)
class CampaignApprovalRequestInput(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(
default="Approve Campaign delivery", min_length=1, max_length=255
)
description: str | None = Field(default=None, max_length=10_000)
steps: list[CampaignApprovalStepInput] = Field(default_factory=list, max_length=100)
template_id: str | None = Field(default=None, max_length=36)
template_revision: int | None = Field(default=None, ge=1)
unique_actors_across_steps: bool = True
expires_at: datetime | None = None
policy_refs: list[str] = Field(default_factory=list, max_length=500)
idempotency_key: str = Field(min_length=1, max_length=160)
@model_validator(mode="after")
def validate_source(self) -> "CampaignApprovalRequestInput":
template = self.template_id is not None or self.template_revision is not None
if template and (self.template_id is None or self.template_revision is None):
raise ValueError("Template id and revision must be supplied together.")
if template and self.steps:
raise ValueError("Use either an Approval template or inline steps.")
if not template and not self.steps:
raise ValueError(
"At least one Approval step is required without a template."
)
return self
__all__ = ["CampaignApprovalRequestInput"]
@@ -13,7 +13,9 @@ CAMPAIGN_MAIL_SERVER_KEYS = frozenset(
"imap_credential_id",
}
)
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"})
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset(
{"created_from", "field_overrides", "opt_ins"}
)
CAMPAIGN_OPT_IN_KEYS = frozenset(
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
)
@@ -27,6 +29,16 @@ CAMPAIGN_REVIEW_STATE_KEYS = frozenset(
"updated_by_user_id",
}
)
CAMPAIGN_APPROVAL_GATE_KEYS = frozenset(
{
"request_id",
"request_revision",
"subject_version",
"subject_digest",
"requested_at",
"requested_by_user_id",
}
)
class CampaignMailProfileBoundaryError(ValueError):
@@ -94,10 +106,13 @@ def validate_campaign_editor_state(
if value is None:
return {}
if not isinstance(value, dict):
raise CampaignMailProfileBoundaryError("Campaign editor state must be an object")
raise CampaignMailProfileBoundaryError(
"Campaign editor state must be an object"
)
allowed = set(CAMPAIGN_CLIENT_EDITOR_STATE_KEYS)
if allow_server_review_state:
allowed.add("review_send")
allowed.add("approval_gate")
if any(key not in allowed for key in value):
raise CampaignMailProfileBoundaryError(
"Campaign editor state contains unsupported or transport-owned fields"
@@ -113,11 +128,13 @@ def validate_campaign_editor_state(
if "opt_ins" in value:
result["opt_ins"] = _validated_opt_ins(value["opt_ins"])
if "field_overrides" in value:
result["field_overrides"] = _validated_field_overrides(
value["field_overrides"]
)
result["field_overrides"] = _validated_field_overrides(value["field_overrides"])
if "review_send" in value:
result["review_send"] = _validated_server_review_state(value["review_send"])
if "approval_gate" in value:
result["approval_gate"] = _validated_server_approval_gate(
value["approval_gate"]
)
return result
@@ -162,6 +179,13 @@ def public_campaign_editor_state(
result["review_send"] = review_state
except CampaignMailProfileBoundaryError:
pass
if "approval_gate" in value:
try:
result["approval_gate"] = _validated_server_approval_gate(
value["approval_gate"]
)
except CampaignMailProfileBoundaryError:
pass
return result
@@ -170,9 +194,48 @@ def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
state = public_campaign_editor_state(value)
state.pop("review_send", None)
state.pop("approval_gate", None)
return state
def _validated_server_approval_gate(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or any(
key not in CAMPAIGN_APPROVAL_GATE_KEYS for key in value
):
raise CampaignMailProfileBoundaryError("Campaign approval gate is invalid")
required_strings = (
"request_id",
"subject_version",
"subject_digest",
"requested_at",
)
if any(
not isinstance(value.get(key), str) or not str(value.get(key)).strip()
for key in required_strings
):
raise CampaignMailProfileBoundaryError(
"Campaign approval gate references are invalid"
)
digest = str(value["subject_digest"])
if len(digest) != 64 or any(
character not in "0123456789abcdef" for character in digest
):
raise CampaignMailProfileBoundaryError(
"Campaign approval gate digest is invalid"
)
revision = value.get("request_revision")
if not isinstance(revision, int) or revision < 1:
raise CampaignMailProfileBoundaryError(
"Campaign approval gate revision is invalid"
)
requested_by = value.get("requested_by_user_id")
if requested_by is not None and not isinstance(requested_by, str):
raise CampaignMailProfileBoundaryError(
"Campaign approval gate actor is invalid"
)
return copy.deepcopy(value)
def _is_valid_reviewed_message_key(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip()) and len(value) <= 512
@@ -214,9 +277,7 @@ def _validated_issue_decisions(value: Any) -> list[dict[str, Any]]:
"issue_codes",
}
for item in value:
if not isinstance(item, dict) or any(
key not in allowed_keys for key in item
):
if not isinstance(item, dict) or any(key not in allowed_keys for key in item):
raise CampaignMailProfileBoundaryError(
"Campaign review issue decision is invalid"
)
@@ -237,9 +298,7 @@ def _validated_issue_decisions(value: Any) -> list[dict[str, Any]]:
error="Campaign review decision is invalid",
),
"reason": item.get("reason"),
"actor_user_id": _validated_review_actor(
item.get("actor_user_id")
),
"actor_user_id": _validated_review_actor(item.get("actor_user_id")),
"decided_at": _validated_required_string(
item.get("decided_at"),
max_length=128,
@@ -364,12 +423,18 @@ def campaign_mail_resource_ids(
}
def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]:
def campaign_mail_profile_boundary_violations(
raw_json: dict[str, Any] | None,
) -> tuple[str, ...]:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return ()
violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS]
violations = [
f"/server/{key}"
for key in sorted(server)
if key not in CAMPAIGN_MAIL_SERVER_KEYS
]
for key in CAMPAIGN_MAIL_SERVER_KEYS:
if key not in server:
continue
@@ -378,9 +443,7 @@ def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -
violations.append(f"/server/{key}")
references = campaign_mail_resource_ids(raw_json)
if references["mail_profile_id"] is None and any(
references[key]
for key in references
if key != "mail_profile_id"
references[key] for key in references if key != "mail_profile_id"
):
violations.append("/server/mail_profile_id")
for protocol in ("smtp", "imap"):
+139 -23
View File
@@ -7,6 +7,13 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from govoplan_core.core.approvals import (
ApprovalCheck,
ApprovalRequestCreateCommand,
ApprovalRequestProvider,
ApprovalRequestRef,
CAPABILITY_APPROVAL_REQUESTS,
)
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_DELIVERY,
@@ -29,6 +36,7 @@ MAIL_CAPABILITY = "mail.campaign_delivery"
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
class OptionalModuleUnavailable(RuntimeError):
@@ -40,7 +48,9 @@ class SmtpConfigurationError(RuntimeError):
class SmtpSendError(RuntimeError):
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False) -> None:
def __init__(
self, message: str, *, temporary: bool = False, outcome_unknown: bool = False
) -> None:
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
@@ -75,6 +85,10 @@ class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
pass
class ApprovalGateUnavailable(OptionalModuleUnavailable):
pass
class _PreparedCampaignSnapshot:
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
self._directory = directory
@@ -103,20 +117,30 @@ class FilesCampaignIntegration:
yield prepared
return
raw_json = kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {}
raw_json = (
kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {}
)
prefix = str(kwargs.get("prefix") or "govoplan-campaign-")
directory = Path(tempfile.mkdtemp(prefix=prefix))
snapshot = _PreparedCampaignSnapshot(directory, directory / "campaign.json", raw_json)
snapshot.path.write_text(json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
snapshot = _PreparedCampaignSnapshot(
directory, directory / "campaign.json", raw_json
)
snapshot.path.write_text(
json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8"
)
try:
yield snapshot
finally:
snapshot.cleanup()
def managed_match_payloads(self, matches: Any, managed_files_by_local_path: dict[str, Any]) -> list[dict[str, Any]]:
def managed_match_payloads(
self, matches: Any, managed_files_by_local_path: dict[str, Any]
) -> list[dict[str, Any]]:
if self._delegate is None:
return []
return self._delegate.managed_match_payloads(matches, managed_files_by_local_path)
return self._delegate.managed_match_payloads(
matches, managed_files_by_local_path
)
def public_attachment_summary_payload(self, attachment: Any) -> dict[str, Any]:
if self._delegate is not None:
@@ -127,20 +151,30 @@ class FilesCampaignIntegration:
return dict(attachment)
return {"path": str(attachment)}
def annotate_built_messages_with_managed_files(self, built_messages: Any, managed_files_by_local_path: dict[str, Any]) -> None:
def annotate_built_messages_with_managed_files(
self, built_messages: Any, managed_files_by_local_path: dict[str, Any]
) -> None:
if self._delegate is not None:
self._delegate.annotate_built_messages_with_managed_files(built_messages, managed_files_by_local_path)
self._delegate.annotate_built_messages_with_managed_files(
built_messages, managed_files_by_local_path
)
def record_campaign_attachment_uses_for_jobs(self, session: Any, jobs: Any, *, stage: str) -> None:
def record_campaign_attachment_uses_for_jobs(
self, session: Any, jobs: Any, *, stage: str
) -> None:
if self._delegate is not None:
self._delegate.record_campaign_attachment_uses_for_jobs(session, jobs, stage=stage)
self._delegate.record_campaign_attachment_uses_for_jobs(
session, jobs, stage=stage
)
def current_version_and_blob(self, session: Any, asset: Any) -> tuple[Any, Any]:
if self._delegate is None:
raise OptionalModuleUnavailable("Files module is not available")
return self._delegate.current_version_and_blob(session, asset)
def share_assets_with_campaign(self, session: Any, **kwargs: Any) -> list[dict[str, Any]]:
def share_assets_with_campaign(
self, session: Any, **kwargs: Any
) -> list[dict[str, Any]]:
if self._delegate is None:
raise OptionalModuleUnavailable("Files module is not available")
return self._delegate.share_assets_with_campaign(session, **kwargs)
@@ -154,7 +188,9 @@ class MailCampaignIntegration:
def __init__(self, delegate: Any | None = None) -> None:
self._delegate = delegate
if delegate is not None:
self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError)
self.MailProfileError = getattr(
delegate, "MailProfileError", MailProfileError
)
MailProfileError = MailProfileError
SmtpConfigurationError = SmtpConfigurationError
@@ -177,26 +213,38 @@ class MailCampaignIntegration:
raise MailProfileError("Mail module is not available")
return self._delegate
def assert_campaign_mail_policy_allows_json(self, session: Any, **kwargs: Any) -> None:
def assert_campaign_mail_policy_allows_json(
self, session: Any, **kwargs: Any
) -> None:
if self._delegate is None:
raw_json = kwargs.get("raw_json")
profile_id = self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {})
profile_id = self.mail_profile_id_from_campaign_json(
raw_json if isinstance(raw_json, dict) else {}
)
if profile_id:
raise MailProfileError("Campaign mail-server profiles require the mail module")
raise MailProfileError(
"Campaign mail-server profiles require the mail module"
)
return None
try:
return self._delegate.assert_campaign_mail_policy_allows_json(session, **kwargs)
return self._delegate.assert_campaign_mail_policy_allows_json(
session, **kwargs
)
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def mail_profile_id_from_campaign_json(self, raw_json: dict[str, Any]) -> str | None:
def mail_profile_id_from_campaign_json(
self, raw_json: dict[str, Any]
) -> str | None:
if self._delegate is not None:
return self._delegate.mail_profile_id_from_campaign_json(raw_json)
server = raw_json.get("server") if isinstance(raw_json, dict) else None
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
return str(profile_id).strip() if profile_id else None
def campaign_profile_delivery_summary(self, session: Any, **kwargs: Any) -> dict[str, Any]:
def campaign_profile_delivery_summary(
self, session: Any, **kwargs: Any
) -> dict[str, Any]:
delegate = self._require()
try:
return delegate.campaign_profile_delivery_summary(session, **kwargs)
@@ -213,8 +261,14 @@ class MailCampaignIntegration:
try:
return delegate.send_campaign_email_bytes(*args, **kwargs)
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
raise SmtpSendError(
str(exc),
temporary=bool(getattr(exc, "temporary", False)),
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
) from exc
except getattr(
delegate, "SmtpConfigurationError", SmtpConfigurationError
) as exc:
raise SmtpConfigurationError(str(exc)) from exc
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
@@ -229,7 +283,9 @@ class MailCampaignIntegration:
temporary=getattr(exc, "temporary", None),
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
) from exc
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
except getattr(
delegate, "ImapConfigurationError", ImapConfigurationError
) as exc:
raise ImapConfigurationError(str(exc)) from exc
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
@@ -302,8 +358,7 @@ class PostboxCampaignIntegration:
@property
def available(self) -> bool:
return (
self._delivery_delegate is not None
and self._directory_delegate is not None
self._delivery_delegate is not None and self._directory_delegate is not None
)
@property
@@ -381,6 +436,63 @@ class PostboxCampaignIntegration:
return summaries
class ApprovalCampaignIntegration:
def __init__(self, delegate: object | None = None) -> None:
self._delegate = (
delegate if isinstance(delegate, ApprovalRequestProvider) else None
)
@property
def available(self) -> bool:
return self._delegate is not None
def create_request(
self,
session: object,
principal: object,
*,
command: ApprovalRequestCreateCommand,
idempotency_key: str,
) -> ApprovalRequestRef:
if self._delegate is None:
raise ApprovalGateUnavailable(
"Campaign approval gates require the Approvals module."
)
return self._delegate.create_request(
session,
principal,
command=command,
idempotency_key=idempotency_key,
)
def check_approved(
self,
session: object,
principal: object,
*,
request_id: str,
subject_module: str,
subject_type: str,
subject_id: str,
subject_version: str | None,
subject_digest: str,
) -> ApprovalCheck:
if self._delegate is None:
raise ApprovalGateUnavailable(
"Campaign delivery is approval-gated, but the Approvals module is unavailable."
)
return self._delegate.check_approved(
session,
principal,
request_id=request_id,
subject_module=subject_module,
subject_type=subject_type,
subject_id=subject_id,
subject_version=subject_version,
subject_digest=subject_digest,
)
def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
@@ -395,3 +507,7 @@ def postbox_integration() -> PostboxCampaignIntegration:
capability(POSTBOX_DIRECTORY_CAPABILITY),
capability(POSTBOX_EVIDENCE_CAPABILITY),
)
def approvals_integration() -> ApprovalCampaignIntegration:
return ApprovalCampaignIntegration(capability(APPROVALS_CAPABILITY))
+378 -79
View File
@@ -2,7 +2,11 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.approvals import CAPABILITY_APPROVAL_REQUESTS
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_ACCESS,
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
@@ -10,7 +14,10 @@ from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION,
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.ownership import OwnershipProviderRegistration
from govoplan_core.core.modules import (
DocumentationCondition,
@@ -39,12 +46,17 @@ from govoplan_core.core.postbox import (
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
from govoplan_campaign.backend.documentation import (
CAMPAIGN_USER_DOCUMENTATION,
documentation_topics,
)
register_campaign_change_tracking()
def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition:
def _permission(
scope: str, label: str, description: str, category: str
) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
@@ -59,32 +71,162 @@ def _permission(scope: str, label: str, description: str, category: str) -> Perm
PERMISSIONS = (
_permission("campaigns:campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"),
_permission("campaigns:campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"),
_permission("campaigns:campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"),
_permission("campaigns:campaign:copy", "Copy campaigns", "Create campaigns or working versions from existing campaigns.", "Campaigns"),
_permission("campaigns:campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"),
_permission("campaigns:campaign:delete", "Delete campaigns", "Delete draft-only campaigns where retention policy allows it.", "Campaigns"),
_permission("campaigns:campaign:share", "Share campaigns", "Grant or revoke explicit campaign access.", "Campaigns"),
_permission("campaigns:ownership:accept_group", "Accept group campaign ownership", "Accept or decline campaign ownership on behalf of a group the actor belongs to.", "Campaign governance"),
_permission("campaigns:ownership:recover", "Recover campaign ownership", "Participate in delayed, quorum-backed administrative campaign ownership recovery.", "Campaign governance"),
_permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"),
_permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"),
_permission("campaigns:campaign:review", "Complete campaign review", "Record review completion and the exact built messages inspected.", "Campaigns"),
_permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
_permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
_permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"),
_permission("campaigns:campaign:send", "Send campaigns", "Start real Mail or Postbox delivery.", "Campaigns"),
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown Mail, Postbox, or IMAP attempts after inspection.", "Campaigns"),
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
_permission("campaigns:recipient:export", "Export recipients", "Export recipient data and reports.", "Recipients"),
_permission("campaigns:report:read", "View reports", "View campaign delivery reports and aggregate outcomes.", "Reports"),
_permission("campaigns:report:export", "Export reports", "Download detailed campaign reports.", "Reports"),
_permission("campaigns:report:send", "Send reports", "Email campaign reports to configured recipients.", "Reports"),
_permission(
"campaigns:campaign:read",
"View campaigns",
"Open campaign metadata, versions and permitted message summaries.",
"Campaigns",
),
_permission(
"campaigns:campaign:create",
"Create campaigns",
"Create new campaigns in an accessible tenant scope.",
"Campaigns",
),
_permission(
"campaigns:campaign:update",
"Edit campaigns",
"Edit current working campaign versions.",
"Campaigns",
),
_permission(
"campaigns:campaign:copy",
"Copy campaigns",
"Create campaigns or working versions from existing campaigns.",
"Campaigns",
),
_permission(
"campaigns:campaign:archive",
"Archive campaigns",
"Archive campaigns without destroying audit evidence.",
"Campaigns",
),
_permission(
"campaigns:campaign:delete",
"Delete campaigns",
"Delete draft-only campaigns where retention policy allows it.",
"Campaigns",
),
_permission(
"campaigns:campaign:share",
"Share campaigns",
"Grant or revoke explicit campaign access.",
"Campaigns",
),
_permission(
"campaigns:ownership:accept_group",
"Accept group campaign ownership",
"Accept or decline campaign ownership on behalf of a group the actor belongs to.",
"Campaign governance",
),
_permission(
"campaigns:ownership:recover",
"Recover campaign ownership",
"Participate in delayed, quorum-backed administrative campaign ownership recovery.",
"Campaign governance",
),
_permission(
"campaigns:campaign:validate",
"Validate campaigns",
"Run technical validation and manage validation locks.",
"Campaigns",
),
_permission(
"campaigns:campaign:build",
"Build campaigns",
"Build exact messages and attachment evidence.",
"Campaigns",
),
_permission(
"campaigns:campaign:review",
"Complete campaign review",
"Record review completion and the exact built messages inspected.",
"Campaigns",
),
_permission(
"campaigns:campaign:send_test",
"Mock-send campaigns",
"Use mock delivery and verification tools.",
"Campaigns",
),
_permission(
"campaigns:campaign:queue",
"Queue campaigns",
"Place approved executions into the delivery queue.",
"Campaigns",
),
_permission(
"campaigns:campaign:control",
"Control delivery",
"Pause, resume or cancel queued and sending jobs.",
"Campaigns",
),
_permission(
"campaigns:campaign:send",
"Send campaigns",
"Start real Mail or Postbox delivery.",
"Campaigns",
),
_permission(
"campaigns:campaign:retry",
"Retry delivery",
"Retry failed or unattempted delivery jobs.",
"Campaigns",
),
_permission(
"campaigns:campaign:reconcile",
"Reconcile delivery",
"Resolve outcome-unknown Mail, Postbox, or IMAP attempts after inspection.",
"Campaigns",
),
_permission(
"campaigns:diagnostic:read",
"View campaign diagnostics",
"Inspect worker claims and internal storage locators for campaign delivery troubleshooting.",
"Campaign operations",
),
_permission(
"campaigns:recipient:read",
"View recipients",
"Read recipient lists and recipient-specific campaign data.",
"Recipients",
),
_permission(
"campaigns:recipient:write",
"Edit recipients",
"Create and edit recipient rows and field values.",
"Recipients",
),
_permission(
"campaigns:recipient:import",
"Import recipients",
"Bulk-import recipient lists.",
"Recipients",
),
_permission(
"campaigns:recipient:export",
"Export recipients",
"Export recipient data and reports.",
"Recipients",
),
_permission(
"campaigns:report:read",
"View reports",
"View campaign delivery reports and aggregate outcomes.",
"Reports",
),
_permission(
"campaigns:report:export",
"Export reports",
"Download detailed campaign reports.",
"Reports",
),
_permission(
"campaigns:report:send",
"Send reports",
"Email campaign reports to configured recipients.",
"Reports",
),
)
OPERATOR_QUEUE_REQUIRED_ANY = (
@@ -108,9 +250,7 @@ ROLE_TEMPLATES = (
slug="campaign_aggregate_reader",
name="Campaign aggregate reader",
description="View privacy-protected outcome totals without recipient or delivery diagnostics.",
permissions=(
"campaigns:report:read",
),
permissions=("campaigns:report:read",),
),
RoleTemplate(
slug="campaign_manager",
@@ -166,7 +306,11 @@ ROLE_TEMPLATES = (
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_campaign.backend.db.models import Campaign
return {"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count()}
return {
"campaigns": session.query(Campaign)
.filter(Campaign.tenant_id == tenant_id)
.count()
}
def _tenant_summary_batch(session, tenant_ids) -> dict[str, dict[str, int]]:
@@ -183,10 +327,7 @@ def _tenant_summary_batch(session, tenant_ids) -> dict[str, dict[str, int]]:
.group_by(Campaign.tenant_id)
.all()
)
return {
tenant_id: {"campaigns": int(count)}
for tenant_id, count in rows
}
return {tenant_id: {"campaigns": int(count)} for tenant_id, count in rows}
def _campaigns_router(context: ModuleContext):
@@ -207,14 +348,21 @@ manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.12",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_capabilities=(CAPABILITY_ACCESS_REFERENCE_OPTIONS,),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
optional_capabilities=(
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
CAPABILITY_APPROVAL_REQUESTS,
),
optional_dependencies=(
"files",
"mail",
"notifications",
"addresses",
"postbox",
"approvals",
),
provides_interfaces=(
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
@@ -278,6 +426,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_APPROVAL_REQUESTS,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS,
route_factory=_campaigns_router,
@@ -285,7 +439,13 @@ manifest = ModuleManifest(
tenant_summary_providers=(_tenant_summary,),
tenant_summary_batch_providers=(_tenant_summary_batch,),
nav_items=(
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20),
NavItem(
path="/campaigns",
label="Campaigns",
icon="campaign",
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
order=20,
),
),
frontend=FrontendModule(
module_id="campaigns",
@@ -321,8 +481,16 @@ manifest = ModuleManifest(
FrontendRoute(path="/templates", component="TemplatesPage", order=90),
),
nav_items=(
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20),
NavItem(path="/templates", label="Templates", icon="layout-template", order=90),
NavItem(
path="/campaigns",
label="Campaigns",
icon="campaign",
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
order=20,
),
NavItem(
path="/templates", label="Templates", icon="layout-template", order=90
),
),
view_surfaces=(
ViewSurface(
@@ -384,21 +552,34 @@ manifest = ModuleManifest(
body="Configure campaign-wide targets and optional per-row additions or replacements. Derived targets resolve a published Postbox template with organization unit, function, and optional context values, including values sourced from Campaign fields. Targets are frozen during build. Fallback crosses to the second channel only after a confirmed pre-acceptance rejection; accepted or outcome-unknown effects never trigger fallback.",
layer="available",
documentation_types=("user", "admin"),
audience=("campaign_manager", "campaign_reviewer", "campaign_sender", "campaign_operator"),
audience=(
"campaign_manager",
"campaign_reviewer",
"campaign_sender",
"campaign_operator",
),
order=45,
conditions=(
DocumentationCondition(
required_modules=("campaigns", "postbox"),
any_scopes=("campaigns:recipient:write", "campaigns:campaign:send", "campaigns:campaign:reconcile"),
any_scopes=(
"campaigns:recipient:write",
"campaigns:campaign:send",
"campaigns:campaign:reconcile",
),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Postboxes", href="/postbox", kind="runtime"),
DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"),
DocumentationLink(
label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"
),
),
related_modules=("postbox", "organizations", "idm", "mail", "audit"),
unlocks=("Audited direct, derived, combined, and pre-acceptance fallback delivery to Postboxes.",),
unlocks=(
"Audited direct, derived, combined, and pre-acceptance fallback delivery to Postboxes.",
),
metadata={
"kind": "workflow",
"route": "/campaigns/{campaign_id}/global-settings",
@@ -439,8 +620,16 @@ manifest = ModuleManifest(
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
DocumentationLink(
label="Mail profiles",
href="/settings?section=mail-profiles",
kind="runtime",
),
DocumentationLink(
label="Campaign handbook",
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
kind="repository",
),
),
related_modules=("mail",),
unlocks=("Profile-backed SMTP delivery and optional IMAP append-to-Sent.",),
@@ -480,13 +669,27 @@ manifest = ModuleManifest(
conditions=(
DocumentationCondition(
required_modules=("campaigns", "mail"),
any_scopes=("mail:profile:write", "admin:policies:read", "system:settings:read"),
any_scopes=(
"mail:profile:write",
"admin:policies:read",
"system:settings:read",
),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"),
DocumentationLink(label="Mail profile boundary", href="govoplan-campaign/docs/MAIL_PROFILE_BOUNDARY.md", kind="repository"),
DocumentationLink(
label="Mail profiles",
href="/settings?section=mail-profiles",
kind="runtime",
),
DocumentationLink(
label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"
),
DocumentationLink(
label="Mail profile boundary",
href="govoplan-campaign/docs/MAIL_PROFILE_BOUNDARY.md",
kind="repository",
),
),
related_modules=("mail", "access"),
unlocks=("Auditable, reusable transport configuration across campaigns.",),
@@ -514,13 +717,27 @@ manifest = ModuleManifest(
conditions=(
DocumentationCondition(
required_modules=("campaigns", "mail"),
any_scopes=("campaigns:diagnostic:read", "campaigns:campaign:reconcile", "mail:profile:test"),
any_scopes=(
"campaigns:diagnostic:read",
"campaigns:campaign:reconcile",
"mail:profile:test",
),
),
),
links=(
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
DocumentationLink(label="Campaign reports", href="/campaigns/reports", kind="runtime"),
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
DocumentationLink(
label="Campaign operator queue",
href="/campaigns/queue",
kind="runtime",
),
DocumentationLink(
label="Campaign reports", href="/campaigns/reports", kind="runtime"
),
DocumentationLink(
label="Campaign delivery runbook",
href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md",
kind="repository",
),
),
related_modules=("mail", "audit"),
unlocks=("Fail-closed recovery without exposing Mail credentials.",),
@@ -548,16 +765,30 @@ manifest = ModuleManifest(
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
required_scopes=("campaigns:campaign:update", "campaigns:campaign:validate", "campaigns:campaign:build"),
required_scopes=(
"campaigns:campaign:update",
"campaigns:campaign:validate",
"campaigns:campaign:build",
),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Recipient import guide", href="govoplan-campaign/docs/RECIPIENT_IMPORT_GUIDE.md", kind="repository"),
DocumentationLink(
label="Campaign handbook",
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
kind="repository",
),
DocumentationLink(
label="Recipient import guide",
href="govoplan-campaign/docs/RECIPIENT_IMPORT_GUIDE.md",
kind="repository",
),
),
related_modules=("addresses", "files", "mail"),
unlocks=("A reviewable build whose exact recipient-specific effects can be inspected before delivery.",),
unlocks=(
"A reviewable build whose exact recipient-specific effects can be inspected before delivery.",
),
metadata={
"kind": "workflow",
"route": "/campaigns/{campaign_id}/global-settings",
@@ -608,15 +839,24 @@ manifest = ModuleManifest(
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:review"),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:review",
),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
DocumentationLink(
label="Campaign handbook",
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
kind="repository",
),
),
related_modules=("files", "mail"),
unlocks=("An attributable review-completion record for the exact built messages inspected.",),
unlocks=(
"An attributable review-completion record for the exact built messages inspected.",
),
metadata={
"kind": "workflow",
"route": "/campaigns/{campaign_id}/review",
@@ -652,20 +892,38 @@ manifest = ModuleManifest(
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
any_scopes=("campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:diagnostic:read"),
any_scopes=(
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:diagnostic:read",
),
),
),
links=(
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
DocumentationLink(
label="Campaign operator queue",
href="/campaigns/queue",
kind="runtime",
),
DocumentationLink(
label="Campaign delivery runbook",
href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md",
kind="repository",
),
),
related_modules=("mail", "audit"),
unlocks=("Evidence-backed recovery without accidental duplicate external effects.",),
unlocks=(
"Evidence-backed recovery without accidental duplicate external effects.",
),
metadata={
"kind": "workflow",
"route": "/campaigns/queue",
"screen": "Campaign operator queue",
"help_contexts": ["campaign.review-send", "campaign.report", "campaign.audit"],
"help_contexts": [
"campaign.review-send",
"campaign.report",
"campaign.audit",
],
"prerequisites": [
"You may perform the selected retry or reconciliation action.",
"Provider, mailbox, worker, and campaign evidence has been preserved.",
@@ -691,20 +949,40 @@ manifest = ModuleManifest(
body="Campaign is a reference composition only when Core, Mail, Files, Addresses, workers, storage, policies, and documentation are tested in the exact installed combination. Normal readers see business state rather than paths, storage keys, worker claims, or raw provider diagnostics; diagnostic and export authority remain separate.",
layer="evidence",
documentation_types=("admin",),
audience=("platform_operator", "security_reviewer", "release_reviewer", "integrator"),
audience=(
"platform_operator",
"security_reviewer",
"release_reviewer",
"integrator",
),
order=52,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
any_scopes=("campaigns:diagnostic:read", "campaigns:report:read", "system:settings:read", "admin:modules:read"),
any_scopes=(
"campaigns:diagnostic:read",
"campaigns:report:read",
"system:settings:read",
"admin:modules:read",
),
),
),
links=(
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Reference examples and release checklist", href="govoplan-campaign/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md", kind="repository"),
DocumentationLink(
label="Campaign handbook",
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
kind="repository",
),
DocumentationLink(
label="Reference examples and release checklist",
href="govoplan-campaign/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md",
kind="repository",
),
),
related_modules=("mail", "postbox", "files", "addresses", "audit"),
unlocks=("A repeatable, supportable Campaign demonstration rather than an unverified module assembly.",),
unlocks=(
"A repeatable, supportable Campaign demonstration rather than an unverified module assembly.",
),
metadata={
"kind": "reference",
"route": "/campaigns",
@@ -735,8 +1013,16 @@ manifest = ModuleManifest(
),
),
links=(
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
DocumentationLink(
label="Campaign operator queue",
href="/campaigns/queue",
kind="runtime",
),
DocumentationLink(
label="Campaign delivery runbook",
href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md",
kind="repository",
),
),
related_modules=("files", "ops", "mail"),
metadata={
@@ -800,9 +1086,22 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/CAMPAIGN_HANDBOOK.md",
test_ref="tests/test_execution_snapshot_integrity.py",
known_limits=("Target-environment recovery and accessibility evidence is incomplete for reference-ready maturity.",),
owned_concepts=("campaign", "campaign version", "recipient snapshot", "delivery job", "delivery report"),
non_owned_concepts=("mail transport", "postbox", "durable address directory", "file storage"),
known_limits=(
"Target-environment recovery and accessibility evidence is incomplete for reference-ready maturity.",
),
owned_concepts=(
"campaign",
"campaign version",
"recipient snapshot",
"delivery job",
"delivery report",
),
non_owned_concepts=(
"mail transport",
"postbox",
"durable address directory",
"file storage",
),
recovery_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
security_docs=("docs/ACCESS_EXPLANATION_COVERAGE.md",),
operations_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
@@ -33,7 +33,10 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus,
JobValidationStatus,
)
from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema
from govoplan_campaign.backend.campaign.loader import (
load_campaign_json,
validate_against_schema,
)
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_id,
@@ -46,7 +49,10 @@ from govoplan_campaign.backend.campaign.postbox_targets import (
)
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_delivery_summary
from govoplan_campaign.backend.sending.execution import (
create_execution_snapshot,
profile_delivery_summary,
)
from govoplan_campaign.backend.campaign.models import (
CampaignConfig,
DeliveryChannelPolicy,
@@ -73,8 +79,6 @@ class _StoredEmlArtifact:
message_id_header: str | None
def load_campaign_config_from_json(
session: Session,
*,
@@ -116,7 +120,11 @@ def _campaign_reference_path(version: CampaignVersion) -> Path:
"""Return a path anchor without persisting a second configuration copy."""
source_base_path = str(version.source_base_path or "").strip()
base = Path(source_base_path).expanduser().resolve() if source_base_path else Path.cwd()
base = (
Path(source_base_path).expanduser().resolve()
if source_base_path
else Path.cwd()
)
return base / "campaign.json"
@@ -142,8 +150,7 @@ def _persist_built_eml_artifacts(
path = Path(message.eml_path)
if not path.is_file():
raise CampaignPersistenceError(
"Generated EML evidence is missing for entry "
f"{message.entry_index}"
f"Generated EML evidence is missing for entry {message.entry_index}"
)
payload = path.read_bytes()
digest = hashlib.sha256(payload).hexdigest()
@@ -191,7 +198,11 @@ def _delete_storage_keys(storage: StorageBackend, keys: list[str]) -> None:
def _next_version_number(session: Session, campaign_id: str) -> int:
current = session.query(func.max(CampaignVersion.version_number)).filter(CampaignVersion.campaign_id == campaign_id).scalar()
current = (
session.query(func.max(CampaignVersion.version_number))
.filter(CampaignVersion.campaign_id == campaign_id)
.scalar()
)
return int(current or 0) + 1
@@ -204,7 +215,9 @@ def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | No
return str((base_path / path).resolve())
def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]:
def normalize_campaign_paths(
raw_json: dict[str, Any], source_base_path: str | Path | None
) -> dict[str, Any]:
"""Resolve paths for an explicitly trusted, file-oriented import.
The CLI naturally resolves relative paths against the campaign.json file.
@@ -217,18 +230,28 @@ def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | P
base = Path(source_base_path).expanduser().resolve() if source_base_path else None
data = copy.deepcopy(raw_json)
template_source = data.get("template", {}).get("source") if isinstance(data.get("template"), dict) else None
template_source = (
data.get("template", {}).get("source")
if isinstance(data.get("template"), dict)
else None
)
if isinstance(template_source, dict):
for key in ("subject_path", "text_path", "html_path"):
template_source[key] = _resolve_runtime_path(base, template_source.get(key))
entries_source = data.get("entries", {}).get("source") if isinstance(data.get("entries"), dict) else None
entries_source = (
data.get("entries", {}).get("source")
if isinstance(data.get("entries"), dict)
else None
)
if isinstance(entries_source, dict):
entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path"))
attachments = data.get("attachments")
if isinstance(attachments, dict):
attachments["base_path"] = _resolve_runtime_path(base, attachments.get("base_path")) or "."
attachments["base_path"] = (
_resolve_runtime_path(base, attachments.get("base_path")) or "."
)
return data
@@ -251,15 +274,21 @@ def create_campaign_version_from_json(
)
if source_base_path is None and source_filename:
source_path = Path(source_filename).expanduser()
source_base_path = str(source_path.parent if source_path.suffix else source_path)
source_base_path = str(
source_path.parent if source_path.suffix else source_path
)
runtime_json = normalize_campaign_paths(raw_json, source_base_path)
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id)
config = load_campaign_config_from_json(
session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id
)
campaign = (
session.query(Campaign)
.filter(Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id)
.filter(
Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id
)
.one_or_none()
)
if campaign is None:
@@ -275,7 +304,11 @@ def create_campaign_version_from_json(
session.add(campaign)
session.flush()
else:
current = session.get(CampaignVersion, campaign.current_version_id) if campaign.current_version_id else None
current = (
session.get(CampaignVersion, campaign.current_version_id)
if campaign.current_version_id
else None
)
if current and not _version_is_audit_safe_snapshot(current):
raise CampaignPersistenceError(
f"Campaign already has active working version #{current.version_number}. "
@@ -303,8 +336,6 @@ def create_campaign_version_from_json(
return campaign, version
def _version_user_lock_state(version: CampaignVersion) -> str | None:
state = getattr(version, "user_lock_state", None)
if state in {"temporary", "permanent"}:
@@ -317,7 +348,9 @@ def _version_is_user_locked(version: CampaignVersion) -> bool:
def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool:
return _version_user_lock_state(version) == "permanent" or version.workflow_state in {
return _version_user_lock_state(
version
) == "permanent" or version.workflow_state in {
CampaignVersionWorkflowState.QUEUED.value,
CampaignVersionWorkflowState.SENDING.value,
CampaignVersionWorkflowState.COMPLETED.value,
@@ -329,7 +362,9 @@ def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool:
}
def _ensure_current_campaign_version(campaign: Campaign, version: CampaignVersion, *, action: str) -> None:
def _ensure_current_campaign_version(
campaign: Campaign, version: CampaignVersion, *, action: str
) -> None:
if campaign.current_version_id != version.id:
raise CampaignPersistenceError(
f"Historical campaign versions are read-only and cannot be used to {action}. "
@@ -338,18 +373,33 @@ def _ensure_current_campaign_version(campaign: Campaign, version: CampaignVersio
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
return bool(version.locked_at and validation_summary.get("ok") is True and not _version_is_user_locked(version))
validation_summary = (
version.validation_summary
if isinstance(version.validation_summary, dict)
else {}
)
return bool(
version.locked_at
and validation_summary.get("ok") is True
and not _version_is_user_locked(version)
)
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
state = _version_user_lock_state(version)
if state == "temporary":
raise CampaignPersistenceError("This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending.")
raise CampaignPersistenceError(
"This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending."
)
if state == "permanent":
raise CampaignPersistenceError("This version is permanently user-locked. Create an editable copy instead.")
raise CampaignPersistenceError(
"This version is permanently user-locked. Create an editable copy instead."
)
if not _version_is_validated_and_locked(version):
raise CampaignPersistenceError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
raise CampaignPersistenceError(
"Campaign version must be validated and locked before building, queueing, dry-run or sending."
)
def load_version_config(session: Session, version_id: str):
version = session.get(CampaignVersion, version_id)
@@ -364,7 +414,16 @@ def load_version_config(session: Session, version_id: str):
managed_files_available=files_integration().available,
)
path = _campaign_reference_path(version)
return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id)
return (
version,
path,
load_campaign_config_from_json(
session,
tenant_id=campaign.tenant_id,
raw_json=raw_json,
campaign_id=campaign.id,
),
)
def validate_campaign_version(
@@ -379,7 +438,9 @@ def validate_campaign_version(
version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
raise CampaignPersistenceError(
"Campaign version is not accessible for this tenant"
)
_ensure_current_campaign_version(campaign, version, action="validate")
if _version_is_user_locked(version) or version.workflow_state in {
CampaignVersionWorkflowState.QUEUED.value,
@@ -391,8 +452,14 @@ def validate_campaign_version(
CampaignVersionWorkflowState.CANCELLED.value,
CampaignVersionWorkflowState.ARCHIVED.value,
}:
lock_label = "temporarily user-locked" if _version_user_lock_state(version) == "temporary" else "permanently locked/final"
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
lock_label = (
"temporarily user-locked"
if _version_user_lock_state(version) == "temporary"
else "permanently locked/final"
)
raise CampaignPersistenceError(
f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead."
)
if check_files:
files = files_integration()
@@ -405,7 +472,12 @@ def validate_campaign_version(
prefix="govoplan-managed-validate-",
) as prepared:
managed_raw = load_campaign_json(prepared.path)
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
managed_config = load_campaign_config_from_json(
session,
tenant_id=tenant_id,
raw_json=managed_raw,
campaign_id=campaign.id,
)
report = validate_campaign_config(
managed_config,
campaign_file=prepared.path,
@@ -434,7 +506,10 @@ def validate_campaign_version(
# Replace version-level semantic issues from previous validations.
(
session.query(CampaignIssue)
.filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_(None))
.filter(
CampaignIssue.campaign_version_id == version.id,
CampaignIssue.job_id.is_(None),
)
.delete(synchronize_session=False)
)
for issue in report.issues:
@@ -450,7 +525,11 @@ def validate_campaign_version(
)
)
campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value
campaign.status = (
CampaignStatus.VALIDATED.value
if report.ok
else CampaignStatus.NEEDS_REVIEW.value
)
if report.ok:
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
version.is_complete = True
@@ -477,7 +556,9 @@ def _eml_evidence(eml_path: str | None) -> tuple[str | None, str | None]:
if not path.exists():
return None, None
payload = path.read_bytes()
message_id = BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID")
message_id = (
BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID")
)
return hashlib.sha256(payload).hexdigest(), str(message_id) if message_id else None
@@ -508,7 +589,9 @@ def _job_from_message(
eml_local_path=message.eml_path if stored_eml is None else None,
eml_size_bytes=stored_eml.size_bytes if stored_eml else message.eml_size_bytes,
eml_sha256=eml_sha256,
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status),
build_status=message.build_status.value
if hasattr(message.build_status, "value")
else str(message.build_status),
validation_status=_job_validation_status(message.validation_status.value),
queue_status=JobQueueStatus.DRAFT.value,
send_status=(
@@ -522,7 +605,9 @@ def _job_from_message(
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
else JobPostboxStatus.NOT_REQUESTED.value
),
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
imap_status=message.imap_status.value
if hasattr(message.imap_status, "value")
else JobImapStatus.NOT_REQUESTED.value,
resolved_recipients={
"from": message.from_.model_dump(mode="json") if message.from_ else None,
"from_all": [item.model_dump(mode="json") for item in message.from_all],
@@ -531,12 +616,21 @@ def _job_from_message(
"bcc": [item.model_dump(mode="json") for item in message.bcc],
"reply_to": [item.model_dump(mode="json") for item in message.reply_to],
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
"disposition_notification_to": [
item.model_dump(mode="json")
for item in message.disposition_notification_to
],
},
resolved_postbox_targets=resolved_postbox_targets or [],
resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments],
resolved_attachments=[
files_integration().public_attachment_summary_payload(item)
for item in message.attachments
],
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
last_error="; ".join(
issue.message for issue in message.issues if issue.severity == "error"
)
or None,
)
@@ -554,7 +648,9 @@ def _resolve_built_postbox_targets(
continue
entry = entries_by_index.get(built.draft.entry_index)
if entry is None:
raise CampaignPersistenceError("Built recipient row is missing from the campaign input.")
raise CampaignPersistenceError(
"Built recipient row is missing from the campaign input."
)
resolved, issues, validation_status = resolve_entry_postbox_targets(
session,
tenant_id=tenant_id,
@@ -571,22 +667,29 @@ def _resolve_built_postbox_targets(
def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
report_json = result.report.model_dump(mode="json", by_alias=True)
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
for message_payload, message in zip(
report_json.get("messages", []), result.report.messages, strict=False
):
if isinstance(message_payload, dict):
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
report_json.update({
"built_at": datetime.now(UTC).isoformat(),
"build_token": uuid4().hex,
"built_count": result.report.built_count,
"build_failed_count": result.report.build_failed_count,
"ready_count": result.report.ready_count,
"warning_count": result.report.warning_count,
"needs_review_count": result.report.needs_review_count,
"blocked_count": result.report.blocked_count,
"excluded_count": result.report.excluded_count,
"inactive_count": result.report.inactive_count,
"queueable_count": result.report.queueable_count,
})
message_payload["attachments"] = [
files.public_attachment_summary_payload(item)
for item in message.attachments
]
report_json.update(
{
"built_at": datetime.now(UTC).isoformat(),
"build_token": uuid4().hex,
"built_count": result.report.built_count,
"build_failed_count": result.report.build_failed_count,
"ready_count": result.report.ready_count,
"warning_count": result.report.warning_count,
"needs_review_count": result.report.needs_review_count,
"blocked_count": result.report.blocked_count,
"excluded_count": result.report.excluded_count,
"inactive_count": result.report.inactive_count,
"queueable_count": result.report.queueable_count,
}
)
return report_json
@@ -614,7 +717,9 @@ def _replace_version_jobs(
CampaignIssue.campaign_version_id == version_id,
CampaignIssue.job_id.is_not(None),
).delete(synchronize_session=False)
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).delete(synchronize_session=False)
session.query(CampaignJob).filter(
CampaignJob.campaign_version_id == version_id
).delete(synchronize_session=False)
session.flush()
pairs: list[tuple[CampaignJob, MessageDraft]] = []
@@ -624,7 +729,9 @@ def _replace_version_jobs(
campaign_id=campaign_id,
version_id=version_id,
message=built.draft,
resolved_postbox_targets=postbox_targets_by_index.get(built.draft.entry_index, []),
resolved_postbox_targets=postbox_targets_by_index.get(
built.draft.entry_index, []
),
stored_eml=stored_eml_by_index.get(built.draft.entry_index),
)
session.add(job)
@@ -640,16 +747,26 @@ def _mail_execution_profile(
config: CampaignConfig,
jobs: list[CampaignJob],
) -> tuple[str | None, dict[str, Any]]:
if not any(DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs):
if not any(
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs
):
return None, {}
if not config.server.profile_capabilities.smtp_available:
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created.")
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
raise CampaignPersistenceError(
"The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created."
)
profile_id = campaign_mail_profile_id(
version.raw_json if isinstance(version.raw_json, dict) else {}
)
if profile_id is None:
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages that use Mail.")
raise CampaignPersistenceError(
"Select an authorized Mail profile before building campaign messages that use Mail."
)
summary = profile_delivery_summary(session, version)
if not summary.get("smtp_transport_revision"):
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision.")
raise CampaignPersistenceError(
"The selected Mail profile has no SMTP transport revision."
)
return profile_id, summary
@@ -661,7 +778,9 @@ def _store_execution_snapshot(
jobs: list[CampaignJob],
build_summary: dict[str, Any],
) -> None:
profile_id, profile = _mail_execution_profile(session, version=version, config=config, jobs=jobs)
profile_id, profile = _mail_execution_profile(
session, version=version, config=config, jobs=jobs
)
snapshot, snapshot_hash = create_execution_snapshot(
version,
mail_profile_id=profile_id,
@@ -689,20 +808,22 @@ def _store_job_issues(
job_build_pairs: list[tuple[CampaignJob, MessageDraft]],
) -> None:
for job, message in job_build_pairs:
session.add_all([
CampaignIssue(
tenant_id=tenant_id,
campaign_id=campaign_id,
campaign_version_id=version_id,
job_id=job.id,
severity=issue.severity,
code=issue.code,
message=issue.message,
source=issue.source,
behavior=issue.behavior,
)
for issue in message.issues
])
session.add_all(
[
CampaignIssue(
tenant_id=tenant_id,
campaign_id=campaign_id,
campaign_version_id=version_id,
job_id=job.id,
severity=issue.severity,
code=issue.code,
message=issue.message,
source=issue.source,
behavior=issue.behavior,
)
for issue in message.issues
]
)
def _apply_campaign_build_state(
@@ -734,13 +855,21 @@ def build_campaign_version(
version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
raise CampaignPersistenceError(
"Campaign version is not accessible for this tenant"
)
_ensure_current_campaign_version(campaign, version, action="build")
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
validation_summary = (
version.validation_summary
if isinstance(version.validation_summary, dict)
else {}
)
if not validation_summary.get("ok"):
raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built")
raise CampaignPersistenceError(
"Campaign version must be successfully validated before messages are built"
)
_ensure_version_validated_and_locked(version)
files = files_integration()
@@ -804,6 +933,7 @@ def build_campaign_version(
version.build_summary = report_json
editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None)
editor_state.pop("approval_gate", None)
version.editor_state = editor_state
job_build_pairs, old_storage_keys = _replace_version_jobs(
@@ -52,22 +52,30 @@ class _PartialValidationCollector:
section: str | None
issues: list[dict[str, Any]] = field(default_factory=list)
def issue(self, severity: str, section: str, field: str, code: str, message: str) -> None:
def issue(
self, severity: str, section: str, field: str, code: str, message: str
) -> None:
if self.section is None or self.section == section:
self.issues.append({
"severity": severity,
"section": section,
"field": field,
"code": code,
"message": message,
})
self.issues.append(
{
"severity": severity,
"section": section,
"field": field,
"code": code,
"message": message,
}
)
def result(self) -> dict[str, Any]:
return {
"ok": not any(item["severity"] == "error" for item in self.issues),
"section": self.section,
"error_count": sum(1 for item in self.issues if item["severity"] == "error"),
"warning_count": sum(1 for item in self.issues if item["severity"] == "warning"),
"error_count": sum(
1 for item in self.issues if item["severity"] == "error"
),
"warning_count": sum(
1 for item in self.issues if item["severity"] == "warning"
),
"info_count": sum(1 for item in self.issues if item["severity"] == "info"),
"issues": self.issues,
}
@@ -101,7 +109,9 @@ def campaign_version_user_lock_state(version: CampaignVersion) -> str | None:
return None
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
def minimal_campaign_json(
*, external_id: str, name: str, description: str | None = None
) -> dict[str, Any]:
"""Return a WebUI-friendly starter campaign JSON.
It is intentionally usable as an editable working copy. It contains the
@@ -216,9 +226,15 @@ def create_minimal_campaign(
current_step: str = "basics",
commit: bool = True,
) -> tuple[Campaign, CampaignVersion]:
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
existing = (
session.query(Campaign)
.filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id)
.one_or_none()
)
if existing:
raise CampaignPersistenceError(f"Campaign with id '{external_id}' already exists for this tenant")
raise CampaignPersistenceError(
f"Campaign with id '{external_id}' already exists for this tenant"
)
campaign = Campaign(
tenant_id=tenant_id,
@@ -235,7 +251,9 @@ def create_minimal_campaign(
version = CampaignVersion(
campaign_id=campaign.id,
version_number=1,
raw_json=minimal_campaign_json(external_id=external_id, name=name, description=description),
raw_json=minimal_campaign_json(
external_id=external_id, name=name, description=description
),
schema_version="1.0",
workflow_state=CampaignVersionWorkflowState.EDITING.value,
current_flow=current_flow,
@@ -264,13 +282,16 @@ def get_campaign_version_for_tenant(
) -> CampaignVersion:
campaign = session.get(Campaign, campaign_id)
version = session.get(CampaignVersion, version_id)
if not campaign or campaign.tenant_id != tenant_id or not version or version.campaign_id != campaign.id:
if (
not campaign
or campaign.tenant_id != tenant_id
or not version
or version.campaign_id != campaign.id
):
raise CampaignPersistenceError("Campaign version not found")
return version
LOCKED_WORKFLOW_STATES = {
CampaignVersionWorkflowState.APPROVED.value,
CampaignVersionWorkflowState.BUILT.value,
@@ -295,7 +316,9 @@ def is_version_locked(version: CampaignVersion) -> bool:
)
def ensure_current_working_version(campaign: Campaign, version: CampaignVersion, *, action: str = "modify") -> None:
def ensure_current_working_version(
campaign: Campaign, version: CampaignVersion, *, action: str = "modify"
) -> None:
"""Require the campaign's single active working version.
Historical versions remain reviewable, but they never become writable in
@@ -326,14 +349,18 @@ def campaign_has_active_working_version(session: Session, campaign: Campaign) ->
def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None:
campaign_meta = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
campaign_meta = (
raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
)
if campaign_meta:
campaign.name = campaign_meta.get("name") or campaign.name
campaign.description = campaign_meta.get("description", campaign.description)
campaign.external_id = campaign_meta.get("id") or campaign.external_id
def _assert_fork_source_allowed(session: Session, *, campaign: Campaign, source: CampaignVersion) -> None:
def _assert_fork_source_allowed(
session: Session, *, campaign: Campaign, source: CampaignVersion
) -> None:
if campaign_has_active_working_version(session, campaign):
current = session.get(CampaignVersion, campaign.current_version_id)
current_number = current.version_number if current else "current"
@@ -375,7 +402,11 @@ def _fork_runtime_json(
source_base_path=source_base_path,
managed_files_available=files_integration().available,
)
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
runtime_json = (
normalize_campaign_paths(base_json, source_base_path)
if source_base_path
else copy.deepcopy(base_json)
)
assert_campaign_uses_mail_profile_reference(runtime_json)
mail_integration().assert_campaign_mail_policy_allows_json(
session,
@@ -404,10 +435,16 @@ def _new_forked_campaign_version(
version_number=_next_version_number(session, campaign.id),
raw_json=runtime_json,
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")),
source_filename=source_filename if source_filename is not None else source.source_filename,
source_base_path=source_base_path if source_base_path is not None else source.source_base_path,
source_filename=source_filename
if source_filename is not None
else source.source_filename,
source_base_path=source_base_path
if source_base_path is not None
else source.source_base_path,
workflow_state=CampaignVersionWorkflowState.EDITING.value,
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
current_flow=current_flow
if current_flow is not None
else (source.current_flow or CampaignVersionFlow.MANUAL.value),
current_step=current_step if current_step is not None else source.current_step,
is_complete=False,
editor_state=(
@@ -461,7 +498,9 @@ def fork_campaign_version_for_edit(
version is permanently user-locked or delivery-final.
"""
source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
source = get_campaign_version_for_tenant(
session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id
)
campaign = _require_campaign(session, campaign_id)
_assert_fork_source_allowed(session, campaign=campaign, source=source)
@@ -487,11 +526,15 @@ def fork_campaign_version_for_edit(
source_base_path=source_base_path,
autosave=autosave,
)
_persist_forked_version(session, campaign=campaign, version=new_version, commit=commit)
_persist_forked_version(
session, campaign=campaign, version=new_version, commit=commit
)
return new_version
def lock_validated_version(version: CampaignVersion, *, user_id: str | None = None) -> None:
def lock_validated_version(
version: CampaignVersion, *, user_id: str | None = None
) -> None:
if version.locked_at is None:
version.locked_at = datetime.now(UTC)
version.locked_by_user_id = user_id
@@ -535,7 +578,11 @@ def is_audit_safe_version(version: CampaignVersion) -> bool:
def is_version_validated_and_locked(version: CampaignVersion) -> bool:
"""Return True when the version was successfully validated and locked as a review snapshot."""
validation = version.validation_summary if isinstance(version.validation_summary, dict) else {}
validation = (
version.validation_summary
if isinstance(version.validation_summary, dict)
else {}
)
return bool(version.locked_at and validation.get("ok") is True)
@@ -554,28 +601,40 @@ def unlock_validated_campaign_version(
be copied instead.
"""
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
version = get_campaign_version_for_tenant(
session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id
)
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="unlock")
if is_temporary_user_locked_version(version):
raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.")
raise LockedCampaignVersionError(
"This version has a temporary user lock. Remove that lock before unlocking validation."
)
if is_permanent_user_locked_version(version):
raise LockedCampaignVersionError("This version is permanently locked and cannot be unlocked. Create an editable copy instead.")
raise LockedCampaignVersionError(
"This version is permanently locked and cannot be unlocked. Create an editable copy instead."
)
if is_version_final_locked(version):
raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.")
raise LockedCampaignVersionError(
"This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead."
)
# A version with sent jobs is final even if workflow_state was not updated for some reason.
sent_jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.campaign_version_id == version.id,
CampaignJob.send_status.in_([JobSendStatus.SENT.value, JobSendStatus.SMTP_ACCEPTED.value]),
CampaignJob.send_status.in_(
[JobSendStatus.SENT.value, JobSendStatus.SMTP_ACCEPTED.value]
),
)
.count()
)
if sent_jobs:
raise LockedCampaignVersionError("This version has sent messages and cannot be unlocked. Create an editable copy instead.")
raise LockedCampaignVersionError(
"This version has sent messages and cannot be unlocked. Create an editable copy instead."
)
version.locked_at = None
version.locked_by_user_id = None
@@ -584,12 +643,17 @@ def unlock_validated_campaign_version(
clear_execution_snapshot(version)
editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None)
editor_state.pop("approval_gate", None)
version.editor_state = editor_state
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
version.is_complete = False
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
session.query(CampaignIssue).filter(
CampaignIssue.campaign_version_id == version.id
).delete(synchronize_session=False)
session.query(CampaignJob).filter(
CampaignJob.campaign_version_id == version.id
).delete(synchronize_session=False)
campaign.current_version_id = version.id
campaign.status = CampaignStatus.DRAFT.value
@@ -601,6 +665,7 @@ def unlock_validated_campaign_version(
session.flush()
return version
def _assert_update_paths_safe(
raw_json: dict[str, Any] | None,
*,
@@ -627,8 +692,14 @@ def _updated_runtime_json(
source_base_path: str | None,
migrate_legacy_mail_settings: bool,
) -> dict[str, Any]:
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
requires_migration = bool(campaign_mail_profile_boundary_violations(version.raw_json))
runtime_json = (
normalize_campaign_paths(raw_json, source_base_path)
if source_base_path
else copy.deepcopy(raw_json)
)
requires_migration = bool(
campaign_mail_profile_boundary_violations(version.raw_json)
)
if requires_migration and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
@@ -679,7 +750,9 @@ def _apply_version_field_updates(
version.autosaved_at = datetime.now(UTC)
def _invalidate_version_content(session: Session, *, campaign: Campaign, version: CampaignVersion) -> None:
def _invalidate_version_content(
session: Session, *, campaign: Campaign, version: CampaignVersion
) -> None:
version.validation_summary = None
version.build_summary = None
clear_execution_snapshot(version)
@@ -688,7 +761,9 @@ def _invalidate_version_content(session: Session, *, campaign: Campaign, version
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
campaign.status = CampaignStatus.DRAFT.value
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
session.query(CampaignIssue).filter(
CampaignIssue.campaign_version_id == version.id
).delete(synchronize_session=False)
def _persist_updated_version(
@@ -726,22 +801,23 @@ def update_campaign_version(
expected_revision: int | None = None,
commit: bool = True,
) -> CampaignVersion:
_assert_update_paths_safe(raw_json, source_filename=source_filename, source_base_path=source_base_path)
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
_assert_update_paths_safe(
raw_json, source_filename=source_filename, source_base_path=source_base_path
)
version = get_campaign_version_for_tenant(
session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id
)
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="edit")
if (
expected_revision is not None
and version.edit_revision != int(expected_revision)
if expected_revision is not None and version.edit_revision != int(
expected_revision
):
raise RevisionConflictError(
resource_type="campaign_version",
resource_id=version.id,
current_revision=version.edit_revision,
submitted_base_revision=int(expected_revision),
refresh_path=(
f"/api/v1/campaigns/{campaign.id}/versions/{version.id}"
),
refresh_path=(f"/api/v1/campaigns/{campaign.id}/versions/{version.id}"),
current_etag=strong_resource_etag(
"campaign_version",
version.id,
@@ -765,7 +841,9 @@ def update_campaign_version(
migrate_legacy_mail_settings=migrate_legacy_mail_settings,
)
version.raw_json = runtime_json
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
version.schema_version = str(
runtime_json.get("version", version.schema_version or "1.0")
)
_apply_campaign_metadata(campaign, runtime_json)
_apply_version_field_updates(
@@ -810,9 +888,7 @@ def update_campaign_version(
if expected_revision is not None
else version.edit_revision
),
refresh_path=(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}"
),
refresh_path=(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}"),
current_etag=strong_resource_etag(
"campaign_version",
version_id,
@@ -850,9 +926,15 @@ def update_campaign_review_state(
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="record review state for")
if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
raise LockedCampaignVersionError(
"Delivery has started; message review state can no longer be changed."
)
build_token = _campaign_review_build_token(version)
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
normalized_reviewed = list(
dict.fromkeys(
str(value) for value in reviewed_message_keys if str(value).strip()
)
)
normalized_decisions: list[dict[str, Any]] = []
if inspection_complete:
normalized_reviewed, normalized_decisions = _complete_campaign_review(
@@ -880,10 +962,14 @@ def update_campaign_review_state(
def _campaign_review_build_token(version: CampaignVersion) -> str:
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
build_summary = (
version.build_summary if isinstance(version.build_summary, dict) else {}
)
if not build_summary:
raise CampaignPersistenceError("Build messages before recording review state.")
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
build_token = str(
build_summary.get("build_token") or build_summary.get("built_at") or ""
).strip()
if build_token:
return build_token
build_token = uuid4().hex
@@ -908,13 +994,20 @@ def _complete_campaign_review(
.order_by(CampaignJob.entry_index.asc())
.all()
)
blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"]
blocking = [
job
for job in jobs
if job.build_status != "built" or job.validation_status == "blocked"
]
if blocking:
raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.")
raise CampaignPersistenceError(
"Blocked or failed messages must be resolved before review can be completed."
)
missing = sorted(_required_review_keys(jobs) - set(reviewed_message_keys))
if missing:
raise CampaignPersistenceError(
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
"Messages requiring an explicit decision must be opened before review can be completed: "
+ ", ".join(missing)
)
decisions = _normalize_review_issue_decisions(
jobs,
@@ -924,9 +1017,7 @@ def _complete_campaign_review(
)
return (
list(
dict.fromkeys(
[*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]
)
dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)])
),
decisions,
)
@@ -957,9 +1048,7 @@ def _normalize_review_issue_decisions(
"Only one review decision may be recorded per built message."
)
if str(item.get("decision") or "accept") != "accept":
raise CampaignPersistenceError(
"Unsupported campaign review decision."
)
raise CampaignPersistenceError("Unsupported campaign review decision.")
requested_by_job[job_id] = item
timestamp = (decided_at or datetime.now(UTC)).isoformat()
@@ -990,9 +1079,7 @@ def _normalize_review_issue_decisions(
"Attachment exception decisions require a reason."
)
evidence_issues = reviewable_issues or [
issue
for issue in (job.issues_snapshot or [])
if isinstance(issue, dict)
issue for issue in (job.issues_snapshot or []) if isinstance(issue, dict)
]
issue_payload = [
{
@@ -1092,13 +1179,17 @@ def lock_campaign_version_temporarily(
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="lock")
if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
raise LockedCampaignVersionError(
"Delivery/final versions are permanently locked and cannot receive a temporary user lock."
)
if is_permanent_user_locked_version(version):
raise LockedCampaignVersionError("This version is already permanently locked.")
if is_temporary_user_locked_version(version):
return version
if version.locked_at:
raise LockedCampaignVersionError("This version is already temporarily locked by validation. Unlock validation before applying a user lock.")
raise LockedCampaignVersionError(
"This version is already temporarily locked by validation. Unlock validation before applying a user lock."
)
version.user_lock_state = USER_LOCK_TEMPORARY
version.user_locked_at = datetime.now(UTC)
@@ -1131,11 +1222,17 @@ def unlock_user_locked_campaign_version(
ensure_current_working_version(campaign, version, action="unlock")
state = campaign_version_user_lock_state(version)
if state == USER_LOCK_PERMANENT:
raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.")
raise LockedCampaignVersionError(
"Permanently locked versions cannot be unlocked. Create an editable copy instead."
)
if state != USER_LOCK_TEMPORARY:
raise LockedCampaignVersionError("This version does not have a temporary user lock.")
raise LockedCampaignVersionError(
"This version does not have a temporary user lock."
)
if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery/final versions cannot be unlocked. Create an editable copy instead.")
raise LockedCampaignVersionError(
"Delivery/final versions cannot be unlocked. Create an editable copy instead."
)
version.user_lock_state = None
version.user_locked_at = None
@@ -1172,7 +1269,9 @@ def permanently_lock_campaign_version(
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="lock permanently")
if is_version_final_locked(version):
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
raise LockedCampaignVersionError(
"This version is already permanently locked by its delivery/final state."
)
if is_permanent_user_locked_version(version):
return version
@@ -1211,7 +1310,9 @@ def publish_campaign_version(
)
def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]:
def validate_campaign_partial(
raw_json: dict[str, Any], *, section: str | None = None
) -> dict[str, Any]:
"""Lightweight UI-facing validation for incomplete campaign working copies.
This is intentionally less strict than campaign.schema.json validation. It
@@ -1235,20 +1336,44 @@ def _dict_value(value: dict[str, Any], key: str) -> dict[str, Any]:
return candidate if isinstance(candidate, dict) else {}
def _validate_partial_basics(collector: _PartialValidationCollector, campaign: dict[str, Any]) -> None:
def _validate_partial_basics(
collector: _PartialValidationCollector, campaign: dict[str, Any]
) -> None:
if not campaign.get("id"):
collector.issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
collector.issue(
"error",
"basics",
"campaign.id",
"missing_campaign_id",
"Campaign id is required.",
)
if not campaign.get("name"):
collector.issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
collector.issue(
"error",
"basics",
"campaign.name",
"missing_campaign_name",
"Campaign name is required.",
)
def _validate_partial_sender(collector: _PartialValidationCollector, recipients: dict[str, Any]) -> None:
def _validate_partial_sender(
collector: _PartialValidationCollector, recipients: dict[str, Any]
) -> None:
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
if not sender.get("email"):
collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
collector.issue(
"warning",
"sender",
"recipients.from.email",
"missing_sender_email",
"Sender email is not configured yet.",
)
def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_json: dict[str, Any]) -> None:
def _validate_partial_mail_profile(
collector: _PartialValidationCollector, raw_json: dict[str, Any]
) -> None:
violations = campaign_mail_profile_boundary_violations(raw_json)
if violations:
collector.issue(
@@ -1268,13 +1393,27 @@ def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_j
)
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
def _validate_partial_recipients(
collector: _PartialValidationCollector, entries: dict[str, Any]
) -> None:
has_inline = bool(entries.get("inline"))
has_source = isinstance(entries.get("source"), dict)
if not has_inline and not has_source:
collector.issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
collector.issue(
"warning",
"recipients",
"entries",
"missing_recipients",
"No inline recipients or external recipient source configured yet.",
)
if has_source and not _entries_source_has_email_mapping(entries):
collector.issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
collector.issue(
"warning",
"recipients",
"entries.mapping",
"missing_email_mapping",
"No email field mapping is configured.",
)
def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool:
@@ -1282,58 +1421,142 @@ def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool:
return any(key in mapping for key in ("to.0.email", "to.email", "email"))
def _validate_partial_template(collector: _PartialValidationCollector, template: dict[str, Any]) -> None:
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
def _validate_partial_template(
collector: _PartialValidationCollector, template: dict[str, Any]
) -> None:
source_template = (
template.get("source") if isinstance(template.get("source"), dict) else {}
)
if not template.get("subject") and not source_template.get("subject_path"):
collector.issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
collector.issue(
"warning",
"template",
"template.subject",
"missing_subject",
"Template subject is empty.",
)
body_state = _partial_template_body_state(template, source_template)
_validate_partial_template_body(collector, body_state)
def _partial_template_body_state(template: dict[str, Any], source_template: dict[str, Any]) -> dict[str, Any]:
def _partial_template_body_state(
template: dict[str, Any], source_template: dict[str, Any]
) -> dict[str, Any]:
return {
"mode": template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None,
"has_text": bool(template.get("text")) or bool(source_template.get("text_path")),
"has_html": bool(template.get("html")) or bool(source_template.get("html_path")),
"mode": template.get("body_mode")
if template.get("body_mode") in {"text", "html", "both"}
else None,
"has_text": bool(template.get("text"))
or bool(source_template.get("text_path")),
"has_html": bool(template.get("html"))
or bool(source_template.get("html_path")),
"has_source": bool(source_template),
}
def _validate_partial_template_body(collector: _PartialValidationCollector, state: dict[str, Any]) -> None:
def _validate_partial_template_body(
collector: _PartialValidationCollector, state: dict[str, Any]
) -> None:
mode = state["mode"]
has_text = bool(state["has_text"])
has_html = bool(state["has_html"])
if mode == "text" and not has_text:
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
collector.issue(
"warning",
"template",
"template.text",
"missing_template_text_body",
"Template body mode is text only, but no text body is configured.",
)
elif mode == "html" and not has_html:
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
collector.issue(
"warning",
"template",
"template.html",
"missing_template_html_body",
"Template body mode is HTML only, but no HTML body is configured.",
)
elif mode == "both":
_validate_partial_dual_body_template(collector, has_text=has_text, has_html=has_html)
_validate_partial_dual_body_template(
collector, has_text=has_text, has_html=has_html
)
elif not has_text and not has_html and not state["has_source"]:
collector.issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
collector.issue(
"warning",
"template",
"template",
"missing_template_body",
"No text, HTML or file-based template body configured yet.",
)
def _validate_partial_dual_body_template(collector: _PartialValidationCollector, *, has_text: bool, has_html: bool) -> None:
def _validate_partial_dual_body_template(
collector: _PartialValidationCollector, *, has_text: bool, has_html: bool
) -> None:
if not has_text:
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
collector.issue(
"warning",
"template",
"template.text",
"missing_template_text_body",
"Template body mode is both, but no text body is configured.",
)
if not has_html:
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
collector.issue(
"warning",
"template",
"template.html",
"missing_template_html_body",
"Template body mode is both, but no HTML body is configured.",
)
def _validate_partial_attachments(collector: _PartialValidationCollector, attachments: dict[str, Any]) -> None:
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths)
def _validate_partial_attachments(
collector: _PartialValidationCollector, attachments: dict[str, Any]
) -> None:
base_paths = (
attachments.get("base_paths")
if isinstance(attachments.get("base_paths"), list)
else []
)
has_named_base_path = any(
isinstance(item, dict) and item.get("path") for item in base_paths
)
if not has_named_base_path and not attachments.get("base_path"):
collector.issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
collector.issue(
"info",
"attachments",
"attachments.base_path",
"missing_attachment_base_path",
"Attachment base path is not configured yet.",
)
def _validate_partial_delivery(collector: _PartialValidationCollector, delivery: dict[str, Any]) -> None:
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
def _validate_partial_delivery(
collector: _PartialValidationCollector, delivery: dict[str, Any]
) -> None:
rate_limit = (
delivery.get("rate_limit")
if isinstance(delivery.get("rate_limit"), dict)
else {}
)
messages_per_minute = rate_limit.get("messages_per_minute")
if messages_per_minute is None:
return
try:
if int(messages_per_minute) < 1:
collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
collector.issue(
"error",
"send",
"delivery.rate_limit.messages_per_minute",
"invalid_rate_limit",
"Messages per minute must be at least 1.",
)
except (TypeError, ValueError):
collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")
collector.issue(
"error",
"send",
"delivery.rate_limit.messages_per_minute",
"invalid_rate_limit",
"Messages per minute must be a number.",
)
@@ -453,9 +453,7 @@ def _update_campaign_version_detail_response(
"base_revision": payload.base_revision,
"result_revision": version.edit_revision,
"reconciliation_kind": payload.reconciliation_kind,
"resolved_conflict_path_count": len(
payload.resolved_conflict_paths
),
"resolved_conflict_path_count": len(payload.resolved_conflict_paths),
"resolved_conflict_sections": sorted(
{
path.strip("/").split("/", 1)[0][:80]
@@ -630,6 +628,7 @@ def _clear_current_version_mail_profile_for_owner_transfer(
editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None)
editor_state.pop("approval_gate", None)
version.editor_state = editor_state
session.query(CampaignIssue).filter(
@@ -20,6 +20,12 @@ from govoplan_campaign.backend.schemas import (
SendCampaignNowRequest,
SendCampaignNowResponse,
)
from govoplan_campaign.backend.approval_gate import (
CampaignApprovalGateError,
campaign_approval_status,
request_campaign_approval,
)
from govoplan_campaign.backend.approval_schemas import CampaignApprovalRequestInput
from govoplan_core.auth import ApiPrincipal, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_campaign.backend.db.models import (
@@ -92,14 +98,21 @@ def campaign_delivery_options(
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
try:
options = synchronous_send_options(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
version = _get_version_for_tenant(
session, str(options["version_id"]), principal.tenant_id
)
return CampaignDeliveryOptionsResponse(
**synchronous_send_options(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
),
**options,
postbox_available=postbox_integration().available,
approval_gate=campaign_approval_status(
session, tenant_id=principal.tenant_id, version=version
),
)
except QueueingError as exc:
raise HTTPException(
@@ -107,6 +120,77 @@ def campaign_delivery_options(
) from exc
@router.post(
"/{campaign_id}/versions/{version_id}/approval-request",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def create_campaign_approval_request(
campaign_id: str,
version_id: str,
payload: CampaignApprovalRequestInput,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")),
) -> dict[str, object]:
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
if campaign.current_version_id != version_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Approval can only be requested for the current Campaign version.",
)
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if not version.build_summary:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Build Campaign messages before requesting approval.",
)
try:
request = request_campaign_approval(
session,
principal,
campaign=campaign,
version=version,
title=payload.title,
description=payload.description,
steps=tuple(step.to_definition() for step in payload.steps),
idempotency_key=payload.idempotency_key,
template_id=payload.template_id,
template_revision=payload.template_revision,
unique_actors_across_steps=payload.unique_actors_across_steps,
expires_at=payload.expires_at,
policy_refs=tuple(payload.policy_refs),
)
audit_from_principal(
session,
principal,
action="campaign.approval_requested",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign.id,
"approval_request_id": request.id,
"approval_request_revision": request.revision,
"execution_snapshot_hash": version.execution_snapshot_hash,
},
commit=True,
)
return {
"request_id": request.id,
"request_revision": request.revision,
"request_state": request.state,
"approval_gate": campaign_approval_status(
session, tenant_id=principal.tenant_id, version=version
),
}
except (CampaignApprovalGateError, ExecutionSnapshotError) as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
def queue_campaign(
campaign_id: str,
+42 -16
View File
@@ -3,7 +3,15 @@ from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from pydantic import (
BaseModel,
BeforeValidator,
ConfigDict,
Field,
ValidationInfo,
field_validator,
model_validator,
)
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
@@ -25,8 +33,6 @@ class CampaignCreateRequest(BaseModel):
source_base_path: str | None = None
class CampaignUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -64,7 +70,9 @@ class CampaignVersionUpdateRequest(BaseModel):
@field_validator("editor_state")
@classmethod
def validate_editor_state(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
def validate_editor_state(
cls, value: dict[str, Any] | None
) -> dict[str, Any] | None:
return validate_campaign_editor_state(value) if value is not None else None
@@ -129,12 +137,16 @@ class CampaignVersionResponse(BaseModel):
build_summary: dict[str, Any] | None = None
execution_snapshot_hash: str | None = None
execution_snapshot_at: datetime | None = None
delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = None
delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = (
None
)
delivery_mode_selected_at: datetime | None = None
@field_validator("editor_state", mode="before")
@classmethod
def remove_unsupported_editor_state(cls, value: Any, info: ValidationInfo) -> dict[str, Any]:
def remove_unsupported_editor_state(
cls, value: Any, info: ValidationInfo
) -> dict[str, Any]:
return public_campaign_editor_state(
value,
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
@@ -291,7 +303,9 @@ class RecipientImportColumnMappingPayload(BaseModel):
column_index: int = Field(ge=0, alias="columnIndex")
kind: RecipientImportColumnKind
field_name: str | None = Field(default=None, max_length=255, alias="fieldName")
new_field_name: str | None = Field(default=None, max_length=255, alias="newFieldName")
new_field_name: str | None = Field(
default=None, max_length=255, alias="newFieldName"
)
class RecipientImportMappingProfilePayload(BaseModel):
@@ -300,14 +314,22 @@ class RecipientImportMappingProfilePayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
column_count: int = Field(ge=0, le=500, alias="columnCount")
headers: list[str] = Field(default_factory=list, max_length=500)
normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders")
ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint")
unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint")
normalized_headers: list[str] = Field(
default_factory=list, max_length=500, alias="normalizedHeaders"
)
ordered_header_fingerprint: str = Field(
min_length=1, max_length=64, alias="orderedHeaderFingerprint"
)
unordered_header_fingerprint: str = Field(
min_length=1, max_length=64, alias="unorderedHeaderFingerprint"
)
delimiter: Literal[",", ";", "\t"]
header_rows: int = Field(ge=0, le=10, alias="headerRows")
quoted: bool = True
value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators")
mappings: list[RecipientImportColumnMappingPayload] = Field(default_factory=list, max_length=500)
mappings: list[RecipientImportColumnMappingPayload] = Field(
default_factory=list, max_length=500
)
@model_validator(mode="after")
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
@@ -583,6 +605,7 @@ class CampaignDeliveryOptionsResponse(BaseModel):
version_id: str
worker_queue_available: bool
postbox_available: bool = False
approval_gate: dict[str, Any] = Field(default_factory=dict)
synchronous_send: dict[str, Any] = Field(default_factory=dict)
@@ -633,14 +656,17 @@ def _normalize_report_recipient(value: Any) -> str:
if len(recipient) > 320:
raise ValueError("report recipient addresses must be at most 320 characters")
if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
raise ValueError("report recipient addresses must not contain control characters")
raise ValueError(
"report recipient addresses must not contain control characters"
)
if recipient.count("@") != 1:
raise ValueError("report recipients must be email addresses")
local, domain = recipient.split("@", 1)
invalid_local = not local or local.startswith(".") or local.endswith(".") or ".." in local
invalid_address = (
any(character.isspace() for character in recipient)
or any(character in ',;:<>[]()\\"' for character in recipient)
invalid_local = (
not local or local.startswith(".") or local.endswith(".") or ".." in local
)
invalid_address = any(character.isspace() for character in recipient) or any(
character in ',;:<>[]()\\"' for character in recipient
)
if invalid_local or invalid_address or not _valid_report_email_domain(domain):
raise ValueError("report recipients must be email addresses")
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from govoplan_core.core.approvals import (
ApprovalActorSelector,
ApprovalCheck,
ApprovalRequestRef,
ApprovalStepDefinition,
)
from govoplan_campaign.backend.approval_gate import (
CampaignApprovalGateError,
assert_campaign_approval,
request_campaign_approval,
)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "requester"
class Session:
def add(self, _value: object) -> None:
return None
def flush(self) -> None:
return None
class ApprovalStub:
available = True
def __init__(self, *, state: str = "pending") -> None:
self.state = state
self.command = None
def create_request(self, _session, _principal, *, command, idempotency_key):
assert idempotency_key == "request-1"
self.command = command
return ApprovalRequestRef("approval-1", 1, "pending", "release")
def check_approved(self, _session, _principal, **kwargs):
return ApprovalCheck(
request_id=str(kwargs["request_id"]),
revision=2,
state=self.state,
approved=self.state == "approved",
subject_module=str(kwargs["subject_module"]),
subject_type=str(kwargs["subject_type"]),
subject_id=str(kwargs["subject_id"]),
subject_version=kwargs["subject_version"],
subject_digest=str(kwargs["subject_digest"]),
)
def _objects():
campaign = SimpleNamespace(
id="campaign-1",
tenant_id="tenant-1",
created_by_user_id="author",
owner_user_id="owner",
)
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
version_number=7,
execution_snapshot_hash="a" * 64,
editor_state={"review_send": {"updated_by_user_id": "reviewer"}},
validation_summary={"validated_by_user_id": "validator"},
build_summary={"built_by_user_id": "builder"},
locked_by_user_id="validator",
)
return campaign, version
def test_request_binds_snapshot_and_action_evidence() -> None:
campaign, version = _objects()
provider = ApprovalStub()
snapshot = SimpleNamespace(build_token="build-7", snapshot_version="7")
with (
patch(
"govoplan_campaign.backend.approval_gate.ensure_execution_snapshot",
return_value=snapshot,
),
patch(
"govoplan_campaign.backend.approval_gate.approvals_integration",
return_value=provider,
),
):
request_campaign_approval(
Session(),
Principal(),
campaign=campaign,
version=version,
title="Release",
description=None,
steps=(
ApprovalStepDefinition(
"release",
"Release",
(ApprovalActorSelector("role", "sender"),),
forbidden_evidence_roles=("builder", "reviewer"),
),
),
idempotency_key="request-1",
)
assert provider.command.subject_digest == "a" * 64
assert provider.command.evidence_actors["builder"] == ("builder",)
assert provider.command.evidence_actors["reviewer"] == ("reviewer",)
assert version.editor_state["approval_gate"]["request_id"] == "approval-1"
def test_gate_fails_closed_until_exact_request_is_approved() -> None:
campaign, version = _objects()
version.editor_state["approval_gate"] = {
"request_id": "approval-1",
"request_revision": 1,
"subject_version": "build-7",
"subject_digest": "a" * 64,
"requested_at": "2026-08-01T00:00:00+00:00",
"requested_by_user_id": "requester",
}
snapshot = SimpleNamespace(build_token="build-7", snapshot_version="7")
provider = ApprovalStub(state="pending")
with (
patch(
"govoplan_campaign.backend.approval_gate.ensure_execution_snapshot",
return_value=snapshot,
),
patch(
"govoplan_campaign.backend.approval_gate.approvals_integration",
return_value=provider,
),
):
with pytest.raises(CampaignApprovalGateError, match="pending"):
assert_campaign_approval(Session(), tenant_id="tenant-1", version=version)
provider.state = "approved"
assert_campaign_approval(Session(), tenant_id="tenant-1", version=version)
version.execution_snapshot_hash = "b" * 64
with pytest.raises(CampaignApprovalGateError, match="changed"):
assert_campaign_approval(Session(), tenant_id="tenant-1", version=version)
+8 -1
View File
@@ -38,7 +38,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 64
assert len(actual) == 65
assert not [operation for operation, count in Counter(actual).items() if count > 1]
@@ -50,6 +50,13 @@ def test_key_routes_are_owned_by_their_focused_router() -> None:
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
(
delivery_router,
(
"POST",
"/campaigns/{campaign_id}/versions/{version_id}/approval-request",
),
),
(
attachments_router,
(
+43
View File
@@ -406,6 +406,16 @@ export type CampaignDeliveryOptions = {
version_id: string;
worker_queue_available: boolean;
postbox_available: boolean;
approval_gate: {
configured?: boolean;
available?: boolean;
approved?: boolean;
state?: string;
explanation?: string | null;
request_id?: string;
request_revision?: number;
subject_digest?: string;
};
synchronous_send: {
allowed?: boolean;
reason?: string | null;
@@ -420,6 +430,26 @@ export type CampaignDeliveryOptions = {
};
};
export type CampaignApprovalRequestPayload = {
title: string;
description?: string | null;
steps: Array<{
key: string;
label: string;
selectors: Array<{
kind: "account" | "group" | "role" | "function_assignment" | "any_account";
value: string;
}>;
required_approvals: number;
rejection_policy: "fail_fast" | "collect";
signature_required: boolean;
forbidden_evidence_roles: Array<"author" | "owner" | "validator" | "builder" | "reviewer">;
}>;
unique_actors_across_steps: boolean;
policy_refs: string[];
idempotency_key: string;
};
export type CampaignSendNowPayload = {
version_id?: string | null;
include_warnings?: boolean;
@@ -1148,6 +1178,19 @@ versionId?: string | null)
return apiFetch<CampaignDeliveryOptions>(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`);
}
export async function requestCampaignApproval(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignApprovalRequestPayload)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(
settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/approval-request`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function sendCampaignNow(
settings: ApiSettings,
campaignId: string,
+130 -7
View File
@@ -22,6 +22,7 @@ import {
pauseCampaign,
previewCampaignAttachments,
queueCampaign,
requestCampaignApproval,
resumeCampaign,
retryCampaignJobs,
sendCampaignJob,
@@ -35,7 +36,7 @@ import {
type CampaignSummary } from
"../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, Dialog, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { Button, Dialog, FormField, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
@@ -106,7 +107,7 @@ import {
synchronousSendReason
} from "./review/reviewPresentation";
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | "";
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "approval" | "send" | "queue" | "control" | "retry" | "imap" | "";
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
@@ -138,6 +139,7 @@ export default function ReviewSendPage({
const canQueueForWorkers = hasScope(auth, "campaigns:campaign:queue");
const canControlDelivery = hasScope(auth, "campaigns:campaign:control");
const canRetryDelivery = hasScope(auth, "campaigns:campaign:retry");
const canRequestApproval = hasScope(auth, "campaigns:campaign:review");
const [deliveryOptions, setDeliveryOptions] = useState<CampaignDeliveryOptions | null>(null);
const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false);
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
@@ -186,6 +188,12 @@ export default function ReviewSendPage({
const [attachmentLinking, setAttachmentLinking] = useState(false);
const [attachmentLockConfirmOpen, setAttachmentLockConfirmOpen] = useState(false);
const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false);
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const [approvalSelectorKind, setApprovalSelectorKind] = useState<"account" | "group" | "role" | "function_assignment" | "any_account">("role");
const [approvalSelectorValue, setApprovalSelectorValue] = useState("");
const [approvalRequiredCount, setApprovalRequiredCount] = useState(1);
const [approvalSignatureRequired, setApprovalSignatureRequired] = useState(false);
const [approvalExcludedRoles, setApprovalExcludedRoles] = useState<Set<"author" | "owner" | "validator" | "builder" | "reviewer">>(() => new Set());
const [dryRun, setDryRun] = useState(false);
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
const [queueConfirmOpen, setQueueConfirmOpen] = useState(false);
@@ -215,6 +223,7 @@ export default function ReviewSendPage({
setAttachmentPreview(null);
setAttachmentPreviewError("");
setAttachmentLockConfirmOpen(false);
setApprovalDialogOpen(false);
setSendResult(null);
setQueueResult(null);
setImapAppendResult(null);
@@ -343,6 +352,11 @@ export default function ReviewSendPage({
const synchronousEligibleCount = numberFrom(synchronousSendOption, ["eligible_recipient_job_count"]);
const synchronousSendAllowed = synchronousSendOption.allowed === true;
const workerQueueAvailable = deliveryOptions?.worker_queue_available === true;
const approvalGate = asRecord(deliveryOptions?.approval_gate);
const approvalGateConfigured = approvalGate.configured === true;
const approvalGateAvailable = approvalGate.available === true;
const approvalGateApproved = approvalGate.approved === true;
const approvalGateBlocksLiveDelivery = approvalGateConfigured && !approvalGateApproved;
const persistedDeliveryMode = version?.delivery_mode ?? null;
const persistedDeliveryModeSelectedAt = version?.delivery_mode_selected_at ?? null;
const selectedDryRun = dryRun && !directQueuedSendAllowed;
@@ -523,7 +537,7 @@ export default function ReviewSendPage({
const mockStateDisplayLabel = !mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : !mockVerificationRequired ? "i18n:govoplan-campaign.optional.0c6c4102" : stateLabel(mockState);
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || mockPartial || downstreamDeliveryActivity);
const singleMessageSendWorkflowBlocked = ["archived", "cancelled"].includes(currentWorkflowState);
const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !singleMessageSendWorkflowBlocked);
const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !approvalGateBlocksLiveDelivery && !singleMessageSendWorkflowBlocked);
const sendLockReason = !inspectionSatisfied ?
"i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" :
mockWorkflowRequired ?
@@ -837,9 +851,43 @@ export default function ReviewSendPage({
}
}
async function createApprovalGate() {
if (!version || busy || !canRequestApproval || !approvalGateAvailable || approvalGateConfigured || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim()) return;
setBusy("approval");
setError("");
setMessage("Requesting approval for the exact built execution...");
try {
await requestCampaignApproval(settings, campaignId, version.id, {
title: `Approve delivery of ${data.campaign?.name ?? "Campaign"}`,
description: "Approval is bound to this version's immutable execution snapshot.",
steps: [{
key: "release",
label: "Approve delivery",
selectors: [{ kind: approvalSelectorKind, value: approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue.trim() }],
required_approvals: approvalRequiredCount,
rejection_policy: "fail_fast",
signature_required: approvalSignatureRequired,
forbidden_evidence_roles: [...approvalExcludedRoles]
}],
unique_actors_across_steps: true,
policy_refs: [],
idempotency_key: crypto.randomUUID()
});
setApprovalDialogOpen(false);
setMessage("Approval requested. Real delivery remains unavailable until the request is approved.");
await reload();
await refreshDeliveryOptions(false);
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function runSendNow() {
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted) return;
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted || !effectiveDryRun && approvalGateBlocksLiveDelivery) return;
setBusy("send");
setMessage(effectiveDryRun ?
"i18n:govoplan-campaign.checking_the_built_queue_without_sending.e15bb876" :
@@ -881,7 +929,7 @@ export default function ReviewSendPage({
}
async function runQueueForWorkers() {
if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return;
if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return;
setBusy("queue");
setMessage("i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2");
setQueueResult(null);
@@ -1259,6 +1307,17 @@ export default function ReviewSendPage({
detail: messagesPerMinute > 0 ? `${messagesPerMinute} message(s) per minute; estimated minimum duration ${estimatedMinutes ?? "unknown"} minute(s).` : "No explicit rate limit is configured for this execution.",
state: messagesPerMinute > 0 ? "ready" : "warning"
},
{
label: "Approval",
detail: approvalGateConfigured ?
approvalGateApproved ?
`Approval ${String(approvalGate.request_id ?? "")} covers this exact execution snapshot.` :
String(approvalGate.explanation ?? "The configured approval request is not approved yet.") :
approvalGateAvailable ?
"No approval gate is configured. Add one when this delivery requires independent release authority." :
"The optional Approvals module is not active; no Campaign approval gate is configured.",
state: approvalGateConfigured ? approvalGateApproved ? "ready" : "blocked" : "info"
},
{
label: "i18n:govoplan-campaign.delivery_mode.109ed9d1",
detail: deliveryOptionsLoading ?
@@ -1527,6 +1586,18 @@ export default function ReviewSendPage({
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div>
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
<div className="review-send-controls">
<span>Approval gate</span>
<StatusBadge
status={approvalGateConfigured ? approvalGateApproved ? "approved" : String(approvalGate.state ?? "pending") : "not_required"}
label={approvalGateConfigured ? approvalGateApproved ? "Approved" : humanize(String(approvalGate.state ?? "pending")) : "Not required"} />
{!approvalGateConfigured && approvalGateAvailable && canRequestApproval &&
<Button disabled={Boolean(busy) || readOnlyVersion || !hasBuild} onClick={() => setApprovalDialogOpen(true)}>
<ShieldCheck size={16} aria-hidden="true" />Request approval
</Button>}
{approvalGateConfigured &&
<Button disabled={Boolean(busy)} onClick={() => navigate("/approvals")}>Open approval</Button>}
</div>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
@@ -1549,12 +1620,12 @@ export default function ReviewSendPage({
<Button
variant="primary"
onClick={() => setQueueConfirmOpen(true)}
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
{busy === "queue" ? "i18n:govoplan-campaign.queueing_for_workers_.d24584fe" : "i18n:govoplan-campaign.queue_for_workers.dae9a3e4"}
</Button>
<Button
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || !selectedDryRun && approvalGateBlocksLiveDelivery || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
{busy === "send" ?
selectedDryRun ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" :
@@ -1770,6 +1841,58 @@ export default function ReviewSendPage({
}
<Dialog
open={approvalDialogOpen}
title="Request Campaign approval"
portal
closeDisabled={busy === "approval"}
onClose={() => setApprovalDialogOpen(false)}
footer={<>
<Button disabled={busy === "approval"} onClick={() => setApprovalDialogOpen(false)}>Cancel</Button>
<Button
variant="primary"
disabled={busy === "approval" || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim() || approvalRequiredCount < 1}
onClick={() => void createApprovalGate()}>
{busy === "approval" ? "Requesting..." : "Request approval"}
</Button>
</>}>
<div className="form-grid">
<p className="muted small-note form-grid-wide">The request covers the exact built message, attachment, recipient, policy, and transport snapshot. Rebuilding invalidates and removes this gate.</p>
<FormField label="Approver type">
<select value={approvalSelectorKind} disabled={busy === "approval"} onChange={(event) => setApprovalSelectorKind(event.target.value as typeof approvalSelectorKind)}>
<option value="account">Account</option>
<option value="group">Group</option>
<option value="role">Role</option>
<option value="function_assignment">Function assignment</option>
<option value="any_account">Any account</option>
</select>
</FormField>
<FormField label="Approver reference">
<input value={approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue} disabled={busy === "approval" || approvalSelectorKind === "any_account"} onChange={(event) => setApprovalSelectorValue(event.target.value)} />
</FormField>
<FormField label="Required approvals">
<input type="number" min={1} max={500} value={approvalRequiredCount} disabled={busy === "approval"} onChange={(event) => setApprovalRequiredCount(Math.max(1, Number(event.target.value) || 1))} />
</FormField>
<ToggleSwitch label="Require signature evidence" checked={approvalSignatureRequired} disabled={busy === "approval"} onChange={setApprovalSignatureRequired} />
<div className="form-grid-wide">
<span className="field-label">Actors excluded by four-eyes policy</span>
<div className="button-row compact-actions">
{(["author", "owner", "validator", "builder", "reviewer"] as const).map((role) =>
<ToggleSwitch
key={role}
label={humanize(role)}
checked={approvalExcludedRoles.has(role)}
disabled={busy === "approval"}
onChange={(checked) => setApprovalExcludedRoles((current) => {
const next = new Set(current);
if (checked) next.add(role); else next.delete(role);
return next;
})} />)}
</div>
</div>
</div>
</Dialog>
<ConfirmDialog
open={attachmentLockConfirmOpen}
title="i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653"