initial commit after split
This commit is contained in:
282
src/govoplan_campaign/backend/sending/execution.py
Normal file
282
src/govoplan_campaign/backend/sending/execution.py
Normal file
@@ -0,0 +1,282 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
|
||||
from govoplan_mail.backend.mail_profiles import MailProfileError, apply_campaign_credentials, effective_profile_credentials_inherited, ensure_mail_profile_allowed_for_campaign, imap_config_from_profile, mail_profile_id_from_campaign_json, smtp_config_from_profile
|
||||
|
||||
SNAPSHOT_VERSION = "3"
|
||||
|
||||
|
||||
class ExecutionSnapshotError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ExecutionSnapshot(BaseModel):
|
||||
"""Immutable delivery inputs for one built campaign version.
|
||||
|
||||
Rendered messages and attachment evidence remain normalized in
|
||||
``CampaignJob`` and ``CampaignAttachmentUse``. This record freezes the
|
||||
mutable transport/runtime configuration and cryptographically binds it to
|
||||
the build and the exact set of persisted jobs without duplicating all
|
||||
recipient data into one large JSON value.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
snapshot_version: str = SNAPSHOT_VERSION
|
||||
campaign_version_id: str
|
||||
campaign_json_sha256: str
|
||||
created_at: str
|
||||
build_token: str | None = None
|
||||
built_at: str | None = None
|
||||
job_count: int = 0
|
||||
queueable_job_count: int = 0
|
||||
job_manifest_sha256: str | None = None
|
||||
effective_policy_sha256: str | None = None
|
||||
smtp_config_fingerprint: str | None = None
|
||||
imap_config_fingerprint: str | None = None
|
||||
smtp: SmtpConfig
|
||||
imap: ImapConfig | None = None
|
||||
delivery: DeliveryConfig
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: Any) -> str:
|
||||
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def snapshot_hash(payload: dict[str, Any]) -> str:
|
||||
return _sha256(payload)
|
||||
|
||||
|
||||
def _transport_fingerprint(config: SmtpConfig | ImapConfig | None) -> str | None:
|
||||
if config is None:
|
||||
return None
|
||||
payload = config.model_dump(mode="json")
|
||||
# The fingerprint is safe to expose in reports. It identifies the effective
|
||||
# account/transport settings without incorporating or revealing the secret.
|
||||
if "password" in payload:
|
||||
payload["password"] = "<configured>" if payload.get("password") else None
|
||||
return _sha256(payload)
|
||||
|
||||
|
||||
def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpConfig | ImapConfig | None:
|
||||
if config is None:
|
||||
return None
|
||||
payload = config.model_dump(mode="json")
|
||||
payload["password"] = None
|
||||
if isinstance(config, SmtpConfig):
|
||||
return SmtpConfig.model_validate(payload)
|
||||
return ImapConfig.model_validate(payload)
|
||||
|
||||
|
||||
def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
config = server.get(name) if isinstance(server, dict) else None
|
||||
password = config.get("password") if isinstance(config, dict) else None
|
||||
if password is None:
|
||||
return None
|
||||
return str(password)
|
||||
|
||||
|
||||
def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any]:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
return server if isinstance(server, dict) else {}
|
||||
|
||||
|
||||
def _profile_for_version(session: Session, version: CampaignVersion):
|
||||
profile_id = mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
if not profile_id:
|
||||
return None
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
try:
|
||||
return ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
|
||||
except MailProfileError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
|
||||
|
||||
def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> SmtpConfig:
|
||||
payload = snapshot.smtp.model_dump(mode="json")
|
||||
if not payload.get("password"):
|
||||
server = _server_from_campaign_json(version.raw_json)
|
||||
profile = _profile_for_version(session, version)
|
||||
if profile is not None:
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
profile_payload = smtp_config_from_profile(profile).model_dump(mode="json")
|
||||
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
|
||||
return SmtpConfig.model_validate(profile_payload)
|
||||
return SmtpConfig.model_validate(apply_campaign_credentials(profile_payload, server, "smtp"))
|
||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp")
|
||||
return SmtpConfig.model_validate(payload)
|
||||
|
||||
|
||||
def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> ImapConfig | None:
|
||||
server = _server_from_campaign_json(version.raw_json)
|
||||
if snapshot.imap is None:
|
||||
profile = _profile_for_version(session, version)
|
||||
if profile is None:
|
||||
return None
|
||||
imap = imap_config_from_profile(profile)
|
||||
if imap is None:
|
||||
return None
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
imap_payload = imap.model_dump(mode="json")
|
||||
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
||||
return ImapConfig.model_validate(imap_payload)
|
||||
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
|
||||
payload = snapshot.imap.model_dump(mode="json")
|
||||
if not payload.get("password"):
|
||||
profile = _profile_for_version(session, version)
|
||||
if profile is not None:
|
||||
imap = imap_config_from_profile(profile)
|
||||
if imap is not None:
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
imap_payload = imap.model_dump(mode="json")
|
||||
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
||||
return ImapConfig.model_validate(imap_payload)
|
||||
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
|
||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap")
|
||||
return ImapConfig.model_validate(payload)
|
||||
|
||||
|
||||
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
|
||||
return _sha256(
|
||||
{
|
||||
"validation_policy": raw_json.get("validation_policy"),
|
||||
"policy": raw_json.get("policy"),
|
||||
"delivery": delivery.model_dump(mode="json"),
|
||||
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
|
||||
if isinstance(raw_json.get("attachments"), dict)
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
|
||||
"""Hash the immutable per-message execution records in stable order."""
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)):
|
||||
payload.append(
|
||||
{
|
||||
"job_id": job.id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
"subject": job.subject,
|
||||
"message_id_header": job.message_id_header,
|
||||
"eml_size_bytes": job.eml_size_bytes,
|
||||
"eml_sha256": job.eml_sha256,
|
||||
"build_status": job.build_status,
|
||||
"validation_status": job.validation_status,
|
||||
"resolved_recipients_sha256": _sha256(job.resolved_recipients or {}),
|
||||
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
|
||||
"issues_sha256": _sha256(job.issues_snapshot or []),
|
||||
}
|
||||
)
|
||||
return _sha256(payload)
|
||||
|
||||
|
||||
def create_execution_snapshot(
|
||||
version: CampaignVersion,
|
||||
*,
|
||||
smtp: SmtpConfig,
|
||||
imap: ImapConfig | None,
|
||||
delivery: DeliveryConfig,
|
||||
jobs: Iterable[CampaignJob] = (),
|
||||
build_summary: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
job_list = list(jobs)
|
||||
summary = build_summary if isinstance(build_summary, dict) else {}
|
||||
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
||||
redacted_smtp = _redacted_transport_config(smtp)
|
||||
redacted_imap = _redacted_transport_config(imap)
|
||||
assert isinstance(redacted_smtp, SmtpConfig)
|
||||
assert redacted_imap is None or isinstance(redacted_imap, ImapConfig)
|
||||
payload = ExecutionSnapshot(
|
||||
campaign_version_id=version.id,
|
||||
campaign_json_sha256=_sha256(raw_json),
|
||||
build_token=str(summary.get("build_token") or "") or None,
|
||||
built_at=str(summary.get("built_at") or "") or None,
|
||||
job_count=len(job_list),
|
||||
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
|
||||
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
|
||||
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
|
||||
smtp_config_fingerprint=_transport_fingerprint(smtp),
|
||||
imap_config_fingerprint=_transport_fingerprint(imap),
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
smtp=redacted_smtp,
|
||||
imap=redacted_imap,
|
||||
delivery=delivery,
|
||||
).model_dump(mode="json")
|
||||
return payload, snapshot_hash(payload)
|
||||
|
||||
|
||||
def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> ExecutionSnapshot:
|
||||
"""Return a validated snapshot, creating one for pre-migration builds.
|
||||
|
||||
New builds create the snapshot after persisting their jobs. The fallback is
|
||||
intentionally limited to legacy built versions so they can be operated
|
||||
without a manual data migration.
|
||||
"""
|
||||
|
||||
if isinstance(version.execution_snapshot, dict):
|
||||
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
||||
expected = snapshot_hash(snapshot.model_dump(mode="json"))
|
||||
if version.execution_snapshot_hash and version.execution_snapshot_hash != expected:
|
||||
raise ExecutionSnapshotError("Execution snapshot checksum mismatch")
|
||||
return snapshot
|
||||
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||
|
||||
_, _, config = load_version_config(session, version.id)
|
||||
if not config.server.smtp:
|
||||
raise ExecutionSnapshotError("Campaign has no SMTP configuration")
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version.id)
|
||||
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||
.all()
|
||||
)
|
||||
if not jobs:
|
||||
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
||||
payload, digest = create_execution_snapshot(
|
||||
version,
|
||||
smtp=config.server.smtp,
|
||||
imap=config.server.imap,
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
||||
)
|
||||
version.execution_snapshot = payload
|
||||
version.execution_snapshot_hash = digest
|
||||
version.execution_snapshot_at = datetime.now(timezone.utc)
|
||||
session.add(version)
|
||||
session.flush()
|
||||
return ExecutionSnapshot.model_validate(payload)
|
||||
|
||||
|
||||
def clear_execution_snapshot(version: CampaignVersion) -> None:
|
||||
version.execution_snapshot = None
|
||||
version.execution_snapshot_hash = None
|
||||
version.execution_snapshot_at = None
|
||||
Reference in New Issue
Block a user