287 lines
11 KiB
Python
287 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from email.message import EmailMessage
|
|
from email.utils import formataddr
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
|
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
|
|
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
|
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
|
from govoplan_campaign.backend.integrations import (
|
|
MailDeliveryCommandError,
|
|
SmtpConfigurationError,
|
|
mail_integration,
|
|
)
|
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
|
|
|
|
|
|
class CampaignReportEmailError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CampaignReportEmailResult:
|
|
campaign_id: str
|
|
version_id: str
|
|
to: list[str]
|
|
subject: str
|
|
dry_run: bool
|
|
sent: bool
|
|
attached_jobs_csv: bool
|
|
attached_report_json: bool
|
|
accepted_count: int | None = None
|
|
command_id: str | None = None
|
|
delivery_status: str | None = None
|
|
duplicate: bool = False
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"campaign_id": self.campaign_id,
|
|
"version_id": self.version_id,
|
|
"to": self.to,
|
|
"subject": self.subject,
|
|
"dry_run": self.dry_run,
|
|
"sent": self.sent,
|
|
"attached_jobs_csv": self.attached_jobs_csv,
|
|
"attached_report_json": self.attached_report_json,
|
|
"accepted_count": self.accepted_count,
|
|
"command_id": self.command_id,
|
|
"delivery_status": self.delivery_status,
|
|
"duplicate": self.duplicate,
|
|
}
|
|
|
|
def audit_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"campaign_id": self.campaign_id,
|
|
"version_id": self.version_id,
|
|
"recipient_count": len(self.to),
|
|
"dry_run": self.dry_run,
|
|
"attached_jobs_csv": self.attached_jobs_csv,
|
|
"attached_report_json": self.attached_report_json,
|
|
"command_id": self.command_id,
|
|
"delivery_status": self.delivery_status,
|
|
"duplicate": self.duplicate,
|
|
}
|
|
|
|
|
|
def _selected_version(
|
|
session: Session,
|
|
campaign: Campaign,
|
|
version_id: str | None = None,
|
|
) -> CampaignVersion:
|
|
wanted = version_id or campaign.current_version_id
|
|
version = session.get(CampaignVersion, wanted) if wanted else None
|
|
if version is None or version.campaign_id != campaign.id:
|
|
raise CampaignReportEmailError("Campaign version not found")
|
|
return version
|
|
|
|
|
|
def _load_config(session: Session, version: CampaignVersion) -> CampaignConfig:
|
|
_campaign, _version, config = load_version_config(session, version.id)
|
|
return config
|
|
|
|
|
|
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
|
|
if config.recipients.from_:
|
|
return config.recipients.from_[0].email, config.recipients.from_[0].name
|
|
raise SmtpConfigurationError("Report email requires a Campaign-owned recipients.from address")
|
|
|
|
|
|
def _text_summary(report: dict[str, Any]) -> str:
|
|
campaign = report["campaign"]
|
|
cards = report["cards"]
|
|
status = report["status_counts"]
|
|
delivery = report.get("delivery", {})
|
|
lines = [
|
|
f"Campaign report: {campaign['name']}",
|
|
"",
|
|
f"Campaign ID: {campaign['id']}",
|
|
f"External ID: {campaign['external_id']}",
|
|
f"Status: {campaign['status']}",
|
|
"",
|
|
"Overview",
|
|
f"- Jobs total: {cards['jobs_total']}",
|
|
f"- Queueable: {cards['queueable']}",
|
|
f"- Needs attention: {cards['needs_attention']}",
|
|
f"- Sent: {cards['sent']}",
|
|
f"- Failed: {cards['failed']}",
|
|
f"- SMTP skipped (excluded): {cards.get('skipped', status.get('send', {}).get('skipped', 0))}",
|
|
f"- IMAP appended: {cards['imap_appended']}",
|
|
f"- IMAP failed: {cards['imap_failed']}",
|
|
f"- IMAP skipped: {cards.get('imap_skipped', status.get('imap', {}).get('skipped', 0))}",
|
|
"",
|
|
f"Build status: {status.get('build', {})}",
|
|
f"Validation status: {status.get('validation', {})}",
|
|
f"Queue status: {status.get('queue', {})}",
|
|
f"Send status: {status.get('send', {})}",
|
|
f"IMAP status: {status.get('imap', {})}",
|
|
]
|
|
if delivery.get("estimated_remaining_send_human"):
|
|
lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"])
|
|
lines.extend(["", "This report was generated by GovOPlaN."])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def build_report_message(
|
|
*,
|
|
campaign: Campaign,
|
|
config: CampaignConfig,
|
|
report: dict[str, Any],
|
|
to: list[str],
|
|
jobs_csv: str | None = None,
|
|
report_json: dict[str, Any] | None = None,
|
|
) -> EmailMessage:
|
|
from_email, from_name = _effective_from(config)
|
|
subject = f"GovOPlaN report: {campaign.name}"
|
|
|
|
msg = EmailMessage()
|
|
msg["Subject"] = subject
|
|
msg["From"] = formataddr((from_name or from_email, from_email))
|
|
msg["To"] = ", ".join(to)
|
|
msg["X-GovOPlaN-Report"] = "campaign"
|
|
msg.set_content(_text_summary(report))
|
|
|
|
if jobs_csv is not None:
|
|
filename = f"govoplan-{campaign.external_id}-jobs.csv"
|
|
msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename)
|
|
if report_json is not None:
|
|
filename = f"govoplan-{campaign.external_id}-report.json"
|
|
msg.add_attachment(
|
|
json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"),
|
|
maintype="application",
|
|
subtype="json",
|
|
filename=filename,
|
|
)
|
|
return msg
|
|
|
|
|
|
def send_campaign_report_email(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
campaign_id: str,
|
|
version_id: str | None = None,
|
|
to: list[str],
|
|
include_jobs: bool = False,
|
|
attach_jobs_csv: bool = False,
|
|
attach_report_json: bool = False,
|
|
dry_run: bool = False,
|
|
idempotency_key: str | None = None,
|
|
created_by_user_id: str | None = None,
|
|
) -> CampaignReportEmailResult:
|
|
campaign = session.get(Campaign, campaign_id)
|
|
if not campaign or campaign.tenant_id != tenant_id:
|
|
raise CampaignReportError("Campaign not found")
|
|
if not to:
|
|
raise CampaignReportEmailError("At least one report recipient is required")
|
|
|
|
version = _selected_version(session, campaign, version_id)
|
|
config = _load_config(session, version)
|
|
if not config.server.profile_capabilities.smtp_available:
|
|
raise SmtpConfigurationError("Campaign has no SMTP configuration")
|
|
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
|
if not profile_id:
|
|
raise SmtpConfigurationError("Campaign has no Mail profile reference")
|
|
if not isinstance(version.execution_snapshot, dict):
|
|
raise CampaignReportEmailError(
|
|
"Report email requires a validated and built campaign version with stored Mail-profile evidence."
|
|
)
|
|
try:
|
|
snapshot = ensure_execution_snapshot(session, version)
|
|
except ExecutionSnapshotError as exc:
|
|
raise CampaignReportEmailError(
|
|
"Report email requires a validated and built campaign version with current Mail-profile evidence."
|
|
) from exc
|
|
if not snapshot.smtp_transport_revision:
|
|
raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision")
|
|
mail = mail_integration()
|
|
if not dry_run:
|
|
if not mail.durable_delivery_available:
|
|
raise CampaignReportEmailError(
|
|
"Report email delivery requires the durable, idempotent Mail-owned outbox."
|
|
)
|
|
if not str(idempotency_key or "").strip():
|
|
raise CampaignReportEmailError(
|
|
"Live report email delivery requires an idempotency key"
|
|
)
|
|
report = generate_campaign_report(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version.id,
|
|
include_jobs=include_jobs,
|
|
include_recent_failures=include_jobs,
|
|
)
|
|
jobs_csv = (
|
|
generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id)
|
|
if attach_jobs_csv
|
|
else None
|
|
)
|
|
report_json = report if attach_report_json else None
|
|
message = build_report_message(
|
|
campaign=campaign,
|
|
config=config,
|
|
report=report,
|
|
to=to,
|
|
jobs_csv=jobs_csv,
|
|
report_json=report_json,
|
|
)
|
|
if not dry_run:
|
|
clean_idempotency_key = str(idempotency_key or "").strip()
|
|
from_email, _from_name = _effective_from(config)
|
|
if not snapshot.mail_profile_id:
|
|
raise CampaignReportEmailError(
|
|
"Campaign build evidence has no Mail profile reference"
|
|
)
|
|
try:
|
|
command = mail.submit_delivery_command(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
command_type="campaign_report",
|
|
source_module="campaigns",
|
|
source_resource_type="campaign",
|
|
source_resource_id=campaign.id,
|
|
source_version_id=version.id,
|
|
idempotency_key=clean_idempotency_key,
|
|
profile_id=snapshot.mail_profile_id,
|
|
message_bytes=message.as_bytes(),
|
|
envelope_from=from_email,
|
|
envelope_recipients=to,
|
|
from_header=str(message["From"]),
|
|
expected_smtp_transport_revision=snapshot.smtp_transport_revision,
|
|
smtp_server_id=snapshot.smtp_server_id,
|
|
smtp_credential_id=snapshot.smtp_credential_id,
|
|
created_by_user_id=created_by_user_id,
|
|
)
|
|
except MailDeliveryCommandError as exc:
|
|
raise CampaignReportEmailError(str(exc)) from exc
|
|
return CampaignReportEmailResult(
|
|
campaign_id=campaign.id,
|
|
version_id=version.id,
|
|
to=to,
|
|
subject=str(message["Subject"]),
|
|
dry_run=False,
|
|
sent=False,
|
|
attached_jobs_csv=jobs_csv is not None,
|
|
attached_report_json=report_json is not None,
|
|
command_id=str(command["id"]),
|
|
delivery_status=str(command["status"]),
|
|
duplicate=bool(command.get("duplicate")),
|
|
)
|
|
return CampaignReportEmailResult(
|
|
campaign_id=campaign.id,
|
|
version_id=version.id,
|
|
to=to,
|
|
subject=str(message["Subject"]),
|
|
dry_run=True,
|
|
sent=False,
|
|
attached_jobs_csv=jobs_csv is not None,
|
|
attached_report_json=report_json is not None,
|
|
)
|