initial commit after split

This commit is contained in:
2026-06-24 01:43:27 +02:00
parent 2406e70597
commit 7f25a3ccdf
125 changed files with 25551 additions and 0 deletions

View 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

File diff suppressed because it is too large Load Diff