from __future__ import annotations import copy from pathlib import Path, PureWindowsPath from typing import Any from govoplan_campaign.backend.campaign.mail_profile_boundary import public_campaign_mail_server # These fields locate process-local or storage-backend resources, or authorize # a worker claim. They are useful for tightly controlled diagnostics but are # not part of the campaign business-data contract. CAMPAIGN_INTERNAL_RESPONSE_KEYS = frozenset( { "campaign_file", "claim_token", "eml_local_path", "eml_path", "eml_storage_key", "local_path", "source_base_path", "storage_bucket", "storage_key", } ) CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS = frozenset( { "background_workers_enabled", "build_token", "celery_enabled", "claimed_at", "effective_policy_sha256", "execution_input_sha256", "imap_claimed_at", "imap_transport_revision", "job_manifest_sha256", "smtp_started_at", "smtp_transport_revision", } ) _SEND_NOW_RESULT_KEYS = ( "campaign_id", "version_id", "attempted_count", "sent_count", "failed_count", "outcome_unknown_count", "skipped_count", "preflight_count", "delivery_mode", "dry_run", ) _SEND_NOW_JOB_RESULT_KEYS = ( "campaign_id", "version_id", "job_id", "status", "attempt_number", "dry_run", "queued_count", "skipped_count", "blocked_count", "enqueued_count", "delivery_mode", "worker_queue_available", ) _SYNCHRONOUS_POLICY_KEYS = ( "max_recipient_jobs", "source", "deployment_max_recipient_jobs", "tenant_max_recipient_jobs", ) _VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count") _BUILD_SUMMARY_KEYS = ( "built_count", "build_failed_count", "ready_count", "warning_count", "needs_review_count", "blocked_count", "excluded_count", "inactive_count", "queueable_count", ) def public_campaign_payload(value: Any, *, include_diagnostics: bool = False) -> Any: """Return a detached payload without infrastructure-only locators.""" if isinstance(value, dict): blocked_keys = CAMPAIGN_INTERNAL_RESPONSE_KEYS if not include_diagnostics: blocked_keys = blocked_keys | CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS return { key: public_campaign_payload(item, include_diagnostics=include_diagnostics) for key, item in value.items() if key not in blocked_keys } if isinstance(value, list): return [public_campaign_payload(item, include_diagnostics=include_diagnostics) for item in value] if isinstance(value, tuple): return tuple(public_campaign_payload(item, include_diagnostics=include_diagnostics) for item in value) return copy.deepcopy(value) def public_delivery_result_message( *, last_error: Any, send_status: Any, imap_status: Any, postbox_status: Any = None, ) -> str | None: """Map persisted provider text to a stable business-safe explanation.""" if not last_error: return None clean_send_status = str(send_status or "") clean_imap_status = str(imap_status or "") clean_postbox_status = str(postbox_status or "") if clean_postbox_status == "outcome_unknown": return "Postbox delivery outcome requires operator reconciliation." if clean_send_status == "outcome_unknown": return "Delivery outcome requires operator reconciliation." if clean_postbox_status in { "rejected_temporary", "rejected_permanent", }: return "Postbox delivery was rejected; an operator can inspect restricted diagnostics." if clean_postbox_status == "partially_accepted": return "Some Postbox targets accepted the message and others rejected it." if clean_send_status in {"failed_temporary", "failed_permanent"}: if clean_postbox_status in {"", "not_requested"}: return "SMTP delivery failed; an operator can inspect restricted diagnostics." return "Delivery failed; an operator can inspect restricted diagnostics." if clean_imap_status in {"outcome_unknown", "appending"}: return "Sent-folder append outcome requires operator reconciliation." if clean_imap_status in {"failed", "skipped"}: return "Sent-folder append did not complete; an operator can inspect restricted diagnostics." return "Delivery recorded a warning; an operator can inspect restricted diagnostics." def public_send_campaign_now_result( value: dict[str, Any], *, validation_summary: dict[str, Any], build_summary: dict[str, Any], ) -> dict[str, Any]: """Project synchronous delivery into its recipient-authorized public contract. Per-job provider messages are deliberately omitted. They can contain SMTP diagnostics or refused envelope addresses and belong only in restricted diagnostics backed by persisted job state. """ result = _selected_payload(value, _SEND_NOW_RESULT_KEYS) policy = value.get("synchronous_send_policy") result["synchronous_send_policy"] = _selected_payload( policy if isinstance(policy, dict) else {}, _SYNCHRONOUS_POLICY_KEYS, ) rows = value.get("results") if isinstance(rows, list): result["results"] = [ _selected_payload(row, _SEND_NOW_JOB_RESULT_KEYS) for row in rows if isinstance(row, dict) ] else: result["results"] = [] result["validation"] = _selected_payload(validation_summary, _VALIDATION_SUMMARY_KEYS) result["build"] = _selected_payload(build_summary, _BUILD_SUMMARY_KEYS) return result def send_campaign_now_audit_details(value: dict[str, Any]) -> dict[str, Any]: """Return aggregate-only evidence for a synchronous Campaign send audit.""" details = _selected_payload(value, _SEND_NOW_RESULT_KEYS) policy = value.get("synchronous_send_policy") details["synchronous_send_policy"] = _selected_payload( policy if isinstance(policy, dict) else {}, _SYNCHRONOUS_POLICY_KEYS, ) return details def _selected_payload(value: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]: return { key: copy.deepcopy(value[key]) for key in keys if key in value } def public_campaign_configuration(value: Any) -> Any: """Return campaign JSON without infrastructure locators or mail secrets. Password-named business fields are intentionally retained. Only the schema-defined SMTP/IMAP credential paths under ``server`` are secrets. """ payload = public_campaign_payload(value) if not isinstance(payload, dict): return payload if "server" in payload: payload["server"] = public_campaign_mail_server(payload) _sanitize_configuration_paths(payload) return payload def public_source_filename(value: Any) -> str | None: if value is None: return None text = str(value).strip() if not text: return text windows_path = PureWindowsPath(text) return windows_path.name if windows_path.is_absolute() or "\\" in text else Path(text).name def _sanitize_configuration_paths(payload: dict[str, Any]) -> None: template = payload.get("template") source = template.get("source") if isinstance(template, dict) else None if isinstance(source, dict): for key in ("subject_path", "text_path", "html_path"): if key in source: source[key] = _public_configuration_path(source[key]) entries = payload.get("entries") entry_source = entries.get("source") if isinstance(entries, dict) else None if isinstance(entry_source, dict) and "path" in entry_source: entry_source["path"] = _public_configuration_path(entry_source["path"]) attachments = payload.get("attachments") if isinstance(attachments, dict): if "base_path" in attachments: attachments["base_path"] = _public_configuration_path(attachments["base_path"]) base_paths = attachments.get("base_paths") if isinstance(base_paths, list): for item in base_paths: if not isinstance(item, dict): continue for key in ("path", "source"): if key in item: item[key] = _public_configuration_path(item[key]) _sanitize_attachment_rules(attachments.get("global")) if isinstance(entries, dict): inline_entries = entries.get("inline") if isinstance(inline_entries, list): for entry in inline_entries: if isinstance(entry, dict): _sanitize_attachment_rules(entry.get("attachments")) defaults = entries.get("defaults") if isinstance(defaults, dict): _sanitize_attachment_rules(defaults.get("attachments")) def _sanitize_attachment_rules(value: Any) -> None: if not isinstance(value, list): return for rule in value: if isinstance(rule, dict) and "base_dir" in rule: rule["base_dir"] = _public_configuration_path(rule["base_dir"]) def _public_configuration_path(value: Any) -> Any: if not isinstance(value, str) or not value.strip(): return value text = value.strip() windows_path = PureWindowsPath(text) path = Path(text) if windows_path.is_absolute(): return windows_path.name if path.is_absolute() or text.startswith("~"): return path.name return value