307 lines
10 KiB
Python
307 lines
10 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",
|
|
"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 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")
|
|
if any(key not in allowed for key in value):
|
|
raise CampaignMailProfileBoundaryError(
|
|
"Campaign editor state contains unsupported or transport-owned fields"
|
|
)
|
|
|
|
result: dict[str, Any] = {}
|
|
if "created_from" in value:
|
|
result["created_from"] = _validated_required_string(
|
|
value["created_from"],
|
|
max_length=128,
|
|
error="Campaign editor created_from must be a short string",
|
|
)
|
|
if "opt_ins" in value:
|
|
result["opt_ins"] = _validated_opt_ins(value["opt_ins"])
|
|
if "field_overrides" in value:
|
|
result["field_overrides"] = _validated_field_overrides(
|
|
value["field_overrides"]
|
|
)
|
|
if "review_send" in value:
|
|
result["review_send"] = _validated_server_review_state(value["review_send"])
|
|
return result
|
|
|
|
|
|
def public_campaign_editor_state(
|
|
value: Any,
|
|
*,
|
|
include_diagnostics: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Return only known non-secret UI metadata from current or legacy rows."""
|
|
|
|
if not isinstance(value, dict):
|
|
return {}
|
|
result: dict[str, Any] = {}
|
|
for key in CAMPAIGN_CLIENT_EDITOR_STATE_KEYS:
|
|
if key not in value:
|
|
continue
|
|
try:
|
|
result.update(validate_campaign_editor_state({key: value[key]}))
|
|
except CampaignMailProfileBoundaryError:
|
|
continue
|
|
if "review_send" in value:
|
|
try:
|
|
review_state = _validated_server_review_state(value["review_send"])
|
|
if not include_diagnostics:
|
|
review_state.pop("build_token", None)
|
|
result["review_send"] = review_state
|
|
except CampaignMailProfileBoundaryError:
|
|
pass
|
|
return result
|
|
|
|
|
|
def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
|
|
"""Copy only client-owned safe metadata into a new editable version."""
|
|
|
|
state = public_campaign_editor_state(value)
|
|
state.pop("review_send", None)
|
|
return state
|
|
|
|
|
|
def _is_valid_reviewed_message_key(value: Any) -> bool:
|
|
return isinstance(value, str) and bool(value.strip()) and len(value) <= 512
|
|
|
|
|
|
def _validated_reviewed_message_keys(value: Any) -> list[str]:
|
|
if not isinstance(value, list) or len(value) > 100_000:
|
|
raise CampaignMailProfileBoundaryError(
|
|
"Campaign reviewed message keys are invalid"
|
|
)
|
|
if any(not _is_valid_reviewed_message_key(key) for key in value):
|
|
raise CampaignMailProfileBoundaryError(
|
|
"Campaign reviewed message keys are invalid"
|
|
)
|
|
return list(dict.fromkeys(key.strip() for key in value))
|
|
|
|
|
|
def _validated_review_actor(value: Any) -> str | None:
|
|
if value is not None and (not isinstance(value, str) or len(value) > 256):
|
|
raise CampaignMailProfileBoundaryError("Campaign review actor is invalid")
|
|
return value
|
|
|
|
|
|
def _validated_server_review_state(value: Any) -> dict[str, Any]:
|
|
if not isinstance(value, dict) or any(
|
|
key not in CAMPAIGN_REVIEW_STATE_KEYS for key in value
|
|
):
|
|
raise CampaignMailProfileBoundaryError(
|
|
"Campaign review editor state is invalid"
|
|
)
|
|
build_token = value.get("build_token")
|
|
inspected = value.get("inspection_complete")
|
|
keys = value.get("reviewed_message_keys", [])
|
|
updated_at = value.get("updated_at")
|
|
updated_by = value.get("updated_by_user_id")
|
|
validated_build_token = _validated_required_string(
|
|
build_token,
|
|
max_length=256,
|
|
error="Campaign review build token is invalid",
|
|
)
|
|
if not isinstance(inspected, bool):
|
|
raise CampaignMailProfileBoundaryError(
|
|
"Campaign review completion state is invalid"
|
|
)
|
|
validated_keys = _validated_reviewed_message_keys(keys)
|
|
validated_updated_at = _validated_required_string(
|
|
updated_at,
|
|
max_length=128,
|
|
error="Campaign review timestamp is invalid",
|
|
)
|
|
validated_updated_by = _validated_review_actor(updated_by)
|
|
return {
|
|
"build_token": validated_build_token,
|
|
"inspection_complete": inspected,
|
|
"reviewed_message_keys": validated_keys,
|
|
"updated_at": validated_updated_at,
|
|
"updated_by_user_id": validated_updated_by,
|
|
}
|
|
|
|
|
|
def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None:
|
|
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
|
if not isinstance(server, dict):
|
|
return None
|
|
value = server.get("mail_profile_id")
|
|
if not isinstance(value, str):
|
|
return None
|
|
normalized = value.strip()
|
|
return normalized or None
|
|
|
|
|
|
def campaign_mail_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
|
|
}
|