feat: harden campaign delivery and editing

This commit is contained in:
2026-07-30 14:27:04 +02:00
parent 4a120e8009
commit cd223cbb95
34 changed files with 3370 additions and 165 deletions

View File

@@ -8,6 +8,7 @@ from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.core.concurrency import strong_resource_etag
from govoplan_core.db.base import Base, TimestampMixin
@@ -188,6 +189,11 @@ class CampaignVersion(Base, TimestampMixin):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
edit_revision: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False)
source_filename: Mapped[str | None] = mapped_column(String(500))
@@ -234,6 +240,18 @@ class CampaignVersion(Base, TimestampMixin):
campaign: Mapped[Campaign] = relationship(back_populates="versions")
__mapper_args__ = {
"version_id_col": edit_revision,
}
@property
def strong_etag(self) -> str:
return strong_resource_etag(
"campaign_version",
self.id,
self.edit_revision,
)
@property
def mail_profile_migration_required(self) -> bool:
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_boundary_violations
@@ -365,6 +383,139 @@ class SendAttempt(Base, TimestampMixin):
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class CampaignMessageAction(Base, TimestampMixin):
__tablename__ = "campaign_message_actions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_campaign_message_actions_idempotency",
),
Index(
"ix_campaign_message_actions_job_created",
"job_id",
"created_at",
),
Index(
"ix_campaign_message_actions_campaign_kind",
"campaign_id",
"kind",
"status",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(
ForeignKey("campaigns.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
campaign_version_id: Mapped[str] = mapped_column(
ForeignKey("campaign_versions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
job_id: Mapped[str] = mapped_column(
ForeignKey("campaign_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
context: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
actor_api_key_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
message_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
message_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
recipient_manifest_sha256: Mapped[str] = mapped_column(
String(64),
nullable=False,
)
recipient_count: Mapped[int] = mapped_column(Integer, nullable=False)
prior_send_status: Mapped[str] = mapped_column(String(50), nullable=False)
prior_attempt_count: Mapped[int] = mapped_column(Integer, nullable=False)
final_send_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
status: Mapped[str] = mapped_column(
String(50),
default="initiated",
nullable=False,
index=True,
)
accepted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
refused_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
refusal_summary: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
error_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
linked_send_attempt_id: Mapped[str | None] = mapped_column(
ForeignKey("send_attempts.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
effect_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
class CampaignMessageActionAttempt(Base, TimestampMixin):
__tablename__ = "campaign_message_action_attempts"
__table_args__ = (
UniqueConstraint(
"action_id",
"attempt_number",
name="uq_campaign_message_action_attempt_number",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
action_id: Mapped[str] = mapped_column(
ForeignKey("campaign_message_actions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
status: Mapped[str] = mapped_column(
String(50),
default="initiated",
nullable=False,
index=True,
)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
effect_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
accepted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
refused_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
outcome_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
diagnostic_summary: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
)
class ImapAppendAttempt(Base, TimestampMixin):
__tablename__ = "imap_append_attempts"
__table_args__ = (

View File

@@ -10,12 +10,15 @@ from typing import Any, Iterator
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
PostboxDeliveryCatalogRef,
PostboxDeliveryProvider,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryRequest,
PostboxDeliveryResult,
PostboxDirectoryEntryRef,
PostboxDirectoryProvider,
PostboxEvidenceProvider,
PostboxTargetRef,
)
from govoplan_campaign.backend.runtime import capability
@@ -25,6 +28,7 @@ FILES_CAPABILITY = "files.campaign_attachments"
MAIL_CAPABILITY = "mail.campaign_delivery"
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
class OptionalModuleUnavailable(RuntimeError):
@@ -63,6 +67,10 @@ class MailProfileError(OptionalModuleUnavailable):
pass
class MailDeliveryCommandError(RuntimeError):
pass
class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
pass
@@ -158,6 +166,12 @@ class MailCampaignIntegration:
def available(self) -> bool:
return self._delegate is not None
@property
def durable_delivery_available(self) -> bool:
return self._delegate is not None and callable(
getattr(self._delegate, "submit_delivery_command", None)
)
def _require(self) -> Any:
if self._delegate is None:
raise MailProfileError("Mail module is not available")
@@ -220,6 +234,42 @@ class MailCampaignIntegration:
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def submit_delivery_command(self, session: Any, **kwargs: Any) -> dict[str, Any]:
delegate = self._require()
method = getattr(delegate, "submit_delivery_command", None)
if not callable(method):
raise MailDeliveryCommandError(
"The installed Mail module does not provide durable delivery commands"
)
try:
return dict(method(session, **kwargs))
except Exception as exc:
raise MailDeliveryCommandError(str(exc)) from exc
def delivery_command_summary(
self,
session: Any,
*,
tenant_id: str,
command_id: str,
) -> dict[str, Any]:
delegate = self._require()
method = getattr(delegate, "delivery_command_summary", None)
if not callable(method):
raise MailDeliveryCommandError(
"The installed Mail module does not provide durable delivery status"
)
try:
return dict(
method(
session,
tenant_id=tenant_id,
command_id=command_id,
)
)
except Exception as exc:
raise MailDeliveryCommandError(str(exc)) from exc
def mock_mailbox(self) -> Any | None:
if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"):
return None
@@ -231,6 +281,7 @@ class PostboxCampaignIntegration:
self,
delivery_delegate: object | None = None,
directory_delegate: object | None = None,
evidence_delegate: object | None = None,
) -> None:
self._delivery_delegate = (
delivery_delegate
@@ -242,6 +293,11 @@ class PostboxCampaignIntegration:
if isinstance(directory_delegate, PostboxDirectoryProvider)
else None
)
self._evidence_delegate = (
evidence_delegate
if isinstance(evidence_delegate, PostboxEvidenceProvider)
else None
)
@property
def available(self) -> bool:
@@ -250,6 +306,10 @@ class PostboxCampaignIntegration:
and self._directory_delegate is not None
)
@property
def receipt_evidence_available(self) -> bool:
return self._evidence_delegate is not None
def delivery_catalog(
self,
session: object,
@@ -298,6 +358,28 @@ class PostboxCampaignIntegration:
)
return self._delivery_delegate.deliver(session, request)
def delivery_receipt_summaries(
self,
session: object,
*,
tenant_id: str,
delivery_ids: list[str] | tuple[str, ...],
) -> dict[str, PostboxDeliveryReceiptSummaryRef]:
if self._evidence_delegate is None:
return {}
unique_ids = tuple(dict.fromkeys(delivery_ids))
summaries: dict[str, PostboxDeliveryReceiptSummaryRef] = {}
for offset in range(0, len(unique_ids), 500):
summaries.update(
self._evidence_delegate.delivery_receipt_summaries(
session,
tenant_id=tenant_id,
producer_module="campaigns",
delivery_ids=unique_ids[offset : offset + 500],
)
)
return summaries
def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
@@ -311,4 +393,5 @@ def postbox_integration() -> PostboxCampaignIntegration:
return PostboxCampaignIntegration(
capability(POSTBOX_CAPABILITY),
capability(POSTBOX_DIRECTORY_CAPABILITY),
capability(POSTBOX_EVIDENCE_CAPABILITY),
)

View File

@@ -31,6 +31,7 @@ from govoplan_core.db.base import Base
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
)
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
@@ -235,6 +236,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.3.0",
optional=True,
),
ModuleInterfaceRequirement(
name="mail.delivery_commands",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="addresses.lookup",
version_min="0.1.0",
@@ -259,6 +266,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_EVIDENCE,
version_min="0.1.2",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS,
route_factory=_campaigns_router,
@@ -330,6 +343,8 @@ manifest = ModuleManifest(
campaign_models.AttachmentBlob,
campaign_models.AttachmentInstance,
campaign_models.SendAttempt,
campaign_models.CampaignMessageAction,
campaign_models.CampaignMessageActionAttempt,
campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt,
label="Campaigns",
@@ -347,6 +362,8 @@ manifest = ModuleManifest(
campaign_models.AttachmentBlob,
campaign_models.AttachmentInstance,
campaign_models.SendAttempt,
campaign_models.CampaignMessageAction,
campaign_models.CampaignMessageActionAttempt,
campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt,
label="Campaigns",

View File

@@ -0,0 +1,22 @@
"""add audit-proof single-message action ledger
Revision ID: c1a69e4f2b70
Revises: f0a1b2c3d4e5
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
message_actions = import_module(
"govoplan_campaign.backend.migrations.versions.c1a69e4f2b70_campaign_message_actions"
)
revision = message_actions.revision
down_revision = message_actions.down_revision
branch_labels = message_actions.branch_labels
depends_on = message_actions.depends_on
upgrade = message_actions.upgrade
downgrade = message_actions.downgrade

View File

@@ -0,0 +1,22 @@
"""add monotonic campaign version edit revision
Revision ID: d2b7af503c81
Revises: c1a69e4f2b70
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
edit_revision = import_module(
"govoplan_campaign.backend.migrations.versions.d2b7af503c81_campaign_version_edit_revision"
)
revision = edit_revision.revision
down_revision = edit_revision.down_revision
branch_labels = edit_revision.branch_labels
depends_on = edit_revision.depends_on
upgrade = edit_revision.upgrade
downgrade = edit_revision.downgrade

View File

@@ -0,0 +1,186 @@
"""add audit-proof single-message action ledger
Revision ID: c1a69e4f2b70
Revises: f0a1b2c3d4e5
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c1a69e4f2b70"
down_revision = "f0a1b2c3d4e5"
branch_labels = None
depends_on = "c91f0a72be34"
def upgrade() -> None:
tables = set(sa.inspect(op.get_bind()).get_table_names())
if "campaign_message_actions" not in tables:
op.create_table(
"campaign_message_actions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("campaign_id", sa.String(length=36), nullable=False),
sa.Column("campaign_version_id", sa.String(length=36), nullable=False),
sa.Column("job_id", sa.String(length=36), nullable=False),
sa.Column("kind", sa.String(length=30), nullable=False),
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("context", sa.JSON(), nullable=False),
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
sa.Column("actor_api_key_id", sa.String(length=36), nullable=True),
sa.Column("message_sha256", sa.String(length=64), nullable=False),
sa.Column("message_size_bytes", sa.Integer(), nullable=True),
sa.Column(
"recipient_manifest_sha256",
sa.String(length=64),
nullable=False,
),
sa.Column("recipient_count", sa.Integer(), nullable=False),
sa.Column("prior_send_status", sa.String(length=50), nullable=False),
sa.Column("prior_attempt_count", sa.Integer(), nullable=False),
sa.Column("final_send_status", sa.String(length=50), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("accepted_count", sa.Integer(), nullable=False),
sa.Column("refused_count", sa.Integer(), nullable=False),
sa.Column("refusal_summary", sa.JSON(), nullable=False),
sa.Column("error_type", sa.String(length=120), nullable=True),
sa.Column("error_message", sa.String(length=500), nullable=True),
sa.Column("linked_send_attempt_id", sa.String(length=36), nullable=True),
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["campaign_id"],
["campaigns.id"],
name=op.f(
"fk_campaign_message_actions_campaign_id_campaigns"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["campaign_version_id"],
["campaign_versions.id"],
name=op.f(
"fk_campaign_message_actions_campaign_version_id_campaign_versions"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["job_id"],
["campaign_jobs.id"],
name=op.f(
"fk_campaign_message_actions_job_id_campaign_jobs"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["actor_user_id"],
["access_users.id"],
name=op.f(
"fk_campaign_message_actions_actor_user_id_access_users"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["linked_send_attempt_id"],
["send_attempts.id"],
name=op.f(
"fk_campaign_message_actions_linked_send_attempt_id_send_attempts"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_campaign_message_actions"),
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_campaign_message_actions_idempotency",
),
)
op.create_index(
"ix_campaign_message_actions_job_created",
"campaign_message_actions",
["job_id", "created_at"],
unique=False,
)
op.create_index(
"ix_campaign_message_actions_campaign_kind",
"campaign_message_actions",
["campaign_id", "kind", "status"],
unique=False,
)
for column in (
"tenant_id",
"campaign_id",
"campaign_version_id",
"job_id",
"kind",
"actor_user_id",
"status",
"linked_send_attempt_id",
):
op.create_index(
op.f(f"ix_campaign_message_actions_{column}"),
"campaign_message_actions",
[column],
unique=False,
)
tables = set(sa.inspect(op.get_bind()).get_table_names())
if "campaign_message_action_attempts" not in tables:
op.create_table(
"campaign_message_action_attempts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("action_id", sa.String(length=36), nullable=False),
sa.Column("attempt_number", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("accepted_count", sa.Integer(), nullable=False),
sa.Column("refused_count", sa.Integer(), nullable=False),
sa.Column("outcome_code", sa.String(length=80), nullable=True),
sa.Column("diagnostic_summary", sa.String(length=500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["action_id"],
["campaign_message_actions.id"],
name=op.f(
"fk_campaign_message_action_attempts_action_id_campaign_message_actions"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_campaign_message_action_attempts"),
),
sa.UniqueConstraint(
"action_id",
"attempt_number",
name="uq_campaign_message_action_attempt_number",
),
)
for column in ("action_id", "status"):
op.create_index(
op.f(f"ix_campaign_message_action_attempts_{column}"),
"campaign_message_action_attempts",
[column],
unique=False,
)
def downgrade() -> None:
tables = set(sa.inspect(op.get_bind()).get_table_names())
if "campaign_message_action_attempts" in tables:
op.drop_table("campaign_message_action_attempts")
if "campaign_message_actions" in tables:
op.drop_table("campaign_message_actions")

View File

@@ -0,0 +1,45 @@
"""add monotonic campaign version edit revision
Revision ID: d2b7af503c81
Revises: c1a69e4f2b70
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d2b7af503c81"
down_revision = "c1a69e4f2b70"
branch_labels = None
depends_on = "c91f0a72be34"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {
column["name"]
for column in inspector.get_columns("campaign_versions")
}
if "edit_revision" not in columns:
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.add_column(
sa.Column(
"edit_revision",
sa.Integer(),
server_default="1",
nullable=False,
)
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {
column["name"]
for column in inspector.get_columns("campaign_versions")
}
if "edit_revision" in columns:
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.drop_column("edit_revision")

View File

@@ -7,7 +7,12 @@ from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
from sqlalchemy.orm.exc import StaleDataError
from govoplan_core.core.concurrency import (
RevisionConflictError,
strong_resource_etag,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
@@ -720,12 +725,31 @@ def update_campaign_version(
source_base_path: str | None = None,
autosave: bool = False,
migrate_legacy_mail_settings: bool = False,
expected_revision: int | None = None,
commit: bool = True,
) -> CampaignVersion:
_assert_update_paths_safe(raw_json, source_filename=source_filename, source_base_path=source_base_path)
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="edit")
if (
expected_revision is not None
and version.edit_revision != int(expected_revision)
):
raise RevisionConflictError(
resource_type="campaign_version",
resource_id=version.id,
current_revision=version.edit_revision,
submitted_base_revision=int(expected_revision),
refresh_path=(
f"/api/v1/campaigns/{campaign.id}/versions/{version.id}"
),
current_etag=strong_resource_etag(
"campaign_version",
version.id,
version.edit_revision,
),
)
if is_version_locked(version):
raise LockedCampaignVersionError(
@@ -761,7 +785,42 @@ def update_campaign_version(
# Changes invalidate previous build and validation summaries.
if raw_json is not None:
_invalidate_version_content(session, campaign=campaign, version=version)
_persist_updated_version(session, campaign=campaign, version=version, commit=commit)
try:
_persist_updated_version(
session,
campaign=campaign,
version=version,
commit=commit,
)
except StaleDataError as exc:
session.rollback()
current_revision = (
session.query(CampaignVersion.edit_revision)
.filter(CampaignVersion.id == version_id)
.scalar()
)
if current_revision is None:
raise CampaignPersistenceError(
f"Campaign version not found: {version_id}"
) from exc
raise RevisionConflictError(
resource_type="campaign_version",
resource_id=version_id,
current_revision=int(current_revision),
submitted_base_revision=int(
expected_revision
if expected_revision is not None
else version.edit_revision
),
refresh_path=(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}"
),
current_etag=strong_resource_etag(
"campaign_version",
version_id,
int(current_revision),
),
) from exc
return version

View File

@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.settings import settings as core_settings
@@ -17,11 +18,14 @@ from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
SendAttempt,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
@@ -586,6 +590,7 @@ def _job_evidence_row(
*,
latest_smtp: SendAttempt | None = None,
latest_imap: ImapAppendAttempt | None = None,
latest_message_action: CampaignMessageAction | None = None,
include_diagnostics: bool = False,
) -> dict[str, Any]:
row = _job_row(job, include_diagnostics=include_diagnostics)
@@ -598,6 +603,20 @@ def _job_evidence_row(
"postbox_targets": _job_postbox_target_summary(job),
"attachment_names": _attachment_names(job.resolved_attachments),
**_job_attempt_evidence(latest_smtp=latest_smtp, latest_imap=latest_imap),
"latest_message_action_kind": (
latest_message_action.kind if latest_message_action else None
),
"latest_message_action_status": (
latest_message_action.status if latest_message_action else None
),
"latest_message_action_id": (
latest_message_action.id if latest_message_action else None
),
"latest_message_action_at": (
_iso_timestamp(latest_message_action.completed_at)
if latest_message_action
else None
),
})
return row
@@ -762,6 +781,18 @@ def _campaign_report_payload(
campaign_id=campaign.id,
version=version,
),
"postbox_receipts": _campaign_postbox_receipt_summary(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
"message_actions": _campaign_message_action_summary(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
"delivery": _load_delivery_info(
version,
pending_job_count=aggregate.pending,
@@ -775,6 +806,85 @@ def _campaign_report_payload(
return report
def _campaign_postbox_receipt_summary(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> dict[str, object]:
if version is None:
return {"status": "not_applicable", "delivery_count": 0}
rows = (
session.query(PostboxDeliveryAttempt.provider_delivery_id)
.join(
CampaignJob,
PostboxDeliveryAttempt.job_id == CampaignJob.id,
)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id,
CampaignJob.campaign_version_id == version.id,
PostboxDeliveryAttempt.tenant_id == tenant_id,
PostboxDeliveryAttempt.provider_delivery_id.is_not(None),
)
.all()
)
delivery_ids = tuple(
dict.fromkeys(
str(row[0])
for row in rows
if row[0]
)
)
if not delivery_ids:
return {"status": "not_applicable", "delivery_count": 0}
integration = postbox_integration()
if not integration.receipt_evidence_available:
return {
"status": "unavailable",
"delivery_count": len(delivery_ids),
"missing_delivery_count": len(delivery_ids),
}
summaries = integration.delivery_receipt_summaries(
session,
tenant_id=tenant_id,
delivery_ids=delivery_ids,
)
values = tuple(summaries.values())
return {
"status": "available",
"delivery_count": len(delivery_ids),
"reported_delivery_count": len(values),
"missing_delivery_count": len(delivery_ids) - len(values),
"currently_readable_delivery_count": sum(
summary.currently_readable for summary in values
),
"read_delivery_count": sum(
summary.read_receipt_count > 0 for summary in values
),
"acknowledged_delivery_count": sum(
summary.acknowledged_receipt_count > 0 for summary in values
),
"read_receipt_count": sum(
summary.read_receipt_count for summary in values
),
"acknowledged_receipt_count": sum(
summary.acknowledged_receipt_count for summary in values
),
"routed_message_count": sum(
summary.routed_message_count for summary in values
),
"withdrawn_message_count": sum(
summary.withdrawn_message_count for summary in values
),
"expired_message_count": sum(
summary.expired_message_count for summary in values
),
}
def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
return {
"id": campaign.id,
@@ -815,6 +925,99 @@ def _campaign_report_attempt_counts(
"send_attempts": count(SendAttempt),
"imap_append_attempts": count(ImapAppendAttempt),
"postbox_delivery_attempts": count(PostboxDeliveryAttempt),
"message_actions": count(CampaignMessageAction),
"message_action_attempts": int(
session.query(CampaignMessageActionAttempt)
.join(
CampaignMessageAction,
CampaignMessageAction.id
== CampaignMessageActionAttempt.action_id,
)
.filter(
CampaignMessageAction.tenant_id == tenant_id,
CampaignMessageAction.campaign_id == campaign_id,
*(
[CampaignMessageAction.campaign_version_id == version.id]
if version is not None
else [False]
),
)
.count()
),
}
def _campaign_message_action_summary(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> dict[str, Any]:
base = [
CampaignMessageAction.tenant_id == tenant_id,
CampaignMessageAction.campaign_id == campaign_id,
]
if version is not None:
base.append(CampaignMessageAction.campaign_version_id == version.id)
else:
base.append(False)
by_kind = {
str(kind): int(count)
for kind, count in (
session.query(
CampaignMessageAction.kind,
func.count(CampaignMessageAction.id),
)
.filter(*base)
.group_by(CampaignMessageAction.kind)
.all()
)
}
by_status = {
str(action_status): int(count)
for action_status, count in (
session.query(
CampaignMessageAction.status,
func.count(CampaignMessageAction.id),
)
.filter(*base)
.group_by(CampaignMessageAction.status)
.all()
)
}
recent = (
session.query(CampaignMessageAction)
.filter(*base)
.order_by(CampaignMessageAction.created_at.desc())
.limit(100)
.all()
)
return {
"total": sum(by_kind.values()),
"by_kind": by_kind,
"by_status": by_status,
"recent": [
{
"id": action.id,
"job_id": action.job_id,
"kind": action.kind,
"status": action.status,
"reason": action.reason,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": action.recipient_manifest_sha256,
"recipient_count": action.recipient_count,
"prior_send_status": action.prior_send_status,
"final_send_status": action.final_send_status,
"accepted_count": action.accepted_count,
"refused_count": action.refused_count,
"linked_send_attempt_id": action.linked_send_attempt_id,
"actor_user_id": action.actor_user_id,
"created_at": _iso_timestamp(action.created_at),
"completed_at": _iso_timestamp(action.completed_at),
}
for action in recent
],
}
@@ -992,11 +1195,16 @@ def generate_jobs_csv(
ImapAppendAttempt,
job_ids,
)
latest_message_actions = _latest_message_actions_for_jobs(
session,
job_ids,
)
rows = [
_job_evidence_row(
job,
latest_smtp=latest_smtp.get(job.id),
latest_imap=latest_imap.get(job.id),
latest_message_action=latest_message_actions.get(job.id),
include_diagnostics=include_diagnostics,
)
for job in jobs
@@ -1046,6 +1254,10 @@ def generate_jobs_csv(
"latest_imap_folder",
"latest_imap_created_at",
"latest_imap_updated_at",
"latest_message_action_kind",
"latest_message_action_status",
"latest_message_action_id",
"latest_message_action_at",
]
if include_diagnostics:
sent_at_index = fieldnames.index("outcome_unknown_at")
@@ -1082,3 +1294,26 @@ def _latest_attempts_for_jobs(
):
latest[attempt.job_id] = attempt
return latest
def _latest_message_actions_for_jobs(
session: Session,
job_ids: list[str],
*,
batch_size: int = 500,
) -> dict[str, CampaignMessageAction]:
latest: dict[str, CampaignMessageAction] = {}
for offset in range(0, len(job_ids), batch_size):
batch = job_ids[offset : offset + batch_size]
actions = (
session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id.in_(batch))
.order_by(
CampaignMessageAction.job_id.asc(),
CampaignMessageAction.created_at.asc(),
)
.yield_per(batch_size)
)
for action in actions:
latest[action.job_id] = action
return latest

View File

@@ -13,7 +13,11 @@ 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 SmtpConfigurationError
from govoplan_campaign.backend.integrations import (
MailDeliveryCommandError,
SmtpConfigurationError,
mail_integration,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
@@ -32,6 +36,9 @@ class CampaignReportEmailResult:
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 {
@@ -44,6 +51,22 @@ class CampaignReportEmailResult:
"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,
}
@@ -149,6 +172,8 @@ def send_campaign_report_email(
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:
@@ -175,11 +200,16 @@ def send_campaign_report_email(
) 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:
raise CampaignReportEmailError(
"Report email delivery is disabled until it uses a durable, idempotent Mail-owned outbox with unknown-outcome reconciliation."
)
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,
@@ -202,6 +232,48 @@ def send_campaign_report_email(
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,

View File

@@ -43,6 +43,12 @@ from govoplan_campaign.backend.sending.execution import (
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.runtime import get_registry
@@ -359,6 +365,9 @@ def _campaign_version_detail_response(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except RevisionConflictError:
session.rollback()
raise
except Exception as exc:
session.rollback()
if validation_error_status is None:
@@ -375,44 +384,109 @@ def _update_campaign_version_detail_response(
version_id: str,
payload: CampaignVersionUpdateRequest,
*,
if_match: str | None,
autosave: bool,
audit_action: str,
) -> CampaignVersionDetailResponse:
_get_campaign_for_principal(session, campaign_id, principal, write=True)
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if payload.base_revision is None:
error = MissingPreconditionError(
resource_type="campaign_version",
resource_id=version_id,
)
raise HTTPException(
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
detail=error.as_dict(),
)
try:
assert_revision_precondition(
if_match,
resource_type="campaign_version",
resource_id=version_id,
submitted_base_revision=payload.base_revision,
)
except MissingPreconditionError as exc:
raise HTTPException(
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
detail=exc.as_dict(),
) from exc
except ConcurrencyError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "invalid_precondition",
"message": str(exc),
},
) from exc
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
_require_permission(principal, "campaigns:recipient:write")
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: update_campaign_version(
try:
return _campaign_version_detail_response(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=payload.campaign_json,
current_flow=payload.current_flow,
current_step=payload.current_step,
workflow_state=payload.workflow_state,
is_complete=payload.is_complete,
editor_state=payload.editor_state,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=autosave,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
commit=False,
),
audit_action=audit_action,
details=lambda version: {
"campaign_id": campaign_id,
"current_flow": version.current_flow,
"current_step": version.current_step,
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
},
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
)
principal,
campaign_id,
lambda: update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=payload.campaign_json,
current_flow=payload.current_flow,
current_step=payload.current_step,
workflow_state=payload.workflow_state,
is_complete=payload.is_complete,
editor_state=payload.editor_state,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=autosave,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
expected_revision=payload.base_revision,
commit=False,
),
audit_action=audit_action,
details=lambda version: {
"campaign_id": campaign_id,
"current_flow": version.current_flow,
"current_step": version.current_step,
"base_revision": payload.base_revision,
"result_revision": version.edit_revision,
"reconciliation_kind": payload.reconciliation_kind,
"resolved_conflict_path_count": len(
payload.resolved_conflict_paths
),
"resolved_conflict_sections": sorted(
{
path.strip("/").split("/", 1)[0][:80]
for path in payload.resolved_conflict_paths[:100]
if path.strip("/")
}
),
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
},
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
)
except RevisionConflictError as exc:
session.rollback()
audit_from_principal(
session,
principal,
action="campaign.version_conflict_detected",
object_type="campaign_version",
object_id=version_id,
details={
"campaign_id": campaign_id,
"submitted_base_revision": exc.submitted_base_revision,
"current_revision": exc.current_revision,
"retryable": True,
},
commit=True,
)
raise HTTPException(
status_code=status.HTTP_412_PRECONDITION_FAILED,
detail=exc.as_dict(),
) from exc
def _require_campaign_profile_use_if_needed(

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -74,6 +75,7 @@ from govoplan_campaign.backend.route_support import (
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
logger = logging.getLogger(__name__)
@router.get(
@@ -236,13 +238,25 @@ def send_unattempted_campaign_jobs(
def send_single_campaign_job_endpoint(
campaign_id: str,
job_id: str,
payload: CampaignSendJobRequest | None = None,
payload: CampaignSendJobRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
principal: ApiPrincipal = Depends(
require_any_scope(
"campaigns:campaign:send",
"campaigns:campaign:send_test",
)
),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or CampaignSendJobRequest()
_require_permission(
principal,
(
"campaigns:campaign:send_test"
if payload.kind == "test"
else "campaigns:campaign:send"
),
)
_require_campaign_profile_use_if_needed(session, principal, campaign_id, None)
try:
result = send_single_campaign_job(
@@ -250,17 +264,20 @@ def send_single_campaign_job_endpoint(
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
job_id=job_id,
kind=payload.kind,
idempotency_key=payload.idempotency_key,
actor_user_id=principal.user.id,
actor_api_key_id=getattr(principal, "api_key_id", None),
reason=payload.reason,
action_context=payload.context,
include_warnings=payload.include_warnings,
dry_run=payload.dry_run,
use_rate_limit=payload.use_rate_limit,
enqueue_imap_task=payload.enqueue_imap_task,
)
audit_from_principal(
session,
principal,
action="campaign.single_message_sent"
if not payload.dry_run
else "campaign.single_message_send_dry_run",
action=f"campaign.message_{payload.kind}",
object_type="campaign_job",
object_id=job_id,
details=result,
@@ -272,8 +289,17 @@ def send_single_campaign_job_endpoint(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except Exception as exc:
logger.exception(
"Unexpected single-message campaign action failure",
extra={
"campaign_id": campaign_id,
"job_id": job_id,
"action_kind": payload.kind,
},
)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="The message action failed because of an internal error.",
) from exc

View File

@@ -23,10 +23,13 @@ from govoplan_campaign.backend.change_tracking import (
)
from govoplan_campaign.backend.db.models import (
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
ImapAppendAttempt,
PostboxDeliveryAttempt,
SendAttempt,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_core.db.session import get_session
@@ -49,6 +52,27 @@ from govoplan_campaign.backend.services.job_queries import (
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _postbox_receipts_for_attempts(
session: Session,
*,
tenant_id: str,
attempts: list[PostboxDeliveryAttempt],
):
integration = postbox_integration()
if not integration.receipt_evidence_available:
return None
delivery_ids = [
attempt.provider_delivery_id
for attempt in attempts
if attempt.provider_delivery_id
]
return integration.delivery_receipt_summaries(
session,
tenant_id=tenant_id,
delivery_ids=delivery_ids,
)
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
def list_jobs(
campaign_id: str,
@@ -373,12 +397,36 @@ def get_job_detail(
),
label="Postbox attempts for this campaign job",
)
message_actions = _job_attempt_rows(
session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id == job.id)
.order_by(CampaignMessageAction.created_at.asc()),
label="Single-message actions for this campaign job",
)
action_ids = [action.id for action in message_actions]
message_action_attempts = (
_job_attempt_rows(
session.query(CampaignMessageActionAttempt)
.filter(CampaignMessageActionAttempt.action_id.in_(action_ids))
.order_by(CampaignMessageActionAttempt.started_at.asc()),
label="Single-message action attempts for this campaign job",
)
if action_ids
else []
)
return CampaignJobDetailResponse(
job=_job_detail_payload(job),
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
postbox_attempts,
message_actions,
message_action_attempts,
postbox_receipts=_postbox_receipts_for_attempts(
session,
tenant_id=principal.tenant_id,
attempts=postbox_attempts,
),
),
)
@@ -428,9 +476,33 @@ def get_job_diagnostics(
),
label="Postbox diagnostics for this campaign job",
)
message_actions = _job_attempt_rows(
session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id == job.id)
.order_by(CampaignMessageAction.created_at.asc()),
label="Single-message action diagnostics for this campaign job",
)
action_ids = [action.id for action in message_actions]
message_action_attempts = (
_job_attempt_rows(
session.query(CampaignMessageActionAttempt)
.filter(CampaignMessageActionAttempt.action_id.in_(action_ids))
.order_by(CampaignMessageActionAttempt.started_at.asc()),
label="Single-message action-attempt diagnostics for this campaign job",
)
if action_ids
else []
)
return _job_diagnostics_payload(
job,
send_attempts,
imap_attempts,
postbox_attempts,
message_actions,
message_action_attempts,
postbox_receipts=_postbox_receipts_for_attempts(
session,
tenant_id=principal.tenant_id,
attempts=postbox_attempts,
),
)

View File

@@ -25,9 +25,11 @@ from govoplan_campaign.backend.reports.emailing import (
send_campaign_report_email,
)
from govoplan_campaign.backend.integrations import (
MailDeliveryCommandError,
MailProfileError,
SmtpConfigurationError,
SmtpSendError,
mail_integration,
)
@@ -41,6 +43,24 @@ router = APIRouter(prefix="/campaigns", tags=["campaigns"])
logger = logging.getLogger(__name__)
def _enqueue_mail_command() -> None:
try:
from govoplan_core.celery_app import celery
from govoplan_core.settings import settings
if settings.celery_enabled:
celery.send_task(
"govoplan.mail.dispatch_outbox",
args=[None, 25],
queue="mail",
)
except Exception:
logger.warning(
"Mail delivery command is durable but immediate worker wake-up failed",
exc_info=True,
)
@router.get("/{campaign_id}/summary")
def campaign_summary(
campaign_id: str,
@@ -164,18 +184,22 @@ def email_campaign_report(
attach_jobs_csv=payload.attach_jobs_csv,
attach_report_json=payload.attach_report_json,
dry_run=payload.dry_run,
idempotency_key=payload.idempotency_key,
created_by_user_id=principal.user.id,
)
audit_from_principal(
session,
principal,
action="report.email_sent"
action="report.email_requested"
if not payload.dry_run
else "report.email_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result.as_dict(),
details=result.audit_dict(),
commit=True,
)
if not payload.dry_run:
_enqueue_mail_command()
return ReportEmailResponse(result=result.as_dict())
except CampaignReportError as exc:
raise HTTPException(
@@ -184,6 +208,7 @@ def email_campaign_report(
except (
CampaignReportEmailError,
MailProfileError,
MailDeliveryCommandError,
SmtpConfigurationError,
SmtpSendError,
) as exc:
@@ -196,3 +221,37 @@ def email_campaign_report(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Campaign report email could not be completed.",
) from exc
@router.get(
"/{campaign_id}/report/email/{command_id}",
response_model=ReportEmailResponse,
)
def campaign_report_email_status(
campaign_id: str,
command_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
try:
result = mail_integration().delivery_command_summary(
session,
tenant_id=principal.tenant_id,
command_id=command_id,
)
except MailDeliveryCommandError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
if (
result.get("source_module") != "campaigns"
or result.get("source_resource_type") != "campaign"
or result.get("source_resource_id") != campaign_id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign report delivery not found",
)
return ReportEmailResponse(result=result)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import (
@@ -101,6 +101,7 @@ def list_versions(
def get_version_detail(
campaign_id: str,
version_id: str,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
@@ -113,10 +114,12 @@ def get_version_detail(
campaign_id=campaign_id,
version_id=version_id,
)
return CampaignVersionDetailResponse.model_validate(
result = CampaignVersionDetailResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
response.headers["ETag"] = version.strong_etag
return result
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
@@ -335,18 +338,23 @@ def update_version_detail(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
return _update_campaign_version_detail_response(
result = _update_campaign_version_detail_response(
session,
principal,
campaign_id,
version_id,
payload,
if_match=if_match,
autosave=False,
audit_action="campaign.version_updated",
)
response.headers["ETag"] = result.strong_etag
return result
@router.post(
@@ -357,18 +365,23 @@ def autosave_version(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
return _update_campaign_version_detail_response(
result = _update_campaign_version_detail_response(
session,
principal,
campaign_id,
version_id,
payload,
if_match=if_match,
autosave=True,
audit_action="campaign.version_autosaved",
)
response.headers["ETag"] = result.strong_etag
return result
@router.post(

View File

@@ -58,6 +58,9 @@ class CampaignVersionUpdateRequest(BaseModel):
source_filename: str | None = None
source_base_path: str | None = None
migrate_legacy_mail_settings: bool = False
base_revision: int | None = Field(default=None, ge=1)
reconciliation_kind: Literal["none", "auto_merge", "manual"] = "none"
resolved_conflict_paths: list[str] = Field(default_factory=list, max_length=100)
@field_validator("editor_state")
@classmethod
@@ -92,6 +95,8 @@ class CampaignVersionResponse(BaseModel):
id: str
campaign_id: str
version_number: int
edit_revision: int = 1
strong_etag: str = ""
schema_version: str
source_filename: str | None = None
workflow_state: str = "editing"
@@ -440,11 +445,21 @@ class CampaignSendUnattemptedRequest(BaseModel):
class CampaignSendJobRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["test", "single_send", "single_resend"]
idempotency_key: str = Field(min_length=1, max_length=200)
reason: str | None = Field(default=None, max_length=2000)
context: dict[str, Any] = Field(default_factory=dict)
include_warnings: bool = True
dry_run: bool = False
use_rate_limit: bool = True
enqueue_imap_task: bool = False
@model_validator(mode="after")
def require_resend_reason(self):
self.reason = (self.reason or "").strip() or None
if self.kind == "single_resend" and not self.reason:
raise ValueError("single_resend requires a reason")
return self
class CampaignResolveOutcomeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -626,6 +641,7 @@ class ReportEmailRequest(BaseModel):
attach_jobs_csv: bool = False
attach_report_json: bool = False
dry_run: bool = False
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
@field_validator("to")
@classmethod

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import json
from collections import Counter
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from email import policy
@@ -11,13 +12,18 @@ from typing import Any
from uuid import uuid4
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.audit.logging import audit_event
from govoplan_core.security.redaction import redact_secret_values
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignStatus,
CampaignVersion,
CampaignVersionWorkflowState,
@@ -1305,49 +1311,139 @@ def send_single_campaign_job(
tenant_id: str,
campaign_id: str,
job_id: str,
kind: str,
idempotency_key: str,
actor_user_id: str | None,
actor_api_key_id: str | None = None,
reason: str | None = None,
action_context: dict[str, Any] | None = None,
include_warnings: bool = True,
dry_run: bool = False,
use_rate_limit: bool = True,
enqueue_imap_task: bool = False,
) -> dict[str, Any]:
"""Explicitly queue and send one built recipient message through the audit send path."""
"""Perform one explicit, idempotent action for one immutable built message."""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
job = session.get(CampaignJob, job_id)
if not job or job.tenant_id != tenant_id or job.campaign_id != campaign.id:
raise QueueingError("Campaign job not found or not accessible")
version = _get_current_version(session, campaign, version_id=job.campaign_version_id)
_ensure_version_validated_and_locked(version)
ensure_execution_snapshot(session, version)
queue_action = _prepare_single_job_for_send(
normalized_kind = str(kind or "").strip()
if normalized_kind not in {"test", "single_send", "single_resend"}:
raise QueueingError(
"Single-message action kind must be test, single_send, or single_resend"
)
clean_key = str(idempotency_key or "").strip()
if not clean_key or len(clean_key) > 200:
raise QueueingError("A bounded idempotency key is required")
clean_reason = " ".join(str(reason or "").split()) or None
if normalized_kind == "single_resend" and not clean_reason:
raise QueueingError("Single-message resend requires a reason")
if clean_reason and len(clean_reason) > 2000:
raise QueueingError("Single-message action reason is too long")
version = _get_version_for_campaign(
session,
campaign,
version_id=job.campaign_version_id,
)
if normalized_kind == "single_send" and campaign.current_version_id != version.id:
raise QueueingError(
"An initial official send is only available for the current campaign version."
)
recipients = _recipients_from_job(job)
recipient_manifest_sha256 = hashlib.sha256(
json.dumps(
recipients,
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
canonical_request_hash = hashlib.sha256(
json.dumps(
{
"tenant_id": tenant_id,
"campaign_id": campaign.id,
"version_id": version.id,
"job_id": job.id,
"kind": normalized_kind,
"message_sha256": job.eml_sha256,
"recipient_manifest_sha256": recipient_manifest_sha256,
"reason": clean_reason,
"context": _bounded_single_action_context(action_context),
},
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
).hexdigest()
action, duplicate = _create_single_message_action(
session,
tenant_id=tenant_id,
campaign=campaign,
version=version,
job=job,
include_warnings=include_warnings,
dry_run=dry_run,
kind=normalized_kind,
idempotency_key=clean_key,
canonical_request_hash=canonical_request_hash,
reason=clean_reason,
action_context=_bounded_single_action_context(action_context),
actor_user_id=actor_user_id,
actor_api_key_id=actor_api_key_id,
recipient_manifest_sha256=recipient_manifest_sha256,
recipient_count=len(recipients),
)
if dry_run:
context = _send_job_delivery_context(session, job)
return {
"campaign_id": campaign.id,
"version_id": version.id,
"job_id": job.id,
"action": "single_send",
"queue_action": queue_action,
"dry_run": True,
"result": SendJobResult(
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=(
f"Would deliver via {job.delivery_channel_policy}: "
f"{len(context.envelope_recipients)} Mail recipient(s), "
f"{len(job.resolved_postbox_targets or [])} Postbox target(s)"
),
).as_dict(),
}
if duplicate:
return _single_message_action_response(action, duplicate=True)
try:
_validate_single_message_action(
version=version,
job=job,
kind=normalized_kind,
include_warnings=include_warnings,
)
delivery_context = _send_job_delivery_context(session, job)
except Exception as exc:
_finish_single_message_action(
session,
action=action,
status="initiation_failed",
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
raise
if normalized_kind in {"test", "single_resend"}:
return _send_single_message_direct(
session,
campaign=campaign,
version=version,
job=job,
action=action,
delivery_context=delivery_context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
try:
queue_action = _prepare_single_job_for_send(
session,
version=version,
job=job,
include_warnings=include_warnings,
dry_run=False,
)
except Exception as exc:
_finish_single_message_action(
session,
action=action,
status="initiation_failed",
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
raise
if queue_action == "queued":
previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value
@@ -1365,24 +1461,565 @@ def send_single_campaign_job(
version_id=version.id,
)
result = send_campaign_job(
session,
job_id=job.id,
dry_run=False,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
action_attempt = _start_single_message_action_attempt(session, action)
try:
result = send_campaign_job(
session,
job_id=job.id,
dry_run=False,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
except Exception as exc:
latest = (
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.desc())
.first()
)
if latest is not None and latest.started_at is not None:
action.linked_send_attempt_id = latest.id
status = _single_action_status_from_job(job)
_finish_single_message_action(
session,
action=action,
attempt=action_attempt,
status=status,
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
raise
latest = (
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.desc())
.first()
)
if latest is not None:
action.linked_send_attempt_id = latest.id
status = _single_action_status_from_result(result.status)
_finish_single_message_action(
session,
action=action,
attempt=action_attempt,
status=status,
final_send_status=job.send_status,
)
response = _single_message_action_response(action)
response["queue_action"] = queue_action
response["result"] = result.as_dict()
return response
def _bounded_single_action_context(
value: dict[str, Any] | None,
) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
result: dict[str, Any] = {}
for raw_key, raw_value in list(value.items())[:20]:
key = str(raw_key).strip()[:80]
if not key:
continue
if isinstance(raw_value, (str, int, float, bool)) or raw_value is None:
result[key] = (
str(raw_value)[:500]
if isinstance(raw_value, str)
else raw_value
)
redacted = redact_secret_values(result)
return redacted if isinstance(redacted, dict) else {}
def _create_single_message_action(
session: Session,
*,
tenant_id: str,
campaign: Campaign,
version: CampaignVersion,
job: CampaignJob,
kind: str,
idempotency_key: str,
canonical_request_hash: str,
reason: str | None,
action_context: dict[str, Any],
actor_user_id: str | None,
actor_api_key_id: str | None,
recipient_manifest_sha256: str,
recipient_count: int,
) -> tuple[CampaignMessageAction, bool]:
existing = (
session.query(CampaignMessageAction)
.filter(
CampaignMessageAction.tenant_id == tenant_id,
CampaignMessageAction.idempotency_key == idempotency_key,
)
.first()
)
if existing is not None:
if existing.canonical_request_hash != canonical_request_hash:
raise QueueingError(
"The idempotency key is already bound to another single-message action"
)
_freeze_replayed_in_progress_action(session, existing)
return existing, True
action = CampaignMessageAction(
tenant_id=tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
job_id=job.id,
kind=kind,
idempotency_key=idempotency_key,
canonical_request_hash=canonical_request_hash,
reason=reason,
context=action_context,
actor_user_id=actor_user_id,
actor_api_key_id=actor_api_key_id,
message_sha256=str(job.eml_sha256 or ""),
message_size_bytes=job.eml_size_bytes,
recipient_manifest_sha256=recipient_manifest_sha256,
recipient_count=recipient_count,
prior_send_status=job.send_status,
prior_attempt_count=job.attempt_count,
status="initiated",
)
try:
with session.begin_nested():
session.add(action)
session.flush()
except IntegrityError:
existing = (
session.query(CampaignMessageAction)
.filter(
CampaignMessageAction.tenant_id == tenant_id,
CampaignMessageAction.idempotency_key == idempotency_key,
)
.first()
)
if (
existing is None
or existing.canonical_request_hash != canonical_request_hash
):
raise QueueingError(
"The idempotency key is already bound to another single-message action"
) from None
_freeze_replayed_in_progress_action(session, existing)
return existing, True
session.commit()
audit_event(
session,
tenant_id=tenant_id,
user_id=actor_user_id,
api_key_id=actor_api_key_id,
action="campaign.message_action_initiated",
object_type="campaign_message_action",
object_id=action.id,
details={
"campaign_id": campaign.id,
"campaign_version_id": version.id,
"job_id": job.id,
"kind": kind,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": recipient_manifest_sha256,
"recipient_count": recipient_count,
"prior_send_status": job.send_status,
"reason": reason,
},
)
session.commit()
return action, False
def _freeze_replayed_in_progress_action(
session: Session,
action: CampaignMessageAction,
) -> None:
if action.status != "effect_in_progress":
return
now = _utcnow()
action.status = "outcome_unknown"
action.completed_at = now
action.error_type = "OutcomeUnknown"
action.error_message = (
"A repeated command found an unfinished provider effect. "
"Automatic resend was stopped."
)
attempt = (
session.query(CampaignMessageActionAttempt)
.filter(CampaignMessageActionAttempt.action_id == action.id)
.order_by(CampaignMessageActionAttempt.attempt_number.desc())
.first()
)
if attempt is not None:
attempt.status = "outcome_unknown"
attempt.completed_at = now
attempt.outcome_code = "replayed_in_progress"
attempt.diagnostic_summary = action.error_message
session.add(attempt)
session.add(action)
session.commit()
def _validate_single_message_action(
*,
version: CampaignVersion,
job: CampaignJob,
kind: str,
include_warnings: bool,
) -> None:
_ensure_version_validated_and_locked(version)
if job.build_status != JobBuildStatus.BUILT.value:
raise QueueingError("This message has not been built yet.")
if not _single_job_validation_allowed(
version,
job,
include_warnings=include_warnings,
):
raise QueueingError(
f"This message cannot be sent while validation status is {job.validation_status}."
)
if not job.eml_sha256 or (not job.eml_local_path and not job.eml_storage_key):
raise QueueingError(
"This message has no immutable generated EML evidence. Rebuild the campaign before sending."
)
if DeliveryChannelPolicy(job.delivery_channel_policy) != DeliveryChannelPolicy.MAIL:
raise QueueingError(
"Single-message SMTP actions currently require a Mail-only delivery policy."
)
if kind == "single_send":
if job.send_status in {
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
JobSendStatus.OUTCOME_UNKNOWN.value,
}:
raise QueueingError(
f"This message is in delivery state {job.send_status}; reconcile or wait before sending it."
)
if job.attempt_count > 0 or job.send_status not in {
JobSendStatus.NOT_QUEUED.value,
JobSendStatus.CANCELLED.value,
JobSendStatus.QUEUED.value,
}:
raise QueueingError(
"Single-send is only available for a message without an official delivery attempt."
)
elif kind == "single_resend" and job.send_status in {
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
JobSendStatus.OUTCOME_UNKNOWN.value,
}:
raise QueueingError(
f"This message is in delivery state {job.send_status}; reconcile it before resending."
)
def _start_single_message_action_attempt(
session: Session,
action: CampaignMessageAction,
) -> CampaignMessageActionAttempt:
now = _utcnow()
attempt = CampaignMessageActionAttempt(
action_id=action.id,
attempt_number=1,
status="effect_in_progress",
started_at=now,
effect_started_at=now,
)
action.status = "effect_in_progress"
action.effect_started_at = now
session.add(action)
session.add(attempt)
session.commit()
return attempt
def _finish_single_message_action(
session: Session,
*,
action: CampaignMessageAction,
status: str,
attempt: CampaignMessageActionAttempt | None = None,
accepted_count: int = 0,
refused_recipients: dict[str, dict[str, Any]] | None = None,
error_type: str | None = None,
error_message: str | None = None,
final_send_status: str | None = None,
) -> None:
now = _utcnow()
refusals = refused_recipients or {}
action.status = status
action.accepted_count = accepted_count
action.refused_count = len(refusals)
action.refusal_summary = dict(
Counter(
str(item.get("classification") or "unknown")
for item in refusals.values()
)
)
action.error_type = str(error_type)[:120] if error_type else None
action.error_message = (
" ".join(str(error_message).split())[:500]
if error_message
else None
)
action.final_send_status = final_send_status
action.completed_at = now
session.add(action)
if attempt is not None:
attempt.status = status
attempt.completed_at = now
attempt.accepted_count = accepted_count
attempt.refused_count = len(refusals)
attempt.outcome_code = action.error_type or status
attempt.diagnostic_summary = action.error_message
session.add(attempt)
session.commit()
audit_event(
session,
tenant_id=action.tenant_id,
user_id=action.actor_user_id,
api_key_id=action.actor_api_key_id,
action="campaign.message_action_completed",
object_type="campaign_message_action",
object_id=action.id,
details={
"campaign_id": action.campaign_id,
"campaign_version_id": action.campaign_version_id,
"job_id": action.job_id,
"kind": action.kind,
"status": status,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": action.recipient_manifest_sha256,
"recipient_count": action.recipient_count,
"accepted_count": accepted_count,
"refused_count": len(refusals),
"refusal_summary": action.refusal_summary,
"prior_send_status": action.prior_send_status,
"final_send_status": final_send_status,
"linked_send_attempt_id": action.linked_send_attempt_id,
"reason": action.reason,
},
)
session.commit()
def _single_action_status_from_result(status: str) -> str:
if status in DELIVERY_ACCEPTED_STATUSES or status == "already_accepted":
return "accepted"
if status == JobSendStatus.OUTCOME_UNKNOWN.value:
return "outcome_unknown"
if status == JobSendStatus.FAILED_TEMPORARY.value:
return "failed_temporary"
if status == JobSendStatus.FAILED_PERMANENT.value:
return "failed_permanent"
return status
def _single_action_status_from_job(job: CampaignJob) -> str:
return _single_action_status_from_result(job.send_status)
def _single_message_action_response(
action: CampaignMessageAction,
*,
duplicate: bool = False,
) -> dict[str, Any]:
return {
"campaign_id": campaign.id,
"version_id": version.id,
"job_id": job.id,
"action": "single_send",
"queue_action": queue_action,
"dry_run": False,
"result": result.as_dict(),
"campaign_id": action.campaign_id,
"version_id": action.campaign_version_id,
"job_id": action.job_id,
"action_id": action.id,
"action": action.kind,
"kind": action.kind,
"status": action.status,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": action.recipient_manifest_sha256,
"recipient_count": action.recipient_count,
"prior_send_status": action.prior_send_status,
"final_send_status": action.final_send_status,
"accepted_count": action.accepted_count,
"refused_count": action.refused_count,
"refusal_summary": dict(action.refusal_summary or {}),
"reason": action.reason,
"linked_send_attempt_id": action.linked_send_attempt_id,
"created_at": action.created_at,
"effect_started_at": action.effect_started_at,
"completed_at": action.completed_at,
"duplicate": duplicate,
"result": SendJobResult(
job_id=action.job_id,
status=action.status,
attempt_number=action.prior_attempt_count,
message=action.error_message,
).as_dict(),
}
def _send_single_message_direct(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
job: CampaignJob,
action: CampaignMessageAction,
delivery_context: _SendJobDeliveryContext,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> dict[str, Any]:
if (
delivery_context.envelope_from is None
or not delivery_context.envelope_recipients
):
_finish_single_message_action(
session,
action=action,
status="initiation_failed",
error_type="SmtpConfigurationError",
error_message="Mail delivery has no frozen envelope sender or recipients.",
final_send_status=job.send_status,
)
raise SmtpConfigurationError(
"Mail delivery has no frozen envelope sender or recipients."
)
mail_integration().wait_for_rate_limit(
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}:single-action",
messages_per_minute=delivery_context.snapshot.delivery.rate_limit.messages_per_minute,
enabled=use_rate_limit,
)
attempt = _start_single_message_action_attempt(session, action)
try:
result = mail_integration().send_campaign_email_bytes(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
profile_id=delivery_context.snapshot.mail_profile_id,
message_bytes=delivery_context.message_bytes,
envelope_from=delivery_context.envelope_from,
envelope_recipients=delivery_context.envelope_recipients,
from_header=_from_header_from_job(job),
expected_smtp_transport_revision=(
delivery_context.snapshot.smtp_transport_revision or ""
),
smtp_server_id=delivery_context.snapshot.smtp_server_id,
smtp_credential_id=delivery_context.snapshot.smtp_credential_id,
)
except SmtpSendError as exc:
if exc.outcome_unknown:
status = "outcome_unknown"
elif exc.temporary:
status = "failed_temporary"
else:
status = "failed_permanent"
_finish_single_message_action(
session,
action=action,
attempt=attempt,
status=status,
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
return _single_message_action_response(action)
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
_finish_single_message_action(
session,
action=action,
attempt=attempt,
status="failed_permanent",
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
return _single_message_action_response(action)
except Exception:
_finish_single_message_action(
session,
action=action,
attempt=attempt,
status="outcome_unknown",
error_type="OutcomeUnknown",
error_message=(
"The provider effect started, but the delivery outcome could "
"not be established."
),
final_send_status=job.send_status,
)
return _single_message_action_response(action)
refusals = dict(result.refused_recipients)
accepted_count = result.accepted_count
if accepted_count <= 0:
classifications = {
str(item.get("classification") or "unknown")
for item in refusals.values()
}
if classifications and classifications <= {"temporary"}:
status = "failed_temporary"
elif "unknown" in classifications:
status = "outcome_unknown"
else:
status = "failed_permanent"
else:
status = "accepted_with_refusals" if refusals else "accepted"
if action.kind == "single_resend" and accepted_count > 0:
if job.send_status not in FULLY_ACCEPTED_STATUSES:
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.sent_at = _utcnow()
job.outcome_unknown_at = None
job.last_error = (
"Some SMTP recipients were refused during explicit resend."
if refusals
else None
)
job.imap_status = (
JobImapStatus.PENDING.value
if delivery_context.snapshot.delivery.imap_append_sent.enabled
else JobImapStatus.NOT_REQUESTED.value
)
files_integration().mark_job_attachment_uses_sent(session, job)
session.add(job)
if campaign.current_version_id == version.id:
_update_campaign_after_job(
session,
job.campaign_id,
job.campaign_version_id,
)
session.commit()
if (
enqueue_imap_task
and _celery_enabled()
and job.imap_status == JobImapStatus.PENDING.value
):
try:
_celery_enqueue_append_sent_job(job.id)
except Exception:
pass
_finish_single_message_action(
session,
action=action,
attempt=attempt,
status=status,
accepted_count=accepted_count,
refused_recipients=refusals,
error_type=(
None
if accepted_count > 0
else f"Smtp{status.title().replace('_', '')}"
),
error_message=(
None
if accepted_count > 0
else "SMTP did not accept an envelope recipient."
),
final_send_status=job.send_status,
)
return _single_message_action_response(action)
def _prepare_single_job_for_send(
session: Session,
*,

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import json
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from typing import Literal
from fastapi import HTTPException, Query, status
@@ -13,6 +13,7 @@ from govoplan_campaign.backend.schemas import (
CampaignJobDiagnosticsResponse,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_core.core.change_sequence import (
encode_sequence_watermark,
max_sequence_id,
@@ -30,6 +31,8 @@ from govoplan_campaign.backend.change_tracking import (
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignVersion,
ImapAppendAttempt,
JobImapStatus,
@@ -128,7 +131,12 @@ def _job_attempts_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
message_actions: Sequence[CampaignMessageAction] = (),
message_action_attempts: Sequence[CampaignMessageActionAttempt] = (),
*,
postbox_receipts: (
Mapping[str, PostboxDeliveryReceiptSummaryRef] | None
) = None,
include_diagnostics: bool = False,
) -> dict[str, list[dict[str, object]]]:
smtp_payloads: list[dict[str, object]] = []
@@ -186,11 +194,109 @@ def _job_attempts_payload(
payload["evidence"] = attempt.evidence or {}
payload["error_type"] = attempt.error_type
payload["error_message"] = attempt.error_message
if attempt.provider_delivery_id:
receipt_summary = (
postbox_receipts.get(attempt.provider_delivery_id)
if postbox_receipts is not None
else None
)
payload["receipt_summary_status"] = (
"available"
if receipt_summary is not None
else "not_found"
if postbox_receipts is not None
else "unavailable"
)
if receipt_summary is not None:
payload["receipt_summary"] = _postbox_receipt_summary_payload(
receipt_summary
)
postbox_payloads.append(payload)
action_attempts_by_action = {
attempt.action_id: attempt
for attempt in message_action_attempts
}
message_action_payloads: list[dict[str, object]] = []
for action in message_actions:
action_attempt = action_attempts_by_action.get(action.id)
payload = {
"id": action.id,
"kind": action.kind,
"status": action.status,
"reason": action.reason,
"actor_user_id": action.actor_user_id,
"actor_api_key_id": action.actor_api_key_id,
"campaign_version_id": action.campaign_version_id,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": action.recipient_manifest_sha256,
"recipient_count": action.recipient_count,
"prior_send_status": action.prior_send_status,
"final_send_status": action.final_send_status,
"accepted_count": action.accepted_count,
"refused_count": action.refused_count,
"refusal_summary": action.refusal_summary or {},
"linked_send_attempt_id": action.linked_send_attempt_id,
"created_at": action.created_at,
"effect_started_at": action.effect_started_at,
"completed_at": action.completed_at,
"attempt": (
{
"id": action_attempt.id,
"status": action_attempt.status,
"started_at": action_attempt.started_at,
"effect_started_at": action_attempt.effect_started_at,
"completed_at": action_attempt.completed_at,
"accepted_count": action_attempt.accepted_count,
"refused_count": action_attempt.refused_count,
}
if action_attempt is not None
else None
),
}
if include_diagnostics:
payload["idempotency_key"] = action.idempotency_key
payload["canonical_request_hash"] = action.canonical_request_hash
payload["context"] = action.context or {}
payload["error_type"] = action.error_type
payload["error_message"] = action.error_message
if action_attempt is not None:
payload["attempt"] = {
**dict(payload["attempt"] or {}),
"outcome_code": action_attempt.outcome_code,
"diagnostic_summary": action_attempt.diagnostic_summary,
}
message_action_payloads.append(payload)
return {
"smtp": smtp_payloads,
"imap": imap_payloads,
"postbox": postbox_payloads,
"message_actions": message_action_payloads,
}
def _postbox_receipt_summary_payload(
summary: PostboxDeliveryReceiptSummaryRef,
) -> dict[str, object]:
return {
"delivery_id": summary.delivery_id,
"message_id": summary.message_id,
"postbox_id": summary.postbox_id,
"delivery_status": summary.delivery_status,
"accepted_at": summary.accepted_at,
"current_holder_count": summary.current_holder_count,
"currently_readable": summary.currently_readable,
"message_count": summary.message_count,
"routed_message_count": summary.routed_message_count,
"readable_message_count": summary.readable_message_count,
"read_receipt_count": summary.read_receipt_count,
"acknowledged_receipt_count": summary.acknowledged_receipt_count,
"withdrawn_message_count": summary.withdrawn_message_count,
"expired_message_count": summary.expired_message_count,
"first_read_at": summary.first_read_at,
"last_read_at": summary.last_read_at,
"first_acknowledged_at": summary.first_acknowledged_at,
"last_acknowledged_at": summary.last_acknowledged_at,
"route_status_counts": dict(summary.route_status_counts),
}
@@ -199,6 +305,12 @@ def _job_diagnostics_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
message_actions: Sequence[CampaignMessageAction] = (),
message_action_attempts: Sequence[CampaignMessageActionAttempt] = (),
*,
postbox_receipts: (
Mapping[str, PostboxDeliveryReceiptSummaryRef] | None
) = None,
) -> CampaignJobDiagnosticsResponse:
return CampaignJobDiagnosticsResponse(
job_id=job.id,
@@ -221,6 +333,9 @@ def _job_diagnostics_payload(
send_attempts,
imap_attempts,
postbox_attempts,
message_actions,
message_action_attempts,
postbox_receipts=postbox_receipts,
include_diagnostics=True,
),
)