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" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.14",
"jsonschema>=4,<5", "jsonschema>=4,<5",
"pydantic>=2,<3", "pydantic>=2,<3",
"SQLAlchemy>=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 import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship 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 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) 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) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
version_number: Mapped[int] = mapped_column(Integer, nullable=False) 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) raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False) schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False)
source_filename: Mapped[str | None] = mapped_column(String(500)) source_filename: Mapped[str | None] = mapped_column(String(500))
@@ -234,6 +240,18 @@ class CampaignVersion(Base, TimestampMixin):
campaign: Mapped[Campaign] = relationship(back_populates="versions") 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 @property
def mail_profile_migration_required(self) -> bool: def mail_profile_migration_required(self) -> bool:
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_boundary_violations 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)) 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): class ImapAppendAttempt(Base, TimestampMixin):
__tablename__ = "imap_append_attempts" __tablename__ = "imap_append_attempts"
__table_args__ = ( __table_args__ = (

View File

@@ -10,12 +10,15 @@ from typing import Any, Iterator
from govoplan_core.core.postbox import ( from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY, CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_DELIVERY, CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
PostboxDeliveryCatalogRef, PostboxDeliveryCatalogRef,
PostboxDeliveryProvider, PostboxDeliveryProvider,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryRequest, PostboxDeliveryRequest,
PostboxDeliveryResult, PostboxDeliveryResult,
PostboxDirectoryEntryRef, PostboxDirectoryEntryRef,
PostboxDirectoryProvider, PostboxDirectoryProvider,
PostboxEvidenceProvider,
PostboxTargetRef, PostboxTargetRef,
) )
from govoplan_campaign.backend.runtime import capability from govoplan_campaign.backend.runtime import capability
@@ -25,6 +28,7 @@ FILES_CAPABILITY = "files.campaign_attachments"
MAIL_CAPABILITY = "mail.campaign_delivery" MAIL_CAPABILITY = "mail.campaign_delivery"
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
class OptionalModuleUnavailable(RuntimeError): class OptionalModuleUnavailable(RuntimeError):
@@ -63,6 +67,10 @@ class MailProfileError(OptionalModuleUnavailable):
pass pass
class MailDeliveryCommandError(RuntimeError):
pass
class PostboxDeliveryUnavailable(OptionalModuleUnavailable): class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
pass pass
@@ -158,6 +166,12 @@ class MailCampaignIntegration:
def available(self) -> bool: def available(self) -> bool:
return self._delegate is not None 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: def _require(self) -> Any:
if self._delegate is None: if self._delegate is None:
raise MailProfileError("Mail module is not available") raise MailProfileError("Mail module is not available")
@@ -220,6 +234,42 @@ class MailCampaignIntegration:
except getattr(delegate, "MailProfileError", MailProfileError) as exc: except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from 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: def mock_mailbox(self) -> Any | None:
if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"): if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"):
return None return None
@@ -231,6 +281,7 @@ class PostboxCampaignIntegration:
self, self,
delivery_delegate: object | None = None, delivery_delegate: object | None = None,
directory_delegate: object | None = None, directory_delegate: object | None = None,
evidence_delegate: object | None = None,
) -> None: ) -> None:
self._delivery_delegate = ( self._delivery_delegate = (
delivery_delegate delivery_delegate
@@ -242,6 +293,11 @@ class PostboxCampaignIntegration:
if isinstance(directory_delegate, PostboxDirectoryProvider) if isinstance(directory_delegate, PostboxDirectoryProvider)
else None else None
) )
self._evidence_delegate = (
evidence_delegate
if isinstance(evidence_delegate, PostboxEvidenceProvider)
else None
)
@property @property
def available(self) -> bool: def available(self) -> bool:
@@ -250,6 +306,10 @@ class PostboxCampaignIntegration:
and self._directory_delegate is not None and self._directory_delegate is not None
) )
@property
def receipt_evidence_available(self) -> bool:
return self._evidence_delegate is not None
def delivery_catalog( def delivery_catalog(
self, self,
session: object, session: object,
@@ -298,6 +358,28 @@ class PostboxCampaignIntegration:
) )
return self._delivery_delegate.deliver(session, request) 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: def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY)) return FilesCampaignIntegration(capability(FILES_CAPABILITY))
@@ -311,4 +393,5 @@ def postbox_integration() -> PostboxCampaignIntegration:
return PostboxCampaignIntegration( return PostboxCampaignIntegration(
capability(POSTBOX_CAPABILITY), capability(POSTBOX_CAPABILITY),
capability(POSTBOX_DIRECTORY_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 ( from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DELIVERY, CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY, CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
) )
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
@@ -235,6 +236,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.3.0", version_max_exclusive="0.3.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name="mail.delivery_commands",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
name="addresses.lookup", name="addresses.lookup",
version_min="0.1.0", version_min="0.1.0",
@@ -259,6 +266,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0", version_max_exclusive="0.2.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_EVIDENCE,
version_min="0.1.2",
version_max_exclusive="0.2.0",
optional=True,
),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_campaigns_router, route_factory=_campaigns_router,
@@ -330,6 +343,8 @@ manifest = ModuleManifest(
campaign_models.AttachmentBlob, campaign_models.AttachmentBlob,
campaign_models.AttachmentInstance, campaign_models.AttachmentInstance,
campaign_models.SendAttempt, campaign_models.SendAttempt,
campaign_models.CampaignMessageAction,
campaign_models.CampaignMessageActionAttempt,
campaign_models.ImapAppendAttempt, campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt, campaign_models.PostboxDeliveryAttempt,
label="Campaigns", label="Campaigns",
@@ -347,6 +362,8 @@ manifest = ModuleManifest(
campaign_models.AttachmentBlob, campaign_models.AttachmentBlob,
campaign_models.AttachmentInstance, campaign_models.AttachmentInstance,
campaign_models.SendAttempt, campaign_models.SendAttempt,
campaign_models.CampaignMessageAction,
campaign_models.CampaignMessageActionAttempt,
campaign_models.ImapAppendAttempt, campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt, campaign_models.PostboxDeliveryAttempt,
label="Campaigns", 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 uuid import uuid4
from sqlalchemy.orm import Session 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 ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignIssue, CampaignIssue,
@@ -720,12 +725,31 @@ def update_campaign_version(
source_base_path: str | None = None, source_base_path: str | None = None,
autosave: bool = False, autosave: bool = False,
migrate_legacy_mail_settings: bool = False, migrate_legacy_mail_settings: bool = False,
expected_revision: int | None = None,
commit: bool = True, commit: bool = True,
) -> CampaignVersion: ) -> CampaignVersion:
_assert_update_paths_safe(raw_json, source_filename=source_filename, source_base_path=source_base_path) _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) 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) campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="edit") 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): if is_version_locked(version):
raise LockedCampaignVersionError( raise LockedCampaignVersionError(
@@ -761,7 +785,42 @@ def update_campaign_version(
# Changes invalidate previous build and validation summaries. # Changes invalidate previous build and validation summaries.
if raw_json is not None: if raw_json is not None:
_invalidate_version_content(session, campaign=campaign, version=version) _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 return version

View File

@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.settings import settings as core_settings from govoplan_core.settings import settings as core_settings
@@ -17,11 +18,14 @@ from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignIssue, CampaignIssue,
CampaignJob, CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignVersion, CampaignVersion,
ImapAppendAttempt, ImapAppendAttempt,
PostboxDeliveryAttempt, PostboxDeliveryAttempt,
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import ( from govoplan_campaign.backend.response_security import (
public_campaign_payload, public_campaign_payload,
@@ -586,6 +590,7 @@ def _job_evidence_row(
*, *,
latest_smtp: SendAttempt | None = None, latest_smtp: SendAttempt | None = None,
latest_imap: ImapAppendAttempt | None = None, latest_imap: ImapAppendAttempt | None = None,
latest_message_action: CampaignMessageAction | None = None,
include_diagnostics: bool = False, include_diagnostics: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
row = _job_row(job, include_diagnostics=include_diagnostics) row = _job_row(job, include_diagnostics=include_diagnostics)
@@ -598,6 +603,20 @@ def _job_evidence_row(
"postbox_targets": _job_postbox_target_summary(job), "postbox_targets": _job_postbox_target_summary(job),
"attachment_names": _attachment_names(job.resolved_attachments), "attachment_names": _attachment_names(job.resolved_attachments),
**_job_attempt_evidence(latest_smtp=latest_smtp, latest_imap=latest_imap), **_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 return row
@@ -762,6 +781,18 @@ def _campaign_report_payload(
campaign_id=campaign.id, campaign_id=campaign.id,
version=version, 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( "delivery": _load_delivery_info(
version, version,
pending_job_count=aggregate.pending, pending_job_count=aggregate.pending,
@@ -775,6 +806,85 @@ def _campaign_report_payload(
return report 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]: def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
return { return {
"id": campaign.id, "id": campaign.id,
@@ -815,6 +925,99 @@ def _campaign_report_attempt_counts(
"send_attempts": count(SendAttempt), "send_attempts": count(SendAttempt),
"imap_append_attempts": count(ImapAppendAttempt), "imap_append_attempts": count(ImapAppendAttempt),
"postbox_delivery_attempts": count(PostboxDeliveryAttempt), "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, ImapAppendAttempt,
job_ids, job_ids,
) )
latest_message_actions = _latest_message_actions_for_jobs(
session,
job_ids,
)
rows = [ rows = [
_job_evidence_row( _job_evidence_row(
job, job,
latest_smtp=latest_smtp.get(job.id), latest_smtp=latest_smtp.get(job.id),
latest_imap=latest_imap.get(job.id), latest_imap=latest_imap.get(job.id),
latest_message_action=latest_message_actions.get(job.id),
include_diagnostics=include_diagnostics, include_diagnostics=include_diagnostics,
) )
for job in jobs for job in jobs
@@ -1046,6 +1254,10 @@ def generate_jobs_csv(
"latest_imap_folder", "latest_imap_folder",
"latest_imap_created_at", "latest_imap_created_at",
"latest_imap_updated_at", "latest_imap_updated_at",
"latest_message_action_kind",
"latest_message_action_status",
"latest_message_action_id",
"latest_message_action_at",
] ]
if include_diagnostics: if include_diagnostics:
sent_at_index = fieldnames.index("outcome_unknown_at") sent_at_index = fieldnames.index("outcome_unknown_at")
@@ -1082,3 +1294,26 @@ def _latest_attempts_for_jobs(
): ):
latest[attempt.job_id] = attempt latest[attempt.job_id] = attempt
return latest 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.campaign.mail_profile_boundary import campaign_mail_profile_id
from govoplan_campaign.backend.persistence.campaigns import load_version_config 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.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 from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
@@ -32,6 +36,9 @@ class CampaignReportEmailResult:
attached_jobs_csv: bool attached_jobs_csv: bool
attached_report_json: bool attached_report_json: bool
accepted_count: int | None = None 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]: def as_dict(self) -> dict[str, Any]:
return { return {
@@ -44,6 +51,22 @@ class CampaignReportEmailResult:
"attached_jobs_csv": self.attached_jobs_csv, "attached_jobs_csv": self.attached_jobs_csv,
"attached_report_json": self.attached_report_json, "attached_report_json": self.attached_report_json,
"accepted_count": self.accepted_count, "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_jobs_csv: bool = False,
attach_report_json: bool = False, attach_report_json: bool = False,
dry_run: bool = False, dry_run: bool = False,
idempotency_key: str | None = None,
created_by_user_id: str | None = None,
) -> CampaignReportEmailResult: ) -> CampaignReportEmailResult:
campaign = session.get(Campaign, campaign_id) campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != tenant_id: if not campaign or campaign.tenant_id != tenant_id:
@@ -175,11 +200,16 @@ def send_campaign_report_email(
) from exc ) from exc
if not snapshot.smtp_transport_revision: if not snapshot.smtp_transport_revision:
raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision") raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision")
mail = mail_integration()
if not dry_run: if not dry_run:
if not mail.durable_delivery_available:
raise CampaignReportEmailError( 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( report = generate_campaign_report(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -202,6 +232,48 @@ def send_campaign_report_email(
jobs_csv=jobs_csv, jobs_csv=jobs_csv,
report_json=report_json, 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( return CampaignReportEmailResult(
campaign_id=campaign.id, campaign_id=campaign.id,
version_id=version.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.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, has_scope from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory 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 from govoplan_core.core.runtime import get_registry
@@ -359,6 +365,9 @@ def _campaign_version_detail_response(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc ) from exc
except RevisionConflictError:
session.rollback()
raise
except Exception as exc: except Exception as exc:
session.rollback() session.rollback()
if validation_error_status is None: if validation_error_status is None:
@@ -375,14 +384,45 @@ def _update_campaign_version_detail_response(
version_id: str, version_id: str,
payload: CampaignVersionUpdateRequest, payload: CampaignVersionUpdateRequest,
*, *,
if_match: str | None,
autosave: bool, autosave: bool,
audit_action: str, audit_action: str,
) -> CampaignVersionDetailResponse: ) -> CampaignVersionDetailResponse:
_get_campaign_for_principal(session, campaign_id, principal, write=True) _get_campaign_for_principal(session, campaign_id, principal, write=True)
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) 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): if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
_require_permission(principal, "campaigns:recipient:write") _require_permission(principal, "campaigns:recipient:write")
_require_mail_profile_use_if_needed(principal, payload.campaign_json) _require_mail_profile_use_if_needed(principal, payload.campaign_json)
try:
return _campaign_version_detail_response( return _campaign_version_detail_response(
session, session,
principal, principal,
@@ -402,6 +442,7 @@ def _update_campaign_version_detail_response(
source_base_path=payload.source_base_path, source_base_path=payload.source_base_path,
autosave=autosave, autosave=autosave,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings, migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
expected_revision=payload.base_revision,
commit=False, commit=False,
), ),
audit_action=audit_action, audit_action=audit_action,
@@ -409,10 +450,43 @@ def _update_campaign_version_detail_response(
"campaign_id": campaign_id, "campaign_id": campaign_id,
"current_flow": version.current_flow, "current_flow": version.current_flow,
"current_step": version.current_step, "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, "legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
}, },
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT, 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( def _require_campaign_profile_use_if_needed(

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -74,6 +75,7 @@ from govoplan_campaign.backend.route_support import (
) )
router = APIRouter(prefix="/campaigns", tags=["campaigns"]) router = APIRouter(prefix="/campaigns", tags=["campaigns"])
logger = logging.getLogger(__name__)
@router.get( @router.get(
@@ -236,13 +238,25 @@ def send_unattempted_campaign_jobs(
def send_single_campaign_job_endpoint( def send_single_campaign_job_endpoint(
campaign_id: str, campaign_id: str,
job_id: str, job_id: str,
payload: CampaignSendJobRequest | None = None, payload: CampaignSendJobRequest,
session: Session = Depends(get_session), 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) _get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read") _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) _require_campaign_profile_use_if_needed(session, principal, campaign_id, None)
try: try:
result = send_single_campaign_job( result = send_single_campaign_job(
@@ -250,17 +264,20 @@ def send_single_campaign_job_endpoint(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
job_id=job_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, include_warnings=payload.include_warnings,
dry_run=payload.dry_run,
use_rate_limit=payload.use_rate_limit, use_rate_limit=payload.use_rate_limit,
enqueue_imap_task=payload.enqueue_imap_task, enqueue_imap_task=payload.enqueue_imap_task,
) )
audit_from_principal( audit_from_principal(
session, session,
principal, principal,
action="campaign.single_message_sent" action=f"campaign.message_{payload.kind}",
if not payload.dry_run
else "campaign.single_message_send_dry_run",
object_type="campaign_job", object_type="campaign_job",
object_id=job_id, object_id=job_id,
details=result, details=result,
@@ -272,8 +289,17 @@ def send_single_campaign_job_endpoint(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc ) from exc
except Exception as 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( 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 ) from exc

View File

@@ -23,10 +23,13 @@ from govoplan_campaign.backend.change_tracking import (
) )
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
CampaignJob, CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
ImapAppendAttempt, ImapAppendAttempt,
PostboxDeliveryAttempt, PostboxDeliveryAttempt,
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_core.db.session import get_session 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"]) 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) @router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
def list_jobs( def list_jobs(
campaign_id: str, campaign_id: str,
@@ -373,12 +397,36 @@ def get_job_detail(
), ),
label="Postbox attempts for this campaign job", 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( return CampaignJobDetailResponse(
job=_job_detail_payload(job), job=_job_detail_payload(job),
attempts=_job_attempts_payload( attempts=_job_attempts_payload(
send_attempts, send_attempts,
imap_attempts, imap_attempts,
postbox_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", 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( return _job_diagnostics_payload(
job, job,
send_attempts, send_attempts,
imap_attempts, imap_attempts,
postbox_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, send_campaign_report_email,
) )
from govoplan_campaign.backend.integrations import ( from govoplan_campaign.backend.integrations import (
MailDeliveryCommandError,
MailProfileError, MailProfileError,
SmtpConfigurationError, SmtpConfigurationError,
SmtpSendError, SmtpSendError,
mail_integration,
) )
@@ -41,6 +43,24 @@ router = APIRouter(prefix="/campaigns", tags=["campaigns"])
logger = logging.getLogger(__name__) 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") @router.get("/{campaign_id}/summary")
def campaign_summary( def campaign_summary(
campaign_id: str, campaign_id: str,
@@ -164,18 +184,22 @@ def email_campaign_report(
attach_jobs_csv=payload.attach_jobs_csv, attach_jobs_csv=payload.attach_jobs_csv,
attach_report_json=payload.attach_report_json, attach_report_json=payload.attach_report_json,
dry_run=payload.dry_run, dry_run=payload.dry_run,
idempotency_key=payload.idempotency_key,
created_by_user_id=principal.user.id,
) )
audit_from_principal( audit_from_principal(
session, session,
principal, principal,
action="report.email_sent" action="report.email_requested"
if not payload.dry_run if not payload.dry_run
else "report.email_dry_run", else "report.email_dry_run",
object_type="campaign", object_type="campaign",
object_id=campaign_id, object_id=campaign_id,
details=result.as_dict(), details=result.audit_dict(),
commit=True, commit=True,
) )
if not payload.dry_run:
_enqueue_mail_command()
return ReportEmailResponse(result=result.as_dict()) return ReportEmailResponse(result=result.as_dict())
except CampaignReportError as exc: except CampaignReportError as exc:
raise HTTPException( raise HTTPException(
@@ -184,6 +208,7 @@ def email_campaign_report(
except ( except (
CampaignReportEmailError, CampaignReportEmailError,
MailProfileError, MailProfileError,
MailDeliveryCommandError,
SmtpConfigurationError, SmtpConfigurationError,
SmtpSendError, SmtpSendError,
) as exc: ) as exc:
@@ -196,3 +221,37 @@ def email_campaign_report(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Campaign report email could not be completed.", detail="Campaign report email could not be completed.",
) from exc ) 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 __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 sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import ( from govoplan_campaign.backend.schemas import (
@@ -101,6 +101,7 @@ def list_versions(
def get_version_detail( def get_version_detail(
campaign_id: str, campaign_id: str,
version_id: str, version_id: str,
response: Response,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
): ):
@@ -113,10 +114,12 @@ def get_version_detail(
campaign_id=campaign_id, campaign_id=campaign_id,
version_id=version_id, version_id=version_id,
) )
return CampaignVersionDetailResponse.model_validate( result = CampaignVersionDetailResponse.model_validate(
version, version,
context=_campaign_response_context(principal), context=_campaign_response_context(principal),
) )
response.headers["ETag"] = version.strong_etag
return result
except CampaignPersistenceError as exc: except CampaignPersistenceError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
@@ -335,18 +338,23 @@ def update_version_detail(
campaign_id: str, campaign_id: str,
version_id: str, version_id: str,
payload: CampaignVersionUpdateRequest, payload: CampaignVersionUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
): ):
return _update_campaign_version_detail_response( result = _update_campaign_version_detail_response(
session, session,
principal, principal,
campaign_id, campaign_id,
version_id, version_id,
payload, payload,
if_match=if_match,
autosave=False, autosave=False,
audit_action="campaign.version_updated", audit_action="campaign.version_updated",
) )
response.headers["ETag"] = result.strong_etag
return result
@router.post( @router.post(
@@ -357,18 +365,23 @@ def autosave_version(
campaign_id: str, campaign_id: str,
version_id: str, version_id: str,
payload: CampaignVersionUpdateRequest, payload: CampaignVersionUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
): ):
return _update_campaign_version_detail_response( result = _update_campaign_version_detail_response(
session, session,
principal, principal,
campaign_id, campaign_id,
version_id, version_id,
payload, payload,
if_match=if_match,
autosave=True, autosave=True,
audit_action="campaign.version_autosaved", audit_action="campaign.version_autosaved",
) )
response.headers["ETag"] = result.strong_etag
return result
@router.post( @router.post(

View File

@@ -58,6 +58,9 @@ class CampaignVersionUpdateRequest(BaseModel):
source_filename: str | None = None source_filename: str | None = None
source_base_path: str | None = None source_base_path: str | None = None
migrate_legacy_mail_settings: bool = False 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") @field_validator("editor_state")
@classmethod @classmethod
@@ -92,6 +95,8 @@ class CampaignVersionResponse(BaseModel):
id: str id: str
campaign_id: str campaign_id: str
version_number: int version_number: int
edit_revision: int = 1
strong_etag: str = ""
schema_version: str schema_version: str
source_filename: str | None = None source_filename: str | None = None
workflow_state: str = "editing" workflow_state: str = "editing"
@@ -440,11 +445,21 @@ class CampaignSendUnattemptedRequest(BaseModel):
class CampaignSendJobRequest(BaseModel): class CampaignSendJobRequest(BaseModel):
model_config = ConfigDict(extra="forbid") 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 include_warnings: bool = True
dry_run: bool = False
use_rate_limit: bool = True use_rate_limit: bool = True
enqueue_imap_task: bool = False 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): class CampaignResolveOutcomeRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -626,6 +641,7 @@ class ReportEmailRequest(BaseModel):
attach_jobs_csv: bool = False attach_jobs_csv: bool = False
attach_report_json: bool = False attach_report_json: bool = False
dry_run: bool = False dry_run: bool = False
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
@field_validator("to") @field_validator("to")
@classmethod @classmethod

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
from collections import Counter
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from email import policy from email import policy
@@ -11,13 +12,18 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider 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_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignJob, CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignStatus, CampaignStatus,
CampaignVersion, CampaignVersion,
CampaignVersionWorkflowState, CampaignVersionWorkflowState,
@@ -1305,49 +1311,139 @@ def send_single_campaign_job(
tenant_id: str, tenant_id: str,
campaign_id: str, campaign_id: str,
job_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, include_warnings: bool = True,
dry_run: bool = False,
use_rate_limit: bool = True, use_rate_limit: bool = True,
enqueue_imap_task: bool = False, enqueue_imap_task: bool = False,
) -> dict[str, Any]: ) -> 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) campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
job = session.get(CampaignJob, job_id) job = session.get(CampaignJob, job_id)
if not job or job.tenant_id != tenant_id or job.campaign_id != campaign.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") raise QueueingError("Campaign job not found or not accessible")
version = _get_current_version(session, campaign, version_id=job.campaign_version_id) normalized_kind = str(kind or "").strip()
_ensure_version_validated_and_locked(version) if normalized_kind not in {"test", "single_send", "single_resend"}:
ensure_execution_snapshot(session, version) 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( queue_action = _prepare_single_job_for_send(
session, session,
version=version, version=version,
job=job, job=job,
include_warnings=include_warnings, include_warnings=include_warnings,
dry_run=dry_run, dry_run=False,
) )
if dry_run: except Exception as exc:
context = _send_job_delivery_context(session, job) _finish_single_message_action(
return { session,
"campaign_id": campaign.id, action=action,
"version_id": version.id, status="initiation_failed",
"job_id": job.id, error_type=exc.__class__.__name__,
"action": "single_send", error_message=str(exc),
"queue_action": queue_action, final_send_status=job.send_status,
"dry_run": True, )
"result": SendJobResult( raise
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=(
f"Would deliver via {job.delivery_channel_policy}: "
f"{len(context.envelope_recipients)} Mail recipient(s), "
f"{len(job.resolved_postbox_targets or [])} Postbox target(s)"
),
).as_dict(),
}
if queue_action == "queued": if queue_action == "queued":
previous_status = campaign.status previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value campaign.status = CampaignStatus.QUEUED.value
@@ -1365,6 +1461,8 @@ def send_single_campaign_job(
version_id=version.id, version_id=version.id,
) )
action_attempt = _start_single_message_action_attempt(session, action)
try:
result = send_campaign_job( result = send_campaign_job(
session, session,
job_id=job.id, job_id=job.id,
@@ -1372,17 +1470,556 @@ def send_single_campaign_job(
use_rate_limit=use_rate_limit, use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task, 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, "campaign_id": campaign.id,
"version_id": version.id, "campaign_version_id": version.id,
"job_id": job.id, "job_id": job.id,
"action": "single_send", "kind": kind,
"queue_action": queue_action, "message_sha256": action.message_sha256,
"dry_run": False, "recipient_manifest_sha256": recipient_manifest_sha256,
"result": result.as_dict(), "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( def _prepare_single_job_for_send(
session: Session, session: Session,
*, *,

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import Sequence from collections.abc import Mapping, Sequence
from typing import Literal from typing import Literal
from fastapi import HTTPException, Query, status from fastapi import HTTPException, Query, status
@@ -13,6 +13,7 @@ from govoplan_campaign.backend.schemas import (
CampaignJobDiagnosticsResponse, CampaignJobDiagnosticsResponse,
) )
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_core.core.change_sequence import ( from govoplan_core.core.change_sequence import (
encode_sequence_watermark, encode_sequence_watermark,
max_sequence_id, max_sequence_id,
@@ -30,6 +31,8 @@ from govoplan_campaign.backend.change_tracking import (
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignJob, CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignVersion, CampaignVersion,
ImapAppendAttempt, ImapAppendAttempt,
JobImapStatus, JobImapStatus,
@@ -128,7 +131,12 @@ def _job_attempts_payload(
send_attempts: list[SendAttempt], send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt], imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (), postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
message_actions: Sequence[CampaignMessageAction] = (),
message_action_attempts: Sequence[CampaignMessageActionAttempt] = (),
*, *,
postbox_receipts: (
Mapping[str, PostboxDeliveryReceiptSummaryRef] | None
) = None,
include_diagnostics: bool = False, include_diagnostics: bool = False,
) -> dict[str, list[dict[str, object]]]: ) -> dict[str, list[dict[str, object]]]:
smtp_payloads: list[dict[str, object]] = [] smtp_payloads: list[dict[str, object]] = []
@@ -186,11 +194,109 @@ def _job_attempts_payload(
payload["evidence"] = attempt.evidence or {} payload["evidence"] = attempt.evidence or {}
payload["error_type"] = attempt.error_type payload["error_type"] = attempt.error_type
payload["error_message"] = attempt.error_message 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) 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 { return {
"smtp": smtp_payloads, "smtp": smtp_payloads,
"imap": imap_payloads, "imap": imap_payloads,
"postbox": postbox_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], send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt], imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (), postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
message_actions: Sequence[CampaignMessageAction] = (),
message_action_attempts: Sequence[CampaignMessageActionAttempt] = (),
*,
postbox_receipts: (
Mapping[str, PostboxDeliveryReceiptSummaryRef] | None
) = None,
) -> CampaignJobDiagnosticsResponse: ) -> CampaignJobDiagnosticsResponse:
return CampaignJobDiagnosticsResponse( return CampaignJobDiagnosticsResponse(
job_id=job.id, job_id=job.id,
@@ -221,6 +333,9 @@ def _job_diagnostics_payload(
send_attempts, send_attempts,
imap_attempts, imap_attempts,
postbox_attempts, postbox_attempts,
message_actions,
message_action_attempts,
postbox_receipts=postbox_receipts,
include_diagnostics=True, 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 campaigns as campaign_routes
from govoplan_campaign.backend.routes import versions as version_routes from govoplan_campaign.backend.routes import versions as version_routes
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
from govoplan_core.core.concurrency import strong_resource_etag
def _principal() -> SimpleNamespace: def _principal() -> SimpleNamespace:
@@ -28,6 +29,7 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
raw_json={}, raw_json={},
current_flow="manual", current_flow="manual",
current_step="recipients", current_step="recipients",
edit_revision=1,
) )
def mutate(*_args, **kwargs): 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] principal, # type: ignore[arg-type]
"campaign-1", "campaign-1",
"version-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, autosave=True,
audit_action="campaign.version_autosaved", 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 import unittest
from unittest.mock import patch from unittest.mock import patch
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_campaign.backend.reports.campaigns import ( from govoplan_campaign.backend.reports.campaigns import (
_campaign_postbox_receipt_summary,
_job_evidence_row, _job_evidence_row,
_latest_by_job_id, _latest_by_job_id,
_load_delivery_info, _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 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): class CampaignReportProjectionTests(unittest.TestCase):
def test_evidence_projection(self) -> None: def test_evidence_projection(self) -> None:
test_job_evidence_row_contains_transport_and_message_evidence() test_job_evidence_row_contains_transport_and_message_evidence()

View File

@@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import UTC, datetime
from govoplan_core.core.postbox import ( from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef, PostboxDeliveryCatalogRef,
PostboxDirectoryEntryRef, PostboxDirectoryEntryRef,
PostboxDeliveryRequest, PostboxDeliveryRequest,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryResult, PostboxDeliveryResult,
PostboxTargetRef, PostboxTargetRef,
) )
@@ -55,11 +57,39 @@ class _PostboxDelivery:
classification="internal", 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): class PostboxCampaignIntegrationTests(unittest.TestCase):
def test_optional_delivery_boundary_is_typed_and_explicit(self) -> None: def test_optional_delivery_boundary_is_typed_and_explicit(self) -> None:
delegate = _PostboxDelivery() delegate = _PostboxDelivery()
integration = PostboxCampaignIntegration(delegate, delegate) integration = PostboxCampaignIntegration(delegate, delegate, delegate)
request = PostboxDeliveryRequest( request = PostboxDeliveryRequest(
tenant_id="tenant-1", tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id="postbox-1"), target=PostboxTargetRef(postbox_id="postbox-1"),
@@ -73,8 +103,15 @@ class PostboxCampaignIntegrationTests(unittest.TestCase):
result = integration.deliver(object(), request) result = integration.deliver(object(), request)
self.assertTrue(integration.available) self.assertTrue(integration.available)
self.assertTrue(integration.receipt_evidence_available)
self.assertEqual("delivery-1", result.delivery_id) self.assertEqual("delivery-1", result.delivery_id)
self.assertEqual(request, delegate.requests[0][1]) 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( def test_missing_postbox_is_reported_without_importing_module_code(
self, self,
@@ -91,6 +128,15 @@ class PostboxCampaignIntegrationTests(unittest.TestCase):
) )
self.assertFalse(integration.available) 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): with self.assertRaises(PostboxDeliveryUnavailable):
integration.deliver(object(), request) integration.deliver(object(), request)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from email.message import EmailMessage
import pytest import pytest
from fastapi import HTTPException 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() 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 datetime import UTC, datetime
from types import SimpleNamespace 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.response_security import public_campaign_payload
from govoplan_campaign.backend.services.job_queries import ( from govoplan_campaign.backend.services.job_queries import (
_job_attempts_payload, _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: def test_public_payload_recursively_removes_infrastructure_locators() -> None:
source = { source = {
"campaign_file": "/tmp/campaign.json", "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"] 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: def test_diagnostics_permission_is_operator_only_by_default() -> None:
from govoplan_campaign.backend.manifest import PERMISSIONS, ROLE_TEMPLATES 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) actual = _operation_keys(router)
assert actual == expected assert actual == expected
assert len(actual) == 62 assert len(actual) == 64
assert not [operation for operation, count in Counter(actual).items() if count > 1] 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" "read-excel-file": "9.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.12", "@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

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

View File

@@ -102,6 +102,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
const version = data.currentVersion; const version = data.currentVersion;
const cards = data.summary?.cards; const cards = data.summary?.cards;
const delivery = asRecord(data.summary?.delivery); const delivery = asRecord(data.summary?.delivery);
const postboxReceipts = asRecord(data.summary?.postbox_receipts);
const rateLimit = asRecord(delivery.rate_limit); const rateLimit = asRecord(delivery.rate_limit);
const imapPolicy = asRecord(delivery.imap_append_sent); const imapPolicy = asRecord(delivery.imap_append_sent);
@@ -262,8 +263,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
attempted += 1; attempted += 1;
try { try {
const sendResponse = await sendCampaignJob(settings, campaignId, jobId, { 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, include_warnings: true,
dry_run: false,
use_rate_limit: true, use_rate_limit: true,
enqueue_imap_task: false enqueue_imap_task: false
}); });
@@ -338,9 +341,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
version_id: version?.id, version_id: version?.id,
include_jobs: false, include_jobs: false,
attach_jobs_csv: attachCsv, 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); setEmailOpen(false);
} catch (err) { } catch (err) {
setActionError(err instanceof Error ? err.message : String(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> <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> </dl>
</Card> </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"> <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> <p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
<div className="button-row compact-actions"> <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>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> <div><dt>Message SHA-256</dt><dd><code>{String(detail.job.eml_sha256 ?? "—")}</code></dd></div>
</dl> </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 : []} /> <AttachmentEvidenceSection attachments={Array.isArray(detail.job.attachments) ? detail.job.attachments : []} />
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} /> <AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} /> <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 = { type AttachmentEvidenceRow = {
id: string; id: string;
rule: string; rule: string;
@@ -718,6 +840,50 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";
kind === "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: "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 ?? "—") }, { 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: "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: "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> } { 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 { 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 type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar"; import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage";
import CampaignFieldsPage from "./CampaignFieldsPage"; const CampaignOverviewPage = lazy(() => import("./CampaignOverviewPage"));
import GlobalSettingsPage from "./GlobalSettingsPage"; const CampaignFieldsPage = lazy(() => import("./CampaignFieldsPage"));
import RecipientDataPage from "./RecipientDataPage"; const GlobalSettingsPage = lazy(() => import("./GlobalSettingsPage"));
import TemplateDataPage from "./TemplateDataPage"; const RecipientDataPage = lazy(() => import("./RecipientDataPage"));
import AttachmentsDataPage from "./AttachmentsDataPage"; const TemplateDataPage = lazy(() => import("./TemplateDataPage"));
import MailSettingsPage from "./MailSettingsPage"; const AttachmentsDataPage = lazy(() => import("./AttachmentsDataPage"));
import ReviewSendPage from "./ReviewSendPage"; const MailSettingsPage = lazy(() => import("./MailSettingsPage"));
import CreateWizard from "./wizard/CreateWizard"; const ReviewSendPage = lazy(() => import("./ReviewSendPage"));
import ReviewWizard from "./wizard/ReviewWizard"; const CreateWizard = lazy(() => import("./wizard/CreateWizard"));
import SendWizard from "./wizard/SendWizard"; const ReviewWizard = lazy(() => import("./wizard/ReviewWizard"));
import CampaignJsonView from "./CampaignJsonView"; const SendWizard = lazy(() => import("./wizard/SendWizard"));
import CampaignReportPage from "./CampaignReportPage"; const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
import CampaignAuditPage from "./CampaignAuditPage"; const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
const sectionPaths: Record<CampaignWorkspaceSection, string> = { const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "", overview: "",
@@ -37,7 +41,11 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
}; };
export default function CampaignWorkspace({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { 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 }) { function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {

View File

@@ -36,7 +36,7 @@ import {
type CampaignSummary } from type CampaignSummary } from
"../../api/campaigns"; "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; 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 { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
import { ConfirmDialog } 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 [newlyReviewedRequiredKeys, setNewlyReviewedRequiredKeys] = useState<Set<string>>(() => new Set());
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null); const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
const [singleSendConfirmIndex, setSingleSendConfirmIndex] = 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 [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
const [mockClearFirst, setMockClearFirst] = useState(true); const [mockClearFirst, setMockClearFirst] = useState(true);
const [mockAppendSent, setMockAppendSent] = useState(true); const [mockAppendSent, setMockAppendSent] = useState(true);
@@ -1103,8 +1105,11 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
setError(""); setError("");
try { try {
const response = await sendCampaignJob(settings, campaignId, jobId, { 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, include_warnings: true,
dry_run: false,
use_rate_limit: true, use_rate_limit: true,
enqueue_imap_task: backgroundWorkersEnabled enqueue_imap_task: backgroundWorkersEnabled
}); });
@@ -1112,18 +1117,20 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const sendResult = asRecord(actionResult.result); const sendResult = asRecord(actionResult.result);
const status = String(sendResult.status ?? "submitted"); const status = String(sendResult.status ?? "submitted");
const attemptCount = Number(sendResult.attempt_number ?? row.attempt_count ?? 0); 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, ...item,
send_status: status === "already_accepted" ? "smtp_accepted" : status, send_status: finalSendStatus || (status === "already_accepted" ? "smtp_accepted" : status),
queue_status: ["smtp_accepted", "already_accepted"].includes(status) ? "draft" : item.queue_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, imap_status: sendResult.imap_status ?? item.imap_status,
attempt_count: Number.isFinite(attemptCount) ? attemptCount : item.attempt_count, attempt_count: Number.isFinite(attemptCount) ? attemptCount : item.attempt_count,
last_error: sendResult.message ?? "" last_error: sendResult.message ?? ""
} : } :
item)); item));
setMessage(`Single message send finished: ${humanize(status)}.`); setMessage(`${humanize(singleMessageActionKind)} finished: ${humanize(String(actionResult.status ?? status))}.`);
setSingleSendConfirmIndex(null); setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
setSelectedBuiltIndex(null); setSelectedBuiltIndex(null);
await refreshQueueStatus(true); await refreshQueueStatus(true);
await reload(); await reload();
@@ -1146,6 +1153,7 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
setNewlyReviewedRequiredKeys(new Set()); setNewlyReviewedRequiredKeys(new Set());
setSelectedBuiltIndex(null); setSelectedBuiltIndex(null);
setSingleSendConfirmIndex(null); setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
setMockResult(null); setMockResult(null);
setSelectedMockMessage(null); setSelectedMockMessage(null);
resetDeltaWatermark(); resetDeltaWatermark();
@@ -1660,7 +1668,18 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
canStartSingleMessageSend={canStartSingleMessageSend} canStartSingleMessageSend={canStartSingleMessageSend}
singleMessageSendBusy={busy === "send"} singleMessageSendBusy={busy === "send"}
onSelect={openBuiltMessageAtIndex} 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)} /> onClose={() => setSelectedBuiltIndex(null)} />
} }
@@ -1724,16 +1743,103 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
onCancel={() => setCancelDeliveryConfirmOpen(false)} onCancel={() => setCancelDeliveryConfirmOpen(false)}
onConfirm={() => void runDeliveryControl("cancel")} /> onConfirm={() => void runDeliveryControl("cancel")} />
<ConfirmDialog <Dialog
open={singleSendConfirmRow !== null} open={singleSendConfirmRow !== null}
title="Send this one message now?" title="One-message delivery"
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.` : ""} portal
confirmLabel="Send one message" closeDisabled={busy === "send"}
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213" onClose={() => {
tone="danger" setSingleSendConfirmIndex(null);
busy={busy === "send"} setSingleMessageResendReason("");
onCancel={() => setSingleSendConfirmIndex(null)} }}
onConfirm={() => void sendSingleBuiltMessage()} /> 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 && {selectedDeliveryJobDetail &&
<DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} /> <DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} />
@@ -1776,6 +1882,23 @@ reviewedKeys: Set<string>)
list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" }, list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
value: (row) => String(row.validation_status ?? "unknown") 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: "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: "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) }]} /> }]; { 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
revisionConflictFromError,
threeWayMerge,
useConcurrencyConflictResolver
} from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types"; 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 { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor"; import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext"; 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"; 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({ export function useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
@@ -60,6 +90,10 @@ export function useCampaignDraftEditor({
const transformLoadedDraftRef = useRef(transformLoadedDraft); const transformLoadedDraftRef = useRef(transformLoadedDraft);
const transformDraftBeforeSaveRef = useRef(transformDraftBeforeSave); const transformDraftBeforeSaveRef = useRef(transformDraftBeforeSave);
const onLoadedRef = useRef(onLoaded); 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(() => { useEffect(() => {
loadedLabelRef.current = loadedLabel; loadedLabelRef.current = loadedLabel;
@@ -77,6 +111,11 @@ export function useCampaignDraftEditor({
if (!version) return; if (!version) return;
const initialDraft = ensureCampaignDraft(version); const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft; 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); setDraft(loadedDraft);
setDirty(false); setDirty(false);
setLocalError(""); setLocalError("");
@@ -102,15 +141,100 @@ export function useCampaignDraftEditor({
setLocalError(""); setLocalError("");
try { try {
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft; const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, { const additionalPayload = extraPayload?.() ?? {};
campaign_json: draftToSave, let mutation = campaignVersionMutation(
version,
draftToSave,
{
current_flow: currentFlow, current_flow: currentFlow,
current_step: resolveStep(currentStep), current_step: resolveStep(currentStep),
workflow_state: workflowState, workflow_state: workflowState,
is_complete: isComplete, 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)); setDraft(getCampaignJson(saved));
baseDraftRef.current = getCampaignJson(saved);
baseRevisionRef.current = saved.edit_revision;
baseEtagRef.current = saved.strong_etag;
setDirty(false); setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`); setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
onSaved?.(saved); onSaved?.(saved);
@@ -122,7 +246,7 @@ export function useCampaignDraftEditor({
setSaveState("i18n:govoplan-campaign.save_failed.0a444467"); setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
return false; 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 () => { const discardDraft = useCallback(async () => {
if (version) { if (version) {
@@ -162,3 +286,23 @@ export function useCampaignDraftEditor({
saveDraft 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 <Button
variant="primary" variant="primary"
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)} disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
title={singleSendDisabledReason || "Send only this built message"} title={singleSendDisabledReason || "Test, send, or resend only this built message"}
onClick={() => onSendSingle(index)} onClick={() => onSendSingle(index)}
> >
{singleMessageSendBusy ? "Sending..." : "Send this message..."} {singleMessageSendBusy ? "Sending..." : "Message actions..."}
</Button> </Button>
} }
onClose={onClose} onClose={onClose}
@@ -139,28 +139,15 @@ function singleMessageSendDisabledReason(
return `This message is ${humanize(validationStatus)}.`; return `This message is ${humanize(validationStatus)}.`;
} }
const sendStatus = String(row.send_status ?? "not_queued"); 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)) { if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) {
return `This message is in delivery state ${humanize(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 "";
}
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
} }
function builtMessageMetaItems(row: Record<string, unknown>) { function builtMessageMetaItems(row: Record<string, unknown>) {
const recipients = asRecord(row.resolved_recipients); const recipients = asRecord(row.resolved_recipients);
const postboxTargets = asArray(row.resolved_postbox_targets).map(asRecord);
return [ return [
{ {
label: "i18n:govoplan-campaign.from.3f66052a", label: "i18n:govoplan-campaign.from.3f66052a",
@@ -182,6 +169,23 @@ function builtMessageMetaItems(row: Record<string, unknown>) {
label: "i18n:govoplan-campaign.validation.dd74d182", label: "i18n:govoplan-campaign.validation.dd74d182",
value: String(row.validation_status ?? "—") 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", label: "i18n:govoplan-campaign.mime_size.c8b9d519",
value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—"