from __future__ import annotations import hashlib import json from dataclasses import dataclass, field from email import policy from email.parser import BytesParser from typing import Any from sqlalchemy import func from sqlalchemy.orm import Session from govoplan_core.core.postbox import ( PostboxAttachmentRef, PostboxDeliveryOutcomeUnknown, PostboxDeliveryRejected, PostboxDeliveryRequest, PostboxParticipantRef, PostboxTargetRef, ) from govoplan_campaign.backend.db.models import ( CampaignJob, JobPostboxStatus, PostboxDeliveryAttempt, ) from govoplan_campaign.backend.integrations import ( PostboxDeliveryUnavailable, postbox_integration, ) from govoplan_core.security.time import utc_now ACCEPTED_POSTBOX_ATTEMPT_STATUSES = { JobPostboxStatus.ACCEPTED.value, JobPostboxStatus.ACCEPTED_VACANT.value, } @dataclass(slots=True) class PostboxChannelOutcome: accepted: int = 0 accepted_vacant: int = 0 rejected_temporary: int = 0 rejected_permanent: int = 0 outcome_unknown: int = 0 messages: list[str] = field(default_factory=list) @property def accepted_count(self) -> int: return self.accepted + self.accepted_vacant @property def rejected_count(self) -> int: return self.rejected_temporary + self.rejected_permanent @property def all_rejected_before_acceptance(self) -> bool: return ( self.accepted_count == 0 and self.outcome_unknown == 0 and self.rejected_count > 0 ) @property def status(self) -> str: if self.outcome_unknown: return JobPostboxStatus.OUTCOME_UNKNOWN.value if self.accepted_count and self.rejected_count: return JobPostboxStatus.PARTIALLY_ACCEPTED.value if self.accepted_vacant and not self.accepted: return JobPostboxStatus.ACCEPTED_VACANT.value if self.accepted_count: return JobPostboxStatus.ACCEPTED.value if self.rejected_temporary: return JobPostboxStatus.REJECTED_TEMPORARY.value return JobPostboxStatus.REJECTED_PERMANENT.value def _target_key(target: dict[str, Any]) -> str: payload = json.dumps( target, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str, ).encode("utf-8") return hashlib.sha256(payload).hexdigest() def _attempt_number( session: Session, *, job_id: str, target_key: str, ) -> int: current = ( session.query(func.max(PostboxDeliveryAttempt.attempt_number)) .filter( PostboxDeliveryAttempt.job_id == job_id, PostboxDeliveryAttempt.target_key == target_key, ) .scalar() ) return int(current or 0) + 1 def _accepted_attempt( session: Session, *, job_id: str, target_key: str, ) -> PostboxDeliveryAttempt | None: return ( session.query(PostboxDeliveryAttempt) .filter( PostboxDeliveryAttempt.job_id == job_id, PostboxDeliveryAttempt.target_key == target_key, PostboxDeliveryAttempt.status.in_( list(ACCEPTED_POSTBOX_ATTEMPT_STATUSES) ), ) .order_by(PostboxDeliveryAttempt.attempt_number.desc()) .first() ) def _message_body(message_bytes: bytes) -> str | None: message = BytesParser(policy=policy.default).parsebytes(message_bytes) body = message.get_body(preferencelist=("plain", "html")) if body is None: payload = message.get_payload(decode=True) if not isinstance(payload, bytes): return None return payload.decode(message.get_content_charset() or "utf-8", "replace") try: content = body.get_content() except (LookupError, UnicodeError): payload = body.get_payload(decode=True) if not isinstance(payload, bytes): return None return payload.decode(body.get_content_charset() or "utf-8", "replace") return str(content) def _participants(job: CampaignJob) -> tuple[PostboxParticipantRef, ...]: recipients = job.resolved_recipients or {} values: list[PostboxParticipantRef] = [] for key in ( "from_all", "to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to", ): for item in recipients.get(key) or []: if not isinstance(item, dict): continue address = str(item.get("email") or "").strip() or None label = str(item.get("name") or "").strip() or None if address is None and label is None: continue values.append( PostboxParticipantRef( kind="sender" if key == "from_all" else key, reference_type="mail_address", label=label, address=address, ) ) return tuple(values) def _attachments(job: CampaignJob) -> tuple[PostboxAttachmentRef, ...]: values = [ PostboxAttachmentRef( reference_type="campaign_eml", reference_id=job.id, name=f"{job.entry_id or job.entry_index}.eml", media_type="message/rfc822", size_bytes=job.eml_size_bytes, digest=job.eml_sha256, metadata={ "campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id, }, ) ] for rule_index, rule in enumerate(job.resolved_attachments or []): if not isinstance(rule, dict): continue managed_matches = rule.get("managed_matches") if isinstance(managed_matches, list) and managed_matches: for match in managed_matches: if not isinstance(match, dict): continue reference_id = str( match.get("version_id") or match.get("asset_id") or match.get("blob_id") or "" ).strip() if not reference_id: continue values.append( PostboxAttachmentRef( reference_type=( "file_version" if match.get("version_id") else "file_asset" ), reference_id=reference_id, name=str(match.get("filename") or "").strip() or None, media_type=( str(match.get("content_type")) if match.get("content_type") else None ), size_bytes=( int(match["size_bytes"]) if match.get("size_bytes") is not None else None ), digest=( str(match.get("checksum_sha256")) if match.get("checksum_sha256") else None ), metadata={ key: value for key, value in match.items() if key in { "asset_id", "version_id", "blob_id", "display_path", "relative_path", "owner_type", "owner_id", "source_revision", } }, ) ) continue matches = rule.get("matches") if not isinstance(matches, list): continue for match_index, match in enumerate(matches): values.append( PostboxAttachmentRef( reference_type="campaign_attachment", reference_id=f"{job.id}:{rule_index}:{match_index}", name=str(match).rsplit("/", 1)[-1] or None, metadata={ "campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id, "job_id": job.id, }, ) ) return tuple(values) def _sender_label(job: CampaignJob) -> str | None: recipients = job.resolved_recipients or {} sender = recipients.get("from") if not isinstance(sender, dict): return None name = str(sender.get("name") or "").strip() address = str(sender.get("email") or "").strip() if name and address: return f"{name} <{address}>" return address or name or None def _request( job: CampaignJob, target: dict[str, Any], *, target_key: str, body_text: str | None, classification: str, ) -> PostboxDeliveryRequest: return PostboxDeliveryRequest( tenant_id=job.tenant_id, target=PostboxTargetRef(postbox_id=str(target["postbox_id"])), producer_module="campaigns", producer_resource_type="campaign_job", producer_resource_id=job.id, idempotency_key=( f"campaign:{job.campaign_version_id}:{job.id}:postbox:" f"{target_key}" ), subject=(job.subject or "").strip() or "(No subject)", body_text=body_text, sender_label=_sender_label(job), classification=classification, participants=_participants(job), attachments=_attachments(job), metadata={ "campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id, "campaign_job_id": job.id, "entry_id": job.entry_id, "entry_index": job.entry_index, "delivery_channel_policy": job.delivery_channel_policy, "target_snapshot": target, }, ) def _record_rejection( session: Session, *, job_id: str, attempt_id: str, exc: Exception, temporary: bool, ) -> None: session.rollback() attempt = session.get(PostboxDeliveryAttempt, attempt_id) job = session.get(CampaignJob, job_id) if attempt is None or job is None: raise RuntimeError( "Postbox rejection could not be written to Campaign evidence." ) from exc attempt.status = ( JobPostboxStatus.REJECTED_TEMPORARY.value if temporary else JobPostboxStatus.REJECTED_PERMANENT.value ) attempt.error_type = exc.__class__.__name__ attempt.error_code = str(getattr(exc, "code", "") or "") or None attempt.error_message = str(exc) attempt.finished_at = utc_now() session.add(attempt) session.add(job) session.commit() def _record_unknown( session: Session, *, job_id: str, attempt_id: str, exc: Exception, ) -> None: session.rollback() attempt = session.get(PostboxDeliveryAttempt, attempt_id) job = session.get(CampaignJob, job_id) if attempt is None or job is None: raise RuntimeError( "Unknown Postbox outcome could not be written to Campaign evidence." ) from exc attempt.status = JobPostboxStatus.OUTCOME_UNKNOWN.value attempt.error_type = exc.__class__.__name__ attempt.error_code = str(getattr(exc, "code", "") or "") or None attempt.error_message = str(exc) attempt.finished_at = utc_now() job.postbox_status = JobPostboxStatus.OUTCOME_UNKNOWN.value session.add(attempt) session.add(job) session.commit() def deliver_campaign_job_to_postboxes( session: Session, *, job: CampaignJob, message_bytes: bytes, classification: str, ) -> PostboxChannelOutcome: outcome = PostboxChannelOutcome() targets = [ target for target in (job.resolved_postbox_targets or []) if isinstance(target, dict) and target.get("postbox_id") ] if not targets: outcome.rejected_permanent = 1 outcome.messages.append("No frozen Postbox target is available.") job.postbox_status = JobPostboxStatus.REJECTED_PERMANENT.value session.add(job) session.commit() return outcome body_text = _message_body(message_bytes) for target_index, target in enumerate(targets): key = _target_key(target) accepted = _accepted_attempt( session, job_id=job.id, target_key=key, ) if accepted is not None: if accepted.vacant: outcome.accepted_vacant += 1 else: outcome.accepted += 1 continue attempt_number = _attempt_number( session, job_id=job.id, target_key=key, ) request = _request( job, target, target_key=key, body_text=body_text, classification=classification, ) attempt = PostboxDeliveryAttempt( tenant_id=job.tenant_id, job_id=job.id, target_key=key, target_index=target_index, attempt_number=attempt_number, idempotency_key=request.idempotency_key, status=JobPostboxStatus.DELIVERING.value, target_snapshot=target, evidence={}, started_at=utc_now(), ) job.postbox_attempt_count += 1 job.postbox_status = JobPostboxStatus.DELIVERING.value session.add(attempt) session.add(job) session.commit() attempt_id = attempt.id try: result = postbox_integration().deliver(session, request) current_attempt = session.get(PostboxDeliveryAttempt, attempt_id) current_job = session.get(CampaignJob, job.id) if current_attempt is None or current_job is None: raise RuntimeError( "Campaign Postbox attempt disappeared before acceptance." ) current_attempt.status = ( JobPostboxStatus.ACCEPTED_VACANT.value if result.vacant else JobPostboxStatus.ACCEPTED.value ) current_attempt.provider_delivery_id = result.delivery_id current_attempt.provider_message_id = result.message_id current_attempt.postbox_id = result.postbox_id current_attempt.address = result.address current_attempt.holder_count = result.holder_count current_attempt.vacant = result.vacant current_attempt.duplicate = result.duplicate current_attempt.evidence = dict(result.evidence) current_attempt.finished_at = utc_now() session.add(current_attempt) session.add(current_job) session.commit() if result.vacant: outcome.accepted_vacant += 1 else: outcome.accepted += 1 except PostboxDeliveryOutcomeUnknown as exc: _record_unknown( session, job_id=job.id, attempt_id=attempt_id, exc=exc, ) outcome.outcome_unknown += 1 outcome.messages.append(str(exc)) except PostboxDeliveryRejected as exc: if exc.code == "idempotency_conflict": _record_unknown( session, job_id=job.id, attempt_id=attempt_id, exc=exc, ) outcome.outcome_unknown += 1 else: _record_rejection( session, job_id=job.id, attempt_id=attempt_id, exc=exc, temporary=exc.temporary, ) if exc.temporary: outcome.rejected_temporary += 1 else: outcome.rejected_permanent += 1 outcome.messages.append(str(exc)) except PostboxDeliveryUnavailable as exc: _record_rejection( session, job_id=job.id, attempt_id=attempt_id, exc=exc, temporary=True, ) outcome.rejected_temporary += 1 outcome.messages.append(str(exc)) except Exception as exc: _record_unknown( session, job_id=job.id, attempt_id=attempt_id, exc=exc, ) outcome.outcome_unknown += 1 outcome.messages.append(str(exc)) current_job = session.get(CampaignJob, job.id) if current_job is not None: current_job.postbox_status = outcome.status session.add(current_job) session.commit() return outcome