Files
govoplan-campaign/src/govoplan_campaign/backend/sending/postbox_delivery.py

522 lines
17 KiB
Python

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