Files
govoplan-campaign/src/govoplan_campaign/backend/campaign/mail_profile_boundary.py
T

486 lines
17 KiB
Python

from __future__ import annotations
import copy
from typing import Any
CAMPAIGN_MAIL_SERVER_KEYS = frozenset(
{
"mail_profile_id",
"smtp_server_id",
"smtp_credential_id",
"imap_server_id",
"imap_credential_id",
}
)
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset(
{"created_from", "field_overrides", "opt_ins"}
)
CAMPAIGN_OPT_IN_KEYS = frozenset(
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
)
CAMPAIGN_REVIEW_STATE_KEYS = frozenset(
{
"build_token",
"inspection_complete",
"reviewed_message_keys",
"issue_decisions",
"updated_at",
"updated_by_user_id",
}
)
CAMPAIGN_APPROVAL_GATE_KEYS = frozenset(
{
"request_id",
"request_revision",
"subject_version",
"subject_digest",
"requested_at",
"requested_by_user_id",
}
)
class CampaignMailProfileBoundaryError(ValueError):
"""Raised when campaign JSON owns mail transport configuration.
SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON
may select Mail-owned profile, server, and credential identifiers, but it
must never copy or override transport configuration.
"""
def _validated_opt_ins(value: Any) -> dict[str, bool]:
if not isinstance(value, dict) or any(
key not in CAMPAIGN_OPT_IN_KEYS for key in value
):
raise CampaignMailProfileBoundaryError(
"Campaign editor opt_ins contains unsupported fields"
)
if any(not isinstance(item, bool) for item in value.values()):
raise CampaignMailProfileBoundaryError(
"Campaign editor opt_ins values must be booleans"
)
return copy.deepcopy(value)
def _is_valid_field_override(key: Any, value: Any) -> bool:
return (
isinstance(key, str)
and bool(key.strip())
and len(key) <= 256
and isinstance(value, bool)
)
def _validated_field_overrides(value: Any) -> dict[str, bool]:
if not isinstance(value, dict) or len(value) > 10_000:
raise CampaignMailProfileBoundaryError(
"Campaign editor field_overrides must be a bounded object"
)
if any(not _is_valid_field_override(key, item) for key, item in value.items()):
raise CampaignMailProfileBoundaryError(
"Campaign editor field_overrides must map short field names to booleans"
)
return copy.deepcopy(value)
def _validated_required_string(value: Any, *, max_length: int, error: str) -> str:
if not isinstance(value, str) or not value.strip() or len(value) > max_length:
raise CampaignMailProfileBoundaryError(error)
return value.strip()
def validate_campaign_editor_state(
value: dict[str, Any] | None,
*,
allow_server_review_state: bool = False,
) -> dict[str, Any]:
"""Validate the bounded Campaign-owned UI metadata contract.
Arbitrary editor metadata would be a second, weakly typed persistence and
response channel for Mail credentials. Review evidence is server-owned and
cannot be supplied through ordinary version create/update requests.
"""
if value is None:
return {}
if not isinstance(value, dict):
raise CampaignMailProfileBoundaryError(
"Campaign editor state must be an object"
)
allowed = set(CAMPAIGN_CLIENT_EDITOR_STATE_KEYS)
if allow_server_review_state:
allowed.add("review_send")
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"
)
result: dict[str, Any] = {}
if "created_from" in value:
result["created_from"] = _validated_required_string(
value["created_from"],
max_length=128,
error="Campaign editor created_from must be a short string",
)
if "opt_ins" in value:
result["opt_ins"] = _validated_opt_ins(value["opt_ins"])
if "field_overrides" in value:
result["field_overrides"] = _validated_field_overrides(value["field_overrides"])
if "review_send" in value:
result["review_send"] = _validated_server_review_state(value["review_send"])
if "approval_gate" in value:
result["approval_gate"] = _validated_server_approval_gate(
value["approval_gate"]
)
return result
def public_campaign_editor_state(
value: Any,
*,
include_diagnostics: bool = False,
) -> dict[str, Any]:
"""Return only known non-secret UI metadata from current or legacy rows."""
if not isinstance(value, dict):
return {}
result: dict[str, Any] = {}
for key in CAMPAIGN_CLIENT_EDITOR_STATE_KEYS:
if key not in value:
continue
try:
result.update(validate_campaign_editor_state({key: value[key]}))
except CampaignMailProfileBoundaryError:
continue
if "review_send" in value:
try:
review_state = _validated_server_review_state(value["review_send"])
if not include_diagnostics:
review_state.pop("build_token", None)
review_state["issue_decisions"] = [
{
key: item[key]
for key in (
"job_id",
"review_key",
"decision",
"reason",
"actor_user_id",
"decided_at",
"issue_codes",
)
if key in item
}
for item in review_state.get("issue_decisions", [])
]
result["review_send"] = review_state
except CampaignMailProfileBoundaryError:
pass
if "approval_gate" in value:
try:
result["approval_gate"] = _validated_server_approval_gate(
value["approval_gate"]
)
except CampaignMailProfileBoundaryError:
pass
return result
def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
"""Copy only client-owned safe metadata into a new editable version."""
state = public_campaign_editor_state(value)
state.pop("review_send", None)
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
def _validated_reviewed_message_keys(value: Any) -> list[str]:
if not isinstance(value, list) or len(value) > 100_000:
raise CampaignMailProfileBoundaryError(
"Campaign reviewed message keys are invalid"
)
if any(not _is_valid_reviewed_message_key(key) for key in value):
raise CampaignMailProfileBoundaryError(
"Campaign reviewed message keys are invalid"
)
return list(dict.fromkeys(key.strip() for key in value))
def _validated_review_actor(value: Any) -> str | None:
if value is not None and (not isinstance(value, str) or len(value) > 256):
raise CampaignMailProfileBoundaryError("Campaign review actor is invalid")
return value
def _validated_issue_decisions(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list) or len(value) > 100_000:
raise CampaignMailProfileBoundaryError(
"Campaign review issue decisions are invalid"
)
result: list[dict[str, Any]] = []
allowed_keys = {
"job_id",
"review_key",
"decision",
"reason",
"actor_user_id",
"decided_at",
"build_token",
"message_sha256",
"issue_fingerprint",
"issue_codes",
}
for item in value:
if not isinstance(item, dict) or any(key not in allowed_keys for key in item):
raise CampaignMailProfileBoundaryError(
"Campaign review issue decision is invalid"
)
normalized = {
"job_id": _validated_required_string(
item.get("job_id"),
max_length=36,
error="Campaign review decision job is invalid",
),
"review_key": _validated_required_string(
item.get("review_key"),
max_length=512,
error="Campaign review decision key is invalid",
),
"decision": _validated_required_string(
item.get("decision"),
max_length=30,
error="Campaign review decision is invalid",
),
"reason": item.get("reason"),
"actor_user_id": _validated_review_actor(item.get("actor_user_id")),
"decided_at": _validated_required_string(
item.get("decided_at"),
max_length=128,
error="Campaign review decision timestamp is invalid",
),
"build_token": _validated_required_string(
item.get("build_token"),
max_length=256,
error="Campaign review decision build token is invalid",
),
"message_sha256": item.get("message_sha256"),
"issue_fingerprint": _validated_required_string(
item.get("issue_fingerprint"),
max_length=64,
error="Campaign review issue fingerprint is invalid",
),
"issue_codes": item.get("issue_codes"),
}
if normalized["decision"] != "accept":
raise CampaignMailProfileBoundaryError(
"Campaign review decision outcome is invalid"
)
if normalized["reason"] is not None and (
not isinstance(normalized["reason"], str)
or len(normalized["reason"]) > 4_000
):
raise CampaignMailProfileBoundaryError(
"Campaign review decision reason is invalid"
)
if normalized["message_sha256"] is not None and (
not isinstance(normalized["message_sha256"], str)
or len(normalized["message_sha256"]) > 64
):
raise CampaignMailProfileBoundaryError(
"Campaign review decision message hash is invalid"
)
if (
not isinstance(normalized["issue_codes"], list)
or len(normalized["issue_codes"]) > 100
or any(
not isinstance(code, str) or not code or len(code) > 100
for code in normalized["issue_codes"]
)
):
raise CampaignMailProfileBoundaryError(
"Campaign review decision issue codes are invalid"
)
result.append(normalized)
return result
def _validated_server_review_state(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or any(
key not in CAMPAIGN_REVIEW_STATE_KEYS for key in value
):
raise CampaignMailProfileBoundaryError(
"Campaign review editor state is invalid"
)
build_token = value.get("build_token")
inspected = value.get("inspection_complete")
keys = value.get("reviewed_message_keys", [])
issue_decisions = value.get("issue_decisions", [])
updated_at = value.get("updated_at")
updated_by = value.get("updated_by_user_id")
validated_build_token = _validated_required_string(
build_token,
max_length=256,
error="Campaign review build token is invalid",
)
if not isinstance(inspected, bool):
raise CampaignMailProfileBoundaryError(
"Campaign review completion state is invalid"
)
validated_keys = _validated_reviewed_message_keys(keys)
validated_decisions = _validated_issue_decisions(issue_decisions)
validated_updated_at = _validated_required_string(
updated_at,
max_length=128,
error="Campaign review timestamp is invalid",
)
validated_updated_by = _validated_review_actor(updated_by)
return {
"build_token": validated_build_token,
"inspection_complete": inspected,
"reviewed_message_keys": validated_keys,
"issue_decisions": validated_decisions,
"updated_at": validated_updated_at,
"updated_by_user_id": validated_updated_by,
}
def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return None
value = server.get("mail_profile_id")
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized or None
def campaign_mail_resource_ids(
raw_json: dict[str, Any] | None,
) -> dict[str, str | None]:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return {
"mail_profile_id": None,
"smtp_server_id": None,
"smtp_credential_id": None,
"imap_server_id": None,
"imap_credential_id": None,
}
return {
key: (
value.strip()
if isinstance((value := server.get(key)), str) and value.strip()
else None
)
for key in CAMPAIGN_MAIL_SERVER_KEYS
}
def campaign_mail_profile_boundary_violations(
raw_json: dict[str, Any] | None,
) -> tuple[str, ...]:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return ()
violations = [
f"/server/{key}"
for key in sorted(server)
if key not in CAMPAIGN_MAIL_SERVER_KEYS
]
for key in CAMPAIGN_MAIL_SERVER_KEYS:
if key not in server:
continue
value = server[key]
if not isinstance(value, str) or not value.strip():
violations.append(f"/server/{key}")
references = campaign_mail_resource_ids(raw_json)
if references["mail_profile_id"] is None and any(
references[key] for key in references if key != "mail_profile_id"
):
violations.append("/server/mail_profile_id")
for protocol in ("smtp", "imap"):
if (
references[f"{protocol}_credential_id"]
and not references[f"{protocol}_server_id"]
):
violations.append(f"/server/{protocol}_server_id")
return tuple(violations)
def assert_campaign_uses_mail_profile_reference(
raw_json: dict[str, Any] | None,
*,
require_profile: bool = False,
) -> None:
violations = campaign_mail_profile_boundary_violations(raw_json)
if violations:
fields = ", ".join(violations)
raise CampaignMailProfileBoundaryError(
"Campaign JSON may only reference Mail-owned profiles, servers, and credentials; "
f"remove campaign-local SMTP/IMAP settings or invalid references ({fields}), "
"select authorized Mail resources, and save a new campaign version."
)
if require_profile and campaign_mail_profile_id(raw_json) is None:
raise CampaignMailProfileBoundaryError(
"Campaign delivery requires server.mail_profile_id. Select an authorized, active "
"profile from the Mail module and validate the campaign again."
)
def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]:
"""Return the complete public/persisted Campaign-to-Mail contract."""
return {
key: value
for key, value in campaign_mail_resource_ids(raw_json).items()
if value
}