feat: add governed postbox delivery and report hardening

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent f11c56e890
commit 5240749ae1
47 changed files with 5538 additions and 288 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.11",
"version": "0.1.12",
"private": true,
"type": "module",
"main": "webui/src/index.ts",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.11"
version = "0.1.12"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -44,6 +44,7 @@ def _parse_scalar_for_target(target: str, value: Any) -> Any:
"merge_reply_to",
"merge_bounce_to",
"merge_disposition_notification_to",
"merge_postbox_targets",
"combine_to",
"combine_cc",
"combine_bcc",

View File

@@ -23,6 +23,8 @@ class FieldType(StrEnum):
DOUBLE = "double"
DATE = "date"
PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary.
ORGANIZATION_UNIT = "organization_unit"
ORGANIZATION_FUNCTION = "organization_function"
class RecipientType(StrEnum):
@@ -92,6 +94,104 @@ class SendStatus(StrEnum):
SKIPPED = "skipped"
class DeliveryChannelPolicy(StrEnum):
MAIL = "mail"
POSTBOX = "postbox"
MAIL_AND_POSTBOX = "mail_and_postbox"
MAIL_THEN_POSTBOX = "mail_then_postbox"
POSTBOX_THEN_MAIL = "postbox_then_mail"
@property
def uses_mail(self) -> bool:
return self in {
DeliveryChannelPolicy.MAIL,
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
}
@property
def uses_postbox(self) -> bool:
return self != DeliveryChannelPolicy.MAIL
class PostboxTargetMode(StrEnum):
DIRECT = "direct"
DERIVED = "derived"
class PostboxTargetMatch(StrEnum):
ID = "id"
SLUG = "slug"
class PostboxTargetConfig(StrictModel):
id: str = Field(min_length=1, max_length=120)
mode: PostboxTargetMode = PostboxTargetMode.DIRECT
label: str | None = Field(default=None, max_length=500)
postbox_id: str | None = Field(default=None, max_length=36)
address_key: str | None = Field(default=None, max_length=500)
template_id: str | None = Field(default=None, max_length=36)
organization_unit_id: str | None = Field(default=None, max_length=36)
organization_unit_field: str | None = Field(default=None, max_length=255)
organization_unit_match: PostboxTargetMatch = PostboxTargetMatch.ID
function_id: str | None = Field(default=None, max_length=36)
function_field: str | None = Field(default=None, max_length=255)
function_match: PostboxTargetMatch = PostboxTargetMatch.ID
context_key: str | None = Field(default=None, max_length=255)
context_field: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_target_shape(self) -> "PostboxTargetConfig":
direct_values = [self.postbox_id, self.address_key]
if self.mode == PostboxTargetMode.DIRECT:
if sum(bool(value) for value in direct_values) != 1:
raise ValueError(
"A direct Postbox target requires exactly one postbox_id "
"or address_key."
)
if any(
(
self.template_id,
self.organization_unit_id,
self.organization_unit_field,
self.function_id,
self.function_field,
self.context_key,
self.context_field,
)
):
raise ValueError(
"A direct Postbox target cannot contain derived target fields."
)
return self
if any(direct_values):
raise ValueError(
"A derived Postbox target cannot contain postbox_id or address_key."
)
if not self.template_id:
raise ValueError("A derived Postbox target requires template_id.")
if bool(self.organization_unit_id) == bool(self.organization_unit_field):
raise ValueError(
"A derived Postbox target requires exactly one fixed or "
"field-derived organization unit."
)
if bool(self.function_id) == bool(self.function_field):
raise ValueError(
"A derived Postbox target requires exactly one fixed or "
"field-derived function."
)
if self.context_key and self.context_field:
raise ValueError(
"A derived Postbox target may use a fixed context or a context "
"field, not both."
)
return self
class CampaignMeta(StrictModel):
id: str
name: str
@@ -450,6 +550,13 @@ class EntryConfig(StrictModel):
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
merge_disposition_notification_to: bool = True
channel_policy: DeliveryChannelPolicy | None = None
postbox_targets: list[PostboxTargetConfig] = Field(
default_factory=list,
max_length=50,
)
merge_postbox_targets: bool = True
attachments: list[AttachmentConfig] = Field(default_factory=list)
combine_attachments: bool = True
@@ -563,7 +670,17 @@ class RetryConfig(StrictModel):
return values
class PostboxDeliveryConfig(StrictModel):
targets: list[PostboxTargetConfig] = Field(default_factory=list, max_length=50)
classification: str = Field(default="internal", min_length=1, max_length=50)
unresolved_target: Behavior = Behavior.BLOCK
vacant_target: Behavior = Behavior.WARN
duplicate_target: Behavior = Behavior.WARN
class DeliveryConfig(StrictModel):
channel_policy: DeliveryChannelPolicy = DeliveryChannelPolicy.MAIL
postbox: PostboxDeliveryConfig = Field(default_factory=PostboxDeliveryConfig)
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
retry: RetryConfig = Field(default_factory=RetryConfig)
@@ -606,3 +723,23 @@ class CampaignConfig(StrictModel):
if path.is_absolute():
return path
return (campaign_file.parent / path).resolve()
def effective_delivery_channel_policy(
config: CampaignConfig,
entry: EntryConfig,
) -> DeliveryChannelPolicy:
return entry.channel_policy or config.delivery.channel_policy
def effective_postbox_targets(
config: CampaignConfig,
entry: EntryConfig,
) -> list[PostboxTargetConfig]:
global_targets = list(config.delivery.postbox.targets)
individual_targets = list(entry.postbox_targets)
if not individual_targets:
return global_targets
if entry.merge_postbox_targets:
return [*global_targets, *individual_targets]
return individual_targets

View File

@@ -0,0 +1,321 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDirectoryEntryRef,
PostboxTargetRef,
)
from govoplan_campaign.backend.campaign.field_values import (
effective_entry_field_values,
)
from govoplan_campaign.backend.campaign.models import (
Behavior,
CampaignConfig,
EntryConfig,
PostboxTargetConfig,
PostboxTargetMatch,
PostboxTargetMode,
effective_postbox_targets,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.messages.models import (
MessageIssue,
MessageValidationStatus,
)
def _apply_behavior(
current: MessageValidationStatus,
behavior: Behavior,
) -> MessageValidationStatus:
if behavior == Behavior.BLOCK:
return MessageValidationStatus.BLOCKED
if behavior == Behavior.DROP:
return MessageValidationStatus.EXCLUDED
if behavior == Behavior.ASK and current not in {
MessageValidationStatus.BLOCKED,
MessageValidationStatus.EXCLUDED,
}:
return MessageValidationStatus.NEEDS_REVIEW
if behavior == Behavior.WARN and current == MessageValidationStatus.READY:
return MessageValidationStatus.WARNING
return current
def _issue(
*,
code: str,
message: str,
behavior: Behavior,
) -> MessageIssue:
return MessageIssue(
severity="error" if behavior == Behavior.BLOCK else "warning",
code=code,
message=message,
behavior=behavior.value,
source="postbox",
)
def _field_value(
values: dict[str, Any],
field_name: str | None,
) -> str | None:
if not field_name:
return None
value = values.get(field_name)
if value is None:
return None
text = str(value).strip()
return text or None
def _match_unit(
catalog: PostboxDeliveryCatalogRef,
value: str | None,
match: PostboxTargetMatch,
):
if not value:
return None
return next(
(
unit
for unit in catalog.organization_units
if (unit.id if match == PostboxTargetMatch.ID else unit.slug) == value
),
None,
)
def _match_function(unit, value: str | None, match: PostboxTargetMatch):
if unit is None or not value:
return None
return next(
(
function
for function in unit.functions
if (
function.id
if match == PostboxTargetMatch.ID
else function.slug
)
== value
),
None,
)
def _target_ref(
target: PostboxTargetConfig,
*,
values: dict[str, Any],
catalog: PostboxDeliveryCatalogRef,
) -> tuple[PostboxTargetRef | None, str | None]:
if target.mode == PostboxTargetMode.DIRECT:
return (
PostboxTargetRef(
postbox_id=target.postbox_id,
address_key=target.address_key,
),
None,
)
unit_value = target.organization_unit_id or _field_value(
values,
target.organization_unit_field,
)
unit_match = (
PostboxTargetMatch.ID
if target.organization_unit_id
else target.organization_unit_match
)
unit = _match_unit(catalog, unit_value, unit_match)
if unit is None:
return None, (
f"Organization unit {unit_value!r} could not be resolved by "
f"{unit_match.value}."
)
function_value = target.function_id or _field_value(
values,
target.function_field,
)
function_match = (
PostboxTargetMatch.ID
if target.function_id
else target.function_match
)
function = _match_function(unit, function_value, function_match)
if function is None:
return None, (
f"Organization function {function_value!r} could not be resolved "
f"inside {unit.name!r} by {function_match.value}."
)
context_key = target.context_key or _field_value(
values,
target.context_field,
)
return (
PostboxTargetRef(
template_id=target.template_id,
organization_unit_id=unit.id,
function_id=function.id,
context_key=context_key,
),
None,
)
def _resolved_target_payload(
target: PostboxTargetConfig,
entry: PostboxDirectoryEntryRef,
*,
position: int,
) -> dict[str, Any]:
return {
"target_id": target.id,
"position": position,
"mode": target.mode.value,
"requested": target.model_dump(mode="json", exclude_none=True),
"postbox_id": entry.id,
"address": entry.address,
"address_key": entry.address_key,
"name": entry.name,
"status": entry.status,
"classification": entry.classification,
"organization_unit_id": entry.organization_unit_id,
"organization_unit_name": entry.organization_unit_name,
"function_id": entry.function_id,
"function_name": entry.function_name,
"context_key": entry.context_key,
"template_revision_id": entry.template_revision_id,
"holder_count": entry.holder_count,
"vacant": entry.vacant,
}
def resolve_entry_postbox_targets(
session: Session,
*,
tenant_id: str,
config: CampaignConfig,
entry: EntryConfig,
validation_status: MessageValidationStatus,
materialize: bool,
) -> tuple[
list[dict[str, Any]],
list[MessageIssue],
MessageValidationStatus,
]:
integration = postbox_integration()
policy = config.delivery.postbox
targets = effective_postbox_targets(config, entry)
if not targets:
issue = _issue(
code="postbox_target_missing",
message="Postbox delivery requires at least one target.",
behavior=policy.unresolved_target,
)
return (
[],
[issue],
_apply_behavior(validation_status, policy.unresolved_target),
)
try:
catalog = integration.delivery_catalog(session, tenant_id=tenant_id)
except Exception as exc:
issue = _issue(
code="postbox_unavailable",
message=str(exc),
behavior=Behavior.BLOCK,
)
return [], [issue], MessageValidationStatus.BLOCKED
values = effective_entry_field_values(config, entry)
resolved: list[dict[str, Any]] = []
issues: list[MessageIssue] = []
status = validation_status
seen_postbox_ids: set[str] = set()
for position, target in enumerate(targets):
target_ref, resolution_error = _target_ref(
target,
values=values,
catalog=catalog,
)
if target_ref is None:
issue = _issue(
code="postbox_target_unresolved",
message=resolution_error or "Postbox target could not be resolved.",
behavior=policy.unresolved_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.unresolved_target)
continue
try:
entry_ref = integration.resolve_postbox(
session,
tenant_id=tenant_id,
target=target_ref,
materialize=materialize,
)
except Exception as exc:
issue = _issue(
code="postbox_target_unresolved",
message=f"Postbox target {target.id!r} could not be resolved: {exc}",
behavior=policy.unresolved_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.unresolved_target)
continue
if entry_ref is None:
issue = _issue(
code="postbox_target_unresolved",
message=f"Postbox target {target.id!r} does not exist.",
behavior=policy.unresolved_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.unresolved_target)
continue
if entry_ref.id in seen_postbox_ids:
issue = _issue(
code="postbox_target_duplicate",
message=(
f"Postbox {entry_ref.address!r} is selected more than once; "
"it will receive one message."
),
behavior=policy.duplicate_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.duplicate_target)
continue
seen_postbox_ids.add(entry_ref.id)
resolved.append(
_resolved_target_payload(
target,
entry_ref,
position=position,
)
)
if entry_ref.vacant:
issue = _issue(
code="postbox_target_vacant",
message=(
f"Postbox {entry_ref.address!r} currently has no function "
"holder."
),
behavior=policy.vacant_target,
)
issues.append(issue)
status = _apply_behavior(status, policy.vacant_target)
return resolved, issues, status
def delivery_catalog_payload(catalog: PostboxDeliveryCatalogRef) -> dict[str, Any]:
return asdict(catalog)

View File

@@ -10,7 +10,21 @@ from pydantic import BaseModel, ConfigDict, Field
from .addressing import effective_address_lists
from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
from .models import (
AttachmentConfig,
CampaignConfig,
DeliveryChannelPolicy,
EntryConfig,
FieldType,
PostboxTargetConfig,
SourceType,
ZipArchiveConfig,
ZipPasswordMode,
ZipPasswordScope,
ZipRuleMode,
effective_delivery_channel_policy,
effective_postbox_targets,
)
from ..attachments.resolver import resolve_campaign_attachments
@@ -90,6 +104,8 @@ def _mapping_target_known(target: str, field_names: set[str]) -> bool:
"merge_reply_to",
"merge_bounce_to",
"merge_disposition_notification_to",
"merge_postbox_targets",
"channel_policy",
"combine_to",
"combine_cc",
"combine_bcc",
@@ -337,10 +353,148 @@ def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> li
]
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
def _active_delivery_entries(config: CampaignConfig) -> list[EntryConfig]:
if config.entries.is_inline:
return [
entry
for entry in (config.entries.inline or [])
if entry.active
]
return [config.entries.defaults or EntryConfig()]
def _delivery_policies(config: CampaignConfig) -> set[DeliveryChannelPolicy]:
return {
effective_delivery_channel_policy(config, entry)
for entry in _active_delivery_entries(config)
}
def _postbox_target_field_issues(
config: CampaignConfig,
target: PostboxTargetConfig,
path: str,
) -> list[SemanticIssue]:
definitions = {field.name: field for field in config.fields}
checks = (
(
target.organization_unit_field,
FieldType.ORGANIZATION_UNIT,
"organization unit",
"organization_unit_field",
),
(
target.function_field,
FieldType.ORGANIZATION_FUNCTION,
"organization function",
"function_field",
),
(target.context_field, None, "context", "context_field"),
)
issues: list[SemanticIssue] = []
for field_name, expected_type, label, key in checks:
if not field_name:
continue
definition = definitions.get(field_name)
if definition is None:
issues.append(
_issue(
Severity.ERROR,
"postbox_target_field_missing",
f"Postbox {label} field {field_name!r} is not declared.",
f"{path}/{key}",
)
)
elif expected_type is not None and definition.type != expected_type:
issues.append(
_issue(
Severity.WARNING,
"postbox_target_field_type",
(
f"Postbox {label} field {field_name!r} should use "
f"field type {expected_type.value!r}."
),
f"{path}/{key}",
)
)
return issues
def _postbox_delivery_issues(
config: CampaignConfig,
*,
postbox_available: bool,
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
policies = _delivery_policies(config)
if not any(policy.uses_postbox for policy in policies):
return issues
if not postbox_available:
issues.append(
_issue(
Severity.ERROR,
"postbox_unavailable",
(
"This campaign uses Postbox delivery, but the Postbox "
"module and its delivery directory are not active."
),
"/delivery/channel_policy",
)
)
for entry_index, entry in enumerate(_active_delivery_entries(config)):
policy = effective_delivery_channel_policy(config, entry)
if not policy.uses_postbox:
continue
targets = effective_postbox_targets(config, entry)
if not targets:
issues.append(
_issue(
Severity.ERROR,
"postbox_target_missing",
"Postbox delivery requires at least one target.",
f"/entries/inline/{entry_index}/postbox_targets",
)
)
continue
seen_ids: set[str] = set()
for target_index, target in enumerate(targets):
target_path = (
f"/entries/inline/{entry_index}/postbox_targets/"
f"{target_index}"
)
if target.id in seen_ids:
issues.append(
_issue(
Severity.WARNING,
"postbox_target_id_duplicate",
f"Postbox target id {target.id!r} is repeated.",
f"{target_path}/id",
)
)
seen_ids.add(target.id)
issues.extend(
_postbox_target_field_issues(config, target, target_path)
)
return issues
def _delivery_issues(
config: CampaignConfig,
*,
postbox_available: bool,
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
policies = _delivery_policies(config)
uses_mail = any(policy.uses_mail for policy in policies)
profile_id = (config.server.mail_profile_id or "").strip()
if (config.campaign.mode == "send" or config.delivery.imap_append_sent.enabled) and not profile_id:
if (
(
config.campaign.mode == "send"
and uses_mail
or config.delivery.imap_append_sent.enabled
)
and not profile_id
):
issues.append(
_issue(
Severity.ERROR,
@@ -350,7 +504,12 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
)
)
capabilities = config.server.profile_capabilities
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available:
if (
config.campaign.mode == "send"
and uses_mail
and profile_id
and not capabilities.smtp_available
):
issues.append(
_issue(
Severity.ERROR,
@@ -368,13 +527,22 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
"/server/mail_profile_id",
)
)
issues.extend(
_postbox_delivery_issues(
config,
postbox_available=postbox_available,
)
)
return issues
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
"""Require Campaign-owned sender data before a send-mode build."""
if config.campaign.mode != "send":
if (
config.campaign.mode != "send"
or not any(policy.uses_mail for policy in _delivery_policies(config))
):
return []
if config.entries.is_inline:
return [
@@ -385,7 +553,11 @@ def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
f"/entries/inline/{index}/from",
)
for index, entry in enumerate(config.entries.inline or [])
if entry.active and not effective_address_lists(config, entry)["from"]
if (
entry.active
and effective_delivery_channel_policy(config, entry).uses_mail
and not effective_address_lists(config, entry)["from"]
)
]
if config.recipients.from_:
return []
@@ -611,6 +783,7 @@ def validate_campaign_config(
*,
campaign_file: str | Path | None = None,
check_files: bool = False,
postbox_available: bool = False,
) -> SemanticReport:
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
issues: list[SemanticIssue] = []
@@ -622,7 +795,12 @@ def validate_campaign_config(
issues.extend(_global_value_issues(config, declared_names))
issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config))
issues.extend(_delivery_issues(config))
issues.extend(
_delivery_issues(
config,
postbox_available=postbox_available,
)
)
issues.extend(_sender_issues(config))
entries = _entries_validation(

View File

@@ -21,6 +21,7 @@ from govoplan_campaign.backend.db.models import (
CampaignShare,
CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
SendAttempt,
new_uuid,
)
@@ -55,7 +56,10 @@ def _record_campaign_changes(session: OrmSession, _flush_context: object, _insta
_record_job_change(session, obj)
elif isinstance(obj, CampaignIssue):
_record_issue_change(session, obj)
elif isinstance(obj, (SendAttempt, ImapAppendAttempt)):
elif isinstance(
obj,
(SendAttempt, ImapAppendAttempt, PostboxDeliveryAttempt),
):
_record_attempt_change(session, obj)
@@ -182,8 +186,11 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"validation_status",
"queue_status",
"send_status",
"delivery_channel_policy",
"postbox_status",
"imap_status",
"attempt_count",
"postbox_attempt_count",
"last_error",
"queued_at",
"claimed_at",
@@ -191,6 +198,7 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"outcome_unknown_at",
"sent_at",
"resolved_recipients",
"resolved_postbox_targets",
"resolved_attachments",
"issues_snapshot",
),
@@ -217,6 +225,8 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"delivery_channel_policy": job.delivery_channel_policy,
"postbox_status": job.postbox_status,
"imap_status": job.imap_status,
},
)
@@ -247,10 +257,25 @@ def _record_issue_change(session: OrmSession, issue: CampaignIssue) -> None:
)
def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppendAttempt) -> None:
def _record_attempt_change(
session: OrmSession,
attempt: SendAttempt | ImapAppendAttempt | PostboxDeliveryAttempt,
) -> None:
operation = _operation_for_object(
attempt,
changed_attrs=("status", "claim_token", "smtp_status_code", "smtp_response", "error_type", "error_message", "folder"),
changed_attrs=(
"status",
"claim_token",
"smtp_status_code",
"smtp_response",
"error_type",
"error_message",
"folder",
"provider_delivery_id",
"provider_message_id",
"postbox_id",
"evidence",
),
)
if operation is None:
return
@@ -270,7 +295,13 @@ def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppen
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
"attempt_id": attempt_id,
"attempt_kind": "imap" if isinstance(attempt, ImapAppendAttempt) else "smtp",
"attempt_kind": (
"postbox"
if isinstance(attempt, PostboxDeliveryAttempt)
else "imap"
if isinstance(attempt, ImapAppendAttempt)
else "smtp"
),
"job_id": job.id,
"version_id": job.campaign_version_id,
},

View File

@@ -83,6 +83,9 @@ class JobSendStatus(StrEnum):
CLAIMED = "claimed"
SENDING = "sending"
SMTP_ACCEPTED = "smtp_accepted"
POSTBOX_ACCEPTED = "postbox_accepted"
DELIVERED = "delivered"
PARTIALLY_ACCEPTED = "partially_accepted"
SENT = "sent" # legacy value retained for existing databases/reports
OUTCOME_UNKNOWN = "outcome_unknown"
FAILED_TEMPORARY = "failed_temporary"
@@ -90,6 +93,19 @@ class JobSendStatus(StrEnum):
CANCELLED = "cancelled"
class JobPostboxStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
DELIVERING = "delivering"
ACCEPTED = "accepted"
ACCEPTED_VACANT = "accepted_vacant"
PARTIALLY_ACCEPTED = "partially_accepted"
REJECTED_TEMPORARY = "rejected_temporary"
REJECTED_PERMANENT = "rejected_permanent"
OUTCOME_UNKNOWN = "outcome_unknown"
SKIPPED = "skipped"
class JobImapStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
@@ -249,9 +265,26 @@ class CampaignJob(Base, TimestampMixin):
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True)
send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True)
delivery_channel_policy: Mapped[str] = mapped_column(
String(30),
default="mail",
nullable=False,
index=True,
)
postbox_status: Mapped[str] = mapped_column(
String(50),
default=JobPostboxStatus.NOT_REQUESTED.value,
nullable=False,
index=True,
)
imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
postbox_attempt_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
)
last_error: Mapped[str | None] = mapped_column(Text)
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -263,6 +296,11 @@ class CampaignJob(Base, TimestampMixin):
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
resolved_postbox_targets: Mapped[list[dict[str, Any]]] = mapped_column(
JSON,
default=list,
nullable=False,
)
resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
@@ -342,6 +380,79 @@ class ImapAppendAttempt(Base, TimestampMixin):
error_message: Mapped[str | None] = mapped_column(Text)
class PostboxDeliveryAttempt(Base, TimestampMixin):
__tablename__ = "campaign_postbox_delivery_attempts"
__table_args__ = (
UniqueConstraint(
"job_id",
"target_key",
"attempt_number",
name="uq_campaign_postbox_attempt_target_number",
),
Index(
"ix_campaign_postbox_attempt_idempotency",
"tenant_id",
"idempotency_key",
),
Index(
"ix_campaign_postbox_attempt_job_status",
"job_id",
"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,
)
job_id: Mapped[str] = mapped_column(
ForeignKey("campaign_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
target_key: Mapped[str] = mapped_column(String(64), nullable=False)
target_index: Mapped[int] = mapped_column(Integer, nullable=False)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True,
)
target_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
provider_delivery_id: Mapped[str | None] = mapped_column(String(36))
provider_message_id: Mapped[str | None] = mapped_column(String(36))
postbox_id: Mapped[str | None] = mapped_column(String(36), index=True)
address: Mapped[str | None] = mapped_column(String(500))
holder_count: Mapped[int | None] = mapped_column(Integer)
vacant: Mapped[bool | None] = mapped_column(Boolean)
duplicate: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
error_type: Mapped[str | None] = mapped_column(String(255))
error_code: Mapped[str | None] = mapped_column(String(100))
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
)
__all__ = [
@@ -359,8 +470,10 @@ __all__ = [
"IssueSeverity",
"JobBuildStatus",
"JobImapStatus",
"JobPostboxStatus",
"JobQueueStatus",
"JobSendStatus",
"JobValidationStatus",
"SendAttempt",
"PostboxDeliveryAttempt",
]

View File

@@ -7,11 +7,24 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_DELIVERY,
PostboxDeliveryCatalogRef,
PostboxDeliveryProvider,
PostboxDeliveryRequest,
PostboxDeliveryResult,
PostboxDirectoryEntryRef,
PostboxDirectoryProvider,
PostboxTargetRef,
)
from govoplan_campaign.backend.runtime import capability
FILES_CAPABILITY = "files.campaign_attachments"
MAIL_CAPABILITY = "mail.campaign_delivery"
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
class OptionalModuleUnavailable(RuntimeError):
@@ -50,6 +63,10 @@ class MailProfileError(OptionalModuleUnavailable):
pass
class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
pass
class _PreparedCampaignSnapshot:
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
self._directory = directory
@@ -209,9 +226,89 @@ class MailCampaignIntegration:
return self._delegate.mock_mailbox()
class PostboxCampaignIntegration:
def __init__(
self,
delivery_delegate: object | None = None,
directory_delegate: object | None = None,
) -> None:
self._delivery_delegate = (
delivery_delegate
if isinstance(delivery_delegate, PostboxDeliveryProvider)
else None
)
self._directory_delegate = (
directory_delegate
if isinstance(directory_delegate, PostboxDirectoryProvider)
else None
)
@property
def available(self) -> bool:
return (
self._delivery_delegate is not None
and self._directory_delegate is not None
)
def delivery_catalog(
self,
session: object,
*,
tenant_id: str,
) -> PostboxDeliveryCatalogRef:
if self._directory_delegate is None:
raise PostboxDeliveryUnavailable(
"Postbox targets are unavailable because the Postbox module "
"is not active."
)
return self._directory_delegate.delivery_catalog(
session,
tenant_id=tenant_id,
)
def resolve_postbox(
self,
session: object,
*,
tenant_id: str,
target: PostboxTargetRef,
materialize: bool = False,
) -> PostboxDirectoryEntryRef | None:
if self._directory_delegate is None:
raise PostboxDeliveryUnavailable(
"Postbox targets are unavailable because the Postbox module "
"is not active."
)
return self._directory_delegate.resolve_postbox(
session,
tenant_id=tenant_id,
target=target,
materialize=materialize,
)
def deliver(
self,
session: object,
request: PostboxDeliveryRequest,
) -> PostboxDeliveryResult:
if self._delivery_delegate is None:
raise PostboxDeliveryUnavailable(
"Postbox delivery is unavailable because the Postbox module "
"is not active."
)
return self._delivery_delegate.deliver(session, request)
def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
def mail_integration() -> MailCampaignIntegration:
return MailCampaignIntegration(capability(MAIL_CAPABILITY))
def postbox_integration() -> PostboxCampaignIntegration:
return PostboxCampaignIntegration(
capability(POSTBOX_CAPABILITY),
capability(POSTBOX_DIRECTORY_CAPABILITY),
)

View File

@@ -26,7 +26,12 @@ from govoplan_core.core.modules import (
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
)
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
@@ -62,9 +67,9 @@ PERMISSIONS = (
_permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
_permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
_permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"),
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
_permission("campaigns:campaign:send", "Send campaigns", "Start real Mail or Postbox delivery.", "Campaigns"),
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP or IMAP attempts after inspection.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown Mail, Postbox, or IMAP attempts after inspection.", "Campaigns"),
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
@@ -157,9 +162,15 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.11",
version="0.1.12",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("files", "mail", "notifications", "addresses"),
optional_dependencies=(
"files",
"mail",
"notifications",
"addresses",
"postbox",
),
provides_interfaces=(
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
@@ -192,6 +203,18 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_DELIVERY,
version_min="0.1.1",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_DIRECTORY,
version_min="0.1.1",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS,
route_factory=_campaigns_router,
@@ -268,6 +291,15 @@ manifest = ModuleManifest(
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
NavItem(path="/templates", label="Templates", icon="layout-template", order=90),
),
view_surfaces=(
ViewSurface(
id="campaigns.widget.activity",
module_id="campaigns",
kind="section",
label="Campaign activity widget",
order=50,
),
),
),
migration_spec=MigrationSpec(
module_id="campaigns",
@@ -285,6 +317,7 @@ manifest = ModuleManifest(
campaign_models.AttachmentInstance,
campaign_models.SendAttempt,
campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt,
label="Campaigns",
),
retirement_notes="Destructive retirement drops campaign-owned database tables after the installer captures a database snapshot.",
@@ -301,11 +334,57 @@ manifest = ModuleManifest(
campaign_models.AttachmentInstance,
campaign_models.SendAttempt,
campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt,
label="Campaigns",
),
),
documentation=(
*CAMPAIGN_USER_DOCUMENTATION,
DocumentationTopic(
id="campaigns.postbox-delivery",
title="Deliver Campaign messages to Postboxes",
summary="Target one or more exact or organization-derived Postboxes per recipient row, independently or alongside Mail.",
body="Configure campaign-wide targets and optional per-row additions or replacements. Derived targets resolve a published Postbox template with organization unit, function, and optional context values, including values sourced from Campaign fields. Targets are frozen during build. Fallback crosses to the second channel only after a confirmed pre-acceptance rejection; accepted or outcome-unknown effects never trigger fallback.",
layer="available",
documentation_types=("user", "admin"),
audience=("campaign_manager", "campaign_reviewer", "campaign_sender", "campaign_operator"),
order=45,
conditions=(
DocumentationCondition(
required_modules=("campaigns", "postbox"),
any_scopes=("campaigns:recipient:write", "campaigns:campaign:send", "campaigns:campaign:reconcile"),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Postboxes", href="/postbox", kind="runtime"),
DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"),
),
related_modules=("postbox", "organizations", "idm", "mail", "audit"),
unlocks=("Audited direct, derived, combined, and pre-acceptance fallback delivery to Postboxes.",),
metadata={
"kind": "workflow",
"route": "/campaigns/{campaign_id}/global-settings",
"screen": "Campaign delivery defaults and recipient data",
"prerequisites": [
"Campaign and Postbox are installed and active.",
"At least one exact Postbox or published Postbox template is available.",
],
"steps": [
"Choose Postbox, Mail and Postbox, or an ordered fallback policy.",
"Configure one or more campaign-wide targets.",
"Optionally add or replace targets for individual recipient rows.",
"Validate and build to freeze the resolved Postbox addresses before review.",
"Review, queue, and inspect channel-specific delivery evidence in the report.",
],
"outcome": "Each active row resolves an auditable set of Postbox targets without introducing a hard Campaign dependency on Postbox.",
"verification": "Confirm the frozen target list in the built job and verify accepted, rejected, or outcome-unknown attempts in Campaign reporting.",
"related_topic_ids": [
"campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.retry-and-reconcile",
],
},
),
DocumentationTopic(
id="campaigns.mail-profile-user-journey",
title="Choose a Mail profile for campaign delivery",
@@ -527,8 +606,8 @@ manifest = ModuleManifest(
DocumentationTopic(
id="campaigns.workflow.retry-and-reconcile",
title="Retry only known failures and reconcile uncertain effects",
summary="Keep safe-to-retry failures separate from SMTP or IMAP effects whose outcome is unknown.",
body="A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown SMTP or IMAP effect. Inspect external evidence, reconcile SMTP as accepted or not sent, and reconcile IMAP as appended or not appended; repairing Sent never resends accepted SMTP mail.",
summary="Keep safe-to-retry failures separate from Mail, Postbox, or IMAP effects whose outcome is unknown.",
body="A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown Mail, Postbox, or IMAP effect. Inspect external evidence and reconcile the affected channel before continuing. Accepted Mail attempts and accepted Postbox targets are immutable during partial retries, and repairing Sent never resends accepted Mail.",
layer="evidence",
documentation_types=("admin", "user"),
audience=("campaign_sender", "campaign_operator"),
@@ -587,14 +666,14 @@ manifest = ModuleManifest(
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Reference examples and release checklist", href="govoplan-campaign/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md", kind="repository"),
),
related_modules=("mail", "files", "addresses", "audit"),
related_modules=("mail", "postbox", "files", "addresses", "audit"),
unlocks=("A repeatable, supportable Campaign demonstration rather than an unverified module assembly.",),
metadata={
"kind": "reference",
"route": "/campaigns",
"screen": "Campaign reference composition",
"section": "Release, security, integration, and recovery assurance",
"verification": "Run the maintained examples, module-permutation tests, migration and restore drills, target SMTP/IMAP checks, version-alignment gate, WebUI/i18n checks, and full security audit for the pinned composition.",
"verification": "Run the maintained examples, module-permutation tests, migration and restore drills, target Mail/Postbox/IMAP checks, version-alignment gate, WebUI/i18n checks, and full security audit for the pinned composition.",
"related_topic_ids": [
"campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.complete-review",

View File

@@ -33,6 +33,7 @@ from govoplan_campaign.backend.campaign.models import (
ZipArchiveConfig,
ZipPasswordMode,
ZipPasswordScope,
effective_delivery_channel_policy,
)
from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.services.zip_service import create_zip_archive
@@ -425,8 +426,14 @@ def _attach_files(
return attached_count
def _imap_initial_status(config: CampaignConfig) -> ImapStatus:
if config.delivery.imap_append_sent.enabled:
def _imap_initial_status(
config: CampaignConfig,
entry: EntryConfig,
) -> ImapStatus:
if (
effective_delivery_channel_policy(config, entry).uses_mail
and config.delivery.imap_append_sent.enabled
):
return ImapStatus.PENDING
return ImapStatus.NOT_REQUESTED
@@ -520,7 +527,11 @@ def _message_draft(
send_status = SendStatus.SKIPPED
imap_status = ImapStatus.SKIPPED
if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
imap_status = (
_imap_initial_status(config, entry)
if build_status == BuildStatus.BUILT
else ImapStatus.SKIPPED
)
return MessageDraft(
entry_index=entry_index,
entry_id=entry.id,
@@ -529,6 +540,10 @@ def _message_draft(
validation_status=validation_status,
send_status=send_status,
imap_status=imap_status,
delivery_channel_policy=effective_delivery_channel_policy(
config,
entry,
).value,
subject=subject,
from_=_message_address(context.sender),
from_all=_message_addresses(context.senders),
@@ -809,6 +824,8 @@ def build_entry_message(
if not entry.active:
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
channel_policy = effective_delivery_channel_policy(config, entry)
if channel_policy.uses_mail:
context.validation_status = _validate_required_sender(
context.senders,
context.issues,

View File

@@ -78,6 +78,7 @@ class MessageDraft(BaseModel):
validation_status: MessageValidationStatus
send_status: SendStatus
imap_status: ImapStatus
delivery_channel_policy: str = "mail"
subject: str | None = None
from_: MessageAddress | None = Field(default=None, alias="from")

View File

@@ -0,0 +1,32 @@
"""repair a missing IMAP append attempt claim token
Revision ID: e9f0a1b2c3d4
Revises: d8b3e2c1f4a5
Create Date: 2026-07-28 23:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f0a1b2c3d4"
down_revision = "d8b3e2c1f4a5"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
columns = {column["name"] for column in sa.inspect(bind).get_columns("imap_append_attempts")}
if "claim_token" not in columns:
op.add_column(
"imap_append_attempts",
sa.Column("claim_token", sa.String(length=36), nullable=True),
)
def downgrade() -> None:
# The column belongs to revision 3c4d5e6f8192. This repair revision only
# restores drift, so downgrading to d8b3e2c1f4a5 must retain it.
pass

View File

@@ -0,0 +1,22 @@
"""add governed campaign Postbox delivery
Revision ID: f0a1b2c3d4e5
Revises: e9f0a1b2c3d4
Create Date: 2026-07-29 02:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
_migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"f0a1b2c3d4e5_v0115_postbox_delivery"
)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
upgrade = _migration.upgrade
downgrade = _migration.downgrade

View File

@@ -0,0 +1,32 @@
"""repair a missing IMAP append attempt claim token
Revision ID: e9f0a1b2c3d4
Revises: d8b3e2c1f4a5
Create Date: 2026-07-28 23:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f0a1b2c3d4"
down_revision = "d8b3e2c1f4a5"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
columns = {column["name"] for column in sa.inspect(bind).get_columns("imap_append_attempts")}
if "claim_token" not in columns:
op.add_column(
"imap_append_attempts",
sa.Column("claim_token", sa.String(length=36), nullable=True),
)
def downgrade() -> None:
# The column belongs to revision 3c4d5e6f8192. This repair revision only
# restores drift, so downgrading to d8b3e2c1f4a5 must retain it.
pass

View File

@@ -0,0 +1,153 @@
"""add governed campaign Postbox delivery
Revision ID: f0a1b2c3d4e5
Revises: e9f0a1b2c3d4
Create Date: 2026-07-29 02:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f0a1b2c3d4e5"
down_revision = "e9f0a1b2c3d4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaign_jobs",
sa.Column(
"delivery_channel_policy",
sa.String(length=30),
nullable=False,
server_default="mail",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"postbox_status",
sa.String(length=50),
nullable=False,
server_default="not_requested",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"postbox_attempt_count",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"resolved_postbox_targets",
sa.JSON(),
nullable=False,
server_default="[]",
),
)
op.create_index(
op.f("ix_campaign_jobs_delivery_channel_policy"),
"campaign_jobs",
["delivery_channel_policy"],
unique=False,
)
op.create_index(
op.f("ix_campaign_jobs_postbox_status"),
"campaign_jobs",
["postbox_status"],
unique=False,
)
op.create_table(
"campaign_postbox_delivery_attempts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("job_id", sa.String(length=36), nullable=False),
sa.Column("target_key", sa.String(length=64), nullable=False),
sa.Column("target_index", sa.Integer(), nullable=False),
sa.Column("attempt_number", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("target_snapshot", sa.JSON(), nullable=False),
sa.Column("provider_delivery_id", sa.String(length=36), nullable=True),
sa.Column("provider_message_id", sa.String(length=36), nullable=True),
sa.Column("postbox_id", sa.String(length=36), nullable=True),
sa.Column("address", sa.String(length=500), nullable=True),
sa.Column("holder_count", sa.Integer(), nullable=True),
sa.Column("vacant", sa.Boolean(), nullable=True),
sa.Column(
"duplicate",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("error_type", sa.String(length=255), nullable=True),
sa.Column("error_code", sa.String(length=100), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_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(
["job_id"],
["campaign_jobs.id"],
name=op.f(
"fk_campaign_postbox_delivery_attempts_job_id_campaign_jobs"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_campaign_postbox_delivery_attempts"),
),
sa.UniqueConstraint(
"job_id",
"target_key",
"attempt_number",
name="uq_campaign_postbox_attempt_target_number",
),
)
for column in ("tenant_id", "job_id", "status", "postbox_id"):
op.create_index(
op.f(f"ix_campaign_postbox_delivery_attempts_{column}"),
"campaign_postbox_delivery_attempts",
[column],
unique=False,
)
op.create_index(
"ix_campaign_postbox_attempt_job_status",
"campaign_postbox_delivery_attempts",
["job_id", "status"],
unique=False,
)
op.create_index(
"ix_campaign_postbox_attempt_idempotency",
"campaign_postbox_delivery_attempts",
["tenant_id", "idempotency_key"],
unique=False,
)
def downgrade() -> None:
op.drop_table("campaign_postbox_delivery_attempts")
op.drop_index(
op.f("ix_campaign_jobs_postbox_status"),
table_name="campaign_jobs",
)
op.drop_index(
op.f("ix_campaign_jobs_delivery_channel_policy"),
table_name="campaign_jobs",
)
op.drop_column("campaign_jobs", "resolved_postbox_targets")
op.drop_column("campaign_jobs", "postbox_attempt_count")
op.drop_column("campaign_jobs", "postbox_status")
op.drop_column("campaign_jobs", "delivery_channel_policy")

View File

@@ -22,6 +22,7 @@ from govoplan_campaign.backend.db.models import (
CampaignVersion,
CampaignVersionWorkflowState,
JobImapStatus,
JobPostboxStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
@@ -33,11 +34,23 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_mail_resource_ids,
)
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.postbox_targets import (
resolve_entry_postbox_targets,
)
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_delivery_summary
from govoplan_campaign.backend.campaign.models import CampaignConfig, SendStatus
from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.campaign.models import (
CampaignConfig,
DeliveryChannelPolicy,
SendStatus,
)
from govoplan_campaign.backend.integrations import (
files_integration,
mail_integration,
postbox_integration,
)
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
@@ -329,9 +342,19 @@ def validate_campaign_version(
) as prepared:
managed_raw = load_campaign_json(prepared.path)
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
report = validate_campaign_config(managed_config, campaign_file=prepared.path, check_files=True)
report = validate_campaign_config(
managed_config,
campaign_file=prepared.path,
check_files=True,
postbox_available=postbox_integration().available,
)
else:
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=False)
report = validate_campaign_config(
config,
campaign_file=snapshot_path,
check_files=False,
postbox_available=postbox_integration().available,
)
report_json = report.model_dump(mode="json")
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
version.validation_summary = report_json
@@ -394,6 +417,7 @@ def _job_from_message(
campaign_id: str,
version_id: str,
message: MessageDraft,
resolved_postbox_targets: list[dict[str, Any]] | None = None,
) -> CampaignJob:
recipient_email = message.to[0].email if message.to else None
eml_sha256, message_id_header = _eml_evidence(message.eml_path)
@@ -417,6 +441,12 @@ def _job_from_message(
if message.send_status == SendStatus.SKIPPED
else JobSendStatus.NOT_QUEUED.value
),
delivery_channel_policy=message.delivery_channel_policy,
postbox_status=(
JobPostboxStatus.PENDING.value
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
else JobPostboxStatus.NOT_REQUESTED.value
),
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
resolved_recipients={
"from": message.from_.model_dump(mode="json") if message.from_ else None,
@@ -428,6 +458,7 @@ def _job_from_message(
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
},
resolved_postbox_targets=resolved_postbox_targets or [],
resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments],
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
@@ -467,6 +498,46 @@ def build_campaign_version(
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
files.annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
entries_by_index = {
index: entry
for index, entry in enumerate(
load_campaign_entries(
managed_config,
campaign_file=prepared.path,
),
start=1,
)
}
resolved_postbox_targets_by_index: dict[
int,
list[dict[str, Any]],
] = {}
for built in result.built_messages:
policy = DeliveryChannelPolicy(
built.draft.delivery_channel_policy
)
if not policy.uses_postbox:
continue
entry = entries_by_index.get(built.draft.entry_index)
if entry is None:
raise CampaignPersistenceError(
"Built recipient row is missing from the campaign input."
)
resolved_targets, postbox_issues, validation_status = (
resolve_entry_postbox_targets(
session,
tenant_id=tenant_id,
config=managed_config,
entry=entry,
validation_status=built.draft.validation_status,
materialize=True,
)
)
built.draft.issues.extend(postbox_issues)
built.draft.validation_status = validation_status
resolved_postbox_targets_by_index[built.draft.entry_index] = (
resolved_targets
)
report_json = result.report.model_dump(mode="json", by_alias=True)
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
if isinstance(message_payload, dict):
@@ -501,6 +572,10 @@ def build_campaign_version(
campaign_id=campaign.id,
version_id=version.id,
message=built.draft,
resolved_postbox_targets=resolved_postbox_targets_by_index.get(
built.draft.entry_index,
[],
),
)
session.add(job)
job_build_pairs.append((job, built.draft))
@@ -514,14 +589,31 @@ def build_campaign_version(
[job for job, _message in job_build_pairs],
stage="built",
)
uses_mail = any(
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
for job, _message in job_build_pairs
)
profile_id: str | None = None
profile_summary: dict[str, Any] = {}
if uses_mail:
if not managed_config.server.profile_capabilities.smtp_available:
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created")
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
raise CampaignPersistenceError(
"The selected Mail profile has no SMTP configuration; an "
"execution snapshot cannot be created."
)
profile_id = campaign_mail_profile_id(
version.raw_json if isinstance(version.raw_json, dict) else {}
)
if profile_id is None:
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages")
raise CampaignPersistenceError(
"Select an authorized Mail profile before building campaign "
"messages that use Mail."
)
profile_summary = profile_delivery_summary(session, version)
if not profile_summary.get("smtp_transport_revision"):
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision")
raise CampaignPersistenceError(
"The selected Mail profile has no SMTP transport revision."
)
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
version,
mail_profile_id=profile_id,
@@ -529,7 +621,9 @@ def build_campaign_version(
smtp_credential_id=profile_summary.get("smtp_credential_id"),
imap_server_id=profile_summary.get("imap_server_id"),
imap_credential_id=profile_summary.get("imap_credential_id"),
smtp_transport_revision=profile_summary["smtp_transport_revision"],
smtp_transport_revision=profile_summary.get(
"smtp_transport_revision"
),
imap_transport_revision=profile_summary.get("imap_transport_revision"),
delivery=managed_config.delivery,
jobs=[job for job, _message in job_build_pairs],

View File

@@ -58,6 +58,9 @@ class AggregateOutcomeCounts(BaseModel):
model_config = ConfigDict(extra="forbid")
smtp_accepted: AggregateCount
postbox_accepted: AggregateCount
delivered: AggregateCount
partially_accepted: AggregateCount
failed: AggregateCount
outcome_unknown: AggregateCount
queued_or_active: AggregateCount
@@ -105,6 +108,9 @@ class AggregateCampaignReport(BaseModel):
_OUTCOME_KEYS = (
"smtp_accepted",
"postbox_accepted",
"delivered",
"partially_accepted",
"failed",
"outcome_unknown",
"queued_or_active",
@@ -175,7 +181,18 @@ def _query_aggregate_facts(
if version is None:
return _AggregateFacts.empty()
accepted = CampaignJob.send_status.in_({"smtp_accepted", "sent"})
smtp_accepted = CampaignJob.send_status.in_(
{"smtp_accepted", "sent"}
)
accepted = CampaignJob.send_status.in_(
{
"smtp_accepted",
"postbox_accepted",
"delivered",
"partially_accepted",
"sent",
}
)
failed = CampaignJob.send_status.in_({"failed_temporary", "failed_permanent"})
unknown = CampaignJob.send_status == "outcome_unknown"
active = CampaignJob.send_status.in_({"queued", "claimed", "sending"})
@@ -188,7 +205,33 @@ def _query_aggregate_facts(
row = (
session.query(
func.count(CampaignJob.id).label("denominator"),
func.sum(case((accepted, 1), else_=0)).label("smtp_accepted"),
func.sum(case((smtp_accepted, 1), else_=0)).label(
"smtp_accepted"
),
func.sum(
case(
(
CampaignJob.send_status == "postbox_accepted",
1,
),
else_=0,
)
).label("postbox_accepted"),
func.sum(
case(
(CampaignJob.send_status == "delivered", 1),
else_=0,
)
).label("delivered"),
func.sum(
case(
(
CampaignJob.send_status == "partially_accepted",
1,
),
else_=0,
)
).label("partially_accepted"),
func.sum(case((failed, 1), else_=0)).label("failed"),
func.sum(case((unknown, 1), else_=0)).label("outcome_unknown"),
func.sum(case((active, 1), else_=0)).label("queued_or_active"),
@@ -353,6 +396,12 @@ def _outcome_counts(jobs: list[CampaignJob]) -> dict[str, int]:
status = job.send_status
if status in {"smtp_accepted", "sent"}:
counts["smtp_accepted"] += 1
elif status == "postbox_accepted":
counts["postbox_accepted"] += 1
elif status == "delivered":
counts["delivered"] += 1
elif status == "partially_accepted":
counts["partially_accepted"] += 1
elif status in {"failed_temporary", "failed_permanent"}:
counts["failed"] += 1
elif status == "outcome_unknown":
@@ -460,10 +509,15 @@ def _completion_state(
return "outcome_unknown"
if counts["queued_or_active"]:
return "in_progress"
accepted = counts["smtp_accepted"]
if accepted == total:
fully_accepted = (
counts["smtp_accepted"]
+ counts["postbox_accepted"]
+ counts["delivered"]
)
partially_accepted = counts["partially_accepted"]
if fully_accepted == total:
return "completed"
if accepted:
if fully_accepted or partially_accepted:
return "partially_completed"
return "incomplete"

View File

@@ -5,6 +5,7 @@ import io
import logging
import math
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@@ -18,6 +19,7 @@ from govoplan_campaign.backend.db.models import (
CampaignJob,
CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
@@ -33,6 +35,8 @@ class CampaignReportError(RuntimeError):
logger = logging.getLogger(__name__)
CAMPAIGN_JSON_JOB_LIMIT = 5_000
CAMPAIGN_CSV_JOB_LIMIT = 100_000
def _utcnow_iso() -> str:
@@ -96,8 +100,9 @@ def _version_info(
def _load_delivery_info(
version: CampaignVersion | None,
jobs: list[CampaignJob],
jobs: list[CampaignJob] | None = None,
*,
pending_job_count: int | None = None,
include_diagnostics: bool = False,
) -> dict[str, Any]:
"""Read deterministic delivery settings from the immutable execution snapshot."""
@@ -143,10 +148,17 @@ def _load_delivery_info(
return default
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
pending = [job for job in jobs if job.send_status in {"queued", "claimed", "sending"}]
if pending_job_count is None:
pending_job_count = sum(
1
for job in jobs or []
if job.send_status in {"queued", "claimed", "sending"}
)
estimated_seconds = None
if messages_per_minute and pending:
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60))
if messages_per_minute and pending_job_count:
estimated_seconds = int(
math.ceil((pending_job_count / messages_per_minute) * 60)
)
result = {
"rate_limit": {
@@ -259,8 +271,167 @@ def _attachment_summary(jobs: list[CampaignJob]) -> dict[str, Any]:
}
@dataclass(slots=True)
class _JobReportAggregate:
total: int = 0
pending: int = 0
queueable: int = 0
queueable_unattempted: int = 0
retryable: int = 0
cancellable: int = 0
needs_attention: int = 0
build: Counter[str] = field(default_factory=Counter)
validation: Counter[str] = field(default_factory=Counter)
queue: Counter[str] = field(default_factory=Counter)
send: Counter[str] = field(default_factory=Counter)
postbox: Counter[str] = field(default_factory=Counter)
imap: Counter[str] = field(default_factory=Counter)
issue_total: int = 0
issue_severity: Counter[str] = field(default_factory=Counter)
issue_code: Counter[str] = field(default_factory=Counter)
issue_behavior: Counter[str] = field(default_factory=Counter)
attachment_total: int = 0
attachment_matches: int = 0
attachment_zip_enabled: int = 0
attachment_missing: int = 0
attachment_ambiguous: int = 0
attachment_status: Counter[str] = field(default_factory=Counter)
attachment_behavior: Counter[str] = field(default_factory=Counter)
recent_failures: list[CampaignJob] = field(default_factory=list)
def add(
self,
job: CampaignJob,
*,
retry_max_attempts: int | None,
include_recent_failures: bool,
) -> None:
self.total += 1
self.build[job.build_status or "unknown"] += 1
self.validation[job.validation_status or "unknown"] += 1
self.queue[job.queue_status or "unknown"] += 1
self.send[job.send_status or "unknown"] += 1
self.postbox[job.postbox_status or "unknown"] += 1
self.imap[job.imap_status or "unknown"] += 1
if job.send_status in {"queued", "claimed", "sending"}:
self.pending += 1
if (
job.validation_status in {"ready", "warning"}
and job.build_status == "built"
):
self.queueable += 1
if (
job.attempt_count == 0
and job.postbox_attempt_count == 0
and job.send_status in {"not_queued", "cancelled"}
and job.validation_status in {"ready", "warning"}
and job.build_status == "built"
):
self.queueable_unattempted += 1
if (
job.send_status in {"failed_temporary", "partially_accepted"}
and (
retry_max_attempts is None
or job.attempt_count < retry_max_attempts
)
):
self.retryable += 1
if job.send_status not in {
"skipped",
"smtp_accepted",
"postbox_accepted",
"delivered",
"partially_accepted",
"sent",
"outcome_unknown",
"claimed",
"sending",
"cancelled",
}:
self.cancellable += 1
if _job_needs_attention(job):
self.needs_attention += 1
self._add_issues(job)
self._add_attachments(job)
if include_recent_failures and _job_is_recent_failure(job):
self.recent_failures.append(job)
self.recent_failures.sort(
key=lambda item: item.updated_at or item.created_at,
reverse=True,
)
del self.recent_failures[20:]
def _add_issues(self, job: CampaignJob) -> None:
for issue in job.issues_snapshot or []:
if not isinstance(issue, dict):
continue
self.issue_total += 1
self.issue_severity[issue.get("severity") or "unknown"] += 1
self.issue_code[issue.get("code") or "unknown"] += 1
if issue.get("behavior"):
self.issue_behavior[str(issue["behavior"])] += 1
def _add_attachments(self, job: CampaignJob) -> None:
for attachment in job.resolved_attachments or []:
if not isinstance(attachment, dict):
continue
self.attachment_total += 1
status_value = str(attachment.get("status") or "unknown")
self.attachment_status[status_value] += 1
if attachment.get("behavior"):
self.attachment_behavior[str(attachment["behavior"])] += 1
matches = attachment.get("matches")
if isinstance(matches, list):
self.attachment_matches += len(matches)
if attachment.get("zip_enabled"):
self.attachment_zip_enabled += 1
if status_value == "missing":
self.attachment_missing += 1
if status_value == "ambiguous":
self.attachment_ambiguous += 1
def _job_needs_attention(job: CampaignJob) -> bool:
return (
job.validation_status in {"needs_review", "blocked"}
or job.send_status
in {
"failed_temporary",
"failed_permanent",
"partially_accepted",
"outcome_unknown",
"claimed",
"sending",
}
or job.postbox_status
in {
"partially_accepted",
"rejected_temporary",
"rejected_permanent",
"outcome_unknown",
}
or job.imap_status == "failed"
)
def _job_is_recent_failure(job: CampaignJob) -> bool:
return bool(
job.last_error
or str(job.send_status).startswith("failed")
or job.send_status == "partially_accepted"
or job.postbox_status
in {
"partially_accepted",
"rejected_temporary",
"rejected_permanent",
"outcome_unknown",
}
or job.imap_status == "failed"
)
def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[str, Any]]:
failed = [job for job in jobs if job.last_error or str(job.send_status).startswith("failed") or job.imap_status == "failed"]
failed = [job for job in jobs if _job_is_recent_failure(job)]
failed.sort(key=lambda job: job.updated_at or job.created_at, reverse=True)
return [
{
@@ -270,12 +441,15 @@ def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[s
"recipient_email": job.recipient_email,
"validation_status": job.validation_status,
"send_status": job.send_status,
"postbox_status": job.postbox_status,
"imap_status": job.imap_status,
"attempt_count": job.attempt_count,
"postbox_attempt_count": job.postbox_attempt_count,
"last_error": public_delivery_result_message(
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
postbox_status=job.postbox_status,
),
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
}
@@ -294,8 +468,22 @@ def _job_row(job: CampaignJob, *, include_diagnostics: bool = False) -> dict[str
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"delivery_channel_policy": getattr(
job,
"delivery_channel_policy",
"mail",
),
"postbox_status": getattr(
job,
"postbox_status",
"not_requested",
),
"imap_status": job.imap_status,
"attempt_count": job.attempt_count,
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
"postbox_target_count": len(
getattr(job, "resolved_postbox_targets", None) or []
),
"queued_at": job.queued_at.isoformat() if job.queued_at else None,
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None,
"sent_at": job.sent_at.isoformat() if job.sent_at else None,
@@ -303,6 +491,7 @@ def _job_row(job: CampaignJob, *, include_diagnostics: bool = False) -> dict[str
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
postbox_status=getattr(job, "postbox_status", "not_requested"),
),
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
@@ -393,6 +582,13 @@ def _job_evidence_row(
"cc": _address_summary(recipients.get("cc")),
"bcc": _address_summary(recipients.get("bcc")),
"reply_to": _address_summary(recipients.get("reply_to")),
"postbox_targets": "; ".join(
str(target.get("address") or target.get("name") or target.get("postbox_id") or "")
for target in (
getattr(job, "resolved_postbox_targets", None) or []
)
if isinstance(target, dict)
),
"attachment_names": _attachment_names(job.resolved_attachments),
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
@@ -427,21 +623,43 @@ def generate_campaign_report(
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
version = _selected_version(session, campaign, version_id)
jobs = _report_jobs(session, tenant_id=tenant_id, campaign_id=campaign.id, version=version)
jobs = _report_jobs(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
)
aggregate = _JobReportAggregate()
job_rows: list[dict[str, Any]] = []
retry_max_attempts = _retry_max_attempts(version)
iterator = jobs.yield_per(500) if hasattr(jobs, "yield_per") else jobs
for job in iterator:
aggregate.add(
job,
retry_max_attempts=retry_max_attempts,
include_recent_failures=include_recent_failures,
)
if include_jobs:
if len(job_rows) >= CAMPAIGN_JSON_JOB_LIMIT:
raise CampaignReportError(
"The recipient-level JSON report exceeds the safe row "
f"limit of {CAMPAIGN_JSON_JOB_LIMIT}; use the CSV export "
"or the paginated recipient table."
)
job_rows.append(
_job_row(job, include_diagnostics=include_diagnostics)
)
report = _campaign_report_payload(
session,
campaign=campaign,
version=version,
jobs=jobs,
aggregate=aggregate,
tenant_id=tenant_id,
include_recent_failures=include_recent_failures,
include_diagnostics=include_diagnostics,
)
if include_jobs:
report["jobs"] = [
_job_row(job, include_diagnostics=include_diagnostics)
for job in jobs
]
report["jobs"] = job_rows
return report
@@ -451,7 +669,7 @@ def _report_jobs(
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> list[CampaignJob]:
) -> Any:
jobs_query = session.query(CampaignJob).filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id,
@@ -460,7 +678,7 @@ def _report_jobs(
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
else:
jobs_query = jobs_query.filter(False)
return jobs_query.order_by(CampaignJob.entry_index.asc()).all()
return jobs_query.order_by(CampaignJob.entry_index.asc())
def _campaign_report_payload(
@@ -468,21 +686,26 @@ def _campaign_report_payload(
*,
campaign: Campaign,
version: CampaignVersion | None,
jobs: list[CampaignJob],
aggregate: _JobReportAggregate,
tenant_id: str,
include_recent_failures: bool,
include_diagnostics: bool,
) -> dict[str, Any]:
job_ids = [job.id for job in jobs]
report = {
"generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign),
"current_version": _version_info(version, include_diagnostics=include_diagnostics),
"selected_version_id": version.id if version else None,
"cards": _campaign_report_cards(version, jobs),
"status_counts": _campaign_report_status_counts(version, jobs),
"cards": _campaign_report_cards_from_aggregate(version, aggregate),
"status_counts": _campaign_report_status_counts_from_aggregate(
version,
aggregate,
),
"issues": {
**_issue_summary_from_jobs(jobs),
"total": aggregate.issue_total,
"by_severity": dict(aggregate.issue_severity),
"by_code": dict(aggregate.issue_code),
"by_behavior": dict(aggregate.issue_behavior),
"persisted_campaign_issue_count": _persisted_campaign_issue_count(
session,
tenant_id=tenant_id,
@@ -490,16 +713,31 @@ def _campaign_report_payload(
version=version,
),
},
"attachments": _attachment_summary(jobs),
"attempts": _campaign_report_attempt_counts(session, job_ids),
"attachments": {
"total_attachment_configs": aggregate.attachment_total,
"total_matched_files": aggregate.attachment_matches,
"zip_enabled_configs": aggregate.attachment_zip_enabled,
"missing_configs": aggregate.attachment_missing,
"ambiguous_configs": aggregate.attachment_ambiguous,
"by_status": dict(aggregate.attachment_status),
"by_behavior": dict(aggregate.attachment_behavior),
},
"attempts": _campaign_report_attempt_counts(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
"delivery": _load_delivery_info(
version,
jobs,
pending_job_count=aggregate.pending,
include_diagnostics=include_diagnostics,
),
}
if include_recent_failures:
report["recent_failures"] = _recent_failures(jobs)
report["recent_failures"] = _recent_failures(
aggregate.recent_failures,
)
return report
@@ -515,10 +753,34 @@ def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
}
def _campaign_report_attempt_counts(session: Session, job_ids: list[str]) -> dict[str, int]:
def _campaign_report_attempt_counts(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> dict[str, int]:
def count(model: type[Any]) -> int:
query = (
session.query(model)
.join(CampaignJob, model.job_id == CampaignJob.id)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id,
)
)
if version is not None:
query = query.filter(
CampaignJob.campaign_version_id == version.id,
)
else:
query = query.filter(False)
return int(query.count())
return {
"send_attempts": int(session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
"imap_append_attempts": int(session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
"send_attempts": count(SendAttempt),
"imap_append_attempts": count(ImapAppendAttempt),
"postbox_delivery_attempts": count(PostboxDeliveryAttempt),
}
@@ -541,84 +803,109 @@ def _persisted_campaign_issue_count(
def _campaign_report_status_counts(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, dict[str, int]]:
validation_counts = _counter([job.validation_status for job in jobs])
aggregate = _aggregate_job_list(version, jobs)
return _campaign_report_status_counts_from_aggregate(version, aggregate)
def _campaign_report_status_counts_from_aggregate(
version: CampaignVersion | None,
aggregate: _JobReportAggregate,
) -> dict[str, dict[str, int]]:
validation_counts = dict(aggregate.validation)
inactive_entries = _inactive_entry_count(version)
if inactive_entries:
validation_counts["inactive"] = inactive_entries
return {
"build": _counter([job.build_status for job in jobs]),
"build": dict(aggregate.build),
"validation": validation_counts,
"queue": _counter([job.queue_status for job in jobs]),
"send": _counter([job.send_status for job in jobs]),
"imap": _counter([job.imap_status for job in jobs]),
"queue": dict(aggregate.queue),
"send": dict(aggregate.send),
"postbox": dict(aggregate.postbox),
"imap": dict(aggregate.imap),
}
def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]:
send_counts = _counter([job.send_status for job in jobs])
imap_counts = _counter([job.imap_status for job in jobs])
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
queueable_unattempted = sum(
1
for job in jobs
if job.attempt_count == 0
and job.send_status in {"not_queued", "cancelled"}
and job.validation_status in {"ready", "warning"}
and job.build_status == "built"
def _campaign_report_cards_from_aggregate(
version: CampaignVersion | None,
aggregate: _JobReportAggregate,
) -> dict[str, object]:
send_counts = aggregate.send
imap_counts = aggregate.imap
sent = (
send_counts.get("sent", 0)
+ send_counts.get("smtp_accepted", 0)
+ send_counts.get("postbox_accepted", 0)
+ send_counts.get("delivered", 0)
+ send_counts.get("partially_accepted", 0)
)
retry_max_attempts = _retry_max_attempts(version)
retryable = sum(
1
for job in jobs
if job.send_status == "failed_temporary"
and (retry_max_attempts is None or job.attempt_count < retry_max_attempts)
failed = (
send_counts.get("failed_temporary", 0)
+ send_counts.get("failed_permanent", 0)
)
cancellable = sum(
1
for job in jobs
if job.send_status
not in {"skipped", "smtp_accepted", "sent", "outcome_unknown", "claimed", "sending", "cancelled"}
)
needs_attention = sum(
1
for job in jobs
if job.validation_status in {"needs_review", "blocked"}
or job.send_status in {"failed_temporary", "failed_permanent", "outcome_unknown", "claimed", "sending"}
or job.imap_status == "failed"
)
sent = send_counts.get("sent", 0) + send_counts.get("smtp_accepted", 0)
failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0)
outcome_unknown = send_counts.get("outcome_unknown", 0)
not_attempted = send_counts.get("not_queued", 0)
skipped = send_counts.get("skipped", 0)
queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 0)
cancelled = send_counts.get("cancelled", 0)
inactive_entries = _inactive_entry_count(version)
return {
"jobs_total": len(jobs),
"jobs_total": aggregate.total,
"inactive": inactive_entries,
"queueable": queueable,
"queueable_unattempted": queueable_unattempted,
"retryable": retryable,
"cancellable": cancellable,
"needs_attention": needs_attention,
"queueable": aggregate.queueable,
"queueable_unattempted": aggregate.queueable_unattempted,
"retryable": aggregate.retryable,
"cancellable": aggregate.cancellable,
"needs_attention": aggregate.needs_attention,
"sent": sent,
"smtp_accepted": sent,
"smtp_accepted": (
send_counts.get("sent", 0)
+ send_counts.get("smtp_accepted", 0)
),
"postbox_accepted": send_counts.get("postbox_accepted", 0),
"delivered": send_counts.get("delivered", 0),
"partially_accepted": send_counts.get("partially_accepted", 0),
"failed": failed,
"outcome_unknown": outcome_unknown,
"not_attempted": not_attempted,
"skipped": skipped,
"queued_or_active": queued,
"skipped": send_counts.get("skipped", 0),
"queued_or_active": aggregate.pending,
"cancelled": cancelled,
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
"partially_completed": bool(
send_counts.get("partially_accepted", 0)
or sent
and (failed or outcome_unknown or not_attempted or cancelled)
),
"imap_appended": imap_counts.get("appended", 0),
"imap_failed": imap_counts.get("failed", 0),
"imap_skipped": imap_counts.get("skipped", 0),
}
def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]:
return _campaign_report_cards_from_aggregate(
version,
_aggregate_job_list(version, jobs),
)
def _aggregate_job_list(
version: CampaignVersion | None,
jobs: list[CampaignJob],
) -> _JobReportAggregate:
aggregate = _JobReportAggregate()
retry_max_attempts = _retry_max_attempts(version)
for job in jobs:
aggregate.add(
job,
retry_max_attempts=retry_max_attempts,
include_recent_failures=False,
)
return aggregate
def _retry_max_attempts(version: CampaignVersion | None) -> int | None:
if version is None or not isinstance(version.execution_snapshot, dict):
if version is None or not isinstance(
getattr(version, "execution_snapshot", None),
dict,
):
return None
try:
return ExecutionSnapshot.model_validate(version.execution_snapshot).delivery.retry.max_attempts
@@ -652,27 +939,25 @@ def generate_jobs_csv(
jobs = (
jobs_query
.order_by(CampaignJob.entry_index.asc())
.limit(CAMPAIGN_CSV_JOB_LIMIT + 1)
.all()
)
if len(jobs) > CAMPAIGN_CSV_JOB_LIMIT:
raise CampaignReportError(
"The campaign CSV export exceeds the safe row limit of "
f"{CAMPAIGN_CSV_JOB_LIMIT}. Split the export by recipient filter."
)
job_ids = [job.id for job in jobs]
smtp_attempts = (
session.query(SendAttempt)
.filter(SendAttempt.job_id.in_(job_ids))
.order_by(SendAttempt.job_id.asc(), SendAttempt.attempt_number.asc())
.all()
if job_ids
else []
latest_smtp = _latest_attempts_for_jobs(
session,
SendAttempt,
job_ids,
)
imap_attempts = (
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id.in_(job_ids))
.order_by(ImapAppendAttempt.job_id.asc(), ImapAppendAttempt.attempt_number.asc())
.all()
if job_ids
else []
latest_imap = _latest_attempts_for_jobs(
session,
ImapAppendAttempt,
job_ids,
)
latest_smtp = _latest_by_job_id(smtp_attempts)
latest_imap = _latest_by_job_id(imap_attempts)
rows = [
_job_evidence_row(
job,
@@ -700,8 +985,13 @@ def generate_jobs_csv(
"validation_status",
"queue_status",
"send_status",
"delivery_channel_policy",
"postbox_status",
"imap_status",
"attempt_count",
"postbox_attempt_count",
"postbox_target_count",
"postbox_targets",
"queued_at",
"outcome_unknown_at",
"sent_at",
@@ -731,3 +1021,30 @@ def generate_jobs_csv(
writer.writeheader()
writer.writerows(rows)
return buffer.getvalue()
def _latest_attempts_for_jobs(
session: Session,
model: type[Any],
job_ids: list[str],
*,
batch_size: int = 500,
) -> dict[str, Any]:
latest: dict[str, Any] = {}
for offset in range(0, len(job_ids), batch_size):
batch = job_ids[offset : offset + batch_size]
attempts = (
session.query(model)
.filter(model.job_id.in_(batch))
.order_by(model.job_id.asc(), model.attempt_number.asc())
.yield_per(batch_size)
)
for attempt in attempts:
current = latest.get(attempt.job_id)
if (
current is None
or (attempt.attempt_number or 0)
>= (current.attempt_number or 0)
):
latest[attempt.job_id] = attempt
return latest

View File

@@ -110,6 +110,7 @@ def public_delivery_result_message(
last_error: Any,
send_status: Any,
imap_status: Any,
postbox_status: Any = None,
) -> str | None:
"""Map persisted provider text to a stable business-safe explanation."""
@@ -117,10 +118,22 @@ def public_delivery_result_message(
return None
clean_send_status = str(send_status or "")
clean_imap_status = str(imap_status or "")
clean_postbox_status = str(postbox_status or "")
if clean_postbox_status == "outcome_unknown":
return "Postbox delivery outcome requires operator reconciliation."
if clean_send_status == "outcome_unknown":
return "SMTP delivery outcome requires operator reconciliation."
return "Delivery outcome requires operator reconciliation."
if clean_postbox_status in {
"rejected_temporary",
"rejected_permanent",
}:
return "Postbox delivery was rejected; an operator can inspect restricted diagnostics."
if clean_postbox_status == "partially_accepted":
return "Some Postbox targets accepted the message and others rejected it."
if clean_send_status in {"failed_temporary", "failed_permanent"}:
if clean_postbox_status in {"", "not_requested"}:
return "SMTP delivery failed; an operator can inspect restricted diagnostics."
return "Delivery failed; an operator can inspect restricted diagnostics."
if clean_imap_status in {"outcome_unknown", "appending"}:
return "Sent-folder append outcome requires operator reconciliation."
if clean_imap_status in {"failed", "skipped"}:

View File

@@ -4,7 +4,7 @@ import copy
import dataclasses
import json
import logging
from collections.abc import Callable
from collections.abc import Callable, Sequence
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
@@ -29,6 +29,7 @@ from govoplan_campaign.backend.schemas import (
CampaignOwnerUpdateRequest,
CampaignAddressLookupCandidate,
CampaignAddressLookupResponse,
CampaignPostboxCatalogResponse,
CampaignRecipientAddressSource,
CampaignRecipientAddressSourcesResponse,
CampaignRecipientAddressSourceSnapshotRequest,
@@ -98,12 +99,21 @@ from govoplan_campaign.backend.db.models import (
CampaignVersionWorkflowState,
ImapAppendAttempt,
JobImapStatus,
JobPostboxStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
RecipientImportMappingProfile,
PostboxDeliveryAttempt,
SendAttempt,
)
from govoplan_campaign.backend.campaign.postbox_targets import (
delivery_catalog_payload,
)
from govoplan_campaign.backend.integrations import (
PostboxDeliveryUnavailable,
postbox_integration,
)
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.report_privacy_policy import CampaignReportPrivacyPolicyError
@@ -719,14 +729,38 @@ def create_minimal_campaign_endpoint(
@router.get("", response_model=CampaignListResponse)
def list_campaigns(
limit: int = 200,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
campaigns = _campaign_query_for_principal(session, principal).order_by(Campaign.updated_at.desc()).all()
campaigns = (
_campaign_query_for_principal(session, principal)
.order_by(Campaign.updated_at.desc())
.limit(max(1, min(limit, 500)))
.all()
)
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
def _bounded_query_rows(query, *, limit: int, label: str):
rows = query.limit(limit + 1).all()
if len(rows) > limit:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
f"{label} exceeds the maximum response size of {limit} rows. "
"Narrow the request or use a paginated/delta endpoint."
),
)
return rows
def _job_attempt_rows(query, *, label: str):
return _bounded_query_rows(query, limit=1000, label=label)
_CAMPAIGN_LIST_DELTA_COLLECTIONS = (CAMPAIGNS_COLLECTION,)
_CAMPAIGN_FULL_CURSOR_PREFIX = "full:campaigns:"
_CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS = (
CAMPAIGNS_COLLECTION,
CAMPAIGN_VERSIONS_COLLECTION,
@@ -786,14 +820,89 @@ def _campaign_entry_matches_principal(session: Session, principal: ApiPrincipal,
return False
def _campaign_list_full_delta_response(session: Session, principal: ApiPrincipal) -> CampaignDeltaResponse:
campaigns = _campaign_query_for_principal(session, principal).order_by(Campaign.updated_at.desc()).all()
def _campaign_full_cursor(
*,
page: int,
snapshot_sequence: int,
) -> str:
return (
f"{_CAMPAIGN_FULL_CURSOR_PREFIX}"
f"{int(page)}:{int(snapshot_sequence)}"
)
def _decode_campaign_full_cursor(
value: str | None,
) -> tuple[int, int] | None:
if not value or not value.startswith(_CAMPAIGN_FULL_CURSOR_PREFIX):
return None
parts = value[len(_CAMPAIGN_FULL_CURSOR_PREFIX):].split(":", 1)
if len(parts) != 2:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid campaign full snapshot cursor",
)
try:
page, snapshot_sequence = (int(item) for item in parts)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid campaign full snapshot cursor",
) from exc
if page < 1 or snapshot_sequence < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid campaign full snapshot cursor",
)
return page, snapshot_sequence
def _campaign_list_full_delta_response(
session: Session,
principal: ApiPrincipal,
*,
cursor: tuple[int, int] | None = None,
limit: int = 500,
) -> CampaignDeltaResponse:
page = cursor[0] if cursor is not None else 1
snapshot_sequence = (
cursor[1]
if cursor is not None
else decode_sequence_watermark(
_campaign_delta_watermark(
session,
principal.tenant_id,
_CAMPAIGN_LIST_DELTA_COLLECTIONS,
),
)
)
query = _campaign_query_for_principal(session, principal)
total = query.order_by(None).count()
pages = max(1, (total + limit - 1) // limit)
campaigns = (
query.order_by(Campaign.updated_at.desc(), Campaign.id.asc())
.offset((page - 1) * limit)
.limit(limit)
.all()
)
has_more = page < pages
return CampaignDeltaResponse(
campaigns=[CampaignResponse.model_validate(item) for item in campaigns],
deleted=[],
watermark=_campaign_delta_watermark(session, principal.tenant_id, _CAMPAIGN_LIST_DELTA_COLLECTIONS),
has_more=False,
watermark=(
_campaign_full_cursor(
page=page + 1,
snapshot_sequence=snapshot_sequence,
)
if has_more
else encode_sequence_watermark(snapshot_sequence)
),
has_more=has_more,
full=True,
total=total,
page=page,
page_size=limit,
pages=pages,
)
@@ -809,7 +918,11 @@ def _campaign_list_delta_response(session: Session, principal: ApiPrincipal, *,
module_id=CAMPAIGNS_MODULE_ID,
collections=_CAMPAIGN_LIST_DELTA_COLLECTIONS,
):
return _campaign_list_full_delta_response(session, principal)
return _campaign_list_full_delta_response(
session,
principal,
limit=limit,
)
entries_plus_one = sequence_entries_since(
session,
@@ -858,6 +971,10 @@ def _campaign_list_delta_response(session: Session, principal: ApiPrincipal, *,
watermark=watermark,
has_more=has_more,
full=False,
total=len(visible_campaigns),
page=1,
page_size=limit,
pages=1,
)
@@ -868,24 +985,35 @@ def list_campaigns_delta(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
if since is None:
return _campaign_list_full_delta_response(session, principal)
full_cursor = _decode_campaign_full_cursor(since)
if since is None or full_cursor is not None:
return _campaign_list_full_delta_response(
session,
principal,
cursor=full_cursor,
limit=limit,
)
return _campaign_list_delta_response(session, principal, since=since, limit=limit)
@router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse)
def list_recipient_import_mapping_profiles(
limit: int = Query(default=200, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profiles = (
profiles = _bounded_query_rows(
session.query(RecipientImportMappingProfile)
.filter(
RecipientImportMappingProfile.tenant_id == principal.tenant_id,
RecipientImportMappingProfile.owner_user_id == principal.user.id,
)
.order_by(RecipientImportMappingProfile.updated_at.desc(), RecipientImportMappingProfile.name.asc())
.all()
.order_by(
RecipientImportMappingProfile.updated_at.desc(),
RecipientImportMappingProfile.name.asc(),
),
limit=limit,
label="Recipient import mapping profile list",
)
return RecipientImportMappingProfileListResponse(
profiles=[RecipientImportMappingProfileResponse.model_validate(profile) for profile in profiles]
@@ -978,15 +1106,17 @@ def delete_recipient_import_mapping_profile(
@router.get("/aggregate-reports", response_model=AggregateReportCampaignList)
def list_aggregate_campaign_reports(
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
):
"""List only the business metadata needed to select an aggregate report."""
campaigns = (
campaigns = _bounded_query_rows(
_campaign_query_for_principal(session, principal)
.order_by(Campaign.updated_at.desc())
.all()
.order_by(Campaign.updated_at.desc(), Campaign.id.asc()),
limit=limit,
label="Aggregate report campaign list",
)
return AggregateReportCampaignList(
campaigns=[aggregate_report_campaign_item(campaign) for campaign in campaigns]
@@ -1058,6 +1188,38 @@ def list_campaign_recipient_address_sources(
)
@router.get(
"/{campaign_id}/postbox-catalog",
response_model=CampaignPostboxCatalogResponse,
)
def campaign_postbox_catalog(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("campaigns:recipient:write")
),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
integration = postbox_integration()
if not integration.available:
return CampaignPostboxCatalogResponse(available=False)
try:
payload = delivery_catalog_payload(
integration.delivery_catalog(
session,
tenant_id=principal.tenant_id,
)
)
except PostboxDeliveryUnavailable:
return CampaignPostboxCatalogResponse(available=False)
return CampaignPostboxCatalogResponse(
available=True,
postboxes=payload.get("postboxes", []),
templates=payload.get("templates", []),
organization_units=payload.get("organization_units", []),
)
@router.post("/{campaign_id}/recipient-address-sources/snapshot", response_model=CampaignRecipientAddressSourceSnapshotResponse)
def snapshot_campaign_recipient_address_source(
campaign_id: str,
@@ -1112,11 +1274,12 @@ def _campaign_workspace_response(
versions: list[CampaignVersion] = []
if include_versions or include_current_version:
versions = (
versions = _bounded_query_rows(
session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.desc())
.all()
.order_by(CampaignVersion.version_number.desc()),
limit=1000,
label="Campaign version history",
)
selected_version_id = version_id or campaign.current_version_id or (versions[0].id if versions else None)
@@ -1588,16 +1751,18 @@ def delete_draft_campaign(
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
def list_versions(
campaign_id: str,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
versions = (
versions = _bounded_query_rows(
session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.desc())
.all()
.order_by(CampaignVersion.version_number.desc()),
limit=limit,
label="Campaign version history",
)
return [
CampaignVersionResponse.model_validate(
@@ -2118,14 +2283,19 @@ def _job_summary_payload(
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"delivery_channel_policy": getattr(job, "delivery_channel_policy", "mail"),
"postbox_status": getattr(job, "postbox_status", "not_requested"),
"imap_status": job.imap_status,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"attempt_count": job.attempt_count,
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
"postbox_target_count": len(getattr(job, "resolved_postbox_targets", None) or []),
"last_error": public_delivery_result_message(
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
postbox_status=getattr(job, "postbox_status", "not_requested"),
),
"queued_at": job.queued_at,
"outcome_unknown_at": job.outcome_unknown_at,
@@ -2151,12 +2321,14 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
"issues": job.issues_snapshot or [],
"attachments": public_campaign_payload(job.resolved_attachments or []),
"resolved_recipients": job.resolved_recipients or {},
"resolved_postbox_targets": getattr(job, "resolved_postbox_targets", None) or [],
}
def _job_attempts_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
*,
include_diagnostics: bool = False,
) -> dict[str, list[dict[str, object]]]:
@@ -2191,13 +2363,43 @@ def _job_attempts_payload(
payload["claim_token"] = attempt.claim_token
payload["error_message"] = attempt.error_message
imap_payloads.append(payload)
return {"smtp": smtp_payloads, "imap": imap_payloads}
postbox_payloads: list[dict[str, object]] = []
for attempt in postbox_attempts:
payload = {
"id": attempt.id,
"target_index": attempt.target_index,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"postbox_id": attempt.postbox_id,
"address": attempt.address,
"holder_count": attempt.holder_count,
"vacant": attempt.vacant,
"duplicate": attempt.duplicate,
"target": attempt.target_snapshot or {},
"started_at": attempt.started_at,
"finished_at": attempt.finished_at,
"error_code": attempt.error_code,
}
if include_diagnostics:
payload["idempotency_key"] = attempt.idempotency_key
payload["provider_delivery_id"] = attempt.provider_delivery_id
payload["provider_message_id"] = attempt.provider_message_id
payload["evidence"] = attempt.evidence or {}
payload["error_type"] = attempt.error_type
payload["error_message"] = attempt.error_message
postbox_payloads.append(payload)
return {
"smtp": smtp_payloads,
"imap": imap_payloads,
"postbox": postbox_payloads,
}
def _job_diagnostics_payload(
job: CampaignJob,
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
) -> CampaignJobDiagnosticsResponse:
return CampaignJobDiagnosticsResponse(
job_id=job.id,
@@ -2219,6 +2421,7 @@ def _job_diagnostics_payload(
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
postbox_attempts,
include_diagnostics=True,
),
)
@@ -2307,7 +2510,14 @@ def _review_metadata_counts(review_rows: list[tuple[object, int, str, str]], rev
def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[str, int]]:
result: dict[str, dict[str, int]] = {}
for field_name in ("build_status", "validation_status", "queue_status", "send_status", "imap_status"):
for field_name in (
"build_status",
"validation_status",
"queue_status",
"send_status",
"postbox_status",
"imap_status",
):
column = getattr(CampaignJob, field_name)
rows = session.query(column, func.count(CampaignJob.id)).filter(*filters).group_by(column).all()
result[field_name.removesuffix("_status")] = {str(value or "unknown"): int(count) for value, count in rows}
@@ -2321,6 +2531,7 @@ CAMPAIGN_JOB_GRID_SORT_COLUMNS = {
"validation": CampaignJob.validation_status,
"queue": CampaignJob.queue_status,
"send": CampaignJob.send_status,
"postbox": CampaignJob.postbox_status,
"imap": CampaignJob.imap_status,
"attempts": CampaignJob.attempt_count,
"updated": CampaignJob.updated_at,
@@ -2329,6 +2540,10 @@ CAMPAIGN_JOB_GRID_LIST_FILTERS = {
"validation": (CampaignJob.validation_status, {item.value for item in JobValidationStatus}),
"queue": (CampaignJob.queue_status, {item.value for item in JobQueueStatus}),
"send": (CampaignJob.send_status, {item.value for item in JobSendStatus}),
"postbox": (
CampaignJob.postbox_status,
{item.value for item in JobPostboxStatus},
),
"imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}),
}
@@ -2643,13 +2858,14 @@ class CampaignJobsQuery:
validation_status: list[str] | None = Query(default=None),
imap_status: list[str] | None = Query(default=None),
query_text: str | None = Query(default=None, alias="q", max_length=200),
sort_by: Literal["number", "recipient", "subject", "validation", "queue", "send", "imap", "attempts", "updated"] = Query(default="number"),
sort_by: Literal["number", "recipient", "subject", "validation", "queue", "send", "postbox", "imap", "attempts", "updated"] = Query(default="number"),
sort_direction: Literal["asc", "desc"] = Query(default="asc"),
filter_recipient: str | None = Query(default=None, max_length=500),
filter_subject: str | None = Query(default=None, max_length=1000),
filter_validation: str | None = Query(default=None, max_length=1000),
filter_queue: str | None = Query(default=None, max_length=1000),
filter_send: str | None = Query(default=None, max_length=1000),
filter_postbox: str | None = Query(default=None, max_length=1000),
filter_imap: str | None = Query(default=None, max_length=1000),
filter_attempts: str | None = Query(default=None, max_length=100),
filter_evidence: str | None = Query(default=None, max_length=500),
@@ -2672,6 +2888,7 @@ class CampaignJobsQuery:
"validation": filter_validation,
"queue": filter_queue,
"send": filter_send,
"postbox": filter_postbox,
"imap": filter_imap,
"attempts": filter_attempts,
"evidence": filter_evidence,
@@ -2966,21 +3183,34 @@ def get_job_detail(
job = session.get(CampaignJob, job_id)
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
send_attempts = (
send_attempts = _job_attempt_rows(
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.asc())
.all()
.order_by(SendAttempt.attempt_number.asc()),
label="SMTP attempts for this campaign job",
)
imap_attempts = (
imap_attempts = _job_attempt_rows(
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.asc())
.all()
.order_by(ImapAppendAttempt.attempt_number.asc()),
label="IMAP attempts for this campaign job",
)
postbox_attempts = _job_attempt_rows(
session.query(PostboxDeliveryAttempt)
.filter(PostboxDeliveryAttempt.job_id == job.id)
.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.asc(),
),
label="Postbox attempts for this campaign job",
)
return CampaignJobDetailResponse(
job=_job_detail_payload(job),
attempts=_job_attempts_payload(send_attempts, imap_attempts),
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
postbox_attempts,
),
)
@@ -3002,19 +3232,33 @@ def get_job_diagnostics(
job = session.get(CampaignJob, job_id)
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
send_attempts = (
send_attempts = _job_attempt_rows(
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.asc())
.all()
.order_by(SendAttempt.attempt_number.asc()),
label="SMTP diagnostics for this campaign job",
)
imap_attempts = (
imap_attempts = _job_attempt_rows(
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.asc())
.all()
.order_by(ImapAppendAttempt.attempt_number.asc()),
label="IMAP diagnostics for this campaign job",
)
postbox_attempts = _job_attempt_rows(
session.query(PostboxDeliveryAttempt)
.filter(PostboxDeliveryAttempt.job_id == job.id)
.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.asc(),
),
label="Postbox diagnostics for this campaign job",
)
return _job_diagnostics_payload(
job,
send_attempts,
imap_attempts,
postbox_attempts,
)
return _job_diagnostics_payload(job, send_attempts, imap_attempts)
@router.get("/{campaign_id}/summary")
@@ -3156,6 +3400,7 @@ def email_campaign_report(
@router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse)
def list_campaign_share_targets(
campaign_id: str,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
):
@@ -3163,6 +3408,14 @@ def list_campaign_share_targets(
directory = _access_directory()
users = [user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active"]
groups = [group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active"]
if len(users) > limit or len(groups) > limit:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
f"Campaign share targets exceed the maximum response size of {limit} "
"users or groups. Use a searchable directory selector."
),
)
return CampaignShareTargetsResponse(
users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users],
groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) for item in groups],
@@ -3172,17 +3425,35 @@ def list_campaign_share_targets(
@router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse)
def list_campaign_shares(
campaign_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
shares = (
query = (
session.query(CampaignShare)
.filter(CampaignShare.tenant_id == principal.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None))
.order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc())
)
total = query.order_by(None).count()
pages = max(1, (total + page_size - 1) // page_size)
shares = (
query.order_by(
CampaignShare.target_type.asc(),
CampaignShare.target_id.asc(),
CampaignShare.id.asc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return CampaignShareListResponse(shares=[CampaignShareItem.model_validate(item) for item in shares])
return CampaignShareListResponse(
shares=[CampaignShareItem.model_validate(item) for item in shares],
total=total,
page=page,
page_size=page_size,
pages=pages,
)
@router.put("/{campaign_id}/owner", response_model=CampaignResponse)
@@ -3306,7 +3577,8 @@ def campaign_delivery_options(
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
),
postbox_available=postbox_integration().available,
)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@@ -3476,6 +3748,7 @@ def resolve_campaign_job_outcome(
job_id=job_id,
decision=payload.decision,
note=payload.note,
attempt_id=payload.attempt_id,
commit=False,
)
audit_from_principal(

View File

@@ -60,7 +60,9 @@
"integer",
"double",
"date",
"password"
"password",
"organization_unit",
"organization_function"
],
"default": "string"
},
@@ -529,6 +531,12 @@
"delivery": {
"type": "object",
"properties": {
"channel_policy": {
"$ref": "#/$defs/delivery_channel_policy"
},
"postbox": {
"$ref": "#/$defs/postbox_delivery"
},
"rate_limit": {
"type": "object",
"properties": {
@@ -646,6 +654,179 @@
},
"additionalProperties": false
},
"delivery_channel_policy": {
"type": "string",
"enum": [
"mail",
"postbox",
"mail_and_postbox",
"mail_then_postbox",
"postbox_then_mail"
],
"default": "mail"
},
"postbox_target": {
"type": "object",
"required": [
"id",
"mode"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"mode": {
"type": "string",
"enum": [
"direct",
"derived"
]
},
"label": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"postbox_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"address_key": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"template_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"organization_unit_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"organization_unit_field": {
"type": [
"string",
"null"
],
"maxLength": 255
},
"organization_unit_match": {
"type": "string",
"enum": [
"id",
"slug"
],
"default": "id"
},
"function_id": {
"type": [
"string",
"null"
],
"maxLength": 36
},
"function_field": {
"type": [
"string",
"null"
],
"maxLength": 255
},
"function_match": {
"type": "string",
"enum": [
"id",
"slug"
],
"default": "id"
},
"context_key": {
"type": [
"string",
"null"
],
"maxLength": 255
},
"context_field": {
"type": [
"string",
"null"
],
"maxLength": 255
}
},
"additionalProperties": false
},
"postbox_delivery": {
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {
"$ref": "#/$defs/postbox_target"
},
"maxItems": 50,
"default": []
},
"classification": {
"type": "string",
"minLength": 1,
"maxLength": 50,
"default": "internal"
},
"unresolved_target": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "block"
},
"vacant_target": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "warn"
},
"duplicate_target": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "warn"
}
},
"additionalProperties": false,
"default": {}
},
"attachment_config": {
"type": "object",
"required": [
@@ -889,6 +1070,29 @@
"default": true,
"description": "Deprecated compatibility alias for merge_disposition_notification_to. New campaign JSON should use merge_*."
},
"channel_policy": {
"oneOf": [
{
"$ref": "#/$defs/delivery_channel_policy"
},
{
"type": "null"
}
],
"default": null
},
"postbox_targets": {
"type": "array",
"items": {
"$ref": "#/$defs/postbox_target"
},
"maxItems": 50,
"default": []
},
"merge_postbox_targets": {
"type": "boolean",
"default": true
},
"attachments": {
"type": "array",
"items": {

View File

@@ -194,6 +194,10 @@ class CampaignDeltaResponse(BaseModel):
watermark: str | None = None
has_more: bool = False
full: bool = False
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse):
@@ -216,6 +220,10 @@ class CampaignShareItem(BaseModel):
class CampaignShareListResponse(BaseModel):
shares: list[CampaignShareItem]
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class CampaignShareTargetItem(BaseModel):
@@ -342,6 +350,13 @@ class CampaignRecipientAddressSourcesResponse(BaseModel):
sources: list[CampaignRecipientAddressSource] = Field(default_factory=list)
class CampaignPostboxCatalogResponse(BaseModel):
available: bool = False
postboxes: list[dict[str, Any]] = Field(default_factory=list)
templates: list[dict[str, Any]] = Field(default_factory=list)
organization_units: list[dict[str, Any]] = Field(default_factory=list)
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -439,8 +454,11 @@ class CampaignResolveOutcomeRequest(BaseModel):
"not_sent",
"imap_appended",
"imap_not_appended",
"postbox_accepted",
"postbox_not_accepted",
]
note: str | None = Field(default=None, max_length=2000)
attempt_id: str | None = Field(default=None, max_length=36)
@model_validator(mode="after")
def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest":
@@ -520,6 +538,7 @@ class CampaignDeliveryOptionsResponse(BaseModel):
campaign_id: str
version_id: str
worker_queue_available: bool
postbox_available: bool = False
synchronous_send: dict[str, Any] = Field(default_factory=dict)

View File

@@ -9,7 +9,10 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.campaign.models import (
DeliveryChannelPolicy,
DeliveryConfig,
)
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
assert_campaign_uses_mail_profile_reference,
@@ -19,7 +22,8 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
SNAPSHOT_VERSION = "6"
SNAPSHOT_VERSION = "7"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", SNAPSHOT_VERSION}
class ExecutionSnapshotError(RuntimeError):
@@ -41,7 +45,7 @@ class ExecutionSnapshot(BaseModel):
snapshot_version: str = SNAPSHOT_VERSION
campaign_version_id: str
campaign_json_sha256: str
mail_profile_id: str
mail_profile_id: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
imap_server_id: str | None = None
@@ -55,6 +59,8 @@ class ExecutionSnapshot(BaseModel):
effective_policy_sha256: str | None = None
smtp_transport_revision: str | None = None
imap_transport_revision: str | None = None
uses_mail: bool = True
uses_postbox: bool = False
delivery: DeliveryConfig
@@ -72,7 +78,7 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json)
_assert_version_mail_profile_boundary(raw_json, require_profile=True)
mail = mail_integration()
profile_id = campaign_mail_profile_id(raw_json)
if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
@@ -106,7 +112,12 @@ def profile_transport_revisions(session: Session, version: CampaignVersion) -> d
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json)
_assert_version_mail_profile_boundary(
raw_json,
require_profile=snapshot.uses_mail,
)
if not snapshot.uses_mail:
return
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
raise ExecutionSnapshotError(
"The campaign's Mail profile reference differs from the built execution snapshot. "
@@ -127,19 +138,35 @@ def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot:
)
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None:
def _assert_version_mail_profile_boundary(
raw_json: dict[str, Any],
*,
require_profile: bool,
) -> None:
try:
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True)
assert_campaign_uses_mail_profile_reference(
raw_json,
require_profile=require_profile,
)
except CampaignMailProfileBoundaryError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
def _policy_fingerprint(
raw_json: dict[str, Any],
delivery: DeliveryConfig,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
delivery_payload = delivery.model_dump(mode="json")
if snapshot_version == "6":
delivery_payload.pop("channel_policy", None)
delivery_payload.pop("postbox", None)
return _sha256(
{
"validation_policy": raw_json.get("validation_policy"),
"policy": raw_json.get("policy"),
"delivery": delivery.model_dump(mode="json"),
"delivery": delivery_payload,
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
if isinstance(raw_json.get("attachments"), dict)
else None,
@@ -147,8 +174,12 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
)
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
return {
def _job_execution_input_payload(
job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> dict[str, Any]:
payload = {
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
@@ -163,17 +194,47 @@ def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []),
}
if snapshot_version != "6":
payload.update(
{
"delivery_channel_policy": getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
),
"resolved_postbox_targets_sha256": _sha256(
getattr(job, "resolved_postbox_targets", None) or []
),
}
)
return payload
def job_execution_input_hash(job: CampaignJob) -> str:
return _sha256(_job_execution_input_payload(job))
def job_execution_input_hash(
job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
return _sha256(
_job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
)
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
def job_manifest_hash(
jobs: Iterable[CampaignJob],
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
"""Hash the immutable per-message execution records in stable order."""
payload = [
_job_execution_input_payload(job)
_job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
]
return _sha256(payload)
@@ -182,8 +243,8 @@ def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
def create_execution_snapshot(
version: CampaignVersion,
*,
mail_profile_id: str,
smtp_transport_revision: str,
mail_profile_id: str | None,
smtp_transport_revision: str | None,
imap_transport_revision: str | None,
delivery: DeliveryConfig,
smtp_server_id: str | None = None,
@@ -195,8 +256,23 @@ def create_execution_snapshot(
) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs)
channel_policies = {
DeliveryChannelPolicy(
getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
)
)
for job in job_list
}
uses_mail = any(policy.uses_mail for policy in channel_policies)
uses_postbox = any(policy.uses_postbox for policy in channel_policies)
for job in job_list:
job.execution_input_sha256 = job_execution_input_hash(job)
job.execution_input_sha256 = job_execution_input_hash(
job,
snapshot_version=SNAPSHOT_VERSION,
)
summary = build_summary if isinstance(build_summary, dict) else {}
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
payload = ExecutionSnapshot(
@@ -211,10 +287,23 @@ def create_execution_snapshot(
built_at=str(summary.get("built_at") or "") or None,
job_count=len(job_list),
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
job_manifest_sha256=(
job_manifest_hash(
job_list,
snapshot_version=SNAPSHOT_VERSION,
)
if job_list
else None
),
effective_policy_sha256=_policy_fingerprint(
raw_json,
delivery,
snapshot_version=SNAPSHOT_VERSION,
),
smtp_transport_revision=smtp_transport_revision,
imap_transport_revision=imap_transport_revision,
uses_mail=uses_mail,
uses_postbox=uses_postbox,
created_at=datetime.now(timezone.utc).isoformat(),
delivery=delivery,
).model_dump(mode="json")
@@ -238,13 +327,17 @@ def _assert_snapshot_matches_persisted_inputs(
"Campaign inputs changed after this execution snapshot was built. "
"Revalidate and rebuild the campaign before delivery."
)
if not snapshot.smtp_transport_revision:
if snapshot.uses_mail and not snapshot.smtp_transport_revision:
raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision")
if not snapshot.job_manifest_sha256:
raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum")
if not snapshot.effective_policy_sha256:
raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum")
if snapshot.effective_policy_sha256 != _policy_fingerprint(raw_json, snapshot.delivery):
if snapshot.effective_policy_sha256 != _policy_fingerprint(
raw_json,
snapshot.delivery,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError(
"Campaign delivery policy changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
@@ -255,7 +348,10 @@ def _assert_snapshot_matches_persisted_inputs(
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
if not getattr(effect_job, "execution_input_sha256", None):
raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery")
if effect_job.execution_input_sha256 != job_execution_input_hash(effect_job):
if effect_job.execution_input_sha256 != job_execution_input_hash(
effect_job,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
@@ -277,8 +373,19 @@ def _assert_snapshot_matches_persisted_inputs(
queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses)
if (
snapshot.queueable_job_count != queueable_count
or snapshot.job_manifest_sha256 != job_manifest_hash(jobs)
or any(getattr(job, "execution_input_sha256", None) != job_execution_input_hash(job) for job in jobs)
or snapshot.job_manifest_sha256
!= job_manifest_hash(
jobs,
snapshot_version=snapshot.snapshot_version,
)
or any(
getattr(job, "execution_input_sha256", None)
!= job_execution_input_hash(
job,
snapshot_version=snapshot.snapshot_version,
)
for job in jobs
)
):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
@@ -307,17 +414,20 @@ def ensure_execution_snapshot(
)
except CampaignPathSecurityError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
_assert_version_mail_profile_boundary(raw_json)
_assert_version_mail_profile_boundary(raw_json, require_profile=False)
if isinstance(version.execution_snapshot, dict):
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION:
stored_version = str(
version.execution_snapshot.get("snapshot_version") or ""
)
if stored_version not in SUPPORTED_SNAPSHOT_VERSIONS:
raise ExecutionSnapshotError(
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. "
"It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
"and rebuild a new campaign version."
)
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
expected = snapshot_hash(snapshot.model_dump(mode="json"))
expected = snapshot_hash(version.execution_snapshot)
if not version.execution_snapshot_hash:
raise ExecutionSnapshotError("Execution snapshot checksum is missing")
if version.execution_snapshot_hash != expected:
@@ -334,11 +444,6 @@ def ensure_execution_snapshot(
from govoplan_campaign.backend.persistence.campaigns import load_version_config
_, _, config = load_version_config(session, version.id)
profile_id = campaign_mail_profile_id(raw_json)
if not config.server.profile_capabilities.smtp_available:
raise ExecutionSnapshotError("The selected Mail profile has no SMTP configuration")
if profile_id is None:
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
@@ -347,9 +452,24 @@ def ensure_execution_snapshot(
)
if not jobs:
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
uses_mail = any(
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
for job in jobs
)
profile_id = campaign_mail_profile_id(raw_json)
summary: dict[str, Any] = {}
if uses_mail:
if not config.server.profile_capabilities.smtp_available:
raise ExecutionSnapshotError(
"The selected Mail profile has no SMTP configuration"
)
if profile_id is None:
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
summary = profile_delivery_summary(session, version)
if not summary.get("smtp_transport_revision"):
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
raise ExecutionSnapshotError(
"The selected Mail profile has no SMTP transport revision"
)
payload, digest = create_execution_snapshot(
version,
mail_profile_id=profile_id,
@@ -357,7 +477,7 @@ def ensure_execution_snapshot(
smtp_credential_id=summary.get("smtp_credential_id"),
imap_server_id=summary.get("imap_server_id"),
imap_credential_id=summary.get("imap_credential_id"),
smtp_transport_revision=summary["smtp_transport_revision"],
smtp_transport_revision=summary.get("smtp_transport_revision"),
imap_transport_revision=summary.get("imap_transport_revision"),
delivery=config.delivery,
jobs=jobs,

View File

@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
@@ -22,12 +23,15 @@ from govoplan_campaign.backend.db.models import (
CampaignVersionWorkflowState,
JobBuildStatus,
JobImapStatus,
JobPostboxStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
ImapAppendAttempt,
PostboxDeliveryAttempt,
SendAttempt,
)
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError,
SynchronousSendPolicy,
@@ -39,6 +43,10 @@ from govoplan_campaign.backend.sending.execution import (
ensure_execution_snapshot,
profile_delivery_summary,
)
from govoplan_campaign.backend.sending.postbox_delivery import (
PostboxChannelOutcome,
deliver_campaign_job_to_postboxes,
)
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.integrations import (
ImapAppendError,
@@ -180,6 +188,7 @@ class AppendSentResult:
@dataclass(frozen=True, slots=True)
class _CampaignDeliveryCounts:
accepted: int
partial: int
unknown: int
failed: int
active: int
@@ -192,15 +201,41 @@ class _SendJobDeliveryContext:
version: CampaignVersion
snapshot: ExecutionSnapshot
message_bytes: bytes
envelope_from: str
envelope_from: str | None
envelope_recipients: list[str]
@dataclass(frozen=True, slots=True)
class _MailChannelOutcome:
accepted: bool = False
rejected_temporary: bool = False
rejected_permanent: bool = False
outcome_unknown: bool = False
message: str | None = None
@property
def rejected_before_acceptance(self) -> bool:
return (
not self.accepted
and not self.outcome_unknown
and (self.rejected_temporary or self.rejected_permanent)
)
QUEUEABLE_VALIDATION_STATUSES = {
JobValidationStatus.READY.value,
JobValidationStatus.WARNING.value,
}
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
DELIVERY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.POSTBOX_ACCEPTED.value,
JobSendStatus.DELIVERED.value,
JobSendStatus.PARTIALLY_ACCEPTED.value,
}
FULLY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.POSTBOX_ACCEPTED.value,
JobSendStatus.DELIVERED.value,
}
DELIVERY_MODE_SYNCHRONOUS = "synchronous"
DELIVERY_MODE_WORKER_QUEUE = "worker_queue"
DELIVERY_MODE_DATABASE_QUEUE = "database_queue"
@@ -211,7 +246,7 @@ DELIVERY_MODES = {
}
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = SMTP_ACCEPTED_STATUSES | {
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = DELIVERY_ACCEPTED_STATUSES | {
JobSendStatus.SKIPPED.value,
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
@@ -835,7 +870,7 @@ def send_campaign_now(
)
result_dict = result.as_dict()
results.append(result_dict)
if result.status in {JobSendStatus.SMTP_ACCEPTED.value, "already_accepted"}:
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
sent_count += 1
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
outcome_unknown_count += 1
@@ -902,8 +937,14 @@ def _preflight_synchronous_send_batch(
f"Job {job.id} changed to {state_result.status} during synchronous preflight"
)
contexts[job.id] = _send_job_delivery_context(session, job)
mail_contexts = [
context
for context in contexts.values()
if getattr(context.snapshot, "uses_mail", True)
]
if mail_contexts:
profile_summary = profile_delivery_summary(session, version)
expected_revision = next(iter(contexts.values())).snapshot.smtp_transport_revision
expected_revision = mail_contexts[0].snapshot.smtp_transport_revision
if profile_summary.get("smtp_transport_revision") != expected_revision:
raise SendJobError(
"The selected Mail profile changed after this execution was built. Revalidate and rebuild before delivery."
@@ -1019,7 +1060,7 @@ def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str)
if job.send_status == JobSendStatus.SKIPPED.value:
skipped_count += 1
continue
if job.send_status in SMTP_ACCEPTED_STATUSES | {
if job.send_status in DELIVERY_ACCEPTED_STATUSES | {
JobSendStatus.OUTCOME_UNKNOWN.value,
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
@@ -1074,13 +1115,21 @@ def queue_failed_jobs_for_retry(
enqueue_celery: bool = True,
dry_run: bool = False,
) -> dict[str, Any]:
"""Explicitly queue failed jobs; never includes uncertain or accepted jobs."""
"""Queue known failures and incomplete multi-channel deliveries.
Accepted SMTP attempts and accepted Postbox targets remain immutable and
are skipped by the channel orchestrator. Unknown outcomes are deliberately
excluded until an operator reconciles them.
"""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_version_for_campaign(session, campaign, version_id=version_id)
_ensure_version_validated_and_locked(version)
snapshot = ensure_execution_snapshot(session, version)
allowed = {JobSendStatus.FAILED_TEMPORARY.value}
allowed = {
JobSendStatus.FAILED_TEMPORARY.value,
JobSendStatus.PARTIALLY_ACCEPTED.value,
}
if include_permanent:
allowed.add(JobSendStatus.FAILED_PERMANENT.value)
@@ -1096,7 +1145,19 @@ def queue_failed_jobs_for_retry(
if job.send_status not in allowed:
skipped.append({"job_id": job.id, "reason": f"status {job.send_status} is not an explicit retry candidate"})
continue
if not force_max_attempts and job.attempt_count >= snapshot.delivery.retry.max_attempts:
delivery_rounds = max(
job.attempt_count,
int(
session.query(func.max(PostboxDeliveryAttempt.attempt_number))
.filter(PostboxDeliveryAttempt.job_id == job.id)
.scalar()
or 0
),
)
if (
not force_max_attempts
and delivery_rounds >= snapshot.delivery.retry.max_attempts
):
skipped.append({"job_id": job.id, "reason": "configured maximum attempts reached"})
continue
selected.append(job)
@@ -1165,6 +1226,7 @@ def queue_unattempted_jobs(
):
eligible = (
job.attempt_count == 0
and job.postbox_attempt_count == 0
and job.send_status in {JobSendStatus.NOT_QUEUED.value, JobSendStatus.CANCELLED.value}
and job.build_status == JobBuildStatus.BUILT.value
and job.validation_status in QUEUEABLE_VALIDATION_STATUSES
@@ -1251,7 +1313,11 @@ def send_single_campaign_job(
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=f"Would send to {len(context.envelope_recipients)} recipient(s) from {context.envelope_from}",
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(),
}
@@ -1300,14 +1366,20 @@ def _prepare_single_job_for_send(
) -> str:
if job.queue_status == JobQueueStatus.QUEUED.value and job.send_status == JobSendStatus.QUEUED.value:
return "already_queued"
if job.send_status in SMTP_ACCEPTED_STATUSES:
raise QueueingError("This message has already been accepted by SMTP and cannot be sent again from preview.")
if job.send_status in DELIVERY_ACCEPTED_STATUSES:
raise QueueingError(
"This message has already been accepted by a delivery channel and "
"cannot be sent again from preview."
)
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 again.")
if job.send_status in {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}:
raise QueueingError("This message has failed before. Use the explicit retry action so the retry is visible in the delivery protocol.")
if job.attempt_count > 0:
raise QueueingError("This message already has SMTP attempts. Use retry or reconciliation instead of preview send.")
if job.attempt_count > 0 or job.postbox_attempt_count > 0:
raise QueueingError(
"This message already has delivery attempts. Use retry or "
"reconciliation instead of preview send."
)
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):
@@ -1356,6 +1428,7 @@ def reconcile_job_outcome(
job_id: str,
decision: str,
note: str | None = None,
attempt_id: str | None = None,
commit: bool = True,
) -> dict[str, Any]:
"""Record an operator decision for an uncertain/incomplete worker state.
@@ -1379,6 +1452,16 @@ def reconcile_job_outcome(
note=note,
commit=commit,
)
if decision in {"postbox_accepted", "postbox_not_accepted"}:
return _reconcile_postbox_outcome(
session,
campaign=campaign,
job=job,
decision=decision,
note=note,
attempt_id=attempt_id,
commit=commit,
)
evidence_note = (note or "").strip()
if not evidence_note:
raise QueueingError("SMTP reconciliation requires an evidence note")
@@ -1430,6 +1513,131 @@ def reconcile_job_outcome(
}
def _postbox_outcome_from_attempts(
session: Session,
job: CampaignJob,
) -> PostboxChannelOutcome:
attempts = (
session.query(PostboxDeliveryAttempt)
.filter(PostboxDeliveryAttempt.job_id == job.id)
.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.desc(),
)
.all()
)
latest_by_target: dict[str, PostboxDeliveryAttempt] = {}
for attempt in attempts:
latest_by_target.setdefault(attempt.target_key, attempt)
outcome = PostboxChannelOutcome()
for attempt in latest_by_target.values():
if attempt.status == JobPostboxStatus.ACCEPTED.value:
outcome.accepted += 1
elif attempt.status == JobPostboxStatus.ACCEPTED_VACANT.value:
outcome.accepted_vacant += 1
elif attempt.status == JobPostboxStatus.REJECTED_TEMPORARY.value:
outcome.rejected_temporary += 1
elif attempt.status == JobPostboxStatus.REJECTED_PERMANENT.value:
outcome.rejected_permanent += 1
elif attempt.status in {
JobPostboxStatus.OUTCOME_UNKNOWN.value,
JobPostboxStatus.DELIVERING.value,
}:
outcome.outcome_unknown += 1
return outcome
def _reconcile_postbox_outcome(
session: Session,
*,
campaign: Campaign,
job: CampaignJob,
decision: str,
note: str | None,
attempt_id: str | None,
commit: bool,
) -> dict[str, Any]:
evidence_note = (note or "").strip()
if not evidence_note:
raise QueueingError("Postbox reconciliation requires an evidence note")
unknown_query = session.query(PostboxDeliveryAttempt).filter(
PostboxDeliveryAttempt.job_id == job.id,
PostboxDeliveryAttempt.status.in_(
[
JobPostboxStatus.OUTCOME_UNKNOWN.value,
JobPostboxStatus.DELIVERING.value,
]
),
)
if attempt_id:
unknown_query = unknown_query.filter(
PostboxDeliveryAttempt.id == attempt_id
)
unknown_attempts = unknown_query.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.desc(),
).all()
if not unknown_attempts:
raise QueueingError(
"No unresolved Postbox attempt matches this reconciliation."
)
if not attempt_id and len(unknown_attempts) > 1:
raise QueueingError(
"More than one Postbox attempt is unresolved; select a specific "
"attempt before reconciling."
)
attempt = unknown_attempts[0]
evidence = dict(attempt.evidence or {})
evidence["operator_reconciliation"] = {
"decision": decision,
"note": evidence_note,
"at": _utcnow().isoformat(),
}
attempt.evidence = evidence
attempt.error_type = "OperatorReconciliation"
attempt.error_message = evidence_note
attempt.finished_at = attempt.finished_at or _utcnow()
attempt.status = (
JobPostboxStatus.ACCEPTED.value
if decision == "postbox_accepted"
else JobPostboxStatus.REJECTED_TEMPORARY.value
)
session.add(attempt)
session.flush()
postbox_outcome = _postbox_outcome_from_attempts(session, job)
postbox_outcome.messages.append(evidence_note)
job.postbox_status = postbox_outcome.status
mail_outcome = (
_MailChannelOutcome(accepted=True)
if _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
)
else None
)
result = _finalize_multichannel_job(
session,
job_id=job.id,
channel_policy=DeliveryChannelPolicy(job.delivery_channel_policy),
mail=mail_outcome,
postbox=postbox_outcome,
commit=commit,
)
return {
"campaign_id": campaign.id,
"version_id": job.campaign_version_id,
"job_id": job.id,
"channel": "postbox",
"attempt_id": attempt.id,
"decision": decision,
"send_status": result.status,
"postbox_status": job.postbox_status,
"note": evidence_note,
}
def _reconcile_imap_append_outcome(
session: Session,
*,
@@ -1444,7 +1652,11 @@ def _reconcile_imap_append_outcome(
evidence_note = (note or "").strip()
if not evidence_note:
raise QueueingError("IMAP reconciliation requires an evidence note")
if job.send_status not in SMTP_ACCEPTED_STATUSES:
if not _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
):
raise QueueingError("Only an SMTP-accepted job can reconcile a Sent-folder append")
if job.imap_status != JobImapStatus.OUTCOME_UNKNOWN.value:
raise QueueingError(f"IMAP status {job.imap_status} does not require reconciliation")
@@ -1682,7 +1894,11 @@ def _campaign_delivery_counts(session: Session, *, campaign_id: str, version_id:
for status in {item.value for item in JobSendStatus}
}
return _CampaignDeliveryCounts(
accepted=sum(counts.get(status, 0) for status in SMTP_ACCEPTED_STATUSES),
accepted=sum(
counts.get(status, 0)
for status in DELIVERY_ACCEPTED_STATUSES
),
partial=counts.get(JobSendStatus.PARTIALLY_ACCEPTED.value, 0),
unknown=counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0),
failed=counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0),
active=(
@@ -1732,7 +1948,13 @@ def _apply_campaign_delivery_status(
def _campaign_delivery_status_pair(counts: _CampaignDeliveryCounts) -> tuple[str, str] | None:
if counts.active:
return CampaignStatus.SENDING.value, CampaignVersionWorkflowState.SENDING.value
if counts.accepted and (counts.failed or counts.unknown or counts.cancelled or counts.not_started):
if counts.accepted and (
counts.partial
or counts.failed
or counts.unknown
or counts.cancelled
or counts.not_started
):
return CampaignStatus.PARTIALLY_COMPLETED.value, CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value
if counts.unknown:
return CampaignStatus.OUTCOME_UNKNOWN.value, CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value
@@ -1762,12 +1984,22 @@ def send_campaign_job(
context = _send_job_delivery_context(session, job)
if dry_run:
policy = DeliveryChannelPolicy(job.delivery_channel_policy)
descriptions: list[str] = []
if policy.uses_mail:
descriptions.append(
f"Mail to {len(context.envelope_recipients)} recipient(s)"
)
if policy.uses_postbox:
descriptions.append(
f"Postbox to {len(job.resolved_postbox_targets or [])} target(s)"
)
return SendJobResult(
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=f"Would send to {len(context.envelope_recipients)} recipient(s) from {context.envelope_from}",
message=f"Would deliver via {'; '.join(descriptions)}",
)
claimed = _claimed_campaign_job_for_delivery(session, job)
@@ -1794,7 +2026,7 @@ def _preflight_send_campaign_job(
return SendJobResult(job_id=job.id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
if job.queue_status == JobQueueStatus.PAUSED.value:
return SendJobResult(job_id=job.id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
if job.send_status in SMTP_ACCEPTED_STATUSES:
if job.send_status in FULLY_ACCEPTED_STATUSES:
return SendJobResult(job_id=job.id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run)
if job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value:
return SendJobResult(
@@ -1802,13 +2034,13 @@ def _preflight_send_campaign_job(
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=job.attempt_count,
dry_run=dry_run,
message="SMTP outcome is unresolved; reconcile it before any retry.",
message="A delivery outcome is unresolved; reconcile it before any retry.",
)
if job.send_status == JobSendStatus.SENDING.value:
return mark_job_outcome_unknown(
session,
job,
reason="A delivery task resumed while the previous SMTP attempt was still marked in progress. Automatic resend was stopped.",
reason="A delivery task resumed while the previous channel attempt was still marked in progress. Automatic redelivery was stopped.",
)
if job.send_status == JobSendStatus.CLAIMED.value:
return SendJobResult(
@@ -1833,10 +2065,18 @@ def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDe
raise SendJobError(str(exc)) from exc
message_bytes = _load_eml_bytes_for_job(job)
channel_policy = DeliveryChannelPolicy(
getattr(job, "delivery_channel_policy", DeliveryChannelPolicy.MAIL.value)
)
envelope_from: str | None = None
envelope_recipients: list[str] = []
if channel_policy.uses_mail:
envelope_from = _sender_from_job(job)
envelope_recipients = _recipients_from_job(job)
if not envelope_recipients:
raise SmtpConfigurationError("No envelope recipients could be determined")
raise SmtpConfigurationError(
"No envelope recipients could be determined"
)
return _SendJobDeliveryContext(
version=version,
snapshot=snapshot,
@@ -1887,6 +2127,42 @@ def _send_claimed_campaign_job(
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> SendJobResult:
channel_policy = DeliveryChannelPolicy(
getattr(job, "delivery_channel_policy", DeliveryChannelPolicy.MAIL.value)
)
if channel_policy == DeliveryChannelPolicy.MAIL:
return _send_claimed_mail_only_job(
session,
job=job,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
return _send_claimed_multichannel_job(
session,
job=job,
claim_token=claim_token,
context=context,
channel_policy=channel_policy,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
def _send_claimed_mail_only_job(
session: Session,
*,
job: CampaignJob,
claim_token: str,
context: _SendJobDeliveryContext,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> SendJobResult:
if context.envelope_from is None or not context.envelope_recipients:
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}",
messages_per_minute=context.snapshot.delivery.rate_limit.messages_per_minute,
@@ -1954,6 +2230,310 @@ def _send_claimed_campaign_job(
return success
def _mail_was_accepted(
session: Session,
job_id: str,
*,
send_status: str | None = None,
) -> bool:
if send_status in {
JobSendStatus.SMTP_ACCEPTED.value,
JobSendStatus.SENT.value,
JobSendStatus.DELIVERED.value,
}:
return True
return (
session.query(SendAttempt.id)
.filter(
SendAttempt.job_id == job_id,
SendAttempt.status.in_(
[
JobSendStatus.SMTP_ACCEPTED.value,
"smtp_accepted_with_refusals",
"reconciled_smtp_accepted",
]
),
)
.first()
is not None
)
def _deliver_mail_channel(
session: Session,
*,
job: CampaignJob,
claim_token: str,
context: _SendJobDeliveryContext,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> _MailChannelOutcome:
if _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
):
return _MailChannelOutcome(accepted=True)
try:
result = _send_claimed_mail_only_job(
session,
job=job,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
except Exception as exc:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError(
"Campaign job disappeared while recording Mail delivery."
) from exc
if current.send_status == JobSendStatus.OUTCOME_UNKNOWN.value:
return _MailChannelOutcome(
outcome_unknown=True,
message=current.last_error or str(exc),
)
if current.send_status == JobSendStatus.FAILED_TEMPORARY.value:
return _MailChannelOutcome(
rejected_temporary=True,
message=current.last_error or str(exc),
)
if current.send_status == JobSendStatus.FAILED_PERMANENT.value:
return _MailChannelOutcome(
rejected_permanent=True,
message=current.last_error or str(exc),
)
unknown = mark_job_outcome_unknown(
session,
current,
reason=(
"Mail delivery raised an unexpected error after its durable "
"attempt started. The outcome is unknown and no fallback was "
f"started: {exc}"
),
)
return _MailChannelOutcome(
outcome_unknown=True,
message=unknown.message,
)
return _MailChannelOutcome(
accepted=result.status
in {
JobSendStatus.SMTP_ACCEPTED.value,
"already_accepted",
},
outcome_unknown=result.status == JobSendStatus.OUTCOME_UNKNOWN.value,
message=result.message,
)
def _empty_postbox_outcome() -> PostboxChannelOutcome:
return PostboxChannelOutcome()
def _final_multichannel_status(
*,
channel_policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
) -> str:
mail_accepted = bool(mail and mail.accepted)
postbox_accepted = int(postbox.accepted_count if postbox else 0)
postbox_rejected = int(postbox.rejected_count if postbox else 0)
unknown = bool(
(mail and mail.outcome_unknown)
or (postbox and postbox.outcome_unknown)
)
if unknown:
return JobSendStatus.OUTCOME_UNKNOWN.value
accepted_count = int(mail_accepted) + postbox_accepted
if accepted_count:
if channel_policy == DeliveryChannelPolicy.POSTBOX:
return (
JobSendStatus.PARTIALLY_ACCEPTED.value
if postbox_rejected
else JobSendStatus.POSTBOX_ACCEPTED.value
)
if channel_policy == DeliveryChannelPolicy.MAIL_AND_POSTBOX:
if mail_accepted and postbox_accepted and not postbox_rejected:
return JobSendStatus.DELIVERED.value
return JobSendStatus.PARTIALLY_ACCEPTED.value
if postbox_rejected:
return JobSendStatus.PARTIALLY_ACCEPTED.value
return (
JobSendStatus.SMTP_ACCEPTED.value
if mail_accepted
else JobSendStatus.POSTBOX_ACCEPTED.value
)
temporary = bool(
(mail and mail.rejected_temporary)
or (postbox and postbox.rejected_temporary)
)
return (
JobSendStatus.FAILED_TEMPORARY.value
if temporary
else JobSendStatus.FAILED_PERMANENT.value
)
def _multichannel_messages(
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
) -> list[str]:
values: list[str] = []
if mail and mail.message:
values.append(f"Mail: {mail.message}")
if postbox:
values.extend(
f"Postbox: {message}"
for message in postbox.messages
if message
)
return values
def _finalize_multichannel_job(
session: Session,
*,
job_id: str,
channel_policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
commit: bool = True,
) -> SendJobResult:
job = session.get(CampaignJob, job_id)
if job is None:
raise SendJobError(
"Campaign job disappeared while finalizing delivery."
)
status = _final_multichannel_status(
channel_policy=channel_policy,
mail=mail,
postbox=postbox,
)
accepted = status in DELIVERY_ACCEPTED_STATUSES
unknown = status == JobSendStatus.OUTCOME_UNKNOWN.value
messages = _multichannel_messages(mail, postbox)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = status
job.claim_token = None
job.last_error = "\n".join(messages) or None
job.outcome_unknown_at = _utcnow() if unknown else None
if accepted or (unknown and bool((mail and mail.accepted) or (postbox and postbox.accepted_count))):
job.sent_at = job.sent_at or _utcnow()
files_integration().mark_job_attachment_uses_sent(session, job)
if not (mail and mail.accepted):
job.imap_status = JobImapStatus.NOT_REQUESTED.value
session.add(job)
_update_campaign_after_job(
session,
job.campaign_id,
job.campaign_version_id,
)
if commit:
session.commit()
else:
session.flush()
return SendJobResult(
job_id=job.id,
status=status,
attempt_number=job.attempt_count + job.postbox_attempt_count,
message=job.last_error,
)
def _send_claimed_multichannel_job(
session: Session,
*,
job: CampaignJob,
claim_token: str,
context: _SendJobDeliveryContext,
channel_policy: DeliveryChannelPolicy,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> SendJobResult:
mail_outcome: _MailChannelOutcome | None = None
postbox_outcome: PostboxChannelOutcome | None = None
if channel_policy == DeliveryChannelPolicy.MAIL_THEN_POSTBOX:
prior_postbox_outcome = _postbox_outcome_from_attempts(session, job)
if prior_postbox_outcome.outcome_unknown:
postbox_outcome = prior_postbox_outcome
elif prior_postbox_outcome.accepted_count:
postbox_outcome = deliver_campaign_job_to_postboxes(
session,
job=job,
message_bytes=context.message_bytes,
classification=context.snapshot.delivery.postbox.classification,
)
else:
mail_outcome = _deliver_mail_channel(
session,
job=job,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
if mail_outcome.rejected_before_acceptance:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError(
"Campaign job disappeared before Postbox fallback."
)
postbox_outcome = deliver_campaign_job_to_postboxes(
session,
job=current,
message_bytes=context.message_bytes,
classification=context.snapshot.delivery.postbox.classification,
)
else:
current = session.get(CampaignJob, job.id)
if current is not None:
current.postbox_status = JobPostboxStatus.SKIPPED.value
session.add(current)
session.commit()
else:
postbox_outcome = deliver_campaign_job_to_postboxes(
session,
job=job,
message_bytes=context.message_bytes,
classification=context.snapshot.delivery.postbox.classification,
)
should_deliver_mail = (
channel_policy == DeliveryChannelPolicy.MAIL_AND_POSTBOX
or (
channel_policy == DeliveryChannelPolicy.POSTBOX_THEN_MAIL
and postbox_outcome.all_rejected_before_acceptance
)
)
if should_deliver_mail:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError(
"Campaign job disappeared before Mail delivery."
)
mail_outcome = _deliver_mail_channel(
session,
job=current,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
return _finalize_multichannel_job(
session,
job_id=job.id,
channel_policy=channel_policy,
mail=mail_outcome,
postbox=postbox_outcome,
)
def _record_smtp_send_success(
session: Session,
*,
@@ -2057,12 +2637,21 @@ def _imap_attempt_count(session: Session, job_id: str) -> int:
def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None:
"""Atomically grant one worker permission to invoke the IMAP provider."""
if not _mail_was_accepted(
session,
job.id,
send_status=getattr(
job,
"send_status",
JobSendStatus.SMTP_ACCEPTED.value,
),
):
return None
claim_token = str(uuid4())
changed = (
session.query(CampaignJob)
.filter(
CampaignJob.id == job.id,
CampaignJob.send_status.in_(list(SMTP_ACCEPTED_STATUSES)),
CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]),
CampaignJob.imap_claim_token.is_(None),
)
@@ -2348,7 +2937,11 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
job = session.get(CampaignJob, job_id)
if not job:
raise SendJobError(f"Job not found: {job_id}")
if job.send_status not in SMTP_ACCEPTED_STATUSES:
if not _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
):
return AppendSentResult(job_id=job_id, status="not_sent", attempt_number=0, dry_run=dry_run, message="SMTP has not accepted this job")
blocked = _imap_blocked_result(session, job, dry_run=dry_run)
if blocked is not None:
@@ -2496,12 +3089,20 @@ def enqueue_pending_imap_appends(
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
CampaignJob.send_status.in_(list(SMTP_ACCEPTED_STATUSES)),
CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]),
)
.order_by(CampaignJob.entry_index.asc())
.all()
)
jobs = [
job
for job in jobs
if _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
)
]
should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run and not run_inline
results: list[dict[str, Any]] = []
appended_count = 0

View File

@@ -0,0 +1,521 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from email import policy
from email.parser import BytesParser
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.postbox import (
PostboxAttachmentRef,
PostboxDeliveryOutcomeUnknown,
PostboxDeliveryRejected,
PostboxDeliveryRequest,
PostboxParticipantRef,
PostboxTargetRef,
)
from govoplan_campaign.backend.db.models import (
CampaignJob,
JobPostboxStatus,
PostboxDeliveryAttempt,
)
from govoplan_campaign.backend.integrations import (
PostboxDeliveryUnavailable,
postbox_integration,
)
from govoplan_core.security.time import utc_now
ACCEPTED_POSTBOX_ATTEMPT_STATUSES = {
JobPostboxStatus.ACCEPTED.value,
JobPostboxStatus.ACCEPTED_VACANT.value,
}
@dataclass(slots=True)
class PostboxChannelOutcome:
accepted: int = 0
accepted_vacant: int = 0
rejected_temporary: int = 0
rejected_permanent: int = 0
outcome_unknown: int = 0
messages: list[str] = field(default_factory=list)
@property
def accepted_count(self) -> int:
return self.accepted + self.accepted_vacant
@property
def rejected_count(self) -> int:
return self.rejected_temporary + self.rejected_permanent
@property
def all_rejected_before_acceptance(self) -> bool:
return (
self.accepted_count == 0
and self.outcome_unknown == 0
and self.rejected_count > 0
)
@property
def status(self) -> str:
if self.outcome_unknown:
return JobPostboxStatus.OUTCOME_UNKNOWN.value
if self.accepted_count and self.rejected_count:
return JobPostboxStatus.PARTIALLY_ACCEPTED.value
if self.accepted_vacant and not self.accepted:
return JobPostboxStatus.ACCEPTED_VACANT.value
if self.accepted_count:
return JobPostboxStatus.ACCEPTED.value
if self.rejected_temporary:
return JobPostboxStatus.REJECTED_TEMPORARY.value
return JobPostboxStatus.REJECTED_PERMANENT.value
def _target_key(target: dict[str, Any]) -> str:
payload = json.dumps(
target,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _attempt_number(
session: Session,
*,
job_id: str,
target_key: str,
) -> int:
current = (
session.query(func.max(PostboxDeliveryAttempt.attempt_number))
.filter(
PostboxDeliveryAttempt.job_id == job_id,
PostboxDeliveryAttempt.target_key == target_key,
)
.scalar()
)
return int(current or 0) + 1
def _accepted_attempt(
session: Session,
*,
job_id: str,
target_key: str,
) -> PostboxDeliveryAttempt | None:
return (
session.query(PostboxDeliveryAttempt)
.filter(
PostboxDeliveryAttempt.job_id == job_id,
PostboxDeliveryAttempt.target_key == target_key,
PostboxDeliveryAttempt.status.in_(
list(ACCEPTED_POSTBOX_ATTEMPT_STATUSES)
),
)
.order_by(PostboxDeliveryAttempt.attempt_number.desc())
.first()
)
def _message_body(message_bytes: bytes) -> str | None:
message = BytesParser(policy=policy.default).parsebytes(message_bytes)
body = message.get_body(preferencelist=("plain", "html"))
if body is None:
payload = message.get_payload(decode=True)
if not isinstance(payload, bytes):
return None
return payload.decode(message.get_content_charset() or "utf-8", "replace")
try:
content = body.get_content()
except (LookupError, UnicodeError):
payload = body.get_payload(decode=True)
if not isinstance(payload, bytes):
return None
return payload.decode(body.get_content_charset() or "utf-8", "replace")
return str(content)
def _participants(job: CampaignJob) -> tuple[PostboxParticipantRef, ...]:
recipients = job.resolved_recipients or {}
values: list[PostboxParticipantRef] = []
for key in (
"from_all",
"to",
"cc",
"bcc",
"reply_to",
"bounce_to",
"disposition_notification_to",
):
for item in recipients.get(key) or []:
if not isinstance(item, dict):
continue
address = str(item.get("email") or "").strip() or None
label = str(item.get("name") or "").strip() or None
if address is None and label is None:
continue
values.append(
PostboxParticipantRef(
kind="sender" if key == "from_all" else key,
reference_type="mail_address",
label=label,
address=address,
)
)
return tuple(values)
def _attachments(job: CampaignJob) -> tuple[PostboxAttachmentRef, ...]:
values = [
PostboxAttachmentRef(
reference_type="campaign_eml",
reference_id=job.id,
name=f"{job.entry_id or job.entry_index}.eml",
media_type="message/rfc822",
size_bytes=job.eml_size_bytes,
digest=job.eml_sha256,
metadata={
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
},
)
]
for rule_index, rule in enumerate(job.resolved_attachments or []):
if not isinstance(rule, dict):
continue
managed_matches = rule.get("managed_matches")
if isinstance(managed_matches, list) and managed_matches:
for match in managed_matches:
if not isinstance(match, dict):
continue
reference_id = str(
match.get("version_id")
or match.get("asset_id")
or match.get("blob_id")
or ""
).strip()
if not reference_id:
continue
values.append(
PostboxAttachmentRef(
reference_type=(
"file_version"
if match.get("version_id")
else "file_asset"
),
reference_id=reference_id,
name=str(match.get("filename") or "").strip() or None,
media_type=(
str(match.get("content_type"))
if match.get("content_type")
else None
),
size_bytes=(
int(match["size_bytes"])
if match.get("size_bytes") is not None
else None
),
digest=(
str(match.get("checksum_sha256"))
if match.get("checksum_sha256")
else None
),
metadata={
key: value
for key, value in match.items()
if key
in {
"asset_id",
"version_id",
"blob_id",
"display_path",
"relative_path",
"owner_type",
"owner_id",
"source_revision",
}
},
)
)
continue
matches = rule.get("matches")
if not isinstance(matches, list):
continue
for match_index, match in enumerate(matches):
values.append(
PostboxAttachmentRef(
reference_type="campaign_attachment",
reference_id=f"{job.id}:{rule_index}:{match_index}",
name=str(match).rsplit("/", 1)[-1] or None,
metadata={
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"job_id": job.id,
},
)
)
return tuple(values)
def _sender_label(job: CampaignJob) -> str | None:
recipients = job.resolved_recipients or {}
sender = recipients.get("from")
if not isinstance(sender, dict):
return None
name = str(sender.get("name") or "").strip()
address = str(sender.get("email") or "").strip()
if name and address:
return f"{name} <{address}>"
return address or name or None
def _request(
job: CampaignJob,
target: dict[str, Any],
*,
target_key: str,
body_text: str | None,
classification: str,
) -> PostboxDeliveryRequest:
return PostboxDeliveryRequest(
tenant_id=job.tenant_id,
target=PostboxTargetRef(postbox_id=str(target["postbox_id"])),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id=job.id,
idempotency_key=(
f"campaign:{job.campaign_version_id}:{job.id}:postbox:"
f"{target_key}"
),
subject=(job.subject or "").strip() or "(No subject)",
body_text=body_text,
sender_label=_sender_label(job),
classification=classification,
participants=_participants(job),
attachments=_attachments(job),
metadata={
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"campaign_job_id": job.id,
"entry_id": job.entry_id,
"entry_index": job.entry_index,
"delivery_channel_policy": job.delivery_channel_policy,
"target_snapshot": target,
},
)
def _record_rejection(
session: Session,
*,
job_id: str,
attempt_id: str,
exc: Exception,
temporary: bool,
) -> None:
session.rollback()
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
job = session.get(CampaignJob, job_id)
if attempt is None or job is None:
raise RuntimeError(
"Postbox rejection could not be written to Campaign evidence."
) from exc
attempt.status = (
JobPostboxStatus.REJECTED_TEMPORARY.value
if temporary
else JobPostboxStatus.REJECTED_PERMANENT.value
)
attempt.error_type = exc.__class__.__name__
attempt.error_code = str(getattr(exc, "code", "") or "") or None
attempt.error_message = str(exc)
attempt.finished_at = utc_now()
session.add(attempt)
session.add(job)
session.commit()
def _record_unknown(
session: Session,
*,
job_id: str,
attempt_id: str,
exc: Exception,
) -> None:
session.rollback()
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
job = session.get(CampaignJob, job_id)
if attempt is None or job is None:
raise RuntimeError(
"Unknown Postbox outcome could not be written to Campaign evidence."
) from exc
attempt.status = JobPostboxStatus.OUTCOME_UNKNOWN.value
attempt.error_type = exc.__class__.__name__
attempt.error_code = str(getattr(exc, "code", "") or "") or None
attempt.error_message = str(exc)
attempt.finished_at = utc_now()
job.postbox_status = JobPostboxStatus.OUTCOME_UNKNOWN.value
session.add(attempt)
session.add(job)
session.commit()
def deliver_campaign_job_to_postboxes(
session: Session,
*,
job: CampaignJob,
message_bytes: bytes,
classification: str,
) -> PostboxChannelOutcome:
outcome = PostboxChannelOutcome()
targets = [
target
for target in (job.resolved_postbox_targets or [])
if isinstance(target, dict) and target.get("postbox_id")
]
if not targets:
outcome.rejected_permanent = 1
outcome.messages.append("No frozen Postbox target is available.")
job.postbox_status = JobPostboxStatus.REJECTED_PERMANENT.value
session.add(job)
session.commit()
return outcome
body_text = _message_body(message_bytes)
for target_index, target in enumerate(targets):
key = _target_key(target)
accepted = _accepted_attempt(
session,
job_id=job.id,
target_key=key,
)
if accepted is not None:
if accepted.vacant:
outcome.accepted_vacant += 1
else:
outcome.accepted += 1
continue
attempt_number = _attempt_number(
session,
job_id=job.id,
target_key=key,
)
request = _request(
job,
target,
target_key=key,
body_text=body_text,
classification=classification,
)
attempt = PostboxDeliveryAttempt(
tenant_id=job.tenant_id,
job_id=job.id,
target_key=key,
target_index=target_index,
attempt_number=attempt_number,
idempotency_key=request.idempotency_key,
status=JobPostboxStatus.DELIVERING.value,
target_snapshot=target,
evidence={},
started_at=utc_now(),
)
job.postbox_attempt_count += 1
job.postbox_status = JobPostboxStatus.DELIVERING.value
session.add(attempt)
session.add(job)
session.commit()
attempt_id = attempt.id
try:
result = postbox_integration().deliver(session, request)
current_attempt = session.get(PostboxDeliveryAttempt, attempt_id)
current_job = session.get(CampaignJob, job.id)
if current_attempt is None or current_job is None:
raise RuntimeError(
"Campaign Postbox attempt disappeared before acceptance."
)
current_attempt.status = (
JobPostboxStatus.ACCEPTED_VACANT.value
if result.vacant
else JobPostboxStatus.ACCEPTED.value
)
current_attempt.provider_delivery_id = result.delivery_id
current_attempt.provider_message_id = result.message_id
current_attempt.postbox_id = result.postbox_id
current_attempt.address = result.address
current_attempt.holder_count = result.holder_count
current_attempt.vacant = result.vacant
current_attempt.duplicate = result.duplicate
current_attempt.evidence = dict(result.evidence)
current_attempt.finished_at = utc_now()
session.add(current_attempt)
session.add(current_job)
session.commit()
if result.vacant:
outcome.accepted_vacant += 1
else:
outcome.accepted += 1
except PostboxDeliveryOutcomeUnknown as exc:
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
outcome.messages.append(str(exc))
except PostboxDeliveryRejected as exc:
if exc.code == "idempotency_conflict":
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
else:
_record_rejection(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
temporary=exc.temporary,
)
if exc.temporary:
outcome.rejected_temporary += 1
else:
outcome.rejected_permanent += 1
outcome.messages.append(str(exc))
except PostboxDeliveryUnavailable as exc:
_record_rejection(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
temporary=True,
)
outcome.rejected_temporary += 1
outcome.messages.append(str(exc))
except Exception as exc:
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
outcome.messages.append(str(exc))
current_job = session.get(CampaignJob, job.id)
if current_job is not None:
current_job.postbox_status = outcome.status
session.add(current_job)
session.commit()
return outcome

View File

@@ -94,7 +94,7 @@ def _ensure(session: _Session, version) -> None:
ensure_execution_snapshot(session, version) # type: ignore[arg-type]
def test_v5_snapshot_requires_its_persisted_checksum() -> None:
def test_current_snapshot_requires_its_persisted_checksum() -> None:
job = _job()
version = _snapshotted_version(job)
version.execution_snapshot_hash = None

View File

@@ -7,7 +7,9 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine, text
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import create_engine, inspect, text
from govoplan_campaign.backend import router
from govoplan_campaign.backend.db.models import (
@@ -128,6 +130,10 @@ def test_post_provider_persistence_failure_freezes_imap_retry() -> None:
mail_profile_id="profile-1",
smtp_transport_revision="smtp-revision",
imap_transport_revision="imap-revision",
smtp_server_id=None,
smtp_credential_id=None,
imap_server_id=None,
imap_credential_id=None,
delivery=SimpleNamespace(
imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"),
),
@@ -218,6 +224,31 @@ def test_imap_claim_migration_preserves_and_renumbers_duplicate_attempts() -> No
]
def test_imap_attempt_claim_repair_adds_only_the_missing_column() -> None:
migration = importlib.import_module(
"govoplan_campaign.backend.migrations.versions.e9f0a1b2c3d4_v0114_repair_imap_attempt_claim"
)
engine = create_engine("sqlite+pysqlite:///:memory:")
with engine.begin() as connection:
connection.execute(
text(
"CREATE TABLE imap_append_attempts ("
"id VARCHAR(36) PRIMARY KEY, job_id VARCHAR(36) NOT NULL)"
)
)
context = MigrationContext.configure(connection)
with patch.object(migration, "op", Operations(context)):
migration.upgrade()
migration.upgrade()
columns = {
column["name"]
for column in inspect(connection).get_columns("imap_append_attempts")
}
assert columns == {"id", "job_id", "claim_token"}
@pytest.mark.parametrize("decision", ["smtp_accepted", "not_sent", "imap_appended", "imap_not_appended"])
def test_reconciliation_requires_an_evidence_note(decision: str) -> None:
with pytest.raises(ValueError, match="evidence note"):

View File

@@ -73,7 +73,7 @@ def test_mail_profile_documentation_is_classified_for_adaptive_views() -> None:
def test_campaign_mail_contract_rejects_every_legacy_server_field(legacy_key: str) -> None:
raw = _campaign_json({"mail_profile_id": "profile-1", legacy_key: {}})
with pytest.raises(CampaignMailProfileBoundaryError, match="select an authorized Mail profile"):
with pytest.raises(CampaignMailProfileBoundaryError, match="select authorized Mail resources"):
assert_campaign_uses_mail_profile_reference(raw)
@@ -138,7 +138,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
delivery=DeliveryConfig(),
)
assert payload["snapshot_version"] == "5"
assert payload["snapshot_version"] == "7"
assert payload["mail_profile_id"] == "profile-1"
assert "smtp" not in payload
assert "imap" not in payload

View File

@@ -0,0 +1,289 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
from govoplan_campaign.backend.db.models import JobSendStatus
from govoplan_campaign.backend.sending.jobs import (
SendJobResult,
_MailChannelOutcome,
_final_multichannel_status,
_send_claimed_multichannel_job,
)
from govoplan_campaign.backend.sending.postbox_delivery import (
PostboxChannelOutcome,
)
def _context():
return SimpleNamespace(
message_bytes=b"message",
snapshot=SimpleNamespace(
delivery=SimpleNamespace(
postbox=SimpleNamespace(classification="internal")
)
),
)
def _job():
return SimpleNamespace(id="job-1")
class _Session:
def __init__(self, job) -> None:
self.job = job
def get(self, _model, _id):
return self.job
def add(self, _value) -> None:
return None
def commit(self) -> None:
return None
class PostboxFallbackOrchestrationTests(unittest.TestCase):
def test_mail_unknown_never_starts_postbox_fallback(self) -> None:
job = _job()
expected = SendJobResult(
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(outcome_unknown=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=PostboxChannelOutcome(),
),
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes"
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=expected,
),
):
result = _send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
self.assertIs(result, expected)
deliver_postbox.assert_not_called()
def test_mail_preacceptance_rejection_starts_postbox_fallback(self) -> None:
job = _job()
postbox_outcome = PostboxChannelOutcome(accepted=1)
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(rejected_permanent=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=PostboxChannelOutcome(),
),
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=postbox_outcome,
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.POSTBOX_ACCEPTED.value,
attempt_number=1,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_postbox.assert_called_once()
def test_accepted_postbox_fallback_prevents_later_mail_retry(self) -> None:
job = _job()
with (
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=PostboxChannelOutcome(
accepted=1,
rejected_temporary=1,
),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=PostboxChannelOutcome(accepted=2),
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.POSTBOX_ACCEPTED.value,
attempt_number=3,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_not_called()
deliver_postbox.assert_called_once()
def test_unknown_postbox_fallback_prevents_every_later_effect(self) -> None:
job = _job()
prior = PostboxChannelOutcome(outcome_unknown=1)
with (
patch(
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
return_value=prior,
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes"
) as deliver_postbox,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
),
) as finalize,
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_not_called()
deliver_postbox.assert_not_called()
self.assertIs(finalize.call_args.kwargs["postbox"], prior)
def test_postbox_acceptance_stops_mail_fallback(self) -> None:
job = _job()
with (
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=PostboxChannelOutcome(
accepted=1,
rejected_permanent=1,
),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.PARTIALLY_ACCEPTED.value,
attempt_number=2,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_not_called()
def test_all_postbox_rejections_start_mail_fallback(self) -> None:
job = _job()
with (
patch(
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
return_value=PostboxChannelOutcome(
rejected_temporary=1,
rejected_permanent=1,
),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(accepted=True),
) as deliver_mail,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.SMTP_ACCEPTED.value,
attempt_number=1,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_mail.assert_called_once()
def test_explicit_dual_delivery_reports_partial_and_unknown(self) -> None:
self.assertEqual(
JobSendStatus.PARTIALLY_ACCEPTED.value,
_final_multichannel_status(
channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX,
mail=_MailChannelOutcome(accepted=True),
postbox=PostboxChannelOutcome(rejected_permanent=1),
),
)
self.assertEqual(
JobSendStatus.OUTCOME_UNKNOWN.value,
_final_multichannel_status(
channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX,
mail=_MailChannelOutcome(accepted=True),
postbox=PostboxChannelOutcome(outcome_unknown=1),
),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import unittest
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDirectoryEntryRef,
PostboxDeliveryRequest,
PostboxDeliveryResult,
PostboxTargetRef,
)
from govoplan_campaign.backend.integrations import (
PostboxCampaignIntegration,
PostboxDeliveryUnavailable,
)
class _PostboxDelivery:
def __init__(self) -> None:
self.requests = []
def deliver(self, session, request):
self.requests.append((session, request))
return PostboxDeliveryResult(
delivery_id="delivery-1",
postbox_id="postbox-1",
message_id="message-1",
address="intake@postbox",
status="accepted",
vacant=False,
holder_count=1,
)
def delivery_catalog(self, session, *, tenant_id):
return PostboxDeliveryCatalogRef()
def list_visible_postboxes(self, session, *, tenant_id, actor):
return ()
def resolve_postbox(
self,
session,
*,
tenant_id,
target,
materialize=False,
):
return PostboxDirectoryEntryRef(
id=target.postbox_id or "postbox-1",
tenant_id=tenant_id,
address="intake@postbox",
address_key="intake",
name="Intake",
status="active",
classification="internal",
)
class PostboxCampaignIntegrationTests(unittest.TestCase):
def test_optional_delivery_boundary_is_typed_and_explicit(self) -> None:
delegate = _PostboxDelivery()
integration = PostboxCampaignIntegration(delegate, delegate)
request = PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id="postbox-1"),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id="job-1",
idempotency_key="campaign-1:job-1:postbox-1",
subject="Decision",
)
result = integration.deliver(object(), request)
self.assertTrue(integration.available)
self.assertEqual("delivery-1", result.delivery_id)
self.assertEqual(request, delegate.requests[0][1])
def test_missing_postbox_is_reported_without_importing_module_code(
self,
) -> None:
integration = PostboxCampaignIntegration()
request = PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id="postbox-1"),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id="job-1",
idempotency_key="campaign-1:job-1:postbox-1",
subject="Decision",
)
self.assertFalse(integration.available)
with self.assertRaises(PostboxDeliveryUnavailable):
integration.deliver(object(), request)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,208 @@
from __future__ import annotations
from unittest.mock import patch
from govoplan_core.core.postbox import (
PostboxDeliveryCatalogRef,
PostboxDeliveryTemplateRef,
PostboxDirectoryEntryRef,
PostboxOrganizationFunctionTargetRef,
PostboxOrganizationUnitTargetRef,
)
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.postbox_targets import (
resolve_entry_postbox_targets,
)
from govoplan_campaign.backend.campaign.validation import (
validate_campaign_config,
)
from govoplan_campaign.backend.messages.models import MessageValidationStatus
def _config() -> CampaignConfig:
return CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {
"id": "campaign-1",
"name": "Postbox campaign",
"mode": "send",
},
"fields": [
{"name": "org_key", "type": "organization_unit"},
{"name": "function_key", "type": "organization_function"},
{"name": "case_key", "type": "string"},
],
"template": {
"subject": "Decision",
"text": "A decision is available.",
"body_mode": "text",
},
"entries": {
"inline": [
{
"id": "recipient-1",
"fields": {
"org_key": "finance",
"function_key": "caseworker",
"case_key": "case-42",
},
}
]
},
"delivery": {
"channel_policy": "postbox",
"postbox": {
"targets": [
{
"id": "direct",
"mode": "direct",
"address_key": "central-intake",
},
{
"id": "derived",
"mode": "derived",
"template_id": "template-1",
"organization_unit_field": "org_key",
"organization_unit_match": "slug",
"function_field": "function_key",
"function_match": "slug",
"context_field": "case_key",
},
]
},
},
}
)
def _catalog() -> PostboxDeliveryCatalogRef:
return PostboxDeliveryCatalogRef(
templates=(
PostboxDeliveryTemplateRef(
id="template-1",
slug="case-inbox",
name="Case inbox",
description=None,
published_revision_id="revision-1",
function_type_id=None,
scope_kind="tenant",
scope_id=None,
classification="internal",
allow_vacant_delivery=True,
),
),
organization_units=(
PostboxOrganizationUnitTargetRef(
id="unit-1",
slug="finance",
name="Finance",
functions=(
PostboxOrganizationFunctionTargetRef(
id="function-1",
slug="caseworker",
name="Caseworker",
),
),
),
),
)
class _PostboxIntegration:
def __init__(self) -> None:
self.targets: list[tuple[object, bool]] = []
def delivery_catalog(self, _session, *, tenant_id: str):
assert tenant_id == "tenant-1"
return _catalog()
def resolve_postbox(
self,
_session,
*,
tenant_id: str,
target,
materialize: bool,
):
self.targets.append((target, materialize))
if target.address_key == "central-intake":
return PostboxDirectoryEntryRef(
id="postbox-direct",
tenant_id=tenant_id,
address="central-intake@postbox",
address_key="central-intake",
name="Central intake",
status="active",
classification="internal",
holder_count=2,
vacant=False,
)
assert target.template_id == "template-1"
assert target.organization_unit_id == "unit-1"
assert target.function_id == "function-1"
assert target.context_key == "case-42"
return PostboxDirectoryEntryRef(
id="postbox-derived",
tenant_id=tenant_id,
address="finance.caseworker.case-42@postbox",
address_key="finance.caseworker.case-42",
name="Finance caseworker",
status="active",
classification="internal",
organization_unit_id="unit-1",
function_id="function-1",
context_key="case-42",
holder_count=1,
vacant=False,
)
def test_postbox_only_campaign_does_not_require_mail() -> None:
config = _config()
available = validate_campaign_config(config, postbox_available=True)
unavailable = validate_campaign_config(config, postbox_available=False)
assert {
issue.code
for issue in available.issues
if issue.severity.value == "error"
} == set()
assert "missing_mail_profile" not in {
issue.code for issue in unavailable.issues
}
assert "missing_sender" not in {
issue.code for issue in unavailable.issues
}
assert "postbox_unavailable" in {
issue.code for issue in unavailable.issues
}
def test_row_resolves_multiple_direct_and_field_derived_postboxes() -> None:
config = _config()
integration = _PostboxIntegration()
with patch(
"govoplan_campaign.backend.campaign.postbox_targets.postbox_integration",
return_value=integration,
):
targets, issues, status = resolve_entry_postbox_targets(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
config=config,
entry=config.entries.inline[0], # type: ignore[index]
validation_status=MessageValidationStatus.READY,
materialize=True,
)
assert issues == []
assert status == MessageValidationStatus.READY
assert [target["postbox_id"] for target in targets] == [
"postbox-direct",
"postbox-derived",
]
assert targets[1]["context_key"] == "case-42"
assert len(integration.targets) == 2
assert all(materialize for _target, materialize in integration.targets)

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.11",
"version": "0.1.12",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -21,7 +21,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"scripts": {
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",

View File

@@ -101,6 +101,10 @@ export type CampaignDeltaResponse = {
watermark?: string | null;
has_more: boolean;
full: boolean;
total: number;
page: number;
page_size: number;
pages: number;
};
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
@@ -185,6 +189,58 @@ export type CampaignRecipientAddressSourcesResponse = {
sources: CampaignRecipientAddressSource[];
};
export type CampaignPostboxDirectoryEntry = {
id: string;
address: string;
address_key: string;
name: string;
status: string;
classification: string;
organization_unit_id?: string | null;
organization_unit_name?: string | null;
function_id?: string | null;
function_name?: string | null;
context_key?: string | null;
holder_count: number;
vacant: boolean;
};
export type CampaignPostboxTemplate = {
id: string;
slug: string;
name: string;
description?: string | null;
published_revision_id: string;
function_type_id?: string | null;
scope_kind: string;
scope_id?: string | null;
classification: string;
allow_vacant_delivery: boolean;
};
export type CampaignPostboxFunction = {
id: string;
slug: string;
name: string;
function_type_id?: string | null;
};
export type CampaignPostboxOrganizationUnit = {
id: string;
slug: string;
name: string;
unit_type_id?: string | null;
parent_id?: string | null;
functions: CampaignPostboxFunction[];
};
export type CampaignPostboxCatalog = {
available: boolean;
postboxes: CampaignPostboxDirectoryEntry[];
templates: CampaignPostboxTemplate[];
organization_units: CampaignPostboxOrganizationUnit[];
};
export type CampaignRecipientSnapshotItem = {
contact_id: string;
display_name: string;
@@ -285,6 +341,7 @@ export type CampaignDeliveryOptions = {
campaign_id: string;
version_id: string;
worker_queue_available: boolean;
postbox_available: boolean;
synchronous_send: {
allowed?: boolean;
reason?: string | null;
@@ -448,6 +505,7 @@ export type CampaignJobDetailResponse = {
attempts: {
smtp?: Record<string, unknown>[];
imap?: Record<string, unknown>[];
postbox?: Record<string, unknown>[];
};
};
@@ -488,6 +546,9 @@ export type AggregateCampaignReport = {
};
outcomes: {
smtp_accepted: AggregateReportCount;
postbox_accepted: AggregateReportCount;
delivered: AggregateReportCount;
partially_accepted: AggregateReportCount;
failed: AggregateReportCount;
outcome_unknown: AggregateReportCount;
queued_or_active: AggregateReportCount;
@@ -582,6 +643,13 @@ campaignId: string)
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
}
export async function getCampaignPostboxCatalog(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignPostboxCatalog> {
return apiFetch<CampaignPostboxCatalog>(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`);
}
export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings,
campaignId: string,
@@ -961,12 +1029,13 @@ export async function resolveCampaignJobOutcome(
settings: ApiSettings,
campaignId: string,
jobId: string,
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended",
note?: string)
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended" | "postbox_accepted" | "postbox_not_accepted",
note?: string,
attemptId?: string)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
method: "POST",
body: JSON.stringify({ decision, note: note || null })
body: JSON.stringify({ decision, note: note || null, attempt_id: attemptId || null })
});
}
@@ -1057,8 +1126,15 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
}
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
return response.shares;
const pageSize = 500;
const shares: CampaignShare[] = [];
for (let page = 1; ; page += 1) {
const response = await apiFetch<{shares: CampaignShare[];pages?: number;}>(settings, `/api/v1/campaigns/${campaignId}/shares?page=${page}&page_size=${pageSize}`);
shares.push(...response.shares);
if (page >= (response.pages ?? 1)) {
return shares;
}
}
}
export async function updateCampaignOwner(

View File

@@ -0,0 +1,125 @@
import { useCallback } from "react";
import { Send } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type CampaignListItem,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import { listCampaigns } from "../../api/campaigns";
const TERMINAL_STATUSES = new Set([
"sent",
"cancelled",
"archived",
"deleted"
]);
export default function CampaignActivityWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const showCompleted = configuration.showCompleted === true;
const load = useCallback(async () => {
const campaigns = await listCampaigns(settings);
return campaigns
.filter(
(campaign) =>
showCompleted || !TERMINAL_STATUSES.has(campaign.status)
)
.sort(compareCampaigns)
.slice(0, maxItems);
}, [maxItems, settings, showCompleted]);
const { data: campaigns, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading campaign activity">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No active campaigns."
items={(campaigns ?? []).map((campaign) => ({
id: campaign.id,
title: campaign.name,
detail: campaignProgress(campaign),
meta: updatedLabel(campaign),
leading: <Send size={17} aria-hidden="true" />,
trailing: (
<StatusBadge status={campaign.status} label={statusLabel(campaign.status)} />
),
to: `/campaigns/${encodeURIComponent(campaign.id)}`
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/campaigns">
Open campaigns
</Link>
</div>
</LoadingFrame>
);
}
function compareCampaigns(
left: CampaignListItem,
right: CampaignListItem
): number {
return timestamp(right) - timestamp(left);
}
function timestamp(campaign: CampaignListItem): number {
const value = campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at;
return value ? new Date(value).getTime() : 0;
}
function updatedLabel(campaign: CampaignListItem): string {
const value = campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at;
return value
? new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short"
}).format(new Date(value))
: "";
}
function campaignProgress(campaign: CampaignListItem): string {
const parts = [
campaign.sent ? `${campaign.sent} sent` : "",
campaign.failed ? `${campaign.failed} failed` : "",
campaign.blocked ? `${campaign.blocked} blocked` : "",
campaign.warnings ? `${campaign.warnings} warnings` : ""
].filter(Boolean);
return parts.join(" · ") || campaign.description || "No delivery totals yet";
}
function statusLabel(status: string): string {
return status.replaceAll("_", " ");
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}

View File

@@ -32,7 +32,11 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
let response: CampaignDeltaResponse;
do {
response = await listCampaignsDelta(settings, { since: nextWatermark });
nextCampaigns = mergeCampaignDelta(nextCampaigns, response);
nextCampaigns = mergeCampaignDelta(
nextCampaigns,
response,
Boolean(response.full && nextWatermark?.startsWith("full:campaigns:"))
);
nextWatermark = response.watermark ?? null;
} while (response.has_more);
setCampaigns(nextCampaigns);
@@ -199,8 +203,12 @@ function formatLoadedAt(value: Date): string {
return formatDateTimeFromDate(value, { second: "2-digit" });
}
function mergeCampaignDelta(current: CampaignListItem[], response: CampaignDeltaResponse): CampaignListItem[] {
if (response.full) return response.campaigns;
function mergeCampaignDelta(
current: CampaignListItem[],
response: CampaignDeltaResponse,
continuingFullSnapshot = false
): CampaignListItem[] {
if (response.full && !continuingFullSnapshot) return response.campaigns;
return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, {
deletedResourceType: "campaign",
sort: sortCampaignsByUpdatedDesc

View File

@@ -43,6 +43,9 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"claimed",
"sending",
"smtp_accepted",
"postbox_accepted",
"delivered",
"partially_accepted",
"sent",
"outcome_unknown",
"failed_temporary",
@@ -50,6 +53,19 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"cancelled"].
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"pending",
"delivering",
"accepted",
"accepted_vacant",
"partially_accepted",
"rejected_temporary",
"rejected_permanent",
"outcome_unknown",
"skipped"].
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"pending",
@@ -253,7 +269,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
});
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
const status = String(sendResult.status ?? "submitted");
if (status === "smtp_accepted" || status === "already_accepted") accepted += 1;
if (["smtp_accepted", "postbox_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1;
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
} catch (err) {
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
@@ -353,9 +369,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, columnType: "from-list", list: { options: VALIDATION_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: QUEUE_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, value: (row) => String(row.send_status ?? "unknown") },
{ id: "send", header: "Delivery", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, value: (row) => String(row.send_status ?? "unknown") },
{ id: "postbox", header: "Postbox", width: 155, sortable: true, filterable: true, columnType: "from-list", list: { options: POSTBOX_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(row.postbox_status ?? "unknown"))} />, value: (row) => String(row.postbox_status ?? "unknown") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} label={deliveryStatusLabel(String(row.imap_status ?? "unknown"))} />, value: (row) => String(row.imap_status ?? "unknown") },
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_attempt_count ?? 0)) },
{
id: "evidence",
header: "i18n:govoplan-campaign.evidence.7ea014de",
@@ -540,10 +557,13 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
<div><dt>Postbox state</dt><dd><StatusBadge status={String(detail.job.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.postbox_status ?? "unknown"))} /></dd></div>
<div><dt>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</dd></div>
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
</dl>
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} />
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
</div>
}
@@ -565,13 +585,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record<string, unknown>[];}) {
const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" : "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";rows: Record<string, unknown>[];}) {
const title = kind === "smtp"
? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
: kind === "postbox"
? "Postbox delivery attempts"
: "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
if (rows.length === 0) {
return (
<section className="attempt-history-section">
<h3>{title}</h3>
<p className="muted small-note">i18n:govoplan-campaign.no.816c52fd {kind === "smtp" ? "i18n:govoplan-campaign.smtp.efff9cca" : "i18n:govoplan-campaign.imap.271f9ef2"} i18n:govoplan-campaign.attempt_has_been_recorded_for_this_job.e4050f01</p>
<p className="muted small-note">No {kind} attempt has been recorded for this job.</p>
</section>);
}
@@ -581,10 +605,12 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
kind === "imap" ?
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
kind === "postbox" ?
{ id: "target", header: "Postbox", width: 220, sortable: true, filterable: true, value: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—"), render: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—") } :
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? "")}>{String(row.smtp_response ?? row.error_message ?? "—")}</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> }
];
return (
@@ -601,9 +627,11 @@ function initialReportGridFilters(): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {};
const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS);
const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS);
const postbox = statusParameters(params, "postbox_status", POSTBOX_STATUS_OPTIONS);
const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS);
if (send.length > 0) result.send = send;
if (imap.length > 0) result.imap = imap;
if (postbox.length > 0) result.postbox = postbox;
if (validation.length > 0) result.validation = validation;
return result;
}
@@ -635,14 +663,14 @@ function serializeInitialGridFilters(filters: Record<string, string | string[]>)
}
function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "imap" || value === "attempts" || value === "updated") {
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "postbox" || value === "imap" || value === "attempts" || value === "updated") {
return value;
}
return "number";
}
function retryableFailedStatus(status: string): boolean {
return status === "failed_temporary" || status === "failed_permanent";
return status === "failed_temporary" || status === "failed_permanent" || status === "partially_accepted";
}
function shortJobId(jobId: string): string {

View File

@@ -1,5 +1,9 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, AuthInfo } from "../../types";
import {
getCampaignPostboxCatalog,
type CampaignPostboxCatalog
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
@@ -14,10 +18,15 @@ import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui";
import { hasScope } from "@govoplan/core-webui";
import { RetentionPolicyEditor } from "@govoplan/core-webui";
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
import { getDraftFields } from "./utils/fieldDefinitions";
import PostboxTargetsDialog, {
normalizePostboxTargets
} from "./components/PostboxTargetsDialog";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
@@ -34,6 +43,14 @@ type GlobalSettingsPageProps = {
export default function GlobalSettingsPage({ settings, auth, campaignId, view = "settings" }: GlobalSettingsPageProps) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [editorState, setEditorState] = useState<EditorState>({});
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
const [postboxTargetsOpen, setPostboxTargetsOpen] = useState(false);
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
const isPolicyView = view === "policy";
const version = data.currentVersion;
@@ -59,12 +76,53 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
const delivery = asRecord(displayDraft.delivery);
const rateLimit = asRecord(delivery.rate_limit);
const retry = asRecord(delivery.retry);
const postboxDelivery = asRecord(delivery.postbox);
const fieldDefinitions = useMemo(
() => getDraftFields(displayDraft),
[displayDraft]
);
const statusTracking = asRecord(displayDraft.status_tracking);
const optIns = asRecord(editorState.opt_ins);
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
const pageTitle = isPolicyView ? "i18n:govoplan-campaign.campaign_policies.0b5de1f5" : "i18n:govoplan-campaign.campaign_settings.efffec26";
useEffect(() => {
if (!postboxModuleInstalled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
return;
}
let cancelled = false;
void getCampaignPostboxCatalog(settings, campaignId)
.then((catalog) => {
if (!cancelled) setPostboxCatalog(catalog);
})
.catch(() => {
if (!cancelled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
}
});
return () => {
cancelled = true;
};
}, [
campaignId,
postboxModuleInstalled,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
function patchEditor(path: string[], value: unknown) {
if (locked) return;
setEditorState((current) => updateNested(current, path, value));
@@ -232,6 +290,43 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
{postboxModuleInstalled &&
<div className="campaign-postbox-delivery-settings">
<FormField label="Delivery channels">
<select
value={getText(delivery, "channel_policy", "mail")}
disabled={locked}
onChange={(event) => patch(["delivery", "channel_policy"], event.target.value)}
>
<option value="mail">Mail</option>
<option value="postbox">Postbox</option>
<option value="mail_and_postbox">Mail and Postbox</option>
<option value="mail_then_postbox">Mail, then Postbox on pre-acceptance rejection</option>
<option value="postbox_then_mail">Postbox, then Mail on pre-acceptance rejection</option>
</select>
</FormField>
<FormField label="Postbox classification">
<input
value={getText(postboxDelivery, "classification", "internal")}
disabled={locked}
onChange={(event) => patch(["delivery", "postbox", "classification"], event.target.value)}
/>
</FormField>
<FormField label="Default Postbox targets">
<Button
disabled={locked || !postboxCatalog.available}
onClick={() => setPostboxTargetsOpen(true)}
>
Configure ({normalizePostboxTargets(postboxDelivery.targets).length})
</Button>
</FormField>
{!postboxCatalog.available &&
<DismissibleAlert tone="warning" dismissible={false}>
Postbox delivery is installed but its delivery catalog is unavailable.
</DismissibleAlert>
}
</div>
}
</Card>
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
@@ -246,6 +341,21 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
</>
}
</LoadingFrame>
{postboxTargetsOpen &&
<PostboxTargetsDialog
open
title="Default Postbox targets"
catalog={postboxCatalog}
fields={fieldDefinitions}
targets={normalizePostboxTargets(postboxDelivery.targets)}
locked={locked}
onSave={(targets) => {
patch(["delivery", "postbox", "targets"], targets);
setPostboxTargetsOpen(false);
}}
onClose={() => setPostboxTargetsOpen(false)}
/>
}
</div>);
}

View File

@@ -3,10 +3,12 @@ import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
createRecipientImportMappingProfile,
getCampaignPostboxCatalog,
listCampaignRecipientAddressSources,
listRecipientImportMappingProfiles,
snapshotCampaignRecipientAddressSource,
updateRecipientImportMappingProfile,
type CampaignPostboxCatalog,
type CampaignRecipientAddressSource,
type CampaignRecipientAddressSourceSnapshot,
type RecipientImportMappingProfilePayload } from
@@ -30,6 +32,9 @@ import { getBool } from "./utils/draftEditor";
import { getDraftFields } from "./utils/fieldDefinitions";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import PostboxTargetsDialog, {
normalizePostboxTargets
} from "./components/PostboxTargetsDialog";
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import {
@@ -105,6 +110,7 @@ const recipientAddressOverlayColumns: EntryAddressColumn[] = [
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { translateText } = usePlatformLanguage();
const filesModuleInstalled = usePlatformModuleInstalled("files");
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
@@ -115,6 +121,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
const [headerAddressEditor, setHeaderAddressEditor] = useState<HeaderAddressEditorState>(null);
const version = data.currentVersion;
@@ -192,12 +205,53 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
return () => {cancelled = true;};
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!postboxModuleInstalled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
return;
}
let cancelled = false;
void getCampaignPostboxCatalog(settings, campaignId)
.then((catalog) => {
if (!cancelled) setPostboxCatalog(catalog);
})
.catch(() => {
if (!cancelled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
}
});
return () => {
cancelled = true;
};
}, [
campaignId,
postboxModuleInstalled,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => {
setRecipientAddressEditorIndex((current) => {
if (current === null) return null;
if (inlineEntries.length === 0) return null;
return Math.max(0, Math.min(current, inlineEntries.length - 1));
});
setPostboxTargetEditorIndex((current) => {
if (current === null) return null;
if (inlineEntries.length === 0) return null;
return Math.max(0, Math.min(current, inlineEntries.length - 1));
});
}, [inlineEntries.length]);
@@ -251,6 +305,19 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
setRecipientAddressEditorIndex(null);
}
function saveEntryPostboxTargets(
index: number,
targets: ReturnType<typeof normalizePostboxTargets>,
merge: boolean
) {
updateEntry(index, (entry) => ({
...entry,
postbox_targets: targets,
merge_postbox_targets: merge
}));
setPostboxTargetEditorIndex(null);
}
function updateEntryField(index: number, field: string, value: unknown) {
updateEntry(index, (entry) => ({
...entry,
@@ -449,12 +516,15 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
draft: displayDraft,
locked,
filesModuleInstalled,
postboxModuleInstalled,
postboxCatalog,
entries: inlineEntries,
fieldDefinitions,
individualAttachmentBasePaths,
zipConfig,
translateText,
openAddressEditor: setRecipientAddressEditorIndex,
openPostboxTargetEditor: setPostboxTargetEditorIndex,
updateEntry,
updateEntryAttachments,
updateEntryField,
@@ -512,6 +582,20 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
onSave={(values, merges) => saveEntryAddresses(recipientAddressEditorIndex, values, merges)}
onClose={() => setRecipientAddressEditorIndex(null)} />
}
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
<PostboxTargetsDialog
open
title={`Recipient ${postboxTargetEditorIndex + 1} - Postbox targets`}
catalog={postboxCatalog}
fields={fieldDefinitions}
targets={normalizePostboxTargets(inlineEntries[postboxTargetEditorIndex].postbox_targets)}
merge={inlineEntries[postboxTargetEditorIndex].merge_postbox_targets !== false}
showMerge
locked={locked}
onSave={(targets, merge) => saveEntryPostboxTargets(postboxTargetEditorIndex, targets, merge)}
onClose={() => setPostboxTargetEditorIndex(null)} />
}
{headerAddressEditor &&
<HeaderAddressEditorDialog
@@ -2012,12 +2096,15 @@ type RecipientProfileColumnContext = {
draft: Record<string, unknown>;
locked: boolean;
filesModuleInstalled: boolean;
postboxModuleInstalled: boolean;
postboxCatalog: CampaignPostboxCatalog;
entries: Record<string, unknown>[];
fieldDefinitions: ReturnType<typeof getDraftFields>;
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
zipConfig: AttachmentZipCollection;
translateText: (value: string) => string;
openAddressEditor: (index: number) => void;
openPostboxTargetEditor: (index: number) => void;
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
updateEntryField: (index: number, field: string, value: unknown) => void;
@@ -2026,7 +2113,7 @@ type RecipientProfileColumnContext = {
removeEntry: (index: number) => void;
};
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{
id: "number",
@@ -2075,6 +2162,45 @@ function recipientProfileColumns({ settings, campaignId, draft, locked, filesMod
value: recipientAddressFilterValue
},
{ id: "active", header: "i18n:govoplan-campaign.active.a733b809", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "inactive", label: "i18n:govoplan-campaign.inactive.09af574c" }] }, render: (entry, index) => <ToggleSwitch label="i18n:govoplan-campaign.active.a733b809" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
...(postboxModuleInstalled ? [{
id: "delivery",
header: "Delivery",
width: "minmax(260px, 0.9fr)",
resizable: true,
filterable: true,
render: (entry, index) => {
const targets = normalizePostboxTargets(entry.postbox_targets);
return (
<div className="campaign-recipient-delivery-cell">
<select
value={typeof entry.channel_policy === "string" ? entry.channel_policy : ""}
disabled={locked}
aria-label={`Recipient ${index + 1} delivery policy`}
onChange={(event) => updateEntry(index, (current) => {
const next = { ...current };
if (event.target.value) next.channel_policy = event.target.value;
else delete next.channel_policy;
return next;
})}
>
<option value="">Campaign default</option>
<option value="mail">Mail</option>
<option value="postbox">Postbox</option>
<option value="mail_and_postbox">Mail and Postbox</option>
<option value="mail_then_postbox">Mail, then Postbox fallback</option>
<option value="postbox_then_mail">Postbox, then Mail fallback</option>
</select>
<Button
disabled={locked || !postboxCatalog.available}
onClick={() => openPostboxTargetEditor(index)}
>
Postboxes ({targets.length})
</Button>
</div>
);
},
value: (entry) => `${String(entry.channel_policy ?? "default")} ${normalizePostboxTargets(entry.postbox_targets).map((target) => target.label ?? target.postbox_id ?? target.template_id ?? "").join(" ")}`
} as DataGridColumn<Record<string, unknown>>] : []),
...(individualAttachmentBasePaths.length > 0 ? [{
id: "attachments",
header: "i18n:govoplan-campaign.attachments.6771ade6",

View File

@@ -0,0 +1,567 @@
import { useMemo, useState } from "react";
import { Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import type {
CampaignPostboxCatalog,
CampaignPostboxOrganizationUnit
} from "../../../api/campaigns";
import type { CampaignFieldDefinition } from "../utils/fieldDefinitions";
import { asRecord } from "../utils/campaignView";
export type CampaignPostboxTarget = {
id: string;
mode: "direct" | "derived";
label?: string;
postbox_id?: string;
address_key?: string;
template_id?: string;
organization_unit_id?: string;
organization_unit_field?: string;
organization_unit_match?: "id" | "slug";
function_id?: string;
function_field?: string;
function_match?: "id" | "slug";
context_key?: string;
context_field?: string;
};
type Props = {
open: boolean;
title: string;
catalog: CampaignPostboxCatalog;
fields: CampaignFieldDefinition[];
targets: CampaignPostboxTarget[];
locked?: boolean;
merge?: boolean;
showMerge?: boolean;
onSave: (targets: CampaignPostboxTarget[], merge: boolean) => void;
onClose: () => void;
};
export default function PostboxTargetsDialog({
open,
title,
catalog,
fields,
targets,
locked = false,
merge = true,
showMerge = false,
onSave,
onClose
}: Props) {
const [draftTargets, setDraftTargets] = useState<CampaignPostboxTarget[]>(
() => targets.map(normalizeTarget)
);
const [draftMerge, setDraftMerge] = useState(merge);
const [error, setError] = useState("");
function updateTarget(index: number, patch: Partial<CampaignPostboxTarget>) {
setDraftTargets((current) =>
current.map((target, currentIndex) =>
currentIndex === index ? normalizeTarget({ ...target, ...patch }) : target
)
);
setError("");
}
function addTarget() {
const firstPostbox = catalog.postboxes[0];
setDraftTargets((current) => [
...current,
{
id: newTargetId(),
mode: "direct",
postbox_id: firstPostbox?.id ?? ""
}
]);
}
function save() {
const prepared = draftTargets.map(normalizeTarget);
const invalidIndex = prepared.findIndex((target) => !targetIsComplete(target));
if (invalidIndex >= 0) {
setError(`Target ${invalidIndex + 1} is incomplete.`);
return;
}
onSave(prepared, draftMerge);
}
return (
<Dialog
open={open}
title={title}
className="campaign-postbox-target-modal"
bodyClassName="campaign-postbox-target-body"
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={locked} onClick={save}>Save</Button>
</>
}
>
<div className="campaign-postbox-target-toolbar">
{showMerge &&
<ToggleSwitch
label="Postbox target defaults"
inactiveLabel="Replace defaults"
activeLabel="Add to defaults"
checked={draftMerge}
disabled={locked}
onChange={setDraftMerge}
/>
}
<Button
type="button"
variant="primary"
disabled={locked || catalog.postboxes.length + catalog.templates.length === 0}
onClick={addTarget}
>
<Plus aria-hidden="true" />
Add target
</Button>
</div>
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
{draftTargets.length === 0 &&
<div className="data-grid-empty">No Postbox targets configured.</div>
}
<div className="campaign-postbox-target-list">
{draftTargets.map((target, index) =>
<PostboxTargetRow
key={target.id}
target={target}
index={index}
count={draftTargets.length}
catalog={catalog}
fields={fields}
locked={locked}
onChange={(patch) => updateTarget(index, patch)}
onMove={(offset) => setDraftTargets((current) => moveTarget(current, index, index + offset))}
onRemove={() => setDraftTargets((current) => current.filter((_, currentIndex) => currentIndex !== index))}
/>
)}
</div>
</Dialog>
);
}
function PostboxTargetRow({
target,
index,
count,
catalog,
fields,
locked,
onChange,
onMove,
onRemove
}: {
target: CampaignPostboxTarget;
index: number;
count: number;
catalog: CampaignPostboxCatalog;
fields: CampaignFieldDefinition[];
locked: boolean;
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
onMove: (offset: number) => void;
onRemove: () => void;
}) {
const selectedUnit = catalog.organization_units.find(
(unit) => unit.id === target.organization_unit_id
);
return (
<section className="campaign-postbox-target-row">
<div className="campaign-postbox-target-row-heading">
<strong>Target {index + 1}</strong>
<TableActionGroup actions={[
{ id: "up", label: "Move target up", icon: <ArrowUp aria-hidden="true" />, disabled: locked || index === 0, onClick: () => onMove(-1) },
{ id: "down", label: "Move target down", icon: <ArrowDown aria-hidden="true" />, disabled: locked || index === count - 1, onClick: () => onMove(1) },
{ id: "remove", label: "Remove target", icon: <Trash2 aria-hidden="true" />, disabled: locked, variant: "danger", onClick: onRemove }
]} />
</div>
<div className="form-grid compact responsive-form-grid campaign-postbox-target-grid">
<FormField label="Target type">
<select
value={target.mode}
disabled={locked}
onChange={(event) => onChange(
event.target.value === "derived"
? derivedTargetDefaults(target.id, catalog, fields)
: directTargetDefaults(target.id, catalog)
)}
>
<option value="direct">Direct Postbox</option>
<option value="derived">Derived from fields</option>
</select>
</FormField>
<FormField label="Label">
<input
value={target.label ?? ""}
disabled={locked}
onChange={(event) => onChange({ label: event.target.value })}
/>
</FormField>
{target.mode === "direct" ?
<DirectTargetFields
target={target}
catalog={catalog}
locked={locked}
onChange={onChange}
/> :
<DerivedTargetFields
target={target}
catalog={catalog}
fields={fields}
selectedUnit={selectedUnit}
locked={locked}
onChange={onChange}
/>
}
</div>
</section>
);
}
function DirectTargetFields({
target,
catalog,
locked,
onChange
}: {
target: CampaignPostboxTarget;
catalog: CampaignPostboxCatalog;
locked: boolean;
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
}) {
return (
<FormField label="Postbox">
<select
value={target.postbox_id ?? ""}
disabled={locked}
onChange={(event) => onChange({ postbox_id: event.target.value })}
>
<option value="">Select a Postbox</option>
{catalog.postboxes.map((postbox) =>
<option key={postbox.id} value={postbox.id}>
{postbox.name} ({postbox.address}){postbox.vacant ? " - vacant" : ""}
</option>
)}
</select>
</FormField>
);
}
function DerivedTargetFields({
target,
catalog,
fields,
selectedUnit,
locked,
onChange
}: {
target: CampaignPostboxTarget;
catalog: CampaignPostboxCatalog;
fields: CampaignFieldDefinition[];
selectedUnit?: CampaignPostboxOrganizationUnit;
locked: boolean;
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
}) {
const organizationFields = useMemo(
() => fields.filter((field) => field.type === "organization_unit" || field.type === "string"),
[fields]
);
const functionFields = useMemo(
() => fields.filter((field) => field.type === "organization_function" || field.type === "string"),
[fields]
);
return (
<>
<FormField label="Postbox template">
<select
value={target.template_id ?? ""}
disabled={locked}
onChange={(event) => onChange({ template_id: event.target.value })}
>
<option value="">Select a template</option>
{catalog.templates.map((template) =>
<option key={template.id} value={template.id}>{template.name}</option>
)}
</select>
</FormField>
<DerivedReferenceField
label="Organization unit"
fixedValue={target.organization_unit_id}
fieldValue={target.organization_unit_field}
match={target.organization_unit_match}
fixedOptions={catalog.organization_units}
fields={organizationFields}
locked={locked}
onFixed={(value) => onChange({
organization_unit_id: value,
organization_unit_field: undefined,
function_id: undefined
})}
onField={(value) => onChange({
organization_unit_id: undefined,
organization_unit_field: value
})}
onMatch={(value) => onChange({ organization_unit_match: value })}
/>
<DerivedReferenceField
label="Function"
fixedValue={target.function_id}
fieldValue={target.function_field}
match={target.function_match}
fixedOptions={selectedUnit?.functions ?? []}
fields={functionFields}
locked={locked}
fixedDisabled={!target.organization_unit_id}
onFixed={(value) => onChange({
function_id: value,
function_field: undefined
})}
onField={(value) => onChange({
function_id: undefined,
function_field: value
})}
onMatch={(value) => onChange({ function_match: value })}
/>
<FormField label="Context source">
<select
value={target.context_field ? "field" : target.context_key ? "fixed" : "none"}
disabled={locked}
onChange={(event) => {
const mode = event.target.value;
onChange({
context_key: mode === "fixed" ? target.context_key ?? "" : undefined,
context_field: mode === "field" ? target.context_field ?? fields[0]?.name ?? "" : undefined
});
}}
>
<option value="none">No context</option>
<option value="fixed">Fixed value</option>
<option value="field">Campaign field</option>
</select>
</FormField>
{target.context_field !== undefined ?
<FormField label="Context field">
<select
value={target.context_field}
disabled={locked}
onChange={(event) => onChange({ context_field: event.target.value })}
>
<option value="">Select a field</option>
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
</FormField> :
target.context_key !== undefined &&
<FormField label="Context value">
<input
value={target.context_key}
disabled={locked}
onChange={(event) => onChange({ context_key: event.target.value })}
/>
</FormField>
}
</>
);
}
function DerivedReferenceField({
label,
fixedValue,
fieldValue,
match,
fixedOptions,
fields,
locked,
fixedDisabled = false,
onFixed,
onField,
onMatch
}: {
label: string;
fixedValue?: string;
fieldValue?: string;
match?: "id" | "slug";
fixedOptions: Array<{ id: string; name: string; slug: string }>;
fields: CampaignFieldDefinition[];
locked: boolean;
fixedDisabled?: boolean;
onFixed: (value: string) => void;
onField: (value: string) => void;
onMatch: (value: "id" | "slug") => void;
}) {
const usesField = fieldValue !== undefined;
return (
<>
<FormField label={`${label} source`}>
<select
value={usesField ? "field" : "fixed"}
disabled={locked}
onChange={(event) =>
event.target.value === "field"
? onField(fieldValue ?? fields[0]?.name ?? "")
: onFixed(fixedValue ?? fixedOptions[0]?.id ?? "")
}
>
<option value="fixed">Fixed value</option>
<option value="field">Campaign field</option>
</select>
</FormField>
{usesField ?
<>
<FormField label={`${label} field`}>
<select value={fieldValue ?? ""} disabled={locked} onChange={(event) => onField(event.target.value)}>
<option value="">Select a field</option>
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
</FormField>
<FormField label="Field contains">
<select value={match ?? "id"} disabled={locked} onChange={(event) => onMatch(event.target.value as "id" | "slug")}>
<option value="id">Stable ID</option>
<option value="slug">Key / slug</option>
</select>
</FormField>
</> :
<FormField label={label}>
<select value={fixedValue ?? ""} disabled={locked || fixedDisabled} onChange={(event) => onFixed(event.target.value)}>
<option value="">Select {label.toLowerCase()}</option>
{fixedOptions.map((option) => <option key={option.id} value={option.id}>{option.name} ({option.slug})</option>)}
</select>
</FormField>
}
</>
);
}
export function normalizePostboxTargets(value: unknown): CampaignPostboxTarget[] {
if (!Array.isArray(value)) return [];
return value
.map(asRecord)
.map((target) => normalizeTarget({
id: String(target.id ?? ""),
mode: target.mode === "derived" ? "derived" : "direct",
label: optionalText(target.label),
postbox_id: optionalText(target.postbox_id),
address_key: optionalText(target.address_key),
template_id: optionalText(target.template_id),
organization_unit_id: optionalText(target.organization_unit_id),
organization_unit_field: optionalText(target.organization_unit_field),
organization_unit_match: target.organization_unit_match === "slug" ? "slug" : "id",
function_id: optionalText(target.function_id),
function_field: optionalText(target.function_field),
function_match: target.function_match === "slug" ? "slug" : "id",
context_key: optionalText(target.context_key),
context_field: optionalText(target.context_field)
}))
.filter((target) => Boolean(target.id));
}
function normalizeTarget(target: CampaignPostboxTarget): CampaignPostboxTarget {
const common = {
id: target.id || newTargetId(),
mode: target.mode,
...(target.label?.trim() ? { label: target.label.trim() } : {})
};
if (target.mode === "direct") {
return {
...common,
mode: "direct",
...(target.postbox_id ? { postbox_id: target.postbox_id } : {}),
...(!target.postbox_id && target.address_key ? { address_key: target.address_key } : {})
};
}
return {
...common,
mode: "derived",
template_id: target.template_id ?? "",
...(target.organization_unit_field !== undefined
? {
organization_unit_field: target.organization_unit_field,
organization_unit_match: target.organization_unit_match ?? "id"
}
: { organization_unit_id: target.organization_unit_id ?? "" }),
...(target.function_field !== undefined
? {
function_field: target.function_field,
function_match: target.function_match ?? "id"
}
: { function_id: target.function_id ?? "" }),
...(target.context_field !== undefined
? { context_field: target.context_field }
: target.context_key !== undefined
? { context_key: target.context_key }
: {})
};
}
function targetIsComplete(target: CampaignPostboxTarget): boolean {
if (!target.id) return false;
if (target.mode === "direct") {
return Boolean(target.postbox_id || target.address_key);
}
return Boolean(
target.template_id
&& (target.organization_unit_id || target.organization_unit_field)
&& (target.function_id || target.function_field)
);
}
function directTargetDefaults(id: string, catalog: CampaignPostboxCatalog): CampaignPostboxTarget {
return {
id,
mode: "direct",
postbox_id: catalog.postboxes[0]?.id ?? ""
};
}
function derivedTargetDefaults(
id: string,
catalog: CampaignPostboxCatalog,
fields: CampaignFieldDefinition[]
): CampaignPostboxTarget {
const unit = catalog.organization_units[0];
return {
id,
mode: "derived",
template_id: catalog.templates[0]?.id ?? "",
...(unit
? {
organization_unit_id: unit.id,
function_id: unit.functions[0]?.id ?? ""
}
: {
organization_unit_field: fields[0]?.name ?? "",
organization_unit_match: "id",
function_field: fields[0]?.name ?? "",
function_match: "id"
})
};
}
function moveTarget(
targets: CampaignPostboxTarget[],
source: number,
destination: number
): CampaignPostboxTarget[] {
if (destination < 0 || destination >= targets.length) return targets;
const next = [...targets];
const [target] = next.splice(source, 1);
next.splice(destination, 0, target);
return next;
}
function optionalText(value: unknown): string | undefined {
const text = String(value ?? "").trim();
return text || undefined;
}
function newTargetId(): string {
const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `postbox-${suffix}`;
}

View File

@@ -1,7 +1,15 @@
import { asArray, asRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
export const fieldTypeOptions = ["string", "integer", "double", "date", "password"] as const;
export const fieldTypeOptions = [
"string",
"integer",
"double",
"date",
"password",
"organization_unit",
"organization_function"
] as const;
export type CampaignFieldType = typeof fieldTypeOptions[number];
export type CampaignFieldDefinition = {

View File

@@ -5,6 +5,7 @@ export type CampaignJobSortColumn =
| "validation"
| "queue"
| "send"
| "postbox"
| "imap"
| "attempts"
| "updated";
@@ -31,6 +32,7 @@ const FILTER_PARAMETERS: Record<string, string> = {
validation: "filter_validation",
queue: "filter_queue",
send: "filter_send",
postbox: "filter_postbox",
imap: "filter_imap",
attempts: "filter_attempts",
evidence: "filter_evidence"

View File

@@ -214,6 +214,9 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
would require a separately suppressed aggregate response from the server. */}
<div className="dashboard-grid">
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" />
<MetricCard label="Postbox accepted" value={countValue(outcomes.postbox_accepted)} tone="good" />
<MetricCard label="Both channels accepted" value={countValue(outcomes.delivered)} tone="good" />
<MetricCard label="Partially accepted" value={countValue(outcomes.partially_accepted)} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={countValue(outcomes.failed)} tone="danger" />
<MetricCard label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={countValue(outcomes.outcome_unknown)} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.queued_or_active.afdcd7da" value={countValue(outcomes.queued_or_active)} tone="info" />

View File

@@ -1,7 +1,14 @@
import { createElement, lazy, useCallback } from "react";
import { useParams } from "react-router-dom";
import { ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
import {
ResourceAccessBoundary,
type ApiSettings,
type AuthInfo,
type DashboardWidgetsUiCapability,
type PlatformWebModule
} from "@govoplan/core-webui";
import { getCampaign } from "./api/campaigns";
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/campaign-workspace.css";
@@ -17,6 +24,50 @@ const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const campaignDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "campaigns.activity",
surfaceId: "campaigns.widget.activity",
title: "Campaign activity",
description: "Recently changed campaigns and delivery state.",
moduleId: "campaigns",
category: "Communication",
order: 50,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: campaignRead,
refreshIntervalMs: 30_000,
defaultConfiguration: {
maxItems: 5,
showCompleted: false
},
configurationFields: [
{
id: "maxItems",
label: "Maximum campaigns",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
},
{
id: "showCompleted",
label: "Include completed campaigns",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(CampaignActivityWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const campaignModule: PlatformWebModule = {
id: "campaigns",
@@ -25,6 +76,15 @@ export const campaignModule: PlatformWebModule = {
dependencies: ["access"],
optionalDependencies: ["files", "mail"],
translations,
viewSurfaces: [
{
id: "campaigns.widget.activity",
moduleId: "campaigns",
kind: "section",
label: "Campaign activity widget",
order: 50
}
],
navItems: [
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 },
{
@@ -43,7 +103,10 @@ export const campaignModule: PlatformWebModule = {
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/operator", anyOf: operatorScopes, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: ({ settings }) => createElement(AggregateReportsPage, { settings }) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
uiCapabilities: {
"dashboard.widgets": campaignDashboardWidgets
}
};

View File

@@ -947,6 +947,83 @@
overflow: auto;
}
.campaign-postbox-delivery-settings {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
align-items: end;
margin-top: 16px;
}
.campaign-postbox-target-modal {
width: min(1040px, calc(100vw - 32px));
}
.campaign-postbox-target-body {
max-height: min(76vh, 820px);
overflow: auto;
}
.campaign-postbox-target-toolbar,
.campaign-postbox-target-row-heading,
.campaign-recipient-delivery-cell {
display: flex;
align-items: center;
gap: 10px;
}
.campaign-postbox-target-toolbar {
justify-content: space-between;
margin-bottom: 12px;
}
.campaign-postbox-target-list {
display: grid;
gap: 10px;
}
.campaign-postbox-target-row {
display: grid;
gap: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
padding: 10px;
}
.campaign-postbox-target-row-heading {
justify-content: space-between;
}
.campaign-postbox-target-grid {
grid-template-columns: repeat(3, minmax(180px, 1fr));
}
.campaign-recipient-delivery-cell {
min-width: 0;
}
.campaign-recipient-delivery-cell select {
min-width: 0;
flex: 1 1 170px;
}
.campaign-recipient-delivery-cell .button {
flex: 0 0 auto;
}
@media (max-width: 900px) {
.campaign-postbox-delivery-settings,
.campaign-postbox-target-grid {
grid-template-columns: 1fr;
}
.campaign-recipient-delivery-cell {
align-items: stretch;
flex-direction: column;
}
}
.recipient-address-editor {
display: grid;
gap: 12px;

View File

@@ -21,7 +21,7 @@ assert(
"message previews use the central stacked Dialog with an accessible close label"
);
assert(
styles.includes("height: min(780px, calc(100dvh - 48px));"),
styles.includes("height: min(936px, calc(100dvh - 48px));"),
"desktop previews use a stable responsive height"
);
assert(

View File

@@ -106,7 +106,7 @@ assert(overlaySource.includes("{actions && <div"), "built-message footer actions
assert(overlaySource.includes("<Dialog") && overlaySource.includes("closeLabel={closeLabel}"), "the preview uses the central Dialog and its accessible close label");
const styles = readFileSync("src/styles/campaign-workspace.css", "utf8");
assert(styles.includes("height: min(780px, calc(100dvh - 48px));"), "desktop previews use a stable responsive height");
assert(styles.includes("height: min(936px, calc(100dvh - 48px));"), "desktop previews use a stable responsive height");
assert(styles.includes("height: calc(100dvh - 16px);"), "small viewports use the available dynamic viewport height");
assert(styles.includes(".message-preview-modal .modal-footer"), "preview footer has a stable layout rule");
assert(styles.includes(".attachment-linking-file-list.is-compact"), "compact attachment links use a bounded scroll surface");