from __future__ import annotations import json import shutil import tempfile 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.calendar import ( CAPABILITY_CALENDAR_INVITATIONS, CalendarInvitationAttendeeRequest, CalendarInvitationCalendarRef, CalendarInvitationProvider, CalendarInvitationRef, CalendarInvitationRequest, ) from govoplan_core.core.postbox import ( CAPABILITY_POSTBOX_DIRECTORY, CAPABILITY_POSTBOX_DELIVERY, CAPABILITY_POSTBOX_EVIDENCE, PostboxDeliveryCatalogRef, PostboxDeliveryProvider, PostboxDeliveryReceiptSummaryRef, PostboxDeliveryRequest, PostboxDeliveryResult, PostboxDirectoryEntryRef, PostboxDirectoryProvider, PostboxEvidenceProvider, PostboxTargetRef, ) from govoplan_core.core.templates import ( CAPABILITY_TEMPLATE_CATALOG, CAPABILITY_TEMPLATE_CONTENT_LIBRARY, CAPABILITY_TEMPLATE_RENDERER, TemplateCatalogProvider, TemplateCompatibility, TemplateContentDraftRequest, TemplateContentLibraryProvider, TemplateRef, TemplateRenderRequest, TemplateRenderResult, TemplateRendererProvider, ) from govoplan_campaign.backend.runtime import capability FILES_CAPABILITY = "files.campaign_attachments" 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 TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG TEMPLATE_CONTENT_LIBRARY_CAPABILITY = CAPABILITY_TEMPLATE_CONTENT_LIBRARY TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS class OptionalModuleUnavailable(RuntimeError): pass class SmtpConfigurationError(RuntimeError): pass class SmtpSendError(RuntimeError): def __init__( self, message: str, *, temporary: bool = False, outcome_unknown: bool = False, systemic: bool = False, reason_code: str | None = None, phase: str = "send", ) -> None: super().__init__(message) self.temporary = temporary self.outcome_unknown = outcome_unknown self.systemic = systemic self.reason_code = reason_code self.phase = phase class ImapConfigurationError(RuntimeError): pass class ImapAppendError(RuntimeError): def __init__( self, message: str, *, temporary: bool | None = None, outcome_unknown: bool = False, ) -> None: super().__init__(message) self.temporary = temporary self.outcome_unknown = outcome_unknown class MailProfileError(OptionalModuleUnavailable): pass class MailDeliveryCommandError(RuntimeError): pass class PostboxDeliveryUnavailable(OptionalModuleUnavailable): pass class ApprovalGateUnavailable(OptionalModuleUnavailable): pass class TemplateOutputUnavailable(OptionalModuleUnavailable): pass class CalendarInvitationUnavailable(OptionalModuleUnavailable): pass class _PreparedCampaignSnapshot: def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None: self._directory = directory self.path = path self.raw_json = raw_json self.managed_files_by_local_path: dict[str, Any] = {} self.shared_assets: list[Any] = [] self.candidate_assets: list[Any] = [] def cleanup(self) -> None: shutil.rmtree(self._directory, ignore_errors=True) class FilesCampaignIntegration: def __init__(self, delegate: Any | None = None) -> None: self._delegate = delegate @property def available(self) -> bool: return self._delegate is not None @contextmanager def prepared_campaign_snapshot(self, *args: Any, **kwargs: Any) -> Iterator[Any]: if self._delegate is not None: with self._delegate.prepared_campaign_snapshot(*args, **kwargs) as prepared: yield prepared return 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" ) 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]]: if self._delegate is None: return [] 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: return self._delegate.public_attachment_summary_payload(attachment) if hasattr(attachment, "model_dump"): return attachment.model_dump(mode="json") if isinstance(attachment, dict): 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: if self._delegate is not None: 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: if self._delegate is not None: 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]]: if self._delegate is None: raise OptionalModuleUnavailable("Files module is not available") return self._delegate.share_assets_with_campaign(session, **kwargs) def mark_job_attachment_uses_sent(self, session: Any, job: Any) -> None: if self._delegate is not None: self._delegate.mark_job_attachment_uses_sent(session, job) class MailCampaignIntegration: def __init__(self, delegate: Any | None = None) -> None: self._delegate = delegate if delegate is not None: self.MailProfileError = getattr( delegate, "MailProfileError", MailProfileError ) MailProfileError = MailProfileError SmtpConfigurationError = SmtpConfigurationError SmtpSendError = SmtpSendError ImapConfigurationError = ImapConfigurationError ImapAppendError = ImapAppendError @property def available(self) -> bool: return self._delegate is not None @property def durable_delivery_available(self) -> bool: return self._delegate is not None and callable( getattr(self._delegate, "submit_delivery_command", None) ) def _require(self) -> Any: if self._delegate is None: raise MailProfileError("Mail module is not available") return self._delegate 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 {} ) if profile_id: raise MailProfileError( "Campaign mail-server profiles require the mail module" ) return None try: 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: 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]: delegate = self._require() try: return delegate.campaign_profile_delivery_summary(session, **kwargs) except getattr(delegate, "MailProfileError", MailProfileError) as exc: raise MailProfileError(str(exc)) from exc def wait_for_rate_limit(self, **kwargs: Any) -> None: if self._delegate is None: return None return self._delegate.wait_for_rate_limit(**kwargs) def send_campaign_email_bytes(self, *args: Any, **kwargs: Any) -> Any: delegate = self._require() 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)), systemic=bool(getattr(exc, "systemic", False)), reason_code=str(getattr(exc, "reason_code", "") or "") or None, phase=str(getattr(exc, "phase", "send") or "send"), ) 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 @contextmanager def campaign_smtp_batch(self, *args: Any, **kwargs: Any) -> Iterator[Any]: delegate = self._require() method = getattr(delegate, "campaign_smtp_batch", None) if not callable(method): yield None return try: with method(*args, **kwargs) as state: yield state 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)), systemic=bool(getattr(exc, "systemic", False)), reason_code=str(getattr(exc, "reason_code", "") or "") or None, phase=str(getattr(exc, "phase", "preflight") or "preflight"), ) 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 def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any: delegate = self._require() try: return delegate.append_campaign_message_to_sent(*args, **kwargs) except getattr(delegate, "ImapAppendError", ImapAppendError) as exc: raise ImapAppendError( str(exc), temporary=getattr(exc, "temporary", None), outcome_unknown=bool(getattr(exc, "outcome_unknown", False)), ) from 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 def submit_delivery_command(self, session: Any, **kwargs: Any) -> dict[str, Any]: delegate = self._require() method = getattr(delegate, "submit_delivery_command", None) if not callable(method): raise MailDeliveryCommandError( "The installed Mail module does not provide durable delivery commands" ) try: return dict(method(session, **kwargs)) except Exception as exc: raise MailDeliveryCommandError(str(exc)) from exc def delivery_command_summary( self, session: Any, *, tenant_id: str, command_id: str, ) -> dict[str, Any]: delegate = self._require() method = getattr(delegate, "delivery_command_summary", None) if not callable(method): raise MailDeliveryCommandError( "The installed Mail module does not provide durable delivery status" ) try: return dict( method( session, tenant_id=tenant_id, command_id=command_id, ) ) except Exception as exc: raise MailDeliveryCommandError(str(exc)) from exc def mock_mailbox(self) -> Any | None: if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"): return None return self._delegate.mock_mailbox() class PostboxCampaignIntegration: def __init__( self, delivery_delegate: object | None = None, directory_delegate: object | None = None, evidence_delegate: object | None = None, ) -> None: self._delivery_delegate = ( delivery_delegate if isinstance(delivery_delegate, PostboxDeliveryProvider) else None ) self._directory_delegate = ( directory_delegate if isinstance(directory_delegate, PostboxDirectoryProvider) else None ) self._evidence_delegate = ( evidence_delegate if isinstance(evidence_delegate, PostboxEvidenceProvider) else None ) @property def available(self) -> bool: return ( self._delivery_delegate is not None and self._directory_delegate is not None ) @property def receipt_evidence_available(self) -> bool: return self._evidence_delegate is not None def delivery_catalog( self, session: object, *, tenant_id: str, ) -> PostboxDeliveryCatalogRef: if self._directory_delegate is None: raise PostboxDeliveryUnavailable( "Postbox targets are unavailable because the Postbox module " "is not active." ) return self._directory_delegate.delivery_catalog( session, tenant_id=tenant_id, ) def resolve_postbox( self, session: object, *, tenant_id: str, target: PostboxTargetRef, materialize: bool = False, ) -> PostboxDirectoryEntryRef | None: if self._directory_delegate is None: raise PostboxDeliveryUnavailable( "Postbox targets are unavailable because the Postbox module " "is not active." ) return self._directory_delegate.resolve_postbox( session, tenant_id=tenant_id, target=target, materialize=materialize, ) def deliver( self, session: object, request: PostboxDeliveryRequest, ) -> PostboxDeliveryResult: if self._delivery_delegate is None: raise PostboxDeliveryUnavailable( "Postbox delivery is unavailable because the Postbox module " "is not active." ) return self._delivery_delegate.deliver(session, request) def delivery_receipt_summaries( self, session: object, *, tenant_id: str, delivery_ids: list[str] | tuple[str, ...], ) -> dict[str, PostboxDeliveryReceiptSummaryRef]: if self._evidence_delegate is None: return {} unique_ids = tuple(dict.fromkeys(delivery_ids)) summaries: dict[str, PostboxDeliveryReceiptSummaryRef] = {} for offset in range(0, len(unique_ids), 500): summaries.update( self._evidence_delegate.delivery_receipt_summaries( session, tenant_id=tenant_id, producer_module="campaigns", delivery_ids=unique_ids[offset : offset + 500], ) ) 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, ) class TemplatesCampaignIntegration: def __init__( self, catalog_delegate: object | None = None, renderer_delegate: object | None = None, content_library_delegate: object | None = None, ) -> None: self._catalog = ( catalog_delegate if isinstance(catalog_delegate, TemplateCatalogProvider) else None ) self._renderer = ( renderer_delegate if isinstance(renderer_delegate, TemplateRendererProvider) else None ) self._content_library = ( content_library_delegate if isinstance(content_library_delegate, TemplateContentLibraryProvider) else None ) @property def available(self) -> bool: return self._catalog is not None and self._renderer is not None @property def content_available(self) -> bool: return self._catalog is not None @property def content_writable(self) -> bool: return self._content_library is not None def list_templates( self, session: object, principal: object, *, query: str = "", limit: int = 100, ) -> tuple[TemplateRef, ...]: if self._catalog is None: return () return tuple( self._catalog.list_templates( session, principal, query=query, usage="campaign_print", limit=limit, ) ) def list_content_templates( self, session: object, principal: object, *, query: str = "", limit: int = 100, ) -> tuple[TemplateRef, ...]: if self._catalog is None: return () return tuple( self._catalog.list_templates( session, principal, query=query, usage="campaign.content", limit=limit, ) ) def create_content_draft( self, session: object, principal: object, *, request: TemplateContentDraftRequest, ) -> TemplateRef: if self._content_library is None: raise TemplateOutputUnavailable( "Saving reusable Campaign content requires the Templates content-library capability." ) return self._content_library.create_content_draft( session, principal, request=request, ) def check_compatibility( self, session: object, principal: object, *, template_id: str, revision: int | None, output_format: str, available_fields: dict[str, str] | tuple[str, ...], ) -> TemplateCompatibility: if self._catalog is None: raise TemplateOutputUnavailable( "Printable output is unavailable because Templates is not active." ) return self._catalog.check_compatibility( session, principal, template_id=template_id, revision=revision, usage="campaign_print", output_format=output_format, available_fields=available_fields, ) def get_template( self, session: object, principal: object, *, template_id: str, revision: int, ) -> TemplateRef | None: if self._catalog is None: return None return self._catalog.get_template( session, principal, template_id=template_id, revision=revision, ) def render( self, session: object, principal: object, *, request: TemplateRenderRequest, ) -> TemplateRenderResult: if self._renderer is None: raise TemplateOutputUnavailable( "Printable output is unavailable because Templates is not active." ) return self._renderer.render(session, principal, request=request) class CalendarCampaignIntegration: def __init__(self, delegate: object | None = None) -> None: self._delegate = ( delegate if isinstance(delegate, CalendarInvitationProvider) else None ) @property def available(self) -> bool: return self._delegate is not None def list_calendars( self, session: object, *, tenant_id: str, user_id: str | None, group_ids: tuple[str, ...] = (), can_admin: bool = False, ) -> tuple[CalendarInvitationCalendarRef, ...]: if self._delegate is None: return () return tuple( self._delegate.list_calendars( session, tenant_id=tenant_id, user_id=user_id, group_ids=group_ids, can_admin=can_admin, ) ) def render_invitation(self, request: CalendarInvitationRequest) -> str: if self._delegate is None: raise CalendarInvitationUnavailable( "Calendar invitations require the optional Calendar module." ) return self._delegate.render_invitation(request) def upsert_invitation( self, session: object, *, tenant_id: str, user_id: str | None, request: CalendarInvitationRequest, ) -> CalendarInvitationRef: if self._delegate is None: raise CalendarInvitationUnavailable( "Calendar invitations require the optional Calendar module." ) return self._delegate.upsert_invitation( session, tenant_id=tenant_id, user_id=user_id, request=request, ) def get_invitations( self, session: object, *, tenant_id: str, correlation_ids: tuple[str, ...], ) -> dict[str, CalendarInvitationRef]: if self._delegate is None or not correlation_ids: return {} result: dict[str, CalendarInvitationRef] = {} unique_ids = tuple(dict.fromkeys(correlation_ids)) for offset in range(0, len(unique_ids), 500): result.update( self._delegate.get_invitations( session, tenant_id=tenant_id, correlation_ids=unique_ids[offset : offset + 500], ) ) return result def summarize_invitations( self, session: object, *, tenant_id: str, source_resource_id: str | None, ) -> dict[str, object]: if self._delegate is None: return { "available": False, "reason": "The Calendar invitation capability is not active.", } return dict( self._delegate.summarize_invitations( session, tenant_id=tenant_id, source_module="campaigns", source_resource_type="campaign_version", source_resource_id=source_resource_id, ) ) @staticmethod def request_from_payload(payload: dict[str, Any]) -> CalendarInvitationRequest: from datetime import datetime attendees = tuple( CalendarInvitationAttendeeRequest( address=str(item.get("address") or ""), name=str(item["name"]) if item.get("name") else None, role=str(item.get("role") or "REQ-PARTICIPANT"), participation_status=str( item.get("participation_status") or "NEEDS-ACTION" ), rsvp=bool(item.get("rsvp", True)), ) for item in payload.get("attendees") or [] if isinstance(item, dict) ) return CalendarInvitationRequest( correlation_id=str(payload.get("correlation_id") or ""), source_module="campaigns", source_resource_type="campaign_version", source_resource_id=( str(payload["source_resource_id"]) if payload.get("source_resource_id") else None ), calendar_id=( str(payload["calendar_id"]) if payload.get("calendar_id") else None ), summary=str(payload.get("summary") or ""), description=( str(payload["description"]) if payload.get("description") else None ), location=str(payload["location"]) if payload.get("location") else None, start_at=datetime.fromisoformat(str(payload.get("start_at") or "")), end_at=( datetime.fromisoformat(str(payload["end_at"])) if payload.get("end_at") else None ), timezone=str(payload["timezone"]) if payload.get("timezone") else None, organizer=( dict(payload["organizer"]) if isinstance(payload.get("organizer"), dict) else None ), attendees=attendees, classification=str(payload.get("classification") or "PUBLIC"), categories=tuple(str(value) for value in payload.get("categories") or []), metadata=( dict(payload["metadata"]) if isinstance(payload.get("metadata"), dict) else {} ), ) def files_integration() -> FilesCampaignIntegration: return FilesCampaignIntegration(capability(FILES_CAPABILITY)) def mail_integration() -> MailCampaignIntegration: return MailCampaignIntegration(capability(MAIL_CAPABILITY)) def postbox_integration() -> PostboxCampaignIntegration: return PostboxCampaignIntegration( capability(POSTBOX_CAPABILITY), capability(POSTBOX_DIRECTORY_CAPABILITY), capability(POSTBOX_EVIDENCE_CAPABILITY), ) def approvals_integration() -> ApprovalCampaignIntegration: return ApprovalCampaignIntegration(capability(APPROVALS_CAPABILITY)) def templates_integration() -> TemplatesCampaignIntegration: return TemplatesCampaignIntegration( capability(TEMPLATE_CATALOG_CAPABILITY), capability(TEMPLATE_RENDERER_CAPABILITY), capability(TEMPLATE_CONTENT_LIBRARY_CAPABILITY), ) def calendar_integration() -> CalendarCampaignIntegration: return CalendarCampaignIntegration(capability(CALENDAR_INVITATIONS_CAPABILITY))