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

205 lines
8.2 KiB
Python

from __future__ import annotations
import copy
from typing import Any
CAMPAIGN_MAIL_SERVER_KEYS = frozenset({"mail_profile_id"})
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"})
CAMPAIGN_OPT_IN_KEYS = frozenset(
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
)
CAMPAIGN_REVIEW_STATE_KEYS = frozenset(
{
"build_token",
"inspection_complete",
"reviewed_message_keys",
"updated_at",
"updated_by_user_id",
}
)
class CampaignMailProfileBoundaryError(ValueError):
"""Raised when campaign JSON owns mail transport configuration.
SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON
may select one Mail-owned profile, but it must never copy or override that
profile's transport configuration.
"""
def validate_campaign_editor_state(
value: dict[str, Any] | None,
*,
allow_server_review_state: bool = False,
) -> dict[str, Any]:
"""Validate the bounded Campaign-owned UI metadata contract.
Arbitrary editor metadata would be a second, weakly typed persistence and
response channel for Mail credentials. Review evidence is server-owned and
cannot be supplied through ordinary version create/update requests.
"""
if value is None:
return {}
if not isinstance(value, dict):
raise CampaignMailProfileBoundaryError("Campaign editor state must be an object")
allowed = set(CAMPAIGN_CLIENT_EDITOR_STATE_KEYS)
if allow_server_review_state:
allowed.add("review_send")
if any(key not in allowed for key in value):
raise CampaignMailProfileBoundaryError(
"Campaign editor state contains unsupported or transport-owned fields"
)
result: dict[str, Any] = {}
if "created_from" in value:
created_from = value["created_from"]
if not isinstance(created_from, str) or not created_from.strip() or len(created_from) > 128:
raise CampaignMailProfileBoundaryError("Campaign editor created_from must be a short string")
result["created_from"] = created_from.strip()
if "opt_ins" in value:
opt_ins = value["opt_ins"]
if not isinstance(opt_ins, dict) or any(key not in CAMPAIGN_OPT_IN_KEYS for key in opt_ins):
raise CampaignMailProfileBoundaryError("Campaign editor opt_ins contains unsupported fields")
if any(not isinstance(item, bool) for item in opt_ins.values()):
raise CampaignMailProfileBoundaryError("Campaign editor opt_ins values must be booleans")
result["opt_ins"] = copy.deepcopy(opt_ins)
if "field_overrides" in value:
overrides = value["field_overrides"]
if not isinstance(overrides, dict) or len(overrides) > 10_000:
raise CampaignMailProfileBoundaryError("Campaign editor field_overrides must be a bounded object")
if any(
not isinstance(key, str)
or not key.strip()
or len(key) > 256
or not isinstance(item, bool)
for key, item in overrides.items()
):
raise CampaignMailProfileBoundaryError(
"Campaign editor field_overrides must map short field names to booleans"
)
result["field_overrides"] = copy.deepcopy(overrides)
if "review_send" in value:
result["review_send"] = _validated_server_review_state(value["review_send"])
return result
def public_campaign_editor_state(
value: Any,
*,
include_diagnostics: bool = False,
) -> dict[str, Any]:
"""Return only known non-secret UI metadata from current or legacy rows."""
if not isinstance(value, dict):
return {}
result: dict[str, Any] = {}
for key in CAMPAIGN_CLIENT_EDITOR_STATE_KEYS:
if key not in value:
continue
try:
result.update(validate_campaign_editor_state({key: value[key]}))
except CampaignMailProfileBoundaryError:
continue
if "review_send" in value:
try:
review_state = _validated_server_review_state(value["review_send"])
if not include_diagnostics:
review_state.pop("build_token", None)
result["review_send"] = review_state
except CampaignMailProfileBoundaryError:
pass
return result
def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
"""Copy only client-owned safe metadata into a new editable version."""
state = public_campaign_editor_state(value)
state.pop("review_send", None)
return state
def _validated_server_review_state(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or any(key not in CAMPAIGN_REVIEW_STATE_KEYS for key in value):
raise CampaignMailProfileBoundaryError("Campaign review editor state is invalid")
build_token = value.get("build_token")
inspected = value.get("inspection_complete")
keys = value.get("reviewed_message_keys", [])
updated_at = value.get("updated_at")
updated_by = value.get("updated_by_user_id")
if not isinstance(build_token, str) or not build_token.strip() or len(build_token) > 256:
raise CampaignMailProfileBoundaryError("Campaign review build token is invalid")
if not isinstance(inspected, bool):
raise CampaignMailProfileBoundaryError("Campaign review completion state is invalid")
if (
not isinstance(keys, list)
or len(keys) > 100_000
or any(not isinstance(key, str) or not key.strip() or len(key) > 512 for key in keys)
):
raise CampaignMailProfileBoundaryError("Campaign reviewed message keys are invalid")
if not isinstance(updated_at, str) or not updated_at.strip() or len(updated_at) > 128:
raise CampaignMailProfileBoundaryError("Campaign review timestamp is invalid")
if updated_by is not None and (not isinstance(updated_by, str) or len(updated_by) > 256):
raise CampaignMailProfileBoundaryError("Campaign review actor is invalid")
return {
"build_token": build_token.strip(),
"inspection_complete": inspected,
"reviewed_message_keys": list(dict.fromkeys(key.strip() for key in keys)),
"updated_at": updated_at.strip(),
"updated_by_user_id": updated_by,
}
def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return None
value = server.get("mail_profile_id")
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized or None
def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
if not isinstance(server, dict):
return ()
violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS]
if "mail_profile_id" in server:
profile_id = server["mail_profile_id"]
if not isinstance(profile_id, str) or not profile_id.strip():
violations.append("/server/mail_profile_id")
return tuple(violations)
def assert_campaign_uses_mail_profile_reference(
raw_json: dict[str, Any] | None,
*,
require_profile: bool = False,
) -> None:
violations = campaign_mail_profile_boundary_violations(raw_json)
if violations:
fields = ", ".join(violations)
raise CampaignMailProfileBoundaryError(
"Campaign JSON may only reference a Mail-module profile through "
f"server.mail_profile_id; remove campaign-local SMTP/IMAP settings ({fields}), "
"select an authorized Mail profile, and save a new campaign version."
)
if require_profile and campaign_mail_profile_id(raw_json) is None:
raise CampaignMailProfileBoundaryError(
"Campaign delivery requires server.mail_profile_id. Select an authorized, active "
"profile from the Mail module and validate the campaign again."
)
def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]:
"""Return the complete public/persisted Campaign-to-Mail contract."""
profile_id = campaign_mail_profile_id(raw_json)
return {"mail_profile_id": profile_id} if profile_id else {}