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

@@ -11,7 +11,7 @@ requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-core>=0.1.14",
"jsonschema>=4,<5",
"pydantic>=2,<3",
"SQLAlchemy>=2,<3",

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:
if not mail.durable_delivery_available:
raise CampaignReportEmailError(
"Report email delivery is disabled until it uses a durable, idempotent Mail-owned outbox with unknown-outcome reconciliation."
"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,14 +384,45 @@ 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)
try:
return _campaign_version_detail_response(
session,
principal,
@@ -402,6 +442,7 @@ def _update_campaign_version_detail_response(
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,
@@ -409,10 +450,43 @@ def _update_campaign_version_detail_response(
"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)
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,
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 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=dry_run,
dry_run=False,
)
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(),
}
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,6 +1461,8 @@ def send_single_campaign_job(
version_id=version.id,
)
action_attempt = _start_single_message_action_attempt(session, action)
try:
result = send_campaign_job(
session,
job_id=job.id,
@@ -1372,17 +1470,556 @@ def send_single_campaign_job(
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
return {
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,
"version_id": version.id,
"campaign_version_id": version.id,
"job_id": job.id,
"action": "single_send",
"queue_action": queue_action,
"dry_run": False,
"result": result.as_dict(),
"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": 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,
),
)

View File

@@ -10,6 +10,7 @@ from govoplan_campaign.backend import route_support
from govoplan_campaign.backend.routes import campaigns as campaign_routes
from govoplan_campaign.backend.routes import versions as version_routes
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
from govoplan_core.core.concurrency import strong_resource_etag
def _principal() -> SimpleNamespace:
@@ -28,6 +29,7 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
raw_json={},
current_flow="manual",
current_step="recipients",
edit_revision=1,
)
def mutate(*_args, **kwargs):
@@ -47,7 +49,15 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
principal, # type: ignore[arg-type]
"campaign-1",
"version-1",
CampaignVersionUpdateRequest(current_step="recipients"),
CampaignVersionUpdateRequest(
current_step="recipients",
base_revision=1,
),
if_match=strong_resource_etag(
"campaign_version",
"version-1",
1,
),
autosave=True,
audit_action="campaign.version_autosaved",
)

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignVersion,
)
from govoplan_campaign.backend.persistence.versions import update_campaign_version
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.concurrency import RevisionConflictError
from govoplan_core.db.base import Base
class CampaignOptimisticConcurrencyTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
database_path = Path(self.temp_dir.name) / "campaign.db"
self.engine = create_engine(f"sqlite+pysqlite:///{database_path}")
access_users = Base.metadata.tables.get("access_users")
if access_users is None:
access_users = Table(
"access_users",
Base.metadata,
Column("id", String(36), primary_key=True),
)
access_groups = Base.metadata.tables.get("access_groups")
if access_groups is None:
access_groups = Table(
"access_groups",
Base.metadata,
Column("id", String(36), primary_key=True),
)
Base.metadata.create_all(
self.engine,
tables=[
access_users,
access_groups,
ChangeSequenceEntry.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignIssue.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
external_id="C-1",
name="Campaign",
current_version_id="version-1",
)
version = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
raw_json={
"version": "1.0",
"campaign": {
"id": "C-1",
"name": "Campaign",
"description": "Base",
},
},
)
session.add_all((campaign, version))
session.commit()
self.addCleanup(self.engine.dispose)
self.addCleanup(self.temp_dir.cleanup)
@staticmethod
def _update(
session: Session,
*,
expected_revision: int,
name: str,
) -> CampaignVersion:
with (
patch(
"govoplan_campaign.backend.persistence.versions._updated_runtime_json",
side_effect=lambda _session, **kwargs: kwargs["raw_json"],
),
patch(
"govoplan_campaign.backend.persistence.versions._write_campaign_snapshot"
),
):
return update_campaign_version(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
version_id="version-1",
raw_json={
"version": "1.0",
"campaign": {
"id": "C-1",
"name": name,
"description": "Base",
},
},
expected_revision=expected_revision,
)
def test_only_one_of_two_writers_can_commit_the_same_revision(self) -> None:
first = self.SessionLocal()
second = self.SessionLocal()
self.addCleanup(first.close)
self.addCleanup(second.close)
first.get(CampaignVersion, "version-1")
second.get(CampaignVersion, "version-1")
saved = self._update(
first,
expected_revision=1,
name="First writer",
)
self.assertEqual(saved.edit_revision, 2)
with self.assertRaises(RevisionConflictError) as raised:
self._update(
second,
expected_revision=1,
name="Second writer",
)
self.assertEqual(raised.exception.current_revision, 2)
self.assertEqual(raised.exception.submitted_base_revision, 1)
with self.SessionLocal() as verification:
current = verification.get(CampaignVersion, "version-1")
assert current is not None
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
self.assertEqual(current.edit_revision, 2)
def test_stale_revision_is_rejected_before_mutation(self) -> None:
with self.SessionLocal() as first:
self._update(
first,
expected_revision=1,
name="First writer",
)
with self.SessionLocal() as stale:
with self.assertRaises(RevisionConflictError):
self._update(
stale,
expected_revision=1,
name="Stale writer",
)
stale.rollback()
with self.SessionLocal() as verification:
current = verification.get(CampaignVersion, "version-1")
assert current is not None
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
if __name__ == "__main__":
unittest.main()

View File

@@ -5,7 +5,9 @@ from types import SimpleNamespace
import unittest
from unittest.mock import patch
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_campaign.backend.reports.campaigns import (
_campaign_postbox_receipt_summary,
_job_evidence_row,
_latest_by_job_id,
_load_delivery_info,
@@ -161,6 +163,64 @@ def test_aggregate_report_omits_recipient_level_failures_by_default() -> None:
assert payload.call_args.kwargs["include_recent_failures"] is False
def test_postbox_receipt_report_aggregates_provider_state_without_identities() -> None:
class _Query:
def join(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def all(self):
return [("delivery-1",), ("delivery-2",), ("delivery-1",)]
integration = SimpleNamespace(
receipt_evidence_available=True,
delivery_receipt_summaries=lambda session, **kwargs: {
"delivery-1": PostboxDeliveryReceiptSummaryRef(
delivery_id="delivery-1",
message_id="message-1",
postbox_id="postbox-1",
delivery_status="accepted",
accepted_at=_dt(),
currently_readable=True,
read_receipt_count=2,
acknowledged_receipt_count=1,
routed_message_count=1,
),
"delivery-2": PostboxDeliveryReceiptSummaryRef(
delivery_id="delivery-2",
message_id="message-2",
postbox_id="postbox-2",
delivery_status="accepted_vacant",
accepted_at=_dt(),
currently_readable=False,
expired_message_count=1,
),
},
)
session = SimpleNamespace(query=lambda *args: _Query())
with patch(
"govoplan_campaign.backend.reports.campaigns.postbox_integration",
return_value=integration,
):
report = _campaign_postbox_receipt_summary(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
version=SimpleNamespace(id="version-1"),
)
assert report["delivery_count"] == 2
assert report["currently_readable_delivery_count"] == 1
assert report["read_delivery_count"] == 1
assert report["acknowledged_delivery_count"] == 1
assert report["routed_message_count"] == 1
assert report["expired_message_count"] == 1
assert "account_id" not in repr(report)
assert "identity_id" not in repr(report)
class CampaignReportProjectionTests(unittest.TestCase):
def test_evidence_projection(self) -> None:
test_job_evidence_row_contains_transport_and_message_evidence()

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDirectoryEntryRef,
PostboxDeliveryRequest,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryResult,
PostboxTargetRef,
)
@@ -55,11 +57,39 @@ class _PostboxDelivery:
classification="internal",
)
def link_evidence(self, session, *, tenant_id, message_id, attachment):
del session, tenant_id, message_id, attachment
return None
def delivery_receipt_summaries(
self,
session,
*,
tenant_id,
producer_module,
delivery_ids,
):
del session
assert tenant_id == "tenant-1"
assert producer_module == "campaigns"
return {
delivery_id: PostboxDeliveryReceiptSummaryRef(
delivery_id=delivery_id,
message_id="message-1",
postbox_id="postbox-1",
delivery_status="accepted",
accepted_at=datetime(2026, 7, 30, tzinfo=UTC),
currently_readable=True,
read_receipt_count=1,
)
for delivery_id in delivery_ids
}
class PostboxCampaignIntegrationTests(unittest.TestCase):
def test_optional_delivery_boundary_is_typed_and_explicit(self) -> None:
delegate = _PostboxDelivery()
integration = PostboxCampaignIntegration(delegate, delegate)
integration = PostboxCampaignIntegration(delegate, delegate, delegate)
request = PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id="postbox-1"),
@@ -73,8 +103,15 @@ class PostboxCampaignIntegrationTests(unittest.TestCase):
result = integration.deliver(object(), request)
self.assertTrue(integration.available)
self.assertTrue(integration.receipt_evidence_available)
self.assertEqual("delivery-1", result.delivery_id)
self.assertEqual(request, delegate.requests[0][1])
summaries = integration.delivery_receipt_summaries(
object(),
tenant_id="tenant-1",
delivery_ids=["delivery-1"],
)
self.assertEqual(1, summaries["delivery-1"].read_receipt_count)
def test_missing_postbox_is_reported_without_importing_module_code(
self,
@@ -91,6 +128,15 @@ class PostboxCampaignIntegrationTests(unittest.TestCase):
)
self.assertFalse(integration.available)
self.assertFalse(integration.receipt_evidence_available)
self.assertEqual(
{},
integration.delivery_receipt_summaries(
object(),
tenant_id="tenant-1",
delivery_ids=["delivery-1"],
),
)
with self.assertRaises(PostboxDeliveryUnavailable):
integration.deliver(object(), request)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from email.message import EmailMessage
import pytest
from fastapi import HTTPException
@@ -183,3 +184,91 @@ def test_report_send_fails_closed_until_durable_mail_outbox_exists() -> None:
)
generate_report.assert_not_called()
def test_live_report_submits_exact_message_to_durable_mail_command() -> None:
campaign = SimpleNamespace(
id="campaign-1",
tenant_id="tenant-1",
name="Campaign",
external_id="campaign-1",
)
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
raw_json={"server": {"mail_profile_id": "profile-1"}},
execution_snapshot={"snapshot_version": "7"},
)
config = SimpleNamespace(
server=SimpleNamespace(
profile_capabilities=SimpleNamespace(smtp_available=True)
),
)
snapshot = SimpleNamespace(
mail_profile_id="profile-1",
smtp_server_id="smtp-1",
smtp_credential_id="credential-1",
smtp_transport_revision="revision-1",
)
message = EmailMessage()
message["From"] = "Sender <sender@example.test>"
message["To"] = "recipient@example.test"
message["Subject"] = "Report"
message.set_content("content")
mail = SimpleNamespace(
durable_delivery_available=True,
submit_delivery_command=lambda _session, **_kwargs: {
"id": "command-1",
"status": "pending",
"duplicate": False,
},
)
class Session:
def get(self, _model, _id):
return campaign
with (
patch(
"govoplan_campaign.backend.reports.emailing._selected_version",
return_value=version,
),
patch(
"govoplan_campaign.backend.reports.emailing._load_config",
return_value=config,
),
patch(
"govoplan_campaign.backend.reports.emailing.ensure_execution_snapshot",
return_value=snapshot,
),
patch(
"govoplan_campaign.backend.reports.emailing.generate_campaign_report",
return_value={},
),
patch(
"govoplan_campaign.backend.reports.emailing.build_report_message",
return_value=message,
),
patch(
"govoplan_campaign.backend.reports.emailing._effective_from",
return_value=("sender@example.test", "Sender"),
),
patch(
"govoplan_campaign.backend.reports.emailing.mail_integration",
return_value=mail,
),
):
result = send_campaign_report_email(
Session(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
to=["recipient@example.test"],
idempotency_key="request-1",
created_by_user_id="user-1",
)
assert result.command_id == "command-1"
assert result.delivery_status == "pending"
assert result.sent is False
assert result.audit_dict()["recipient_count"] == 1
assert "recipient@example.test" not in repr(result.audit_dict())

View File

@@ -5,6 +5,7 @@ import unittest
from datetime import UTC, datetime
from types import SimpleNamespace
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_campaign.backend.response_security import public_campaign_payload
from govoplan_campaign.backend.services.job_queries import (
_job_attempts_payload,
@@ -88,6 +89,30 @@ def _imap_attempt() -> SimpleNamespace:
)
def _postbox_attempt() -> SimpleNamespace:
return SimpleNamespace(
id="postbox-attempt-1",
target_index=0,
attempt_number=1,
status="accepted",
postbox_id="postbox-1",
address="intake@postbox",
holder_count=1,
vacant=False,
duplicate=False,
target_snapshot={"address": "intake@postbox"},
started_at=_now(),
finished_at=_now(),
error_code=None,
idempotency_key="private-idempotency-key",
provider_delivery_id="delivery-1",
provider_message_id="message-1",
evidence={"target_snapshot": {"address": "intake@postbox"}},
error_type=None,
error_message=None,
)
def test_public_payload_recursively_removes_infrastructure_locators() -> None:
source = {
"campaign_file": "/tmp/campaign.json",
@@ -243,6 +268,33 @@ def test_operator_diagnostics_include_claim_and_storage_details() -> None:
assert "provider-secret" in diagnostics["worker_claim"]["last_error"]
def test_postbox_receipt_summary_is_aggregate_and_contains_no_holder_identity() -> None:
summary = PostboxDeliveryReceiptSummaryRef(
delivery_id="delivery-1",
message_id="message-1",
postbox_id="postbox-1",
delivery_status="accepted",
accepted_at=_now(),
current_holder_count=2,
currently_readable=True,
read_receipt_count=1,
acknowledged_receipt_count=1,
)
attempts = _job_attempts_payload(
[],
[],
[_postbox_attempt()], # type: ignore[list-item]
postbox_receipts={"delivery-1": summary},
)
payload = attempts["postbox"][0]
assert payload["receipt_summary_status"] == "available"
assert payload["receipt_summary"]["currently_readable"] is True
assert payload["receipt_summary"]["read_receipt_count"] == 1
assert "account_id" not in repr(payload["receipt_summary"])
assert "identity_id" not in repr(payload["receipt_summary"])
def test_diagnostics_permission_is_operator_only_by_default() -> None:
from govoplan_campaign.backend.manifest import PERMISSIONS, ROLE_TEMPLATES

View File

@@ -38,7 +38,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 62
assert len(actual) == 64
assert not [operation for operation, count in Counter(actual).items() if count > 1]

View File

@@ -0,0 +1,417 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from pydantic import ValidationError
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignVersion,
JobBuildStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
SendAttempt,
)
from govoplan_campaign.backend.schemas import CampaignSendJobRequest
from govoplan_campaign.backend.sending.jobs import (
QueueingError,
SendJobResult,
send_single_campaign_job,
)
from govoplan_campaign.backend.integrations import SmtpSendError
class _Mail:
def __init__(self, result: object | None = None, error: Exception | None = None):
self.result = result or SimpleNamespace(
accepted_count=1,
refused_recipients={},
)
self.error = error
self.send = Mock()
def wait_for_rate_limit(self, **_kwargs) -> None:
return None
def send_campaign_email_bytes(self, *_args, **_kwargs):
self.send(*_args, **_kwargs)
if self.error is not None:
raise self.error
return self.result
class CampaignSingleMessageActionTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
access_users = Base.metadata.tables.get("access_users")
if access_users is None:
access_users = Table(
"access_users",
Base.metadata,
Column("id", String(36), primary_key=True),
)
access_groups = Base.metadata.tables.get("access_groups")
if access_groups is None:
access_groups = Table(
"access_groups",
Base.metadata,
Column("id", String(36), primary_key=True),
)
Base.metadata.create_all(
self.engine,
tables=[
access_users,
access_groups,
ChangeSequenceEntry.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
SendAttempt.__table__,
CampaignMessageAction.__table__,
CampaignMessageActionAttempt.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
external_id="campaign-1",
name="Campaign",
current_version_id="version-1",
)
version = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
raw_json={},
validation_summary={"ok": True},
build_summary={"build_token": "build-1"},
)
job = CampaignJob(
id="job-1",
tenant_id="tenant-1",
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=1,
recipient_email="recipient@example.test",
eml_local_path="/tmp/message.eml",
eml_sha256="a" * 64,
eml_size_bytes=42,
build_status=JobBuildStatus.BUILT.value,
validation_status=JobValidationStatus.READY.value,
queue_status=JobQueueStatus.DRAFT.value,
send_status=JobSendStatus.NOT_QUEUED.value,
delivery_channel_policy="mail",
resolved_recipients={
"from": {"email": "sender@example.test"},
"to": [{"email": "recipient@example.test"}],
},
)
session.add_all((campaign, version, job))
session.commit()
self.addCleanup(self.engine.dispose)
@staticmethod
def _delivery_context():
return SimpleNamespace(
snapshot=SimpleNamespace(
mail_profile_id="profile-1",
smtp_server_id=None,
smtp_credential_id=None,
smtp_transport_revision="revision-1",
delivery=SimpleNamespace(
rate_limit=SimpleNamespace(messages_per_minute=60),
imap_append_sent=SimpleNamespace(enabled=False),
),
),
message_bytes=b"Subject: test\r\n\r\ncontent",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
)
def _run(
self,
session: Session,
*,
kind: str,
key: str,
mail: _Mail,
reason: str | None = None,
):
with (
patch(
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
),
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
return_value=self._delivery_context(),
),
patch(
"govoplan_campaign.backend.sending.jobs.mail_integration",
return_value=mail,
),
patch(
"govoplan_campaign.backend.sending.jobs.audit_event"
),
):
return send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind=kind,
idempotency_key=key,
actor_user_id=None,
reason=reason,
)
def test_test_action_is_idempotent_and_leaves_official_state_unchanged(self) -> None:
mail = _Mail()
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
job.attempt_count = 2
session.commit()
first = self._run(
session,
kind="test",
key="test-1",
mail=mail,
)
repeated = self._run(
session,
kind="test",
key="test-1",
mail=mail,
)
session.refresh(job)
self.assertEqual(first["status"], "accepted")
self.assertTrue(repeated["duplicate"])
self.assertEqual(job.send_status, JobSendStatus.FAILED_TEMPORARY.value)
self.assertEqual(job.attempt_count, 2)
self.assertEqual(mail.send.call_count, 1)
with self.assertRaisesRegex(QueueingError, "idempotency key"):
self._run(
session,
kind="single_resend",
key="test-1",
reason="Different command",
mail=mail,
)
def test_failed_resend_preserves_prior_success(self) -> None:
mail = _Mail(
error=SmtpSendError(
"Temporary delivery failure",
temporary=True,
)
)
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.sent_at = job.created_at
session.commit()
result = self._run(
session,
kind="single_resend",
key="resend-failed",
reason="Recipient requested another copy",
mail=mail,
)
session.refresh(job)
self.assertEqual(result["status"], "failed_temporary")
self.assertEqual(job.send_status, JobSendStatus.SMTP_ACCEPTED.value)
self.assertIsNotNone(job.sent_at)
def test_successful_resend_completes_previously_failed_message(self) -> None:
mail = _Mail()
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
job.send_status = JobSendStatus.FAILED_PERMANENT.value
job.last_error = "old failure"
session.commit()
with (
patch(
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
),
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
return_value=self._delivery_context(),
),
patch(
"govoplan_campaign.backend.sending.jobs.mail_integration",
return_value=mail,
),
patch(
"govoplan_campaign.backend.sending.jobs.files_integration"
) as files,
patch(
"govoplan_campaign.backend.sending.jobs._update_campaign_after_job"
),
patch(
"govoplan_campaign.backend.sending.jobs.audit_event"
),
):
result = send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind="single_resend",
idempotency_key="resend-success",
actor_user_id=None,
reason="Approved operator resend",
)
session.refresh(job)
self.assertEqual(result["status"], "accepted")
self.assertEqual(job.send_status, JobSendStatus.SMTP_ACCEPTED.value)
self.assertIsNotNone(job.sent_at)
files.return_value.mark_job_attachment_uses_sent.assert_called_once()
def test_unknown_resend_is_frozen_and_replay_does_not_send_again(self) -> None:
mail = _Mail(
error=SmtpSendError(
"Unknown delivery outcome",
outcome_unknown=True,
)
)
with self.SessionLocal() as session:
first = self._run(
session,
kind="single_resend",
key="resend-unknown",
reason="Approved operator resend",
mail=mail,
)
repeated = self._run(
session,
kind="single_resend",
key="resend-unknown",
reason="Approved operator resend",
mail=mail,
)
self.assertEqual(first["status"], "outcome_unknown")
self.assertTrue(repeated["duplicate"])
self.assertEqual(mail.send.call_count, 1)
def test_initial_send_links_the_canonical_attempt_and_replay_is_inert(self) -> None:
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
def canonical_send(_session: Session, **_kwargs):
attempt = SendAttempt(
job_id=job.id,
attempt_number=1,
status=JobSendStatus.SMTP_ACCEPTED.value,
started_at=job.created_at,
finished_at=job.created_at,
)
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.queue_status = JobQueueStatus.DRAFT.value
job.attempt_count = 1
_session.add_all((attempt, job))
_session.commit()
return SendJobResult(
job_id=job.id,
status=JobSendStatus.SMTP_ACCEPTED.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
),
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
return_value=self._delivery_context(),
),
patch(
"govoplan_campaign.backend.sending.jobs._prepare_single_job_for_send",
return_value="queued",
),
patch(
"govoplan_campaign.backend.sending.jobs.send_campaign_job",
side_effect=canonical_send,
) as send,
patch(
"govoplan_campaign.backend.sending.jobs._emit_campaign_status_notification"
),
patch(
"govoplan_campaign.backend.sending.jobs.audit_event"
),
):
first = send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind="single_send",
idempotency_key="initial-1",
actor_user_id=None,
)
repeated = send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind="single_send",
idempotency_key="initial-1",
actor_user_id=None,
)
self.assertEqual(first["status"], "accepted")
self.assertIsNotNone(first["linked_send_attempt_id"])
self.assertTrue(repeated["duplicate"])
self.assertEqual(send.call_count, 1)
def test_request_requires_explicit_kind_key_and_resend_reason(self) -> None:
with self.assertRaises(ValidationError):
CampaignSendJobRequest.model_validate({})
with self.assertRaises(ValidationError):
CampaignSendJobRequest.model_validate(
{
"kind": "single_resend",
"idempotency_key": "resend-1",
}
)
request = CampaignSendJobRequest.model_validate(
{
"kind": "test",
"idempotency_key": "test-1",
}
)
self.assertEqual(request.kind, "test")
if __name__ == "__main__":
unittest.main()

View File

@@ -17,7 +17,7 @@
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.12",
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -60,6 +60,8 @@ export type CampaignVersionListItem = {
id: string;
campaign_id: string;
version_number: number;
edit_revision: number;
strong_etag: string;
schema_version?: string;
source_filename?: string | null;
source_base_path?: string | null;
@@ -274,6 +276,9 @@ export type CampaignVersionUpdatePayload = {
source_filename?: string | null;
source_base_path?: string | null;
migrate_legacy_mail_settings?: boolean;
base_revision?: number;
reconciliation_kind?: "none" | "auto_merge" | "manual";
resolved_conflict_paths?: string[];
};
export type CampaignPartialValidationPayload = {
@@ -330,6 +335,7 @@ export type CampaignSummary = {
issues?: Record<string, unknown>;
attachments?: Record<string, unknown>;
attempts?: Record<string, unknown>;
postbox_receipts?: Record<string, unknown>;
delivery?: Record<string, unknown>;
recent_failures?: Record<string, unknown>[];
};
@@ -468,8 +474,11 @@ export type CampaignReviewStatePayload = {
};
export type CampaignSendJobPayload = {
kind: "test" | "single_send" | "single_resend";
idempotency_key: string;
reason?: string;
context?: Record<string, unknown>;
include_warnings?: boolean;
dry_run?: boolean;
use_rate_limit?: boolean;
enqueue_imap_task?: boolean;
};
@@ -520,6 +529,7 @@ export type CampaignReportEmailPayload = {
attach_jobs_csv?: boolean;
attach_report_json?: boolean;
dry_run?: boolean;
idempotency_key?: string;
};
export type AggregateReportCount = {
@@ -797,10 +807,12 @@ export async function updateCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload)
payload: CampaignVersionUpdatePayload,
ifMatch: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT",
headers: { "If-Match": ifMatch },
body: JSON.stringify(payload)
});
}
@@ -821,10 +833,12 @@ export async function autosaveCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload)
payload: CampaignVersionUpdatePayload,
ifMatch: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
method: "POST",
headers: { "If-Match": ifMatch },
body: JSON.stringify(payload)
});
}
@@ -1021,7 +1035,7 @@ export async function sendCampaignJob(
settings: ApiSettings,
campaignId: string,
jobId: string,
payload: CampaignSendJobPayload = {})
payload: CampaignSendJobPayload)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, {
method: "POST",

View File

@@ -102,6 +102,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
const version = data.currentVersion;
const cards = data.summary?.cards;
const delivery = asRecord(data.summary?.delivery);
const postboxReceipts = asRecord(data.summary?.postbox_receipts);
const rateLimit = asRecord(delivery.rate_limit);
const imapPolicy = asRecord(delivery.imap_append_sent);
@@ -262,8 +263,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
attempted += 1;
try {
const sendResponse = await sendCampaignJob(settings, campaignId, jobId, {
kind: "single_resend",
idempotency_key: crypto.randomUUID(),
reason: "Operator requested synchronous resend from the campaign report.",
include_warnings: true,
dry_run: false,
use_rate_limit: true,
enqueue_imap_task: false
});
@@ -338,9 +341,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
version_id: version?.id,
include_jobs: false,
attach_jobs_csv: attachCsv,
attach_report_json: attachJson
attach_report_json: attachJson,
idempotency_key: crypto.randomUUID()
});
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
setActionMessage(`Report queued for ${recipients.join(", ")}.`);
setEmailOpen(false);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
@@ -471,6 +475,30 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
</dl>
</Card>
<Card title="Postbox receipt evidence">
<dl className="detail-list">
<div>
<dt>Evidence state</dt>
<dd>
<StatusBadge
status={
postboxReceipts.status === "available"
? "success"
: postboxReceipts.status === "unavailable"
? "warning"
: "info"
}
label={humanize(String(postboxReceipts.status ?? "not applicable"))}
/>
</dd>
</div>
<div><dt>Accepted deliveries</dt><dd>{String(postboxReceipts.delivery_count ?? 0)}</dd></div>
<div><dt>Currently readable</dt><dd>{String(postboxReceipts.currently_readable_delivery_count ?? "—")}</dd></div>
<div><dt>Read</dt><dd>{String(postboxReceipts.read_delivery_count ?? "—")}</dd></div>
<div><dt>Acknowledged</dt><dd>{String(postboxReceipts.acknowledged_delivery_count ?? "—")}</dd></div>
<div><dt>Withdrawn / expired copies</dt><dd>{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}</dd></div>
</dl>
</Card>
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
<div className="button-row compact-actions">
@@ -563,6 +591,13 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
<div><dt>Message SHA-256</dt><dd><code>{String(detail.job.eml_sha256 ?? "—")}</code></dd></div>
</dl>
<PostboxTargetEvidenceSection
targets={
Array.isArray(detail.job.resolved_postbox_targets)
? detail.job.resolved_postbox_targets
: []
}
/>
<AttachmentEvidenceSection attachments={Array.isArray(detail.job.attachments) ? detail.job.attachments : []} />
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} />
@@ -587,6 +622,93 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}
function PostboxTargetEvidenceSection({ targets }: { targets: unknown[] }) {
const rows = targets.map(asRecord);
if (rows.length === 0) return null;
const columns: DataGridColumn<Record<string, unknown>>[] = [
{
id: "target",
header: "Frozen Postbox target",
width: "minmax(240px, 1fr)",
minWidth: 220,
resizable: true,
filterable: true,
value: (row) => String(row.address ?? row.name ?? row.postbox_id ?? "—"),
render: (row) => (
<span className="campaign-evidence-identifiers">
<strong>{String(row.name ?? row.address ?? row.postbox_id ?? "—")}</strong>
<small>{String(row.address ?? row.address_key ?? "")}</small>
</span>
)
},
{
id: "organization",
header: "Organization / function",
width: 260,
resizable: true,
filterable: true,
value: (row) =>
`${String(row.organization_unit_name ?? row.organization_unit_id ?? "")} ${String(row.function_name ?? row.function_id ?? "")}`,
render: (row) => (
<span className="campaign-evidence-identifiers">
<span>{String(row.organization_unit_name ?? row.organization_unit_id ?? "—")}</span>
<small>{String(row.function_name ?? row.function_id ?? "—")}</small>
</span>
)
},
{
id: "resolution",
header: "Frozen resolution",
width: 220,
resizable: true,
value: (row) =>
`${String(row.mode ?? "direct")} ${String(row.context_key ?? "")} ${String(row.template_revision_id ?? "")}`,
render: (row) => (
<span className="campaign-evidence-identifiers">
<span>{humanize(String(row.mode ?? "direct"))}</span>
<small>
{row.template_revision_id
? `Template ${shortEvidenceId(String(row.template_revision_id))}`
: "Exact Postbox"}
{row.context_key ? ` · Context ${String(row.context_key)}` : ""}
</small>
</span>
)
},
{
id: "holders",
header: "Build-time holders",
width: 150,
align: "right",
sortable: true,
value: (row) => Number(row.holder_count ?? 0),
render: (row) =>
row.vacant === true ? (
<StatusBadge status="warning" label="Vacant" />
) : (
String(row.holder_count ?? 0)
)
}
];
return (
<section className="attempt-history-section">
<h3>Frozen Postbox targets</h3>
<p className="muted small-note">
These addresses, organization/function references, and template revisions
were resolved during build and are the delivery evidence for this job.
</p>
<DataGrid
id="campaign-postbox-target-evidence"
rows={rows}
columns={columns}
getRowKey={(row, index) =>
String(row.target_id ?? row.postbox_id ?? `postbox-target-${index}`)
}
/>
</section>
);
}
type AttachmentEvidenceRow = {
id: string;
rule: string;
@@ -718,6 +840,50 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";
kind === "postbox" ?
{ id: "target", header: "Postbox", width: 220, sortable: true, filterable: true, value: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—"), render: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—") } :
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
...(kind === "postbox" ? [
{
id: "readability",
header: "Current access",
width: 155,
value: (row: Record<string, unknown>) => {
const summary = asRecord(row.receipt_summary);
return String(
row.receipt_summary_status === "available"
? summary.currently_readable === true
? "readable"
: "unavailable"
: row.receipt_summary_status ?? "unavailable"
);
},
render: (row: Record<string, unknown>) => {
const summary = asRecord(row.receipt_summary);
const available = row.receipt_summary_status === "available";
const readable = available && summary.currently_readable === true;
return (
<StatusBadge
status={readable ? "success" : available ? "warning" : "info"}
label={
readable
? "Readable"
: available
? "Not currently readable"
: "Evidence unavailable"
}
/>
);
}
},
{
id: "receipts",
header: "Read / acknowledged",
width: 170,
align: "right" as const,
value: (row: Record<string, unknown>) => {
const summary = asRecord(row.receipt_summary);
return `${String(summary.read_receipt_count ?? 0)} / ${String(summary.acknowledged_receipt_count ?? 0)}`;
}
}
] : []),
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? row.error_code ?? "")}>{String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—")}</span> }

View File

@@ -1,22 +1,26 @@
import { useEffect, useState } from "react";
import { lazy, useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import { useGuardedNavigate } from "@govoplan/core-webui";
import {
ConcurrencyConflictProvider,
useGuardedNavigate
} from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage";
import CampaignFieldsPage from "./CampaignFieldsPage";
import GlobalSettingsPage from "./GlobalSettingsPage";
import RecipientDataPage from "./RecipientDataPage";
import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage";
import ReviewSendPage from "./ReviewSendPage";
import CreateWizard from "./wizard/CreateWizard";
import ReviewWizard from "./wizard/ReviewWizard";
import SendWizard from "./wizard/SendWizard";
import CampaignJsonView from "./CampaignJsonView";
import CampaignReportPage from "./CampaignReportPage";
import CampaignAuditPage from "./CampaignAuditPage";
const CampaignOverviewPage = lazy(() => import("./CampaignOverviewPage"));
const CampaignFieldsPage = lazy(() => import("./CampaignFieldsPage"));
const GlobalSettingsPage = lazy(() => import("./GlobalSettingsPage"));
const RecipientDataPage = lazy(() => import("./RecipientDataPage"));
const TemplateDataPage = lazy(() => import("./TemplateDataPage"));
const AttachmentsDataPage = lazy(() => import("./AttachmentsDataPage"));
const MailSettingsPage = lazy(() => import("./MailSettingsPage"));
const ReviewSendPage = lazy(() => import("./ReviewSendPage"));
const CreateWizard = lazy(() => import("./wizard/CreateWizard"));
const ReviewWizard = lazy(() => import("./wizard/ReviewWizard"));
const SendWizard = lazy(() => import("./wizard/SendWizard"));
const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "",
@@ -37,7 +41,11 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
};
export default function CampaignWorkspace({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
return <CampaignWorkspaceInner settings={settings} auth={auth} />;
return (
<ConcurrencyConflictProvider>
<CampaignWorkspaceInner settings={settings} auth={auth} />
</ConcurrencyConflictProvider>
);
}
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {

View File

@@ -36,7 +36,7 @@ import {
type CampaignSummary } from
"../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { Button, Dialog, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
@@ -159,6 +159,8 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const [newlyReviewedRequiredKeys, setNewlyReviewedRequiredKeys] = useState<Set<string>>(() => new Set());
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
const [singleSendConfirmIndex, setSingleSendConfirmIndex] = useState<number | null>(null);
const [singleMessageActionKind, setSingleMessageActionKind] = useState<"test" | "single_send" | "single_resend">("single_send");
const [singleMessageResendReason, setSingleMessageResendReason] = useState("");
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
const [mockClearFirst, setMockClearFirst] = useState(true);
const [mockAppendSent, setMockAppendSent] = useState(true);
@@ -1103,8 +1105,11 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
setError("");
try {
const response = await sendCampaignJob(settings, campaignId, jobId, {
kind: singleMessageActionKind,
idempotency_key: crypto.randomUUID(),
reason: singleMessageActionKind === "single_resend" ? singleMessageResendReason.trim() : undefined,
context: { surface: "campaign_review_preview" },
include_warnings: true,
dry_run: false,
use_rate_limit: true,
enqueue_imap_task: backgroundWorkersEnabled
});
@@ -1112,18 +1117,20 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const sendResult = asRecord(actionResult.result);
const status = String(sendResult.status ?? "submitted");
const attemptCount = Number(sendResult.attempt_number ?? row.attempt_count ?? 0);
setBuiltReviewRows((current) => current.map((item) => String(item.id ?? "") === jobId ?
const finalSendStatus = String(actionResult.final_send_status ?? "");
if (singleMessageActionKind !== "test") setBuiltReviewRows((current) => current.map((item) => String(item.id ?? "") === jobId ?
{
...item,
send_status: status === "already_accepted" ? "smtp_accepted" : status,
queue_status: ["smtp_accepted", "already_accepted"].includes(status) ? "draft" : item.queue_status,
send_status: finalSendStatus || (status === "already_accepted" ? "smtp_accepted" : status),
queue_status: ["smtp_accepted", "already_accepted", "accepted", "accepted_with_refusals"].includes(finalSendStatus || status) ? "draft" : item.queue_status,
imap_status: sendResult.imap_status ?? item.imap_status,
attempt_count: Number.isFinite(attemptCount) ? attemptCount : item.attempt_count,
last_error: sendResult.message ?? ""
} :
item));
setMessage(`Single message send finished: ${humanize(status)}.`);
setMessage(`${humanize(singleMessageActionKind)} finished: ${humanize(String(actionResult.status ?? status))}.`);
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
setSelectedBuiltIndex(null);
await refreshQueueStatus(true);
await reload();
@@ -1146,6 +1153,7 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
setNewlyReviewedRequiredKeys(new Set());
setSelectedBuiltIndex(null);
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
setMockResult(null);
setSelectedMockMessage(null);
resetDeltaWatermark();
@@ -1660,7 +1668,18 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
canStartSingleMessageSend={canStartSingleMessageSend}
singleMessageSendBusy={busy === "send"}
onSelect={openBuiltMessageAtIndex}
onSendSingle={(targetIndex) => setSingleSendConfirmIndex(targetIndex)}
onSendSingle={(targetIndex) => {
const target = filteredBuiltReviewRows[targetIndex];
const status = String(target?.send_status ?? "not_queued");
setSingleMessageActionKind(
["not_queued", "cancelled", "queued"].includes(status)
&& Number(target?.attempt_count ?? 0) === 0
? "single_send"
: "single_resend"
);
setSingleMessageResendReason("");
setSingleSendConfirmIndex(targetIndex);
}}
onClose={() => setSelectedBuiltIndex(null)} />
}
@@ -1724,16 +1743,103 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
onCancel={() => setCancelDeliveryConfirmOpen(false)}
onConfirm={() => void runDeliveryControl("cancel")} />
<ConfirmDialog
<Dialog
open={singleSendConfirmRow !== null}
title="Send this one message now?"
message={singleSendConfirmRow ? `This sends only the currently selected built message to ${formatAddressList(asRecord(singleSendConfirmRow.resolved_recipients).to) || String(singleSendConfirmRow.recipient_email ?? "the resolved recipient")}. The send is recorded as a normal SMTP attempt in the delivery protocol.` : ""}
confirmLabel="Send one message"
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
tone="danger"
busy={busy === "send"}
onCancel={() => setSingleSendConfirmIndex(null)}
onConfirm={() => void sendSingleBuiltMessage()} />
title="One-message delivery"
portal
closeDisabled={busy === "send"}
onClose={() => {
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
}}
footer={
<>
<Button
disabled={busy === "send"}
onClick={() => {
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
}}
>
i18n:govoplan-campaign.cancel.77dfd213
</Button>
<Button
variant="danger"
disabled={
busy === "send"
|| (singleMessageActionKind === "single_resend" && !singleMessageResendReason.trim())
}
onClick={() => void sendSingleBuiltMessage()}
>
{busy === "send" ? "Sending..." : "Confirm action"}
</Button>
</>
}
>
{singleSendConfirmRow && (
<div className="form-grid">
<SegmentedControl
value={singleMessageActionKind}
width="fill"
size="equal"
ariaLabel="One-message action"
options={[
{ id: "test", label: "Test" },
{
id: "single_send",
label: "Initial send",
disabled: !(
["not_queued", "cancelled", "queued"].includes(String(singleSendConfirmRow.send_status ?? "not_queued"))
&& Number(singleSendConfirmRow.attempt_count ?? 0) === 0
)
},
{
id: "single_resend",
label: "Resend",
disabled: ["claimed", "sending", "outcome_unknown"].includes(String(singleSendConfirmRow.send_status ?? ""))
}
]}
onChange={setSingleMessageActionKind}
/>
<p className="muted small-note">
{singleMessageActionKind === "test"
? "Transports this exact rendered message once without changing its official delivery state."
: singleMessageActionKind === "single_resend"
? "Transports this exact message again. A successful resend can complete it; a failed resend preserves an earlier success."
: "Makes the first official delivery attempt and changes only this message after SMTP acceptance."}
</p>
<dl className="detail-grid">
<div>
<dt>Recipient</dt>
<dd>{formatAddressList(asRecord(singleSendConfirmRow.resolved_recipients).to) || String(singleSendConfirmRow.recipient_email ?? "—")}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{String(version?.version_number ?? "—")}</dd>
</div>
<div>
<dt>Message</dt>
<dd><code>{String(singleSendConfirmRow.eml_sha256 ?? "—").slice(0, 16)}</code></dd>
</div>
<div>
<dt>State</dt>
<dd>{humanize(String(singleSendConfirmRow.send_status ?? "not_queued"))}</dd>
</div>
</dl>
{singleMessageActionKind === "single_resend" && (
<label>
Reason
<textarea
value={singleMessageResendReason}
maxLength={2000}
rows={3}
onChange={(event) => setSingleMessageResendReason(event.target.value)}
/>
</label>
)}
</div>
)}
</Dialog>
{selectedDeliveryJobDetail &&
<DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} />
@@ -1776,6 +1882,23 @@ reviewedKeys: Set<string>)
list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
value: (row) => String(row.validation_status ?? "unknown")
},
{
id: "postboxTargets",
header: "Postbox targets",
width: 145,
align: "right",
sortable: true,
filterType: "integer",
value: (row) => Number(row.postbox_target_count ?? 0),
render: (row) => {
const count = Number(row.postbox_target_count ?? 0);
return count > 0 ? (
<StatusBadge status="info" label={`${count} frozen`} />
) : (
<span className="muted"></span>
);
}
},
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];

View File

@@ -1,6 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
revisionConflictFromError,
threeWayMerge,
useConcurrencyConflictResolver
} from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns";
import {
autosaveCampaignVersion,
getCampaignVersion,
type CampaignVersionDetail,
type CampaignVersionUpdatePayload
} from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
@@ -36,6 +46,26 @@ function defaultLoadedLabel(version: CampaignVersionDetail): string {
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a";
}
const CAMPAIGN_PROTECTED_MERGE_PATHS = [
"/current_flow",
"/current_step",
"/workflow_state",
"/is_complete",
"/editor_state",
"/source_filename",
"/source_base_path",
"/migrate_legacy_mail_settings",
"/campaign_json/delivery",
"/campaign_json/server",
"/campaign_json/security",
"/campaign_json/signatures",
"/campaign_json/encryption",
"/campaign_json/_retention",
"/campaign_json/campaign/owner_user_id",
"/campaign_json/campaign/owner_group_id",
"/campaign_json/campaign/status"
] as const;
export function useCampaignDraftEditor({
settings,
campaignId,
@@ -60,6 +90,10 @@ export function useCampaignDraftEditor({
const transformLoadedDraftRef = useRef(transformLoadedDraft);
const transformDraftBeforeSaveRef = useRef(transformDraftBeforeSave);
const onLoadedRef = useRef(onLoaded);
const baseDraftRef = useRef<Record<string, unknown> | null>(null);
const baseRevisionRef = useRef<number | null>(null);
const baseEtagRef = useRef<string | null>(null);
const resolveConcurrencyConflict = useConcurrencyConflictResolver();
useEffect(() => {
loadedLabelRef.current = loadedLabel;
@@ -77,6 +111,11 @@ export function useCampaignDraftEditor({
if (!version) return;
const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
baseDraftRef.current = (
transformDraftBeforeSaveRef.current?.(loadedDraft) ?? loadedDraft
);
baseRevisionRef.current = version.edit_revision;
baseEtagRef.current = version.strong_etag;
setDraft(loadedDraft);
setDirty(false);
setLocalError("");
@@ -102,15 +141,100 @@ export function useCampaignDraftEditor({
setLocalError("");
try {
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draftToSave,
const additionalPayload = extraPayload?.() ?? {};
let mutation = campaignVersionMutation(
version,
draftToSave,
{
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
is_complete: isComplete,
...(extraPayload?.() ?? {})
...additionalPayload
}
);
let mergeBase = campaignVersionMutation(
version,
baseDraftRef.current ?? draftToSave
);
let expectedRevision = baseRevisionRef.current ?? version.edit_revision;
let expectedEtag = baseEtagRef.current ?? version.strong_etag;
let reconciliationKind: "none" | "auto_merge" | "manual" = "none";
let resolvedConflictPaths: string[] = [];
let saved: CampaignVersionDetail | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
...mutation,
base_revision: expectedRevision,
reconciliation_kind: reconciliationKind,
resolved_conflict_paths: resolvedConflictPaths
}, expectedEtag);
break;
} catch (error) {
const conflict = revisionConflictFromError(error);
if (!conflict) throw error;
const latest = await getCampaignVersion(
settings,
campaignId,
version.id
);
const latestLoaded = transformLoadedDraftRef.current?.(
latest,
ensureCampaignDraft(latest)
) ?? ensureCampaignDraft(latest);
const latestDraft = (
transformDraftBeforeSaveRef.current?.(latestLoaded) ?? latestLoaded
);
const latestMutation = campaignVersionMutation(
latest,
latestDraft
);
const merge = threeWayMerge(
mergeBase,
mutation,
latestMutation,
{
protectedPaths: CAMPAIGN_PROTECTED_MERGE_PATHS,
stableIdFields: ["id", "entry_id"]
}
);
if (merge.conflicts.length === 0) {
mutation = merge.value;
reconciliationKind = "auto_merge";
resolvedConflictPaths = merge.appliedPaths;
} else {
const resolution = await resolveConcurrencyConflict({
resourceLabel: `Campaign version ${latest.version_number}`,
merge
});
if (resolution.action === "cancel") return false;
if (resolution.action === "reload") {
setDirty(false);
await reload({ force: true });
return false;
}
mutation = resolution.value;
reconciliationKind = "manual";
resolvedConflictPaths = resolution.resolvedPaths;
}
mergeBase = latestMutation;
expectedRevision = latest.edit_revision;
expectedEtag = latest.strong_etag;
}
}
if (!saved) {
throw new Error(
"The campaign changed repeatedly while it was being saved. Reload and try again."
);
}
setDraft(getCampaignJson(saved));
baseDraftRef.current = getCampaignJson(saved);
baseRevisionRef.current = saved.edit_revision;
baseEtagRef.current = saved.strong_etag;
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
onSaved?.(saved);
@@ -122,7 +246,7 @@ export function useCampaignDraftEditor({
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
return false;
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setError, settings, version, workflowState]);
const discardDraft = useCallback(async () => {
if (version) {
@@ -162,3 +286,23 @@ export function useCampaignDraftEditor({
saveDraft
};
}
function campaignVersionMutation(
version: CampaignVersionDetail,
campaignJson: Record<string, unknown>,
overrides: Partial<CampaignVersionUpdatePayload> = {}
): CampaignVersionUpdatePayload {
return {
campaign_json: campaignJson,
current_flow: overrides.current_flow ?? version.current_flow ?? null,
current_step: overrides.current_step ?? version.current_step ?? null,
workflow_state: overrides.workflow_state ?? version.workflow_state ?? null,
is_complete: overrides.is_complete ?? version.is_complete ?? false,
editor_state: overrides.editor_state ?? version.editor_state ?? {},
source_filename: overrides.source_filename ?? version.source_filename ?? null,
source_base_path: overrides.source_base_path ?? version.source_base_path ?? null,
migrate_legacy_mail_settings: (
overrides.migrate_legacy_mail_settings ?? false
)
};
}

View File

@@ -112,10 +112,10 @@ export default function BuiltMessagePreview({
<Button
variant="primary"
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
title={singleSendDisabledReason || "Send only this built message"}
title={singleSendDisabledReason || "Test, send, or resend only this built message"}
onClick={() => onSendSingle(index)}
>
{singleMessageSendBusy ? "Sending..." : "Send this message..."}
{singleMessageSendBusy ? "Sending..." : "Message actions..."}
</Button>
}
onClose={onClose}
@@ -139,28 +139,15 @@ function singleMessageSendDisabledReason(
return `This message is ${humanize(validationStatus)}.`;
}
const sendStatus = String(row.send_status ?? "not_queued");
const queueStatus = String(row.queue_status ?? "draft");
if (["smtp_accepted", "sent"].includes(sendStatus)) {
return "This message was already accepted by SMTP.";
}
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) {
return `This message is in delivery state ${humanize(sendStatus)}.`;
}
if (["failed_temporary", "failed_permanent"].includes(sendStatus)) {
return "Use the explicit retry action for failed messages.";
}
if (sendStatus === "queued" && queueStatus === "queued") return "";
if (
["not_queued", "cancelled"].includes(sendStatus) &&
["draft", "cancelled"].includes(queueStatus)
) {
return "";
}
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
}
function builtMessageMetaItems(row: Record<string, unknown>) {
const recipients = asRecord(row.resolved_recipients);
const postboxTargets = asArray(row.resolved_postbox_targets).map(asRecord);
return [
{
label: "i18n:govoplan-campaign.from.3f66052a",
@@ -182,6 +169,23 @@ function builtMessageMetaItems(row: Record<string, unknown>) {
label: "i18n:govoplan-campaign.validation.dd74d182",
value: String(row.validation_status ?? "—")
},
{
label: "Frozen Postbox targets",
value: postboxTargets.length
? postboxTargets
.map((target) => {
const label = String(
target.name ?? target.address ?? target.postbox_id ?? "Postbox"
);
const address = String(target.address ?? "");
const context = target.context_key
? ` [${String(target.context_key)}]`
: "";
return `${label}${address && address !== label ? ` <${address}>` : ""}${context}`;
})
.join(", ")
: null
},
{
label: "i18n:govoplan-campaign.mime_size.c8b9d519",
value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—"