Implement governed hybrid campaign delivery

This commit is contained in:
2026-08-02 13:58:37 +02:00
parent b38597f2be
commit 7733265cc8
40 changed files with 2531 additions and 122 deletions
+14 -7
View File
@@ -28,12 +28,14 @@ provider contract. Reporting owns the global `/reports` route. Campaign keeps
its module-local `/campaigns/reports` view and does not claim the global route its module-local `/campaigns/reports` view and does not claim the global route
when Reporting is absent. when Reporting is absent.
Generated EML is durable execution material, not a node-local runtime cache. Generated EML and printable artifacts are durable execution material, not a
Campaign stores it through Core's shared object-storage contract under opaque node-local runtime cache. Campaign stores EML through Core's shared
Campaign-owned keys; database job rows retain the expected size, digest, and object-storage contract under opaque Campaign-owned keys. Templates returns a
Message-ID. Workers resolve and verify the object before delivery. Build bounded artifact or a Files-managed artifact for printable output. Database job
failure compensates objects written before database commit, and retention keeps rows retain the expected hashes and provenance. Workers resolve and verify the
the database reference when object deletion fails so cleanup can be retried. frozen evidence before delivery. Build failure compensates objects written
before database commit, and retention keeps database references when object
deletion fails so cleanup can be retried.
## Dependencies ## Dependencies
@@ -41,10 +43,15 @@ The module has one required runtime dependency:
- `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration - `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration
Files and mail are optional module integrations declared in the campaign manifest: Files, Mail, Distribution Lists, Templates, and Postbox are optional module integrations declared in the campaign manifest:
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows. - `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
- `govoplan-mail` owns reusable profiles, encrypted SMTP/IMAP credentials, delivery policy checks, connection tests, and transport execution. Campaign JSON stores only `server.mail_profile_id`; inline transport settings and credentials are rejected. Without Mail, campaigns can still be authored, but profile validation and real delivery are unavailable. - `govoplan-mail` owns reusable profiles, encrypted SMTP/IMAP credentials, delivery policy checks, connection tests, and transport execution. Campaign JSON stores only `server.mail_profile_id`; inline transport settings and credentials are rejected. Without Mail, campaigns can still be authored, but profile validation and real delivery are unavailable.
- `govoplan-dist-lists` expands reusable governed audiences. Campaign freezes the exact list revision, provider evidence, candidates, and explicit per-recipient primary/fallback route into its own version.
- `govoplan-templates` validates and renders published label, envelope, letter, and list-layout templates for postal or internal-mail delivery. Generated output is hash-bound to its template, inputs, actor, route decisions, and Campaign version.
- `govoplan-postbox` resolves exact or organization-derived Postbox targets and records provider acceptance and receipt evidence. It remains optional; Mail-only and print-only campaigns do not require it.
Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery.
Public campaign, version, job, and report responses expose business data and Public campaign, version, job, and report responses expose business data and
delivery evidence, but never process-local paths, storage-backend keys, or delivery evidence, but never process-local paths, storage-backend keys, or
+46 -14
View File
@@ -30,12 +30,13 @@ The shorter task documents remain useful companions:
## What Campaign is for ## What Campaign is for
Campaign turns governed source data into individually built messages and then Campaign turns governed source data into individually built messages or
controls their review, delivery, and evidence. It is intentionally a printable output and then controls their review, delivery, and evidence. It is
composition module: it demonstrates how one user journey can use optional Mail, intentionally a composition module: it demonstrates how one user journey can
Files, Addresses, and Notifications capabilities alongside Core access/audit use optional Mail, Files, Addresses, Distribution Lists, Templates, Postbox,
infrastructure without copying ownership from those modules. Policy may consume and Notifications capabilities alongside Core access/audit infrastructure
Campaign context through a narrow capability; Campaign does not import Policy. without copying ownership from those modules. Policy may consume Campaign
context through a narrow capability; Campaign does not import Policy.
Campaign owns: Campaign owns:
@@ -66,9 +67,9 @@ The supported process is a controlled progression, not a single "send" call:
create/edit create/edit
-> validate and resolve policy/integrations -> validate and resolve policy/integrations
-> review warnings and blockers -> review warnings and blockers
-> build exact recipient messages -> build exact recipient messages and/or printable artifacts
-> complete review and queue -> complete review and queue
-> SMTP attempt per job -> selected Mail, Postbox, or print effect per job
-> optional IMAP append per accepted job -> optional IMAP append per accepted job
-> report, retry, reconcile, or correct -> report, retry, reconcile, or correct
-> archive when no active/uncertain delivery remains -> archive when no active/uncertain delivery remains
@@ -413,6 +414,7 @@ Current principal contracts include:
| `addresses.lookup` 0.1.x | Addresses -> Campaign | Optional address suggestions | | `addresses.lookup` 0.1.x | Addresses -> Campaign | Optional address suggestions |
| `addresses.recipient_source` 0.1.x | Addresses -> Campaign | Optional versioned recipient-source snapshots | | `addresses.recipient_source` 0.1.x | Addresses -> Campaign | Optional versioned recipient-source snapshots |
| `dist_lists.source` / `dist_lists.expand` 0.1.x | Distribution Lists -> Campaign | Discover, preview, and freeze reusable audiences without importing module internals | | `dist_lists.source` / `dist_lists.expand` 0.1.x | Distribution Lists -> Campaign | Discover, preview, and freeze reusable audiences without importing module internals |
| `templates.catalog` / `templates.renderer` 0.1.x | Templates -> Campaign | Select compatible published printable templates and produce deterministic, evidence-bearing artifacts |
| `campaigns.access` 0.1.x | Campaign -> platform | Explain campaign access/existence without exporting ORM objects | | `campaigns.access` 0.1.x | Campaign -> platform | Explain campaign access/existence without exporting ORM objects |
| `campaigns.mail_policy_context` 0.1.x | Campaign -> Mail | Resolve campaign tenant/owner context for Mail policy | | `campaigns.mail_policy_context` 0.1.x | Campaign -> Mail | Resolve campaign tenant/owner context for Mail policy |
| `campaigns.delivery_tasks` 0.1.x | Campaign -> workers | Execute narrow queued send/append tasks | | `campaigns.delivery_tasks` 0.1.x | Campaign -> workers | Execute narrow queued send/append tasks |
@@ -433,16 +435,46 @@ the resulting rows into the editable Campaign version.
Each copied row retains the list and revision IDs, definition and expansion Each copied row retains the list and revision IDs, definition and expansion
hashes, snapshot ID, source entry IDs, provider references, channel candidates, hashes, snapshot ID, source entry IDs, provider references, channel candidates,
the one selected route where it is unambiguous, fallback candidates, and the the explicitly selected primary route, optional fallback, and the decision
decision explanation. Campaign-only fields, attachment rules, review state, explanation. Campaign-only fields, attachment rules, review state, and outcomes
and outcomes remain local to Campaign and never mutate the reusable list. remain local to Campaign and never mutate the reusable list.
A later list revision only raises a drift warning. Refresh is deliberate and A later list revision only raises a drift warning. Refresh is deliberate and
uses append or replace; saving that changed Campaign version clears prior uses append or replace; saving that changed Campaign version clears prior
validation, build, review, and execution state through the normal content validation, build, review, and execution state through the normal content
invalidation path. Postal-only or otherwise unsupported routes remain present invalidation path. Preferred or single usable candidates are preselected
in the frozen evidence but inactive until a compatible Campaign output path is visibly; ambiguous rows must be decided before freezing. Postal and
configured. internal-mail routes remain active when a compatible published Templates output
is selected.
### Governed hybrid and printable delivery
Campaign supports Mail, Postbox, printable output, and bounded ordered
fallbacks without making any of those provider modules mandatory. Opt-in and
channel-preference data are inputs to the visible routing decision; they never
silently cause duplicate delivery.
For a printable route, select a published label, envelope, serial-letter,
form-letter, list-layout, or generic template on the Template page. Validation
checks the selected revision, output format, and required fields. Build sends
one deterministic item collection to `templates.renderer`, records template,
input, output, actor, route, and artifact hashes, and stores the resulting
artifact through Files when configured. The review stage exposes that exact
artifact and its hashes before execution.
Each recipient job records an idempotent print acceptance attempt for its item
in the frozen artifact. `mail_then_print` and `postbox_then_print` invoke print
only after a confirmed rejection before acceptance. An accepted or
outcome-unknown digital effect never falls through to print because that could
produce duplicate delivery. Reports and CSV exports include route provenance,
print state, attempts, artifact reference, and hashes.
Without Templates, Campaign still loads and Mail/Postbox authoring remains
available; validation explains why a configured print route cannot proceed.
Without Files, Templates may return a bounded artifact instead of a managed
file. Campaign copies that payload into shared object storage and exposes it
through the Campaign ACL plus `campaigns:recipient:read`; it never redistributes
the broader Templates URL. A print-only Campaign does not require Mail or Postbox.
### External API expectations ### External API expectations
@@ -97,9 +97,12 @@ class SendStatus(StrEnum):
class DeliveryChannelPolicy(StrEnum): class DeliveryChannelPolicy(StrEnum):
MAIL = "mail" MAIL = "mail"
POSTBOX = "postbox" POSTBOX = "postbox"
PRINT = "print"
MAIL_AND_POSTBOX = "mail_and_postbox" MAIL_AND_POSTBOX = "mail_and_postbox"
MAIL_THEN_POSTBOX = "mail_then_postbox" MAIL_THEN_POSTBOX = "mail_then_postbox"
POSTBOX_THEN_MAIL = "postbox_then_mail" POSTBOX_THEN_MAIL = "postbox_then_mail"
MAIL_THEN_PRINT = "mail_then_print"
POSTBOX_THEN_PRINT = "postbox_then_print"
@property @property
def uses_mail(self) -> bool: def uses_mail(self) -> bool:
@@ -108,11 +111,26 @@ class DeliveryChannelPolicy(StrEnum):
DeliveryChannelPolicy.MAIL_AND_POSTBOX, DeliveryChannelPolicy.MAIL_AND_POSTBOX,
DeliveryChannelPolicy.MAIL_THEN_POSTBOX, DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
DeliveryChannelPolicy.POSTBOX_THEN_MAIL, DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
DeliveryChannelPolicy.MAIL_THEN_PRINT,
} }
@property @property
def uses_postbox(self) -> bool: def uses_postbox(self) -> bool:
return self != DeliveryChannelPolicy.MAIL return self in {
DeliveryChannelPolicy.POSTBOX,
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
DeliveryChannelPolicy.POSTBOX_THEN_PRINT,
}
@property
def uses_print(self) -> bool:
return self in {
DeliveryChannelPolicy.PRINT,
DeliveryChannelPolicy.MAIL_THEN_PRINT,
DeliveryChannelPolicy.POSTBOX_THEN_PRINT,
}
class PostboxTargetMode(StrEnum): class PostboxTargetMode(StrEnum):
@@ -192,6 +210,15 @@ class PostboxTargetConfig(StrictModel):
return self return self
class PrintTargetConfig(StrictModel):
channel: Literal["postal", "internal_mail"]
target: str = Field(min_length=1, max_length=4000)
target_key: str = Field(min_length=1, max_length=500)
contact_point_id: str | None = Field(default=None, max_length=36)
locale: str | None = Field(default=None, max_length=35)
decision_provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignMeta(StrictModel): class CampaignMeta(StrictModel):
id: str id: str
name: str name: str
@@ -556,11 +583,16 @@ class EntryConfig(StrictModel):
max_length=50, max_length=50,
) )
merge_postbox_targets: bool = True merge_postbox_targets: bool = True
print_target: PrintTargetConfig | None = None
attachments: list[AttachmentConfig] = Field(default_factory=list) attachments: list[AttachmentConfig] = Field(default_factory=list)
combine_attachments: bool = True combine_attachments: bool = True
fields: dict[str, Any] = Field(default_factory=dict) fields: dict[str, Any] = Field(default_factory=dict)
# Frozen channel candidates, source revisions and the explicit route
# decision imported from Distribution Lists. Campaign owns this snapshot;
# it never re-resolves the audience during build or delivery.
distribution_source: dict[str, Any] = Field(default_factory=dict)
last_sent: str | None = None last_sent: str | None = None
@@ -576,7 +608,7 @@ class ImportProvenance(StrictModel):
id: str id: str
imported_at: str imported_at: str
mode: Literal["append", "replace"] mode: Literal["append", "replace"]
source_type: Literal["csv", "xlsx", "text", "addresses"] source_type: Literal["csv", "xlsx", "text", "addresses", "distribution_list"]
source_id: str | None = None source_id: str | None = None
source_label: str | None = None source_label: str | None = None
source_revision: str | None = None source_revision: str | None = None
@@ -678,9 +710,19 @@ class PostboxDeliveryConfig(StrictModel):
duplicate_target: Behavior = Behavior.WARN duplicate_target: Behavior = Behavior.WARN
class PrintDeliveryConfig(StrictModel):
template_id: str | None = Field(default=None, max_length=36)
template_revision: int | None = Field(default=None, ge=1)
usage: str = Field(default="campaign_print", min_length=1, max_length=100)
output_format: Literal["html", "text"] = "html"
profile_id: str | None = Field(default=None, max_length=120)
persist_to_files: bool = True
class DeliveryConfig(StrictModel): class DeliveryConfig(StrictModel):
channel_policy: DeliveryChannelPolicy = DeliveryChannelPolicy.MAIL channel_policy: DeliveryChannelPolicy = DeliveryChannelPolicy.MAIL
postbox: PostboxDeliveryConfig = Field(default_factory=PostboxDeliveryConfig) postbox: PostboxDeliveryConfig = Field(default_factory=PostboxDeliveryConfig)
print: PrintDeliveryConfig = Field(default_factory=PrintDeliveryConfig)
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig) rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig) imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
retry: RetryConfig = Field(default_factory=RetryConfig) retry: RetryConfig = Field(default_factory=RetryConfig)
@@ -482,6 +482,7 @@ def _delivery_issues(
config: CampaignConfig, config: CampaignConfig,
*, *,
postbox_available: bool, postbox_available: bool,
templates_available: bool,
) -> list[SemanticIssue]: ) -> list[SemanticIssue]:
issues: list[SemanticIssue] = [] issues: list[SemanticIssue] = []
policies = _delivery_policies(config) policies = _delivery_policies(config)
@@ -533,6 +534,46 @@ def _delivery_issues(
postbox_available=postbox_available, postbox_available=postbox_available,
) )
) )
if any(policy.uses_print for policy in policies):
if not templates_available:
issues.append(
_issue(
Severity.ERROR,
"templates_unavailable",
"Printable Campaign delivery requires the optional Templates renderer.",
"/delivery/print/template_id",
)
)
if not config.delivery.print.template_id:
issues.append(
_issue(
Severity.ERROR,
"print_template_missing",
"Select a published compatible template for printable Campaign output.",
"/delivery/print/template_id",
)
)
elif config.delivery.print.template_revision is None:
issues.append(
_issue(
Severity.ERROR,
"print_template_revision_missing",
"Printable delivery must pin one published template revision.",
"/delivery/print/template_revision",
)
)
for entry_index, entry in enumerate(_active_delivery_entries(config)):
if not effective_delivery_channel_policy(config, entry).uses_print:
continue
if entry.print_target is None:
issues.append(
_issue(
Severity.ERROR,
"print_target_missing",
"Printable delivery requires an explicit postal or internal-mail target.",
f"/entries/inline/{entry_index}/print_target",
)
)
return issues return issues
@@ -784,6 +825,7 @@ def validate_campaign_config(
campaign_file: str | Path | None = None, campaign_file: str | Path | None = None,
check_files: bool = False, check_files: bool = False,
postbox_available: bool = False, postbox_available: bool = False,
templates_available: bool = False,
) -> SemanticReport: ) -> SemanticReport:
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json" campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
issues: list[SemanticIssue] = [] issues: list[SemanticIssue] = []
@@ -799,6 +841,7 @@ def validate_campaign_config(
_delivery_issues( _delivery_issues(
config, config,
postbox_available=postbox_available, postbox_available=postbox_available,
templates_available=templates_available,
) )
) )
issues.extend(_sender_issues(config)) issues.extend(_sender_issues(config))
@@ -22,6 +22,7 @@ from govoplan_campaign.backend.db.models import (
CampaignVersion, CampaignVersion,
ImapAppendAttempt, ImapAppendAttempt,
PostboxDeliveryAttempt, PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt, SendAttempt,
new_uuid, new_uuid,
) )
@@ -58,7 +59,7 @@ def _record_campaign_changes(session: OrmSession, _flush_context: object, _insta
_record_issue_change(session, obj) _record_issue_change(session, obj)
elif isinstance( elif isinstance(
obj, obj,
(SendAttempt, ImapAppendAttempt, PostboxDeliveryAttempt), (SendAttempt, ImapAppendAttempt, PostboxDeliveryAttempt, PrintOutputAttempt),
): ):
_record_attempt_change(session, obj) _record_attempt_change(session, obj)
@@ -188,9 +189,11 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"send_status", "send_status",
"delivery_channel_policy", "delivery_channel_policy",
"postbox_status", "postbox_status",
"print_status",
"imap_status", "imap_status",
"attempt_count", "attempt_count",
"postbox_attempt_count", "postbox_attempt_count",
"print_attempt_count",
"last_error", "last_error",
"queued_at", "queued_at",
"claimed_at", "claimed_at",
@@ -198,7 +201,9 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"outcome_unknown_at", "outcome_unknown_at",
"sent_at", "sent_at",
"resolved_recipients", "resolved_recipients",
"delivery_provenance",
"resolved_postbox_targets", "resolved_postbox_targets",
"resolved_print_output",
"resolved_attachments", "resolved_attachments",
"issues_snapshot", "issues_snapshot",
), ),
@@ -227,6 +232,7 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
"send_status": job.send_status, "send_status": job.send_status,
"delivery_channel_policy": job.delivery_channel_policy, "delivery_channel_policy": job.delivery_channel_policy,
"postbox_status": job.postbox_status, "postbox_status": job.postbox_status,
"print_status": job.print_status,
"imap_status": job.imap_status, "imap_status": job.imap_status,
}, },
) )
@@ -259,7 +265,7 @@ def _record_issue_change(session: OrmSession, issue: CampaignIssue) -> None:
def _record_attempt_change( def _record_attempt_change(
session: OrmSession, session: OrmSession,
attempt: SendAttempt | ImapAppendAttempt | PostboxDeliveryAttempt, attempt: SendAttempt | ImapAppendAttempt | PostboxDeliveryAttempt | PrintOutputAttempt,
) -> None: ) -> None:
operation = _operation_for_object( operation = _operation_for_object(
attempt, attempt,
@@ -274,6 +280,8 @@ def _record_attempt_change(
"provider_delivery_id", "provider_delivery_id",
"provider_message_id", "provider_message_id",
"postbox_id", "postbox_id",
"render_id",
"artifact_sha256",
"evidence", "evidence",
), ),
) )
@@ -298,6 +306,8 @@ def _record_attempt_change(
"attempt_kind": ( "attempt_kind": (
"postbox" "postbox"
if isinstance(attempt, PostboxDeliveryAttempt) if isinstance(attempt, PostboxDeliveryAttempt)
else "print"
if isinstance(attempt, PrintOutputAttempt)
else "imap" else "imap"
if isinstance(attempt, ImapAppendAttempt) if isinstance(attempt, ImapAppendAttempt)
else "smtp" else "smtp"
@@ -85,6 +85,7 @@ class JobSendStatus(StrEnum):
SENDING = "sending" SENDING = "sending"
SMTP_ACCEPTED = "smtp_accepted" SMTP_ACCEPTED = "smtp_accepted"
POSTBOX_ACCEPTED = "postbox_accepted" POSTBOX_ACCEPTED = "postbox_accepted"
PRINT_ACCEPTED = "print_accepted"
DELIVERED = "delivered" DELIVERED = "delivered"
PARTIALLY_ACCEPTED = "partially_accepted" PARTIALLY_ACCEPTED = "partially_accepted"
SENT = "sent" # legacy value retained for existing databases/reports SENT = "sent" # legacy value retained for existing databases/reports
@@ -107,6 +108,15 @@ class JobPostboxStatus(StrEnum):
SKIPPED = "skipped" SKIPPED = "skipped"
class JobPrintStatus(StrEnum):
NOT_REQUESTED = "not_requested"
READY = "ready"
ACCEPTING = "accepting"
ACCEPTED = "accepted"
FAILED = "failed"
SKIPPED = "skipped"
class JobImapStatus(StrEnum): class JobImapStatus(StrEnum):
NOT_REQUESTED = "not_requested" NOT_REQUESTED = "not_requested"
PENDING = "pending" PENDING = "pending"
@@ -295,6 +305,12 @@ class CampaignJob(Base, TimestampMixin):
nullable=False, nullable=False,
index=True, index=True,
) )
print_status: Mapped[str] = mapped_column(
String(50),
default=JobPrintStatus.NOT_REQUESTED.value,
nullable=False,
index=True,
)
imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.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) attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -303,6 +319,11 @@ class CampaignJob(Base, TimestampMixin):
default=0, default=0,
nullable=False, nullable=False,
) )
print_attempt_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
)
last_error: Mapped[str | None] = mapped_column(Text) last_error: Mapped[str | None] = mapped_column(Text)
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -314,11 +335,20 @@ class CampaignJob(Base, TimestampMixin):
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
delivery_provenance: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
resolved_postbox_targets: Mapped[list[dict[str, Any]]] = mapped_column( resolved_postbox_targets: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, JSON,
default=list, default=list,
nullable=False, nullable=False,
) )
resolved_print_output: Mapped[dict[str, Any] | None] = mapped_column(
JSON,
nullable=True,
)
resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
@@ -604,6 +634,39 @@ class PostboxDeliveryAttempt(Base, TimestampMixin):
) )
class PrintOutputAttempt(Base, TimestampMixin):
__tablename__ = "campaign_print_output_attempts"
__table_args__ = (
UniqueConstraint(
"job_id",
"attempt_number",
name="uq_campaign_print_attempt_job_number",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_campaign_print_attempt_idempotency",
),
)
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,
)
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)
render_id: Mapped[str | None] = mapped_column(String(36), index=True)
artifact_sha256: Mapped[str | None] = mapped_column(String(64), index=True)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
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__ = [ __all__ = [
@@ -622,9 +685,11 @@ __all__ = [
"JobBuildStatus", "JobBuildStatus",
"JobImapStatus", "JobImapStatus",
"JobPostboxStatus", "JobPostboxStatus",
"JobPrintStatus",
"JobQueueStatus", "JobQueueStatus",
"JobSendStatus", "JobSendStatus",
"JobValidationStatus", "JobValidationStatus",
"SendAttempt", "SendAttempt",
"PostboxDeliveryAttempt", "PostboxDeliveryAttempt",
"PrintOutputAttempt",
] ]
+44 -3
View File
@@ -45,6 +45,8 @@ _ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source" _ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source" _DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand" _DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
_TEMPLATE_CATALOG_INTEGRATION = "templates.catalog"
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch" _NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
@@ -250,15 +252,54 @@ CAMPAIGN_USER_DOCUMENTATION = (
steps=( steps=(
"Open Recipient data and select Import Distribution List.", "Open Recipient data and select Import Distribution List.",
"Choose the list, requested channels, and any declared parameters, then preview the expansion.", "Choose the list, requested channels, and any declared parameters, then preview the expansion.",
"Review included and excluded recipients, stale provider evidence, diagnostics, and unresolved route choices.", "Review included and excluded recipients, stale provider evidence, diagnostics, and every visible primary and optional fallback route.",
"Choose append or replace, freeze and import the expansion, inspect the copied rows, and save the Campaign version.", "Choose append or replace, freeze and import the expansion, inspect the copied rows, and save the Campaign version.",
"Use the drift warning for a deliberate refresh when the reusable list changes later.", "Use the drift warning for a deliberate refresh when the reusable list changes later.",
), ),
outcome="A Campaign-local recipient snapshot with immutable audience, provider, policy, and channel-decision evidence.", outcome="A Campaign-local recipient snapshot with immutable audience, provider, policy, and channel-decision evidence.",
verification="The saved recipient rows retain the list revision and snapshot reference, and later list changes do not alter them automatically.", verification="The saved recipient rows retain the list revision and snapshot reference, and later list changes do not alter them automatically.",
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"), related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
related_modules=("dist_lists",), related_modules=("dist_lists", "templates", "postbox"),
limitations=("Unsupported non-email output routes remain inactive until a compatible Campaign output integration is configured.",), ),
_workflow_topic(
topic_id="campaigns.workflow.prepare-printable-delivery",
title="Prepare governed printable delivery",
summary="Select a published output template, build one deterministic artifact, and review its route and hash evidence before postal or internal-mail distribution.",
body="Printable delivery is optional and provider-neutral. Campaign freezes recipient route decisions while Templates owns compatibility and rendering; Files may own the resulting managed artifact. Ordered fallback is used only after a confirmed rejection before acceptance and never after an accepted or outcome-unknown digital effect.",
order=34,
audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
required_modules=("campaigns", "templates"),
required_capabilities=(
_TEMPLATE_CATALOG_INTEGRATION,
_TEMPLATE_RENDERER_INTEGRATION,
),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:campaign:validate",
"campaigns:campaign:build",
"campaigns:recipient:read",
"templates:template:read",
"templates:template:render",
),
route="/campaigns/{campaign_id}/template",
screen="Template",
help_contexts=("campaign.template", "campaign.review-send"),
prerequisites=(
"At least one included recipient has an explicit postal or internal-mail route.",
"A compatible Templates definition is published and visible to you.",
),
steps=(
"Open Template and select the published printable template, output format, and storage choice.",
"Save, validate, and resolve every missing-field or compatibility error.",
"Build the Campaign to generate one deterministic artifact for the frozen printable recipients.",
"In Review and send, download and inspect the artifact and compare its template, input, and output hashes.",
"Complete review and execute delivery; use reports to verify per-recipient print acceptance and route provenance.",
),
outcome="A reviewed printable artifact and idempotent per-recipient distribution evidence.",
verification="The build summary exposes the artifact and hashes, and the Campaign report records print status and one acceptance attempt per routed recipient.",
related_topic_ids=("campaigns.workflow.import-distribution-list", "campaigns.workflow.prepare-validate-and-build"),
related_modules=("templates", "files", "dist_lists"),
), ),
_workflow_topic( _workflow_topic(
topic_id="campaigns.workflow.use-managed-attachments", topic_id="campaigns.workflow.use-managed-attachments",
@@ -28,6 +28,16 @@ from govoplan_core.core.postbox import (
PostboxEvidenceProvider, PostboxEvidenceProvider,
PostboxTargetRef, PostboxTargetRef,
) )
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_RENDERER,
TemplateCatalogProvider,
TemplateCompatibility,
TemplateRef,
TemplateRenderRequest,
TemplateRenderResult,
TemplateRendererProvider,
)
from govoplan_campaign.backend.runtime import capability from govoplan_campaign.backend.runtime import capability
@@ -37,6 +47,8 @@ POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
class OptionalModuleUnavailable(RuntimeError): class OptionalModuleUnavailable(RuntimeError):
@@ -89,6 +101,10 @@ class ApprovalGateUnavailable(OptionalModuleUnavailable):
pass pass
class TemplateOutputUnavailable(OptionalModuleUnavailable):
pass
class _PreparedCampaignSnapshot: class _PreparedCampaignSnapshot:
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None: def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
self._directory = directory self._directory = directory
@@ -493,6 +509,102 @@ class ApprovalCampaignIntegration:
) )
class TemplatesCampaignIntegration:
def __init__(
self,
catalog_delegate: object | None = None,
renderer_delegate: object | None = None,
) -> None:
self._catalog = (
catalog_delegate
if isinstance(catalog_delegate, TemplateCatalogProvider)
else None
)
self._renderer = (
renderer_delegate
if isinstance(renderer_delegate, TemplateRendererProvider)
else None
)
@property
def available(self) -> bool:
return self._catalog is not None and self._renderer is not None
def list_templates(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> tuple[TemplateRef, ...]:
if self._catalog is None:
return ()
return tuple(
self._catalog.list_templates(
session,
principal,
query=query,
usage="campaign_print",
limit=limit,
)
)
def check_compatibility(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int | None,
output_format: str,
available_fields: dict[str, str] | tuple[str, ...],
) -> TemplateCompatibility:
if self._catalog is None:
raise TemplateOutputUnavailable(
"Printable output is unavailable because Templates is not active."
)
return self._catalog.check_compatibility(
session,
principal,
template_id=template_id,
revision=revision,
usage="campaign_print",
output_format=output_format,
available_fields=available_fields,
)
def get_template(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int,
) -> TemplateRef | None:
if self._catalog is None:
return None
return self._catalog.get_template(
session,
principal,
template_id=template_id,
revision=revision,
)
def render(
self,
session: object,
principal: object,
*,
request: TemplateRenderRequest,
) -> TemplateRenderResult:
if self._renderer is None:
raise TemplateOutputUnavailable(
"Printable output is unavailable because Templates is not active."
)
return self._renderer.render(session, principal, request=request)
def files_integration() -> FilesCampaignIntegration: def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY)) return FilesCampaignIntegration(capability(FILES_CAPABILITY))
@@ -511,3 +623,10 @@ def postbox_integration() -> PostboxCampaignIntegration:
def approvals_integration() -> ApprovalCampaignIntegration: def approvals_integration() -> ApprovalCampaignIntegration:
return ApprovalCampaignIntegration(capability(APPROVALS_CAPABILITY)) return ApprovalCampaignIntegration(capability(APPROVALS_CAPABILITY))
def templates_integration() -> TemplatesCampaignIntegration:
return TemplatesCampaignIntegration(
capability(TEMPLATE_CATALOG_CAPABILITY),
capability(TEMPLATE_RENDERER_CAPABILITY),
)
+24 -2
View File
@@ -40,6 +40,10 @@ from govoplan_core.core.distribution_lists import (
CAPABILITY_DISTRIBUTION_LIST_EXPAND, CAPABILITY_DISTRIBUTION_LIST_EXPAND,
CAPABILITY_DISTRIBUTION_LIST_SOURCE, CAPABILITY_DISTRIBUTION_LIST_SOURCE,
) )
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_RENDERER,
)
from govoplan_core.core.operations import OperationalCheckProviderRegistration from govoplan_core.core.operations import OperationalCheckProviderRegistration
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
@@ -170,7 +174,7 @@ PERMISSIONS = (
_permission( _permission(
"campaigns:campaign:send", "campaigns:campaign:send",
"Send campaigns", "Send campaigns",
"Start real Mail or Postbox delivery.", "Start real Mail, Postbox, or printable delivery.",
"Campaigns", "Campaigns",
), ),
_permission( _permission(
@@ -182,7 +186,7 @@ PERMISSIONS = (
_permission( _permission(
"campaigns:campaign:reconcile", "campaigns:campaign:reconcile",
"Reconcile delivery", "Reconcile delivery",
"Resolve outcome-unknown Mail, Postbox, or IMAP attempts after inspection.", "Resolve outcome-unknown Mail, Postbox, printable, or IMAP attempts after inspection.",
"Campaigns", "Campaigns",
), ),
_permission( _permission(
@@ -368,6 +372,7 @@ manifest = ModuleManifest(
"notifications", "notifications",
"addresses", "addresses",
"dist_lists", "dist_lists",
"templates",
"postbox", "postbox",
"approvals", "approvals",
"reporting", "reporting",
@@ -432,6 +437,18 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0", version_max_exclusive="0.2.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name=CAPABILITY_TEMPLATE_CATALOG,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_TEMPLATE_RENDERER,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_DELIVERY, name=CAPABILITY_POSTBOX_DELIVERY,
version_min="0.1.1", version_min="0.1.1",
@@ -545,6 +562,7 @@ manifest = ModuleManifest(
campaign_models.CampaignMessageActionAttempt, campaign_models.CampaignMessageActionAttempt,
campaign_models.ImapAppendAttempt, campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt, campaign_models.PostboxDeliveryAttempt,
campaign_models.PrintOutputAttempt,
label="Campaigns", label="Campaigns",
), ),
retirement_notes="Destructive retirement drops campaign-owned database tables after the installer captures a database snapshot.", retirement_notes="Destructive retirement drops campaign-owned database tables after the installer captures a database snapshot.",
@@ -564,6 +582,7 @@ manifest = ModuleManifest(
campaign_models.CampaignMessageActionAttempt, campaign_models.CampaignMessageActionAttempt,
campaign_models.ImapAppendAttempt, campaign_models.ImapAppendAttempt,
campaign_models.PostboxDeliveryAttempt, campaign_models.PostboxDeliveryAttempt,
campaign_models.PrintOutputAttempt,
label="Campaigns", label="Campaigns",
), ),
), ),
@@ -1139,6 +1158,9 @@ manifest = ModuleManifest(
non_owned_concepts=( non_owned_concepts=(
"mail transport", "mail transport",
"postbox", "postbox",
"distribution list",
"template definition and rendering",
"print artifact storage",
"durable address directory", "durable address directory",
"file storage", "file storage",
), ),
@@ -0,0 +1,17 @@
"""Development wrapper for the canonical printable-delivery migration."""
from __future__ import annotations
from importlib import import_module
_migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"b7c8d9e0f1a2_campaign_print_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
@@ -0,0 +1,112 @@
"""add governed campaign printable delivery
Revision ID: b7c8d9e0f1a2
Revises: f0a1b2c3d4e5
Create Date: 2026-08-02 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b7c8d9e0f1a2"
down_revision = "f0a1b2c3d4e5"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaign_jobs",
sa.Column(
"print_status",
sa.String(length=50),
nullable=False,
server_default="not_requested",
),
)
op.add_column(
"campaign_jobs",
sa.Column(
"print_attempt_count",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"campaign_jobs",
sa.Column("resolved_print_output", sa.JSON(), nullable=True),
)
op.add_column(
"campaign_jobs",
sa.Column(
"delivery_provenance",
sa.JSON(),
nullable=False,
server_default="{}",
),
)
op.create_index(
op.f("ix_campaign_jobs_print_status"),
"campaign_jobs",
["print_status"],
unique=False,
)
op.create_table(
"campaign_print_output_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("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("render_id", sa.String(length=36), nullable=True),
sa.Column("artifact_sha256", sa.String(length=64), nullable=True),
sa.Column("evidence", sa.JSON(), nullable=False),
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_print_output_attempts_job_id_campaign_jobs"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_campaign_print_output_attempts"),
),
sa.UniqueConstraint(
"job_id",
"attempt_number",
name="uq_campaign_print_attempt_job_number",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_campaign_print_attempt_idempotency",
),
)
for column in ("tenant_id", "job_id", "status", "render_id", "artifact_sha256"):
op.create_index(
op.f(f"ix_campaign_print_output_attempts_{column}"),
"campaign_print_output_attempts",
[column],
unique=False,
)
def downgrade() -> None:
op.drop_table("campaign_print_output_attempts")
op.drop_index(
op.f("ix_campaign_jobs_print_status"),
table_name="campaign_jobs",
)
op.drop_column("campaign_jobs", "resolved_print_output")
op.drop_column("campaign_jobs", "delivery_provenance")
op.drop_column("campaign_jobs", "print_attempt_count")
op.drop_column("campaign_jobs", "print_status")
@@ -2,7 +2,8 @@ from __future__ import annotations
import copy import copy
import hashlib import hashlib
from dataclasses import dataclass import json
from dataclasses import asdict, dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from email import policy from email import policy
from email.parser import BytesParser from email.parser import BytesParser
@@ -14,11 +15,13 @@ from uuid import uuid4
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.object_storage import ( from govoplan_core.core.object_storage import (
StorageBackend, StorageBackend,
StorageBackendError, StorageBackendError,
configured_storage_backend, configured_storage_backend,
) )
from govoplan_core.core.templates import TemplateRenderRequest
from govoplan_core.settings import settings as core_settings from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
@@ -29,6 +32,7 @@ from govoplan_campaign.backend.db.models import (
CampaignVersionWorkflowState, CampaignVersionWorkflowState,
JobImapStatus, JobImapStatus,
JobPostboxStatus, JobPostboxStatus,
JobPrintStatus,
JobQueueStatus, JobQueueStatus,
JobSendStatus, JobSendStatus,
JobValidationStatus, JobValidationStatus,
@@ -42,13 +46,23 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_mail_profile_id, campaign_mail_profile_id,
campaign_mail_resource_ids, campaign_mail_resource_ids,
) )
from govoplan_campaign.backend.campaign.validation import validate_campaign_config from govoplan_campaign.backend.campaign.validation import (
SemanticIssue,
Severity,
validate_campaign_config,
)
from govoplan_campaign.backend.campaign.entries import load_campaign_entries from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.postbox_targets import ( from govoplan_campaign.backend.campaign.postbox_targets import (
resolve_entry_postbox_targets, resolve_entry_postbox_targets,
) )
from govoplan_campaign.backend.campaign.field_values import (
effective_entry_field_values,
)
from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft from govoplan_campaign.backend.messages.models import (
MessageDraft,
MessageValidationStatus,
)
from govoplan_campaign.backend.sending.execution import ( from govoplan_campaign.backend.sending.execution import (
create_execution_snapshot, create_execution_snapshot,
profile_delivery_summary, profile_delivery_summary,
@@ -62,6 +76,7 @@ from govoplan_campaign.backend.integrations import (
files_integration, files_integration,
mail_integration, mail_integration,
postbox_integration, postbox_integration,
templates_integration,
) )
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
from govoplan_campaign.backend.runtime import get_settings from govoplan_campaign.backend.runtime import get_settings
@@ -426,6 +441,129 @@ def load_version_config(session: Session, version_id: str):
) )
def _campaign_uses_print(config: CampaignConfig) -> bool:
entries = (
config.entries.inline
if config.entries.is_inline
else [config.entries.defaults]
)
return any(
entry is not None
and entry.active
and (entry.channel_policy or config.delivery.channel_policy).uses_print
for entry in entries or []
)
def _print_template_available_fields(config: CampaignConfig) -> dict[str, str]:
type_map = {
"double": "number",
"organization_unit": "string",
"organization_function": "string",
"password": "string",
}
fields: dict[str, str] = {
"campaign.id": "string",
"campaign.name": "string",
"recipient_key": "string",
"display_name": "string",
"print_target.channel": "string",
"print_target.target": "string",
"print_target.target_key": "string",
"distribution.list_id": "string",
"distribution.list_revision": "integer",
"distribution.expansion_hash": "string",
}
for field in config.fields:
value_type = type_map.get(field.type.value, field.type.value)
fields[field.name] = value_type
fields[f"fields.{field.name}"] = value_type
return fields
def _print_template_compatibility_issues(
session: Session,
*,
principal: ApiPrincipal | None,
config: CampaignConfig,
) -> list[SemanticIssue]:
if not _campaign_uses_print(config) or not config.delivery.print.template_id:
return []
integration = templates_integration()
if not integration.available:
return [] # The semantic availability check already explains this.
if principal is None:
return [
SemanticIssue(
severity=Severity.ERROR,
code="print_template_principal_missing",
message="Printable output must be validated by an authenticated Campaign actor.",
path="/delivery/print/template_id",
)
]
if not (
principal.has("templates:template:render")
or principal.has("templates:template:admin")
):
return [
SemanticIssue(
severity=Severity.ERROR,
code="print_template_render_forbidden",
message="You may select this template but are not permitted to render printable output.",
path="/delivery/print/template_id",
)
]
if config.delivery.print.template_revision is None:
return [] # The semantic validation report already requires an exact revision.
try:
template = integration.get_template(
session,
principal,
template_id=config.delivery.print.template_id,
revision=config.delivery.print.template_revision,
)
if template is None or template.revision is None:
raise ValueError("Template revision not found.")
if template.revision.published_at is None:
raise ValueError("The selected template revision is not published.")
result = integration.check_compatibility(
session,
principal,
template_id=config.delivery.print.template_id,
revision=config.delivery.print.template_revision,
output_format=config.delivery.print.output_format,
available_fields=_print_template_available_fields(config),
)
except (PermissionError, RuntimeError, ValueError) as exc:
return [
SemanticIssue(
severity=Severity.ERROR,
code="print_template_unavailable",
message=f"The selected printable template cannot be used: {exc}",
path="/delivery/print/template_id",
)
]
if result.compatible:
return []
details = [*result.missing_fields, *result.incompatible_fields]
suffix = (
f" Missing or incompatible fields: {', '.join(details)}."
if details
else ""
)
return [
SemanticIssue(
severity=Severity.ERROR,
code="print_template_incompatible",
message=(
"The selected printable template is not compatible with this Campaign."
f"{suffix}"
),
path="/delivery/print/template_id",
)
]
def validate_campaign_version( def validate_campaign_version(
session: Session, session: Session,
*, *,
@@ -433,6 +571,7 @@ def validate_campaign_version(
version_id: str, version_id: str,
check_files: bool = False, check_files: bool = False,
user_id: str | None = None, user_id: str | None = None,
principal: ApiPrincipal | None = None,
lock_on_success: bool = True, lock_on_success: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
version, snapshot_path, config = load_version_config(session, version_id) version, snapshot_path, config = load_version_config(session, version_id)
@@ -483,6 +622,7 @@ def validate_campaign_version(
campaign_file=prepared.path, campaign_file=prepared.path,
check_files=True, check_files=True,
postbox_available=postbox_integration().available, postbox_available=postbox_integration().available,
templates_available=templates_integration().available,
) )
else: else:
report = validate_campaign_config( report = validate_campaign_config(
@@ -490,7 +630,15 @@ def validate_campaign_version(
campaign_file=snapshot_path, campaign_file=snapshot_path,
check_files=False, check_files=False,
postbox_available=postbox_integration().available, postbox_available=postbox_integration().available,
templates_available=templates_integration().available,
) )
report.issues.extend(
_print_template_compatibility_issues(
session,
principal=principal,
config=config,
)
)
report_json = report.model_dump(mode="json") report_json = report.model_dump(mode="json")
report_json.update( report_json.update(
{ {
@@ -569,6 +717,8 @@ def _job_from_message(
version_id: str, version_id: str,
message: MessageDraft, message: MessageDraft,
resolved_postbox_targets: list[dict[str, Any]] | None = None, resolved_postbox_targets: list[dict[str, Any]] | None = None,
resolved_print_output: dict[str, Any] | None = None,
delivery_provenance: dict[str, Any] | None = None,
stored_eml: _StoredEmlArtifact | None = None, stored_eml: _StoredEmlArtifact | None = None,
) -> CampaignJob: ) -> CampaignJob:
recipient_email = message.to[0].email if message.to else None recipient_email = message.to[0].email if message.to else None
@@ -576,6 +726,7 @@ def _job_from_message(
if stored_eml is not None: if stored_eml is not None:
eml_sha256 = stored_eml.sha256 eml_sha256 = stored_eml.sha256
message_id_header = stored_eml.message_id_header message_id_header = stored_eml.message_id_header
channel_policy = DeliveryChannelPolicy(message.delivery_channel_policy)
return CampaignJob( return CampaignJob(
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
@@ -602,9 +753,16 @@ def _job_from_message(
delivery_channel_policy=message.delivery_channel_policy, delivery_channel_policy=message.delivery_channel_policy,
postbox_status=( postbox_status=(
JobPostboxStatus.PENDING.value JobPostboxStatus.PENDING.value
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox if channel_policy.uses_postbox
else JobPostboxStatus.NOT_REQUESTED.value else JobPostboxStatus.NOT_REQUESTED.value
), ),
print_status=(
JobPrintStatus.READY.value
if channel_policy.uses_print and resolved_print_output
else JobPrintStatus.FAILED.value
if channel_policy.uses_print
else JobPrintStatus.NOT_REQUESTED.value
),
imap_status=message.imap_status.value imap_status=message.imap_status.value
if hasattr(message.imap_status, "value") if hasattr(message.imap_status, "value")
else JobImapStatus.NOT_REQUESTED.value, else JobImapStatus.NOT_REQUESTED.value,
@@ -621,7 +779,9 @@ def _job_from_message(
for item in message.disposition_notification_to for item in message.disposition_notification_to
], ],
}, },
delivery_provenance=delivery_provenance or {},
resolved_postbox_targets=resolved_postbox_targets or [], resolved_postbox_targets=resolved_postbox_targets or [],
resolved_print_output=resolved_print_output,
resolved_attachments=[ resolved_attachments=[
files_integration().public_attachment_summary_payload(item) files_integration().public_attachment_summary_payload(item)
for item in message.attachments for item in message.attachments
@@ -665,6 +825,192 @@ def _resolve_built_postbox_targets(
return resolved_by_index return resolved_by_index
def _print_render_item(
config: CampaignConfig,
entry: Any,
) -> dict[str, Any]:
fields = effective_entry_field_values(config, entry)
distribution = dict(entry.distribution_source or {})
target = entry.print_target.model_dump(mode="json") if entry.print_target else {}
return {
**fields,
"fields": fields,
"campaign": {
"id": config.campaign.id,
"name": config.campaign.name,
"description": config.campaign.description,
},
"recipient_key": str(distribution.get("recipient_key") or entry.id or ""),
"display_name": entry.name or "",
"print_target": target,
"distribution": distribution,
}
def _canonical_sha256(value: object) -> str:
payload = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _resolve_built_print_outputs(
session: Session,
*,
storage: StorageBackend,
tenant_id: str,
build_id: str,
version: CampaignVersion,
principal: ApiPrincipal | None,
config: CampaignConfig,
built_messages: list[Any],
entries_by_index: dict[int, Any],
) -> dict[int, dict[str, Any]]:
printable: list[tuple[Any, Any, dict[str, Any]]] = []
for built in built_messages:
policy = DeliveryChannelPolicy(built.draft.delivery_channel_policy)
if not policy.uses_print:
continue
entry = entries_by_index.get(built.draft.entry_index)
if entry is None:
raise CampaignPersistenceError(
"A printable recipient row is missing from the Campaign input."
)
if built.draft.validation_status not in {
MessageValidationStatus.READY,
MessageValidationStatus.WARNING,
}:
continue
printable.append((built, entry, _print_render_item(config, entry)))
if not printable:
return {}
if principal is None:
raise CampaignPersistenceError(
"Printable output requires the authenticated actor that validated the Campaign."
)
print_config = config.delivery.print
if not print_config.template_id:
raise CampaignPersistenceError(
"Printable output requires a selected template."
)
items = tuple(item for _built, _entry, item in printable)
routes = [
{
"entry_index": built.draft.entry_index,
"entry_id": built.draft.entry_id,
"channel_policy": built.draft.delivery_channel_policy,
"print_target": item["print_target"],
"distribution": item["distribution"],
}
for built, _entry, item in printable
]
input_snapshot = {
"producer_module": "campaigns",
"campaign_id": version.campaign_id,
"campaign_version_id": version.id,
"campaign_version_number": version.version_number,
"actor_account_id": principal.account_id,
"routes": routes,
}
render_key = _canonical_sha256(
{
"template_id": print_config.template_id,
"template_revision": print_config.template_revision,
"usage": print_config.usage,
"output_format": print_config.output_format,
"profile_id": print_config.profile_id,
"items": items,
"input_snapshot": input_snapshot,
}
)
result = templates_integration().render(
session,
principal,
request=TemplateRenderRequest(
template_id=print_config.template_id,
revision=print_config.template_revision,
usage=print_config.usage,
output_format=print_config.output_format,
profile_id=print_config.profile_id,
items=items,
input_snapshot=input_snapshot,
mode="final",
idempotency_key=f"campaign:{version.id}:print:{render_key}",
persist_to_files=print_config.persist_to_files,
),
)
artifact = asdict(result.artifact) if result.artifact is not None else None
if artifact and artifact.get("kind") == "bounded_download":
if result.payload is None:
raise CampaignPersistenceError(
"Templates returned a bounded print artifact without its payload."
)
storage_key = (
f"campaign-artifacts/{tenant_id}/{version.campaign_id}/{version.id}/"
f"{build_id}/print-{result.output_sha256}"
)
try:
storage.put_bytes(
storage_key,
result.payload,
content_type=result.content_type,
)
except StorageBackendError as exc:
raise CampaignPersistenceError(
f"Printable Campaign output could not be persisted: {exc}"
) from exc
artifact["storage_key"] = storage_key
artifact["download_path"] = (
f"/api/v1/campaigns/{version.campaign_id}/versions/{version.id}/"
"print-output/download"
)
artifact["provenance"] = {
**dict(artifact.get("provenance") or {}),
"module": "campaigns",
"source_module": "templates",
"source_render_id": result.render_id,
"bounded": True,
}
common = {
"status": "ready",
"render_id": result.render_id,
"template_id": result.template_id,
"template_revision_id": result.revision_id,
"template_revision": result.revision,
"template_hash": result.template_hash,
"input_hash": result.input_hash,
"renderer_version": result.renderer_version,
"output_format": result.output_format,
"output_sha256": result.output_sha256,
"output_size_bytes": result.output_size_bytes,
"item_count": result.item_count,
"page_count": result.page_count,
"artifact": artifact,
"diagnostics": [dict(item) for item in result.diagnostics],
"actor_account_id": principal.account_id,
"render_idempotency_key": f"campaign:{version.id}:print:{render_key}",
}
resolved: dict[int, dict[str, Any]] = {}
for item_index, (built, entry, _item) in enumerate(printable):
resolved[built.draft.entry_index] = {
**common,
"item_index": item_index,
"recipient_key": str(
entry.distribution_source.get("recipient_key")
or entry.id
or built.draft.entry_index
),
"route": entry.print_target.model_dump(mode="json")
if entry.print_target
else None,
}
return resolved
def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]: def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
report_json = result.report.model_dump(mode="json", by_alias=True) report_json = result.report.model_dump(mode="json", by_alias=True)
for message_payload, message in zip( for message_payload, message in zip(
@@ -701,18 +1047,26 @@ def _replace_version_jobs(
version_id: str, version_id: str,
built_messages: list[Any], built_messages: list[Any],
postbox_targets_by_index: dict[int, list[dict[str, Any]]], postbox_targets_by_index: dict[int, list[dict[str, Any]]],
print_outputs_by_index: dict[int, dict[str, Any]],
delivery_provenance_by_index: dict[int, dict[str, Any]],
stored_eml_by_index: dict[int, _StoredEmlArtifact], stored_eml_by_index: dict[int, _StoredEmlArtifact],
) -> tuple[list[tuple[CampaignJob, MessageDraft]], list[str]]: ) -> tuple[list[tuple[CampaignJob, MessageDraft]], list[str]]:
old_storage_keys = [ old_storage_keys: list[str] = []
str(key) old_job_artifacts = (
for (key,) in session.query(CampaignJob.eml_storage_key) session.query(
.filter( CampaignJob.eml_storage_key,
CampaignJob.campaign_version_id == version_id, CampaignJob.resolved_print_output,
CampaignJob.eml_storage_key.is_not(None),
) )
.filter(CampaignJob.campaign_version_id == version_id)
.all() .all()
if key )
] for eml_storage_key, print_output in old_job_artifacts:
if eml_storage_key:
old_storage_keys.append(str(eml_storage_key))
if isinstance(print_output, dict):
artifact = print_output.get("artifact")
if isinstance(artifact, dict) and artifact.get("storage_key"):
old_storage_keys.append(str(artifact["storage_key"]))
session.query(CampaignIssue).filter( session.query(CampaignIssue).filter(
CampaignIssue.campaign_version_id == version_id, CampaignIssue.campaign_version_id == version_id,
CampaignIssue.job_id.is_not(None), CampaignIssue.job_id.is_not(None),
@@ -732,6 +1086,13 @@ def _replace_version_jobs(
resolved_postbox_targets=postbox_targets_by_index.get( resolved_postbox_targets=postbox_targets_by_index.get(
built.draft.entry_index, [] built.draft.entry_index, []
), ),
resolved_print_output=print_outputs_by_index.get(
built.draft.entry_index
),
delivery_provenance=delivery_provenance_by_index.get(
built.draft.entry_index,
{},
),
stored_eml=stored_eml_by_index.get(built.draft.entry_index), stored_eml=stored_eml_by_index.get(built.draft.entry_index),
) )
session.add(job) session.add(job)
@@ -851,6 +1212,7 @@ def build_campaign_version(
version_id: str, version_id: str,
write_eml: bool = True, write_eml: bool = True,
user_id: str | None = None, user_id: str | None = None,
principal: ApiPrincipal | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
version, snapshot_path, config = load_version_config(session, version_id) version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id) campaign = session.get(Campaign, version.campaign_id)
@@ -911,6 +1273,10 @@ def build_campaign_version(
start=1, start=1,
) )
} }
delivery_provenance_by_index = {
index: dict(entry.distribution_source or {})
for index, entry in entries_by_index.items()
}
resolved_postbox_targets_by_index = _resolve_built_postbox_targets( resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -918,18 +1284,67 @@ def build_campaign_version(
built_messages=result.built_messages, built_messages=result.built_messages,
entries_by_index=entries_by_index, entries_by_index=entries_by_index,
) )
stored_eml_by_index = _persist_built_eml_artifacts( resolved_print_outputs_by_index = _resolve_built_print_outputs(
storage=storage, session,
tenant_id=tenant_id, storage=storage,
campaign_id=campaign.id, tenant_id=tenant_id,
version_id=version.id, build_id=build_id,
build_id=build_id, version=version,
built_messages=result.built_messages, principal=principal,
config=managed_config,
built_messages=result.built_messages,
entries_by_index=entries_by_index,
)
new_print_storage_keys = sorted(
{
str(artifact["storage_key"])
for output in resolved_print_outputs_by_index.values()
if isinstance(output, dict)
for artifact in [output.get("artifact")]
if isinstance(artifact, dict) and artifact.get("storage_key")
}
) )
new_storage_keys = [item.storage_key for item in stored_eml_by_index.values()] try:
stored_eml_by_index = _persist_built_eml_artifacts(
storage=storage,
tenant_id=tenant_id,
campaign_id=campaign.id,
version_id=version.id,
build_id=build_id,
built_messages=result.built_messages,
)
except Exception:
_delete_storage_keys(storage, new_print_storage_keys)
raise
new_storage_keys = [
*new_print_storage_keys,
*(item.storage_key for item in stored_eml_by_index.values()),
]
try: try:
report_json = _campaign_build_report(result, files) report_json = _campaign_build_report(result, files)
report_json["built_by_user_id"] = user_id report_json["built_by_user_id"] = user_id
if resolved_print_outputs_by_index:
first_output = next(iter(resolved_print_outputs_by_index.values()))
report_json["print_output"] = {
key: first_output.get(key)
for key in (
"render_id",
"template_id",
"template_revision_id",
"template_revision",
"template_hash",
"input_hash",
"renderer_version",
"output_format",
"output_sha256",
"output_size_bytes",
"item_count",
"page_count",
"artifact",
"diagnostics",
"actor_account_id",
)
}
version.build_summary = report_json version.build_summary = report_json
editor_state = copy.deepcopy(version.editor_state or {}) editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None) editor_state.pop("review_send", None)
@@ -943,6 +1358,8 @@ def build_campaign_version(
version_id=version.id, version_id=version.id,
built_messages=result.built_messages, built_messages=result.built_messages,
postbox_targets_by_index=resolved_postbox_targets_by_index, postbox_targets_by_index=resolved_postbox_targets_by_index,
print_outputs_by_index=resolved_print_outputs_by_index,
delivery_provenance_by_index=delivery_provenance_by_index,
stored_eml_by_index=stored_eml_by_index, stored_eml_by_index=stored_eml_by_index,
) )
jobs = [job for job, _message in job_build_pairs] jobs = [job for job, _message in job_build_pairs]
@@ -59,6 +59,7 @@ class AggregateOutcomeCounts(BaseModel):
smtp_accepted: AggregateCount smtp_accepted: AggregateCount
postbox_accepted: AggregateCount postbox_accepted: AggregateCount
print_accepted: AggregateCount
delivered: AggregateCount delivered: AggregateCount
partially_accepted: AggregateCount partially_accepted: AggregateCount
failed: AggregateCount failed: AggregateCount
@@ -109,6 +110,7 @@ class AggregateCampaignReport(BaseModel):
_OUTCOME_KEYS = ( _OUTCOME_KEYS = (
"smtp_accepted", "smtp_accepted",
"postbox_accepted", "postbox_accepted",
"print_accepted",
"delivered", "delivered",
"partially_accepted", "partially_accepted",
"failed", "failed",
@@ -188,6 +190,7 @@ def _query_aggregate_facts(
{ {
"smtp_accepted", "smtp_accepted",
"postbox_accepted", "postbox_accepted",
"print_accepted",
"delivered", "delivered",
"partially_accepted", "partially_accepted",
"sent", "sent",
@@ -217,6 +220,12 @@ def _query_aggregate_facts(
else_=0, else_=0,
) )
).label("postbox_accepted"), ).label("postbox_accepted"),
func.sum(
case(
(CampaignJob.send_status == "print_accepted", 1),
else_=0,
)
).label("print_accepted"),
func.sum( func.sum(
case( case(
(CampaignJob.send_status == "delivered", 1), (CampaignJob.send_status == "delivered", 1),
@@ -398,6 +407,8 @@ def _outcome_counts(jobs: list[CampaignJob]) -> dict[str, int]:
counts["smtp_accepted"] += 1 counts["smtp_accepted"] += 1
elif status == "postbox_accepted": elif status == "postbox_accepted":
counts["postbox_accepted"] += 1 counts["postbox_accepted"] += 1
elif status == "print_accepted":
counts["print_accepted"] += 1
elif status == "delivered": elif status == "delivered":
counts["delivered"] += 1 counts["delivered"] += 1
elif status == "partially_accepted": elif status == "partially_accepted":
@@ -512,6 +523,7 @@ def _completion_state(
fully_accepted = ( fully_accepted = (
counts["smtp_accepted"] counts["smtp_accepted"]
+ counts["postbox_accepted"] + counts["postbox_accepted"]
+ counts["print_accepted"]
+ counts["delivered"] + counts["delivered"]
) )
partially_accepted = counts["partially_accepted"] partially_accepted = counts["partially_accepted"]
@@ -295,6 +295,7 @@ class _JobReportAggregate:
queue: Counter[str] = field(default_factory=Counter) queue: Counter[str] = field(default_factory=Counter)
send: Counter[str] = field(default_factory=Counter) send: Counter[str] = field(default_factory=Counter)
postbox: Counter[str] = field(default_factory=Counter) postbox: Counter[str] = field(default_factory=Counter)
print: Counter[str] = field(default_factory=Counter)
imap: Counter[str] = field(default_factory=Counter) imap: Counter[str] = field(default_factory=Counter)
issue_total: int = 0 issue_total: int = 0
issue_severity: Counter[str] = field(default_factory=Counter) issue_severity: Counter[str] = field(default_factory=Counter)
@@ -333,6 +334,7 @@ class _JobReportAggregate:
self.queue[job.queue_status or "unknown"] += 1 self.queue[job.queue_status or "unknown"] += 1
self.send[job.send_status or "unknown"] += 1 self.send[job.send_status or "unknown"] += 1
self.postbox[job.postbox_status or "unknown"] += 1 self.postbox[job.postbox_status or "unknown"] += 1
self.print[getattr(job, "print_status", None) or "unknown"] += 1
self.imap[job.imap_status or "unknown"] += 1 self.imap[job.imap_status or "unknown"] += 1
def _add_delivery_counts(self, job: CampaignJob, *, retry_max_attempts: int | None) -> None: def _add_delivery_counts(self, job: CampaignJob, *, retry_max_attempts: int | None) -> None:
@@ -398,6 +400,7 @@ NON_CANCELLABLE_SEND_STATUSES = {
"skipped", "skipped",
"smtp_accepted", "smtp_accepted",
"postbox_accepted", "postbox_accepted",
"print_accepted",
"delivered", "delivered",
"partially_accepted", "partially_accepted",
"sent", "sent",
@@ -416,6 +419,7 @@ def _job_is_queueable_unattempted(job: CampaignJob) -> bool:
return ( return (
job.attempt_count == 0 job.attempt_count == 0
and job.postbox_attempt_count == 0 and job.postbox_attempt_count == 0
and getattr(job, "print_attempt_count", 0) == 0
and job.send_status in {"not_queued", "cancelled"} and job.send_status in {"not_queued", "cancelled"}
and _job_is_queueable(job) and _job_is_queueable(job)
) )
@@ -449,6 +453,7 @@ def _job_needs_attention(job: CampaignJob) -> bool:
"rejected_permanent", "rejected_permanent",
"outcome_unknown", "outcome_unknown",
} }
or getattr(job, "print_status", "not_requested") == "failed"
or job.imap_status == "failed" or job.imap_status == "failed"
) )
@@ -465,6 +470,7 @@ def _job_is_recent_failure(job: CampaignJob) -> bool:
"rejected_permanent", "rejected_permanent",
"outcome_unknown", "outcome_unknown",
} }
or getattr(job, "print_status", "not_requested") == "failed"
or job.imap_status == "failed" or job.imap_status == "failed"
) )
@@ -601,12 +607,20 @@ def _job_row(
"postbox_status", "postbox_status",
"not_requested", "not_requested",
), ),
"print_status": getattr(job, "print_status", "not_requested"),
"imap_status": job.imap_status, "imap_status": job.imap_status,
"attempt_count": job.attempt_count, "attempt_count": job.attempt_count,
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0), "postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
"print_attempt_count": getattr(job, "print_attempt_count", 0),
"postbox_target_count": len( "postbox_target_count": len(
getattr(job, "resolved_postbox_targets", None) or [] getattr(job, "resolved_postbox_targets", None) or []
), ),
"print_output": public_campaign_payload(
getattr(job, "resolved_print_output", None)
),
"delivery_provenance": public_campaign_payload(
getattr(job, "delivery_provenance", None) or {}
),
"queued_at": job.queued_at.isoformat() if job.queued_at else None, "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, "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, "sent_at": job.sent_at.isoformat() if job.sent_at else None,
@@ -713,6 +727,7 @@ def _job_evidence_row(
"message_id_header": job.message_id_header, "message_id_header": job.message_id_header,
**_job_evidence_addresses(recipients), **_job_evidence_addresses(recipients),
"postbox_targets": _job_postbox_target_summary(job), "postbox_targets": _job_postbox_target_summary(job),
**_job_print_and_route_evidence(job),
"attachment_names": _attachment_names(job.resolved_attachments), "attachment_names": _attachment_names(job.resolved_attachments),
**_job_attempt_evidence(latest_smtp=latest_smtp, latest_imap=latest_imap), **_job_attempt_evidence(latest_smtp=latest_smtp, latest_imap=latest_imap),
"latest_message_action_kind": ( "latest_message_action_kind": (
@@ -766,6 +781,45 @@ def _job_postbox_target_summary(job: CampaignJob) -> str:
) )
def _job_print_and_route_evidence(job: CampaignJob) -> dict[str, Any]:
output = (
job.resolved_print_output
if isinstance(getattr(job, "resolved_print_output", None), dict)
else {}
)
artifact = output.get("artifact") if isinstance(output.get("artifact"), dict) else {}
provenance = (
job.delivery_provenance
if isinstance(getattr(job, "delivery_provenance", None), dict)
else {}
)
selected_route = (
provenance.get("selected_route")
if isinstance(provenance.get("selected_route"), dict)
else {}
)
return {
"distribution_list_id": provenance.get("list_id"),
"distribution_list_revision": provenance.get("list_revision"),
"distribution_snapshot_id": provenance.get("snapshot_id"),
"distribution_expansion_hash": provenance.get("expansion_hash"),
"distribution_recipient_key": provenance.get("recipient_key"),
"selected_route_channel": selected_route.get("channel"),
"selected_route_target_key": selected_route.get("target_key"),
"route_reason": provenance.get("route_reason"),
"print_render_id": output.get("render_id"),
"print_template_id": output.get("template_id"),
"print_template_revision_id": output.get("template_revision_id"),
"print_template_hash": output.get("template_hash"),
"print_input_hash": output.get("input_hash"),
"print_output_sha256": output.get("output_sha256"),
"print_artifact_kind": artifact.get("kind"),
"print_artifact_file_id": artifact.get("file_asset_id"),
"print_artifact_download_path": artifact.get("download_path"),
"print_actor_account_id": output.get("actor_account_id"),
}
def _iso_timestamp(value: datetime | None) -> str | None: def _iso_timestamp(value: datetime | None) -> str | None:
return value.isoformat() if value else None return value.isoformat() if value else None
@@ -1421,6 +1475,7 @@ def _campaign_report_status_counts_from_aggregate(
"queue": dict(aggregate.queue), "queue": dict(aggregate.queue),
"send": dict(aggregate.send), "send": dict(aggregate.send),
"postbox": dict(aggregate.postbox), "postbox": dict(aggregate.postbox),
"print": dict(aggregate.print),
"imap": dict(aggregate.imap), "imap": dict(aggregate.imap),
} }
@@ -1435,6 +1490,7 @@ def _campaign_report_cards_from_aggregate(
send_counts.get("sent", 0) send_counts.get("sent", 0)
+ send_counts.get("smtp_accepted", 0) + send_counts.get("smtp_accepted", 0)
+ send_counts.get("postbox_accepted", 0) + send_counts.get("postbox_accepted", 0)
+ send_counts.get("print_accepted", 0)
+ send_counts.get("delivered", 0) + send_counts.get("delivered", 0)
+ send_counts.get("partially_accepted", 0) + send_counts.get("partially_accepted", 0)
) )
@@ -1460,6 +1516,7 @@ def _campaign_report_cards_from_aggregate(
+ send_counts.get("smtp_accepted", 0) + send_counts.get("smtp_accepted", 0)
), ),
"postbox_accepted": send_counts.get("postbox_accepted", 0), "postbox_accepted": send_counts.get("postbox_accepted", 0),
"print_accepted": send_counts.get("print_accepted", 0),
"delivered": send_counts.get("delivered", 0), "delivered": send_counts.get("delivered", 0),
"partially_accepted": send_counts.get("partially_accepted", 0), "partially_accepted": send_counts.get("partially_accepted", 0),
"failed": failed, "failed": failed,
@@ -1594,11 +1651,31 @@ def generate_jobs_csv(
"send_status", "send_status",
"delivery_channel_policy", "delivery_channel_policy",
"postbox_status", "postbox_status",
"print_status",
"imap_status", "imap_status",
"attempt_count", "attempt_count",
"postbox_attempt_count", "postbox_attempt_count",
"print_attempt_count",
"postbox_target_count", "postbox_target_count",
"postbox_targets", "postbox_targets",
"distribution_list_id",
"distribution_list_revision",
"distribution_snapshot_id",
"distribution_expansion_hash",
"distribution_recipient_key",
"selected_route_channel",
"selected_route_target_key",
"route_reason",
"print_render_id",
"print_template_id",
"print_template_revision_id",
"print_template_hash",
"print_input_hash",
"print_output_sha256",
"print_artifact_kind",
"print_artifact_file_id",
"print_artifact_download_path",
"print_actor_account_id",
"queued_at", "queued_at",
"outcome_unknown_at", "outcome_unknown_at",
"sent_at", "sent_at",
@@ -214,6 +214,12 @@ def _descriptor() -> ReportDescriptor:
"suppressed_count", "suppressed_count",
"Outcomes", "Outcomes",
), ),
(
"outcomes.print_accepted",
"Printable output accepted",
"suppressed_count",
"Outcomes",
),
( (
"outcomes.delivered", "outcomes.delivered",
"Both channels accepted", "Both channels accepted",
@@ -65,6 +65,7 @@ from govoplan_campaign.backend.campaign.postbox_targets import (
from govoplan_campaign.backend.integrations import ( from govoplan_campaign.backend.integrations import (
PostboxDeliveryUnavailable, PostboxDeliveryUnavailable,
postbox_integration, postbox_integration,
templates_integration,
) )
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_core.core.distribution_lists import ( from govoplan_core.core.distribution_lists import (
@@ -755,6 +756,29 @@ def campaign_postbox_catalog(
) )
@router.get("/{campaign_id}/print-templates")
def campaign_print_templates(
campaign_id: str,
query: str = Query(default="", min_length=0, max_length=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
integration = templates_integration()
if not integration.available:
return {"available": False, "templates": []}
templates = integration.list_templates(
session,
principal,
query=query,
limit=250,
)
return {
"available": True,
"templates": [dataclasses.asdict(template) for template in templates],
}
@router.post( @router.post(
"/{campaign_id}/recipient-address-sources/snapshot", "/{campaign_id}/recipient-address-sources/snapshot",
response_model=CampaignRecipientAddressSourceSnapshotResponse, response_model=CampaignRecipientAddressSourceSnapshotResponse,
+21 -6
View File
@@ -27,6 +27,7 @@ from govoplan_campaign.backend.db.models import (
CampaignMessageActionAttempt, CampaignMessageActionAttempt,
ImapAppendAttempt, ImapAppendAttempt,
PostboxDeliveryAttempt, PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.integrations import postbox_integration from govoplan_campaign.backend.integrations import postbox_integration
@@ -397,6 +398,12 @@ def get_job_detail(
), ),
label="Postbox attempts for this campaign job", label="Postbox attempts for this campaign job",
) )
print_attempts = _job_attempt_rows(
session.query(PrintOutputAttempt)
.filter(PrintOutputAttempt.job_id == job.id)
.order_by(PrintOutputAttempt.attempt_number.asc()),
label="Printable output attempts for this campaign job",
)
message_actions = _job_attempt_rows( message_actions = _job_attempt_rows(
session.query(CampaignMessageAction) session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id == job.id) .filter(CampaignMessageAction.job_id == job.id)
@@ -419,9 +426,10 @@ def get_job_detail(
attempts=_job_attempts_payload( attempts=_job_attempts_payload(
send_attempts, send_attempts,
imap_attempts, imap_attempts,
postbox_attempts, postbox_attempts=postbox_attempts,
message_actions, print_attempts=print_attempts,
message_action_attempts, message_actions=message_actions,
message_action_attempts=message_action_attempts,
postbox_receipts=_postbox_receipts_for_attempts( postbox_receipts=_postbox_receipts_for_attempts(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
@@ -476,6 +484,12 @@ def get_job_diagnostics(
), ),
label="Postbox diagnostics for this campaign job", label="Postbox diagnostics for this campaign job",
) )
print_attempts = _job_attempt_rows(
session.query(PrintOutputAttempt)
.filter(PrintOutputAttempt.job_id == job.id)
.order_by(PrintOutputAttempt.attempt_number.asc()),
label="Printable output diagnostics for this campaign job",
)
message_actions = _job_attempt_rows( message_actions = _job_attempt_rows(
session.query(CampaignMessageAction) session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id == job.id) .filter(CampaignMessageAction.job_id == job.id)
@@ -497,9 +511,10 @@ def get_job_diagnostics(
job, job,
send_attempts, send_attempts,
imap_attempts, imap_attempts,
postbox_attempts, postbox_attempts=postbox_attempts,
message_actions, print_attempts=print_attempts,
message_action_attempts, message_actions=message_actions,
message_action_attempts=message_action_attempts,
postbox_receipts=_postbox_receipts_for_attempts( postbox_receipts=_postbox_receipts_for_attempts(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
from urllib.parse import quote
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -19,6 +21,7 @@ from govoplan_campaign.backend.schemas import (
) )
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.object_storage import StorageBackendError
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
CampaignVersion, CampaignVersion,
) )
@@ -28,6 +31,7 @@ from govoplan_campaign.backend.response_security import (
) )
from govoplan_campaign.backend.persistence.campaigns import ( from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError, CampaignPersistenceError,
_object_storage,
build_campaign_version, build_campaign_version,
validate_campaign_version, validate_campaign_version,
) )
@@ -70,6 +74,86 @@ from govoplan_campaign.backend.routes.attachments import (
router = APIRouter(prefix="/campaigns", tags=["campaigns"]) router = APIRouter(prefix="/campaigns", tags=["campaigns"])
@router.get("/{campaign_id}/versions/{version_id}/print-output/download")
def download_print_output(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
) -> Response:
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:read")
try:
version = get_campaign_version_for_tenant(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
build_summary = (
version.build_summary if isinstance(version.build_summary, dict) else {}
)
print_output = build_summary.get("print_output")
artifact = (
print_output.get("artifact")
if isinstance(print_output, dict)
and isinstance(print_output.get("artifact"), dict)
else {}
)
storage_key = artifact.get("storage_key")
if artifact.get("kind") != "bounded_download" or not storage_key:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This printable output is not stored as a Campaign download.",
)
try:
payload = _object_storage().get_bytes(str(storage_key))
except StorageBackendError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The printable output is temporarily unavailable.",
) from exc
expected_sha256 = str(print_output.get("output_sha256") or "")
actual_sha256 = hashlib.sha256(payload).hexdigest()
if not expected_sha256 or actual_sha256 != expected_sha256:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The printable output failed its integrity check.",
)
audit_from_principal(
session,
principal,
action="campaign.print_output_downloaded",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign_id,
"output_sha256": actual_sha256,
"template_id": print_output.get("template_id"),
"template_revision_id": print_output.get("template_revision_id"),
},
commit=True,
)
filename = quote(
str(artifact.get("filename") or "campaign-print-output.html"),
safe="._-",
)
return Response(
content=payload,
media_type=str(artifact.get("content_type") or "application/octet-stream"),
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}",
"X-Content-SHA256": actual_sha256,
"X-Content-Type-Options": "nosniff",
},
)
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse]) @router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
def list_versions( def list_versions(
campaign_id: str, campaign_id: str,
@@ -607,6 +691,7 @@ def validate_version(
version_id=version_id, version_id=version_id,
check_files=payload.check_files, check_files=payload.check_files,
user_id=principal.user.id, user_id=principal.user.id,
principal=principal,
) )
audit_from_principal( audit_from_principal(
session, session,
@@ -659,6 +744,7 @@ def build_version(
version_id=version_id, version_id=version_id,
write_eml=payload.write_eml if payload else True, write_eml=payload.write_eml if payload else True,
user_id=principal.user.id, user_id=principal.user.id,
principal=principal,
) )
audit_from_principal( audit_from_principal(
session, session,
@@ -537,6 +537,9 @@
"postbox": { "postbox": {
"$ref": "#/$defs/postbox_delivery" "$ref": "#/$defs/postbox_delivery"
}, },
"print": {
"$ref": "#/$defs/print_delivery"
},
"rate_limit": { "rate_limit": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -659,9 +662,12 @@
"enum": [ "enum": [
"mail", "mail",
"postbox", "postbox",
"print",
"mail_and_postbox", "mail_and_postbox",
"mail_then_postbox", "mail_then_postbox",
"postbox_then_mail" "postbox_then_mail",
"mail_then_print",
"postbox_then_print"
], ],
"default": "mail" "default": "mail"
}, },
@@ -827,6 +833,73 @@
"additionalProperties": false, "additionalProperties": false,
"default": {} "default": {}
}, },
"print_target": {
"type": "object",
"required": ["channel", "target", "target_key"],
"properties": {
"channel": {
"type": "string",
"enum": ["postal", "internal_mail"]
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"target_key": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"contact_point_id": {
"type": ["string", "null"],
"maxLength": 36
},
"locale": {
"type": ["string", "null"],
"maxLength": 35
},
"decision_provenance": {
"type": "object",
"default": {}
}
},
"additionalProperties": false
},
"print_delivery": {
"type": "object",
"properties": {
"template_id": {
"type": ["string", "null"],
"maxLength": 36
},
"template_revision": {
"type": ["integer", "null"],
"minimum": 1
},
"usage": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"default": "campaign_print"
},
"output_format": {
"type": "string",
"enum": ["html", "text"],
"default": "html"
},
"profile_id": {
"type": ["string", "null"],
"maxLength": 120
},
"persist_to_files": {
"type": "boolean",
"default": true
}
},
"additionalProperties": false,
"default": {}
},
"attachment_config": { "attachment_config": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -1093,6 +1166,13 @@
"type": "boolean", "type": "boolean",
"default": true "default": true
}, },
"print_target": {
"oneOf": [
{ "$ref": "#/$defs/print_target" },
{ "type": "null" }
],
"default": null
},
"attachments": { "attachments": {
"type": "array", "type": "array",
"items": { "items": {
@@ -22,8 +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.integrations import MailProfileError, files_integration, mail_integration
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
SNAPSHOT_VERSION = "7" SNAPSHOT_VERSION = "8"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", SNAPSHOT_VERSION} SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION}
class ExecutionSnapshotError(RuntimeError): class ExecutionSnapshotError(RuntimeError):
@@ -61,6 +61,7 @@ class ExecutionSnapshot(BaseModel):
imap_transport_revision: str | None = None imap_transport_revision: str | None = None
uses_mail: bool = True uses_mail: bool = True
uses_postbox: bool = False uses_postbox: bool = False
uses_print: bool = False
delivery: DeliveryConfig delivery: DeliveryConfig
@@ -162,6 +163,8 @@ def _policy_fingerprint(
if snapshot_version == "6": if snapshot_version == "6":
delivery_payload.pop("channel_policy", None) delivery_payload.pop("channel_policy", None)
delivery_payload.pop("postbox", None) delivery_payload.pop("postbox", None)
if snapshot_version in {"6", "7"}:
delivery_payload.pop("print", None)
return _sha256( return _sha256(
{ {
"validation_policy": raw_json.get("validation_policy"), "validation_policy": raw_json.get("validation_policy"),
@@ -207,6 +210,13 @@ def _job_execution_input_payload(
), ),
} }
) )
if snapshot_version not in {"6", "7"}:
payload["delivery_provenance_sha256"] = _sha256(
getattr(job, "delivery_provenance", None) or {}
)
payload["resolved_print_output_sha256"] = _sha256(
getattr(job, "resolved_print_output", None) or {}
)
return payload return payload
@@ -268,6 +278,7 @@ def create_execution_snapshot(
} }
uses_mail = any(policy.uses_mail for policy in channel_policies) uses_mail = any(policy.uses_mail for policy in channel_policies)
uses_postbox = any(policy.uses_postbox for policy in channel_policies) uses_postbox = any(policy.uses_postbox for policy in channel_policies)
uses_print = any(policy.uses_print for policy in channel_policies)
for job in job_list: for job in job_list:
job.execution_input_sha256 = job_execution_input_hash( job.execution_input_sha256 = job_execution_input_hash(
job, job,
@@ -304,6 +315,7 @@ def create_execution_snapshot(
imap_transport_revision=imap_transport_revision, imap_transport_revision=imap_transport_revision,
uses_mail=uses_mail, uses_mail=uses_mail,
uses_postbox=uses_postbox, uses_postbox=uses_postbox,
uses_print=uses_print,
created_at=datetime.now(timezone.utc).isoformat(), created_at=datetime.now(timezone.utc).isoformat(),
delivery=delivery, delivery=delivery,
).model_dump(mode="json") ).model_dump(mode="json")
+215 -12
View File
@@ -37,11 +37,13 @@ from govoplan_campaign.backend.db.models import (
JobBuildStatus, JobBuildStatus,
JobImapStatus, JobImapStatus,
JobPostboxStatus, JobPostboxStatus,
JobPrintStatus,
JobQueueStatus, JobQueueStatus,
JobSendStatus, JobSendStatus,
JobValidationStatus, JobValidationStatus,
ImapAppendAttempt, ImapAppendAttempt,
PostboxDeliveryAttempt, PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
@@ -241,17 +243,26 @@ class _MailChannelOutcome:
) )
@dataclass(frozen=True, slots=True)
class _PrintChannelOutcome:
accepted: bool = False
rejected_permanent: bool = False
message: str | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _DeliveryOutcomeSummary: class _DeliveryOutcomeSummary:
mail_accepted: bool mail_accepted: bool
postbox_accepted: int postbox_accepted: int
postbox_rejected: int postbox_rejected: int
print_accepted: bool
print_rejected: bool
outcome_unknown: bool outcome_unknown: bool
temporary_rejection: bool temporary_rejection: bool
@property @property
def accepted_count(self) -> int: def accepted_count(self) -> int:
return int(self.mail_accepted) + self.postbox_accepted return int(self.mail_accepted) + self.postbox_accepted + int(self.print_accepted)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -275,11 +286,13 @@ QUEUEABLE_VALIDATION_STATUSES = {
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value} SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
DELIVERY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | { DELIVERY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.POSTBOX_ACCEPTED.value, JobSendStatus.POSTBOX_ACCEPTED.value,
JobSendStatus.PRINT_ACCEPTED.value,
JobSendStatus.DELIVERED.value, JobSendStatus.DELIVERED.value,
JobSendStatus.PARTIALLY_ACCEPTED.value, JobSendStatus.PARTIALLY_ACCEPTED.value,
} }
FULLY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | { FULLY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.POSTBOX_ACCEPTED.value, JobSendStatus.POSTBOX_ACCEPTED.value,
JobSendStatus.PRINT_ACCEPTED.value,
JobSendStatus.DELIVERED.value, JobSendStatus.DELIVERED.value,
} }
DELIVERY_MODE_SYNCHRONOUS = "synchronous" DELIVERY_MODE_SYNCHRONOUS = "synchronous"
@@ -2890,6 +2903,11 @@ def send_campaign_job(
descriptions.append( descriptions.append(
f"Postbox to {len(job.resolved_postbox_targets or [])} target(s)" f"Postbox to {len(job.resolved_postbox_targets or [])} target(s)"
) )
if policy.uses_print:
output = job.resolved_print_output or {}
descriptions.append(
f"Printable output item {int(output.get('item_index') or 0) + 1}"
)
return SendJobResult( return SendJobResult(
job_id=job.id, job_id=job.id,
status="dry_run", status="dry_run",
@@ -2985,10 +3003,14 @@ def _send_job_delivery_context(
except ExecutionSnapshotError as exc: except ExecutionSnapshotError as exc:
raise SendJobError(str(exc)) from exc raise SendJobError(str(exc)) from exc
message_bytes = _load_eml_bytes_for_job(job)
channel_policy = DeliveryChannelPolicy( channel_policy = DeliveryChannelPolicy(
getattr(job, "delivery_channel_policy", DeliveryChannelPolicy.MAIL.value) getattr(job, "delivery_channel_policy", DeliveryChannelPolicy.MAIL.value)
) )
message_bytes = (
_load_eml_bytes_for_job(job)
if channel_policy.uses_mail or channel_policy.uses_postbox
else b""
)
envelope_from: str | None = None envelope_from: str | None = None
envelope_recipients: list[str] = [] envelope_recipients: list[str] = []
if channel_policy.uses_mail: if channel_policy.uses_mail:
@@ -3058,6 +3080,16 @@ def _send_claimed_campaign_job(
use_rate_limit=use_rate_limit, use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task, enqueue_imap_task=enqueue_imap_task,
) )
if channel_policy == DeliveryChannelPolicy.PRINT:
print_outcome = _deliver_print_channel(session, job=job)
return _finalize_multichannel_job(
session,
job_id=job.id,
channel_policy=channel_policy,
mail=None,
postbox=None,
print_output=print_outcome,
)
return _send_claimed_multichannel_job( return _send_claimed_multichannel_job(
session, session,
job=job, job=job,
@@ -3262,13 +3294,115 @@ def _empty_postbox_outcome() -> PostboxChannelOutcome:
return PostboxChannelOutcome() return PostboxChannelOutcome()
def _deliver_print_channel(
session: Session,
*,
job: CampaignJob,
) -> _PrintChannelOutcome:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError("Campaign job disappeared before printable output acceptance.")
if current.print_status == JobPrintStatus.ACCEPTED.value:
return _PrintChannelOutcome(accepted=True, message="Output was already accepted.")
output = (
current.resolved_print_output
if isinstance(current.resolved_print_output, dict)
else {}
)
artifact = output.get("artifact") if isinstance(output.get("artifact"), dict) else {}
output_sha256 = str(output.get("output_sha256") or artifact.get("sha256") or "")
render_id = str(output.get("render_id") or "")
if (
current.print_status != JobPrintStatus.READY.value
or not render_id
or not output_sha256
):
current.print_status = JobPrintStatus.FAILED.value
current.last_error = "The frozen printable output artifact is missing or incomplete."
session.add(current)
session.commit()
return _PrintChannelOutcome(
rejected_permanent=True,
message=current.last_error,
)
attempt_number = current.print_attempt_count + 1
idempotency_key = (
f"campaign:{current.campaign_version_id}:{current.id}:print:"
f"{render_id}:{int(output.get('item_index') or 0)}"
)
existing = (
session.query(PrintOutputAttempt)
.filter(
PrintOutputAttempt.tenant_id == current.tenant_id,
PrintOutputAttempt.idempotency_key == idempotency_key,
)
.one_or_none()
)
if existing is not None and existing.status == JobPrintStatus.ACCEPTED.value:
current.print_status = JobPrintStatus.ACCEPTED.value
current.print_attempt_count = max(
current.print_attempt_count,
existing.attempt_number,
)
session.add(current)
session.commit()
return _PrintChannelOutcome(accepted=True, message="Output was already accepted.")
now = _utcnow()
attempt = PrintOutputAttempt(
tenant_id=current.tenant_id,
job_id=current.id,
attempt_number=attempt_number,
idempotency_key=idempotency_key,
status=JobPrintStatus.ACCEPTED.value,
render_id=render_id,
artifact_sha256=output_sha256,
evidence={
"template_id": output.get("template_id"),
"template_revision_id": output.get("template_revision_id"),
"template_hash": output.get("template_hash"),
"input_hash": output.get("input_hash"),
"output_sha256": output_sha256,
"artifact": artifact,
"route": output.get("route"),
"recipient_key": output.get("recipient_key"),
"item_index": output.get("item_index"),
"actor_account_id": output.get("actor_account_id"),
},
started_at=now,
finished_at=now,
)
current.print_status = JobPrintStatus.ACCEPTED.value
current.print_attempt_count = attempt_number
session.add(attempt)
session.add(current)
session.commit()
return _PrintChannelOutcome(
accepted=True,
message="Frozen printable output was accepted for distribution.",
)
def _skip_print_channel(session: Session, job_id: str) -> None:
current = session.get(CampaignJob, job_id)
if current is None or current.print_status == JobPrintStatus.ACCEPTED.value:
return
current.print_status = JobPrintStatus.SKIPPED.value
session.add(current)
session.commit()
def _final_multichannel_status( def _final_multichannel_status(
*, *,
channel_policy: DeliveryChannelPolicy, channel_policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome | None, mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None, postbox: PostboxChannelOutcome | None,
print_output: _PrintChannelOutcome | None = None,
) -> str: ) -> str:
outcome = _delivery_outcome_summary(mail=mail, postbox=postbox) outcome = _delivery_outcome_summary(
mail=mail,
postbox=postbox,
print_output=print_output,
)
if outcome.outcome_unknown: if outcome.outcome_unknown:
return JobSendStatus.OUTCOME_UNKNOWN.value return JobSendStatus.OUTCOME_UNKNOWN.value
if not outcome.accepted_count: if not outcome.accepted_count:
@@ -3287,11 +3421,14 @@ def _delivery_outcome_summary(
*, *,
mail: _MailChannelOutcome | None, mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None, postbox: PostboxChannelOutcome | None,
print_output: _PrintChannelOutcome | None = None,
) -> _DeliveryOutcomeSummary: ) -> _DeliveryOutcomeSummary:
return _DeliveryOutcomeSummary( return _DeliveryOutcomeSummary(
mail_accepted=bool(mail and mail.accepted), mail_accepted=bool(mail and mail.accepted),
postbox_accepted=int(postbox.accepted_count if postbox else 0), postbox_accepted=int(postbox.accepted_count if postbox else 0),
postbox_rejected=int(postbox.rejected_count if postbox else 0), postbox_rejected=int(postbox.rejected_count if postbox else 0),
print_accepted=bool(print_output and print_output.accepted),
print_rejected=bool(print_output and print_output.rejected_permanent),
outcome_unknown=bool( outcome_unknown=bool(
(mail and mail.outcome_unknown) or (postbox and postbox.outcome_unknown) (mail and mail.outcome_unknown) or (postbox and postbox.outcome_unknown)
), ),
@@ -3310,6 +3447,10 @@ def _classify_postbox_delivery(outcome: _DeliveryOutcomeSummary) -> str:
) )
def _classify_print_delivery(_outcome: _DeliveryOutcomeSummary) -> str:
return JobSendStatus.PRINT_ACCEPTED.value
def _classify_dual_delivery(outcome: _DeliveryOutcomeSummary) -> str: def _classify_dual_delivery(outcome: _DeliveryOutcomeSummary) -> str:
fully_delivered = ( fully_delivered = (
outcome.mail_accepted outcome.mail_accepted
@@ -3326,15 +3467,16 @@ def _classify_dual_delivery(outcome: _DeliveryOutcomeSummary) -> str:
def _classify_fallback_delivery(outcome: _DeliveryOutcomeSummary) -> str: def _classify_fallback_delivery(outcome: _DeliveryOutcomeSummary) -> str:
if outcome.postbox_rejected: if outcome.postbox_rejected:
return JobSendStatus.PARTIALLY_ACCEPTED.value return JobSendStatus.PARTIALLY_ACCEPTED.value
return ( if outcome.mail_accepted:
JobSendStatus.SMTP_ACCEPTED.value return JobSendStatus.SMTP_ACCEPTED.value
if outcome.mail_accepted if outcome.postbox_accepted:
else JobSendStatus.POSTBOX_ACCEPTED.value return JobSendStatus.POSTBOX_ACCEPTED.value
) return JobSendStatus.PRINT_ACCEPTED.value
_ACCEPTED_DELIVERY_CLASSIFIERS = { _ACCEPTED_DELIVERY_CLASSIFIERS = {
DeliveryChannelPolicy.POSTBOX: _classify_postbox_delivery, DeliveryChannelPolicy.POSTBOX: _classify_postbox_delivery,
DeliveryChannelPolicy.PRINT: _classify_print_delivery,
DeliveryChannelPolicy.MAIL_AND_POSTBOX: _classify_dual_delivery, DeliveryChannelPolicy.MAIL_AND_POSTBOX: _classify_dual_delivery,
} }
@@ -3342,12 +3484,15 @@ _ACCEPTED_DELIVERY_CLASSIFIERS = {
def _multichannel_messages( def _multichannel_messages(
mail: _MailChannelOutcome | None, mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None, postbox: PostboxChannelOutcome | None,
print_output: _PrintChannelOutcome | None = None,
) -> list[str]: ) -> list[str]:
values: list[str] = [] values: list[str] = []
if mail and mail.message: if mail and mail.message:
values.append(f"Mail: {mail.message}") values.append(f"Mail: {mail.message}")
if postbox: if postbox:
values.extend(f"Postbox: {message}" for message in postbox.messages if message) values.extend(f"Postbox: {message}" for message in postbox.messages if message)
if print_output and print_output.message:
values.append(f"Print: {print_output.message}")
return values return values
@@ -3358,6 +3503,7 @@ def _finalize_multichannel_job(
channel_policy: DeliveryChannelPolicy, channel_policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome | None, mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None, postbox: PostboxChannelOutcome | None,
print_output: _PrintChannelOutcome | None = None,
commit: bool = True, commit: bool = True,
) -> SendJobResult: ) -> SendJobResult:
job = session.get(CampaignJob, job_id) job = session.get(CampaignJob, job_id)
@@ -3367,10 +3513,11 @@ def _finalize_multichannel_job(
channel_policy=channel_policy, channel_policy=channel_policy,
mail=mail, mail=mail,
postbox=postbox, postbox=postbox,
print_output=print_output,
) )
accepted = status in DELIVERY_ACCEPTED_STATUSES accepted = status in DELIVERY_ACCEPTED_STATUSES
unknown = status == JobSendStatus.OUTCOME_UNKNOWN.value unknown = status == JobSendStatus.OUTCOME_UNKNOWN.value
messages = _multichannel_messages(mail, postbox) messages = _multichannel_messages(mail, postbox, print_output)
job.queue_status = JobQueueStatus.DRAFT.value job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = status job.send_status = status
job.claim_token = None job.claim_token = None
@@ -3378,7 +3525,11 @@ def _finalize_multichannel_job(
job.outcome_unknown_at = _utcnow() if unknown else None job.outcome_unknown_at = _utcnow() if unknown else None
if accepted or ( if accepted or (
unknown unknown
and bool((mail and mail.accepted) or (postbox and postbox.accepted_count)) and bool(
(mail and mail.accepted)
or (postbox and postbox.accepted_count)
or (print_output and print_output.accepted)
)
): ):
job.sent_at = job.sent_at or _utcnow() job.sent_at = job.sent_at or _utcnow()
files_integration().mark_job_attachment_uses_sent(session, job) files_integration().mark_job_attachment_uses_sent(session, job)
@@ -3397,7 +3548,11 @@ def _finalize_multichannel_job(
return SendJobResult( return SendJobResult(
job_id=job.id, job_id=job.id,
status=status, status=status,
attempt_number=job.attempt_count + job.postbox_attempt_count, attempt_number=(
job.attempt_count
+ job.postbox_attempt_count
+ getattr(job, "print_attempt_count", 0)
),
message=job.last_error, message=job.last_error,
) )
@@ -3414,8 +3569,55 @@ def _send_claimed_multichannel_job(
) -> SendJobResult: ) -> SendJobResult:
mail_outcome: _MailChannelOutcome | None = None mail_outcome: _MailChannelOutcome | None = None
postbox_outcome: PostboxChannelOutcome | None = None postbox_outcome: PostboxChannelOutcome | None = None
print_outcome: _PrintChannelOutcome | None = None
if channel_policy == DeliveryChannelPolicy.MAIL_THEN_POSTBOX: if channel_policy == DeliveryChannelPolicy.MAIL_THEN_PRINT:
if job.print_status == JobPrintStatus.ACCEPTED.value:
print_outcome = _PrintChannelOutcome(
accepted=True,
message="Output was already accepted.",
)
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 printable fallback."
)
print_outcome = _deliver_print_channel(session, job=current)
elif mail_outcome.accepted:
_skip_print_channel(session, job.id)
elif channel_policy == DeliveryChannelPolicy.POSTBOX_THEN_PRINT:
if job.print_status == JobPrintStatus.ACCEPTED.value:
print_outcome = _PrintChannelOutcome(
accepted=True,
message="Output was already accepted.",
)
else:
postbox_outcome = deliver_campaign_job_to_postboxes(
session,
job=job,
message_bytes=context.message_bytes,
classification=context.snapshot.delivery.postbox.classification,
)
if postbox_outcome.all_rejected_before_acceptance:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError(
"Campaign job disappeared before printable fallback."
)
print_outcome = _deliver_print_channel(session, job=current)
elif postbox_outcome.accepted_count:
_skip_print_channel(session, job.id)
elif channel_policy == DeliveryChannelPolicy.MAIL_THEN_POSTBOX:
prior_postbox_outcome = _postbox_outcome_from_attempts(session, job) prior_postbox_outcome = _postbox_outcome_from_attempts(session, job)
if prior_postbox_outcome.outcome_unknown: if prior_postbox_outcome.outcome_unknown:
postbox_outcome = prior_postbox_outcome postbox_outcome = prior_postbox_outcome
@@ -3486,6 +3688,7 @@ def _send_claimed_multichannel_job(
channel_policy=channel_policy, channel_policy=channel_policy,
mail=mail_outcome, mail=mail_outcome,
postbox=postbox_outcome, postbox=postbox_outcome,
print_output=print_outcome,
) )
@@ -37,10 +37,12 @@ from govoplan_campaign.backend.db.models import (
ImapAppendAttempt, ImapAppendAttempt,
JobImapStatus, JobImapStatus,
JobPostboxStatus, JobPostboxStatus,
JobPrintStatus,
JobQueueStatus, JobQueueStatus,
JobSendStatus, JobSendStatus,
JobValidationStatus, JobValidationStatus,
PostboxDeliveryAttempt, PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.response_security import ( from govoplan_campaign.backend.response_security import (
@@ -84,11 +86,13 @@ def _job_summary_payload(
"send_status": job.send_status, "send_status": job.send_status,
"delivery_channel_policy": getattr(job, "delivery_channel_policy", "mail"), "delivery_channel_policy": getattr(job, "delivery_channel_policy", "mail"),
"postbox_status": getattr(job, "postbox_status", "not_requested"), "postbox_status": getattr(job, "postbox_status", "not_requested"),
"print_status": getattr(job, "print_status", "not_requested"),
"imap_status": job.imap_status, "imap_status": job.imap_status,
"eml_size_bytes": job.eml_size_bytes, "eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256, "eml_sha256": job.eml_sha256,
"attempt_count": job.attempt_count, "attempt_count": job.attempt_count,
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0), "postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
"print_attempt_count": getattr(job, "print_attempt_count", 0),
"postbox_target_count": len( "postbox_target_count": len(
getattr(job, "resolved_postbox_targets", None) or [] getattr(job, "resolved_postbox_targets", None) or []
), ),
@@ -122,8 +126,12 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
"issues": job.issues_snapshot or [], "issues": job.issues_snapshot or [],
"attachments": public_campaign_payload(job.resolved_attachments or []), "attachments": public_campaign_payload(job.resolved_attachments or []),
"resolved_recipients": job.resolved_recipients or {}, "resolved_recipients": job.resolved_recipients or {},
"delivery_provenance": getattr(job, "delivery_provenance", None) or {},
"resolved_postbox_targets": getattr(job, "resolved_postbox_targets", None) "resolved_postbox_targets": getattr(job, "resolved_postbox_targets", None)
or [], or [],
"resolved_print_output": public_campaign_payload(
getattr(job, "resolved_print_output", None)
),
} }
@@ -131,6 +139,7 @@ def _job_attempts_payload(
send_attempts: list[SendAttempt], send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt], imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (), postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
print_attempts: Sequence[PrintOutputAttempt] = (),
message_actions: Sequence[CampaignMessageAction] = (), message_actions: Sequence[CampaignMessageAction] = (),
message_action_attempts: Sequence[CampaignMessageActionAttempt] = (), message_action_attempts: Sequence[CampaignMessageActionAttempt] = (),
*, *,
@@ -212,6 +221,22 @@ def _job_attempts_payload(
receipt_summary receipt_summary
) )
postbox_payloads.append(payload) postbox_payloads.append(payload)
print_payloads: list[dict[str, object]] = []
for attempt in print_attempts:
payload = {
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"render_id": attempt.render_id,
"artifact_sha256": attempt.artifact_sha256,
"started_at": attempt.started_at,
"finished_at": attempt.finished_at,
}
if include_diagnostics:
payload["idempotency_key"] = attempt.idempotency_key
payload["evidence"] = attempt.evidence or {}
payload["error_message"] = attempt.error_message
print_payloads.append(payload)
action_attempts_by_action = { action_attempts_by_action = {
attempt.action_id: attempt attempt.action_id: attempt
for attempt in message_action_attempts for attempt in message_action_attempts
@@ -270,6 +295,7 @@ def _job_attempts_payload(
"smtp": smtp_payloads, "smtp": smtp_payloads,
"imap": imap_payloads, "imap": imap_payloads,
"postbox": postbox_payloads, "postbox": postbox_payloads,
"print": print_payloads,
"message_actions": message_action_payloads, "message_actions": message_action_payloads,
} }
@@ -305,6 +331,7 @@ def _job_diagnostics_payload(
send_attempts: list[SendAttempt], send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt], imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (), postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
print_attempts: Sequence[PrintOutputAttempt] = (),
message_actions: Sequence[CampaignMessageAction] = (), message_actions: Sequence[CampaignMessageAction] = (),
message_action_attempts: Sequence[CampaignMessageActionAttempt] = (), message_action_attempts: Sequence[CampaignMessageActionAttempt] = (),
*, *,
@@ -333,6 +360,7 @@ def _job_diagnostics_payload(
send_attempts, send_attempts,
imap_attempts, imap_attempts,
postbox_attempts, postbox_attempts,
print_attempts,
message_actions, message_actions,
message_action_attempts, message_action_attempts,
postbox_receipts=postbox_receipts, postbox_receipts=postbox_receipts,
@@ -452,6 +480,7 @@ def _status_counts(
"queue_status", "queue_status",
"send_status", "send_status",
"postbox_status", "postbox_status",
"print_status",
"imap_status", "imap_status",
): ):
column = getattr(CampaignJob, field_name) column = getattr(CampaignJob, field_name)
@@ -475,6 +504,7 @@ CAMPAIGN_JOB_GRID_SORT_COLUMNS = {
"queue": CampaignJob.queue_status, "queue": CampaignJob.queue_status,
"send": CampaignJob.send_status, "send": CampaignJob.send_status,
"postbox": CampaignJob.postbox_status, "postbox": CampaignJob.postbox_status,
"print": CampaignJob.print_status,
"imap": CampaignJob.imap_status, "imap": CampaignJob.imap_status,
"attempts": CampaignJob.attempt_count, "attempts": CampaignJob.attempt_count,
"updated": CampaignJob.updated_at, "updated": CampaignJob.updated_at,
@@ -490,6 +520,10 @@ CAMPAIGN_JOB_GRID_LIST_FILTERS = {
CampaignJob.postbox_status, CampaignJob.postbox_status,
{item.value for item in JobPostboxStatus}, {item.value for item in JobPostboxStatus},
), ),
"print": (
CampaignJob.print_status,
{item.value for item in JobPrintStatus},
),
"imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}), "imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}),
} }
@@ -957,6 +991,7 @@ class CampaignJobsQuery:
"queue", "queue",
"send", "send",
"postbox", "postbox",
"print",
"imap", "imap",
"attempts", "attempts",
"updated", "updated",
@@ -968,6 +1003,7 @@ class CampaignJobsQuery:
filter_queue: 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_send: str | None = Query(default=None, max_length=1000),
filter_postbox: str | None = Query(default=None, max_length=1000), filter_postbox: str | None = Query(default=None, max_length=1000),
filter_print: str | None = Query(default=None, max_length=1000),
filter_imap: 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_attempts: str | None = Query(default=None, max_length=100),
filter_evidence: str | None = Query(default=None, max_length=500), filter_evidence: str | None = Query(default=None, max_length=500),
@@ -991,6 +1027,7 @@ class CampaignJobsQuery:
"queue": filter_queue, "queue": filter_queue,
"send": filter_send, "send": filter_send,
"postbox": filter_postbox, "postbox": filter_postbox,
"print": filter_print,
"imap": filter_imap, "imap": filter_imap,
"attempts": filter_attempts, "attempts": filter_attempts,
"evidence": filter_evidence, "evidence": filter_evidence,
+1 -1
View File
@@ -139,7 +139,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
delivery=DeliveryConfig(), delivery=DeliveryConfig(),
) )
assert payload["snapshot_version"] == "7" assert payload["snapshot_version"] == "8"
assert payload["mail_profile_id"] == "profile-1" assert payload["mail_profile_id"] == "profile-1"
assert "smtp" not in payload assert "smtp" not in payload
assert "imap" not in payload assert "imap" not in payload
+79 -1
View File
@@ -11,6 +11,7 @@ from govoplan_campaign.backend.db.models import JobSendStatus
from govoplan_campaign.backend.sending.jobs import ( from govoplan_campaign.backend.sending.jobs import (
SendJobResult, SendJobResult,
_MailChannelOutcome, _MailChannelOutcome,
_PrintChannelOutcome,
_final_multichannel_status, _final_multichannel_status,
_send_claimed_multichannel_job, _send_claimed_multichannel_job,
) )
@@ -49,6 +50,71 @@ class _Session:
class PostboxFallbackOrchestrationTests(unittest.TestCase): class PostboxFallbackOrchestrationTests(unittest.TestCase):
def test_mail_unknown_never_starts_print_fallback(self) -> None:
job = SimpleNamespace(id="job-1", print_status="ready")
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._deliver_print_channel"
) as deliver_print,
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_PRINT,
use_rate_limit=False,
enqueue_imap_task=False,
)
self.assertIs(result, expected)
deliver_print.assert_not_called()
def test_mail_preacceptance_rejection_starts_print_fallback(self) -> None:
job = SimpleNamespace(id="job-1", print_status="ready")
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(rejected_permanent=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_print_channel",
return_value=_PrintChannelOutcome(accepted=True),
) as deliver_print,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.PRINT_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_PRINT,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_print.assert_called_once()
def test_mail_unknown_never_starts_postbox_fallback(self) -> None: def test_mail_unknown_never_starts_postbox_fallback(self) -> None:
job = _job() job = _job()
expected = SendJobResult( expected = SendJobResult(
@@ -385,6 +451,12 @@ def test_rejection_precedence_is_exhaustive_for_every_delivery_policy(
PostboxChannelOutcome(rejected_permanent=1), PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.PARTIALLY_ACCEPTED.value, JobSendStatus.PARTIALLY_ACCEPTED.value,
), ),
(
DeliveryChannelPolicy.PRINT,
None,
PostboxChannelOutcome(),
JobSendStatus.PRINT_ACCEPTED.value,
),
], ],
) )
def test_accepted_delivery_decision_table( def test_accepted_delivery_decision_table(
@@ -393,7 +465,13 @@ def test_accepted_delivery_decision_table(
postbox: PostboxChannelOutcome, postbox: PostboxChannelOutcome,
expected: str, expected: str,
) -> None: ) -> None:
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected print_output = _PrintChannelOutcome(accepted=True) if policy == DeliveryChannelPolicy.PRINT else None
assert _final_multichannel_status(
channel_policy=policy,
mail=mail,
postbox=postbox,
print_output=print_output,
) == expected
if __name__ == "__main__": if __name__ == "__main__":
+36
View File
@@ -180,6 +180,42 @@ def test_postbox_only_campaign_does_not_require_mail() -> None:
} }
def test_print_only_campaign_requires_templates_and_an_explicit_target_not_mail() -> None:
config = CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {"id": "campaign-print", "name": "Printed notice", "mode": "send"},
"template": {"subject": "Notice", "text": "Printed body", "body_mode": "text"},
"entries": {
"inline": [
{
"id": "recipient-1",
"channel_policy": "print",
"print_target": {
"channel": "postal",
"target": "Example Street 1",
"target_key": "postal:example-street-1",
},
}
]
},
"delivery": {
"channel_policy": "print",
"print": {"template_id": "template-1", "template_revision": 2},
},
}
)
available = validate_campaign_config(config, templates_available=True)
unavailable = validate_campaign_config(config, templates_available=False)
assert "missing_mail_profile" not in {issue.code for issue in available.issues}
assert "missing_sender" not in {issue.code for issue in available.issues}
assert "print_template_missing" not in {issue.code for issue in available.issues}
assert "print_target_missing" not in {issue.code for issue in available.issues}
assert "templates_unavailable" in {issue.code for issue in unavailable.issues}
def test_row_resolves_multiple_direct_and_field_derived_postboxes() -> None: def test_row_resolves_multiple_direct_and_field_derived_postboxes() -> None:
config = _config() config = _config()
integration = _PostboxIntegration() integration = _PostboxIntegration()
+282
View File
@@ -0,0 +1,282 @@
from __future__ import annotations
import hashlib
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignVersion,
JobPrintStatus,
PrintOutputAttempt,
)
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.messages.models import MessageValidationStatus
from govoplan_campaign.backend.persistence.campaigns import _resolve_built_print_outputs
from govoplan_campaign.backend.routes import versions as version_routes
from govoplan_campaign.backend.sending.jobs import _deliver_print_channel
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.templates import TemplateArtifactRef, TemplateRenderResult
from govoplan_core.db.base import Base
def test_print_acceptance_is_idempotent_per_frozen_artifact() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
PrintOutputAttempt.__table__,
ChangeSequenceEntry.__table__,
],
)
with Session(engine) as session:
job = CampaignJob(
id="job-print-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=1,
entry_id="entry-1",
recipient_email=None,
subject="Printable notice",
build_status="built",
validation_status="ready",
queue_status="draft",
send_status="not_queued",
print_status=JobPrintStatus.READY.value,
delivery_channel_policy="print",
resolved_attachments=[],
issues_snapshot=[],
resolved_print_output={
"render_id": "render-1",
"output_sha256": "a" * 64,
"template_id": "template-1",
"template_revision_id": "revision-1",
"template_hash": "b" * 64,
"input_hash": "c" * 64,
"recipient_key": "recipient-1",
"item_index": 0,
"route": {"channel": "postal", "target_key": "postal:1"},
},
)
session.add(job)
session.commit()
first = _deliver_print_channel(session, job=job)
second = _deliver_print_channel(session, job=job)
assert first.accepted is True
assert second.accepted is True
assert session.get(CampaignJob, job.id).print_attempt_count == 1
attempts = session.query(PrintOutputAttempt).all()
assert len(attempts) == 1
assert attempts[0].artifact_sha256 == "a" * 64
engine.dispose()
def test_print_build_uses_one_deterministic_template_render_request() -> None:
config = CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Printed notice", "mode": "send"},
"fields": [{"name": "case_number", "type": "string"}],
"template": {"subject": "Notice", "text": "Body", "body_mode": "text"},
"entries": {
"inline": [
{
"id": "entry-1",
"name": "Ada",
"channel_policy": "print",
"fields": {"case_number": "C-1"},
"print_target": {
"channel": "postal",
"target": "Example Street 1",
"target_key": "postal:example-street-1",
},
"distribution_source": {
"list_id": "list-1",
"list_revision": 3,
"expansion_hash": "expansion-1",
"recipient_key": "recipient-1",
},
}
]
},
"delivery": {
"channel_policy": "print",
"print": {
"template_id": "template-1",
"template_revision": 4,
"output_format": "html",
"persist_to_files": True,
},
},
}
)
draft = SimpleNamespace(
entry_index=1,
entry_id="entry-1",
delivery_channel_policy="print",
validation_status=MessageValidationStatus.READY,
)
built = [SimpleNamespace(draft=draft)]
requests = []
stored = {}
class Storage:
def put_bytes(self, key, data, **_kwargs):
stored[key] = data
class Templates:
def render(self, _session, _principal, *, request):
requests.append(request)
return TemplateRenderResult(
render_id="render-1",
template_id="template-1",
revision_id="revision-4",
revision=4,
template_hash="b" * 64,
input_hash="c" * 64,
renderer_version="templates-1",
output_format="html",
content_type="text/html",
filename="printed-notice.html",
item_count=1,
page_count=1,
output_sha256=hashlib.sha256(b"<p>Printed notice</p>").hexdigest(),
output_size_bytes=len(b"<p>Printed notice</p>"),
artifact=TemplateArtifactRef(
kind="bounded_download",
filename="printed-notice.html",
content_type="text/html",
size_bytes=len(b"<p>Printed notice</p>"),
sha256=hashlib.sha256(b"<p>Printed notice</p>").hexdigest(),
download_path="/api/v1/templates/renders/render-1/download",
),
payload=b"<p>Printed notice</p>",
)
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
version_number=2,
)
principal = SimpleNamespace(account_id="account-1")
storage = Storage()
with patch(
"govoplan_campaign.backend.persistence.campaigns.templates_integration",
return_value=Templates(),
):
first = _resolve_built_print_outputs(
object(), # type: ignore[arg-type]
storage=storage, # type: ignore[arg-type]
tenant_id="tenant-1",
build_id="build-1",
version=version, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
config=config,
built_messages=built,
entries_by_index={1: config.entries.inline[0]},
)
second = _resolve_built_print_outputs(
object(), # type: ignore[arg-type]
storage=storage, # type: ignore[arg-type]
tenant_id="tenant-1",
build_id="build-1",
version=version, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
config=config,
built_messages=built,
entries_by_index={1: config.entries.inline[0]},
)
assert requests[0].idempotency_key == requests[1].idempotency_key
assert requests[0].items[0]["case_number"] == "C-1"
assert requests[0].persist_to_files is True
assert first == second
assert first[1]["artifact"]["storage_key"] in stored
assert first[1]["artifact"]["download_path"] == (
"/api/v1/campaigns/campaign-1/versions/version-1/print-output/download"
)
assert first[1]["route"]["target_key"] == "postal:example-street-1"
class _RoutePrincipal:
tenant_id = "tenant-1"
account_id = "account-1"
user = SimpleNamespace(id="user-1")
def has(self, scope: str) -> bool:
return scope in {"campaigns:campaign:read", "campaigns:recipient:read"}
def test_print_download_is_authorized_by_campaign_and_hash_checked() -> None:
payload = b"<p>Printable output</p>"
digest = hashlib.sha256(payload).hexdigest()
version = SimpleNamespace(
id="version-1",
build_summary={
"print_output": {
"output_sha256": digest,
"template_id": "template-1",
"template_revision_id": "revision-1",
"artifact": {
"kind": "bounded_download",
"filename": "letters.html",
"content_type": "text/html",
"storage_key": "campaign-artifacts/tenant-1/letters.html",
},
}
},
)
storage = SimpleNamespace(get_bytes=lambda _key: payload)
with (
patch.object(version_routes, "_get_campaign_for_principal"),
patch.object(
version_routes,
"get_campaign_version_for_tenant",
return_value=version,
),
patch.object(version_routes, "_object_storage", return_value=storage),
patch.object(version_routes, "audit_from_principal") as audit,
):
response = version_routes.download_print_output(
"campaign-1",
"version-1",
session=Mock(),
principal=_RoutePrincipal(), # type: ignore[arg-type]
)
assert response.body == payload
assert response.headers["x-content-sha256"] == digest
audit.assert_called_once()
def test_print_download_requires_recipient_read_authority() -> None:
principal = _RoutePrincipal()
principal.has = lambda scope: scope == "campaigns:campaign:read" # type: ignore[method-assign]
with (
patch.object(version_routes, "_get_campaign_for_principal"),
pytest.raises(HTTPException) as denied,
):
version_routes.download_print_output(
"campaign-1",
"version-1",
session=Mock(),
principal=principal, # type: ignore[arg-type]
)
assert denied.value.status_code == 403
+15
View File
@@ -57,6 +57,14 @@ def _job() -> SimpleNamespace:
} }
], ],
resolved_recipients={"to": [{"email": "person@example.test"}]}, resolved_recipients={"to": [{"email": "person@example.test"}]},
resolved_print_output={
"output_sha256": "print-sha256",
"artifact": {
"filename": "letters.html",
"storage_key": "campaign/private/letters.html",
"download_path": "/api/v1/campaigns/campaign-1/versions/version-1/print-output/download",
},
},
) )
@@ -238,6 +246,13 @@ def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
assert "claimed_at" not in job_payload assert "claimed_at" not in job_payload
assert "smtp_started_at" not in job_payload assert "smtp_started_at" not in job_payload
assert job_payload["attachments"] == [{"filename": "public.pdf"}] assert job_payload["attachments"] == [{"filename": "public.pdf"}]
assert job_payload["resolved_print_output"] == {
"output_sha256": "print-sha256",
"artifact": {
"filename": "letters.html",
"download_path": "/api/v1/campaigns/campaign-1/versions/version-1/print-output/download",
},
}
assert "claim_token" not in attempts["smtp"][0] assert "claim_token" not in attempts["smtp"][0]
assert "claim_token" not in attempts["imap"][0] assert "claim_token" not in attempts["imap"][0]
assert "smtp_response" not in attempts["smtp"][0] assert "smtp_response" not in attempts["smtp"][0]
+1 -1
View File
@@ -38,7 +38,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router) actual = _operation_keys(router)
assert actual == expected assert actual == expected
assert len(actual) == 65 assert len(actual) == 70
assert not [operation for operation, count in Counter(actual).items() if count > 1] assert not [operation for operation, count in Counter(actual).items() if count > 1]
+60
View File
@@ -368,6 +368,39 @@ export type CampaignPostboxCatalog = {
organization_units: CampaignPostboxOrganizationUnit[]; organization_units: CampaignPostboxOrganizationUnit[];
}; };
export type CampaignPrintTemplateOutputProfile = {
id: string;
label: string;
output_format: "html" | "text";
media_type: string;
channel: string;
capabilities: string[];
page: Record<string, unknown>;
};
export type CampaignPrintTemplate = {
id: string;
name: string;
slug: string;
template_type: string;
status: string;
current_revision: number;
current_revision_id: string;
published_revision_id?: string | null;
description?: string | null;
read_only: boolean;
revision?: {
revision: number;
output_profiles: CampaignPrintTemplateOutputProfile[];
required_fields: Array<{path: string;label?: string | null;required: boolean;}>;
} | null;
};
export type CampaignPrintTemplatesResponse = {
available: boolean;
templates: CampaignPrintTemplate[];
};
export type CampaignRecipientSnapshotItem = { export type CampaignRecipientSnapshotItem = {
contact_id: string; contact_id: string;
display_name: string; display_name: string;
@@ -494,6 +527,8 @@ export type CampaignSummary = {
needs_attention?: number; needs_attention?: number;
sent?: number; sent?: number;
smtp_accepted?: number; smtp_accepted?: number;
postbox_accepted?: number;
print_accepted?: number;
failed?: number; failed?: number;
outcome_unknown?: number; outcome_unknown?: number;
not_attempted?: number; not_attempted?: number;
@@ -729,6 +764,7 @@ export type CampaignJobDetailResponse = {
smtp?: Record<string, unknown>[]; smtp?: Record<string, unknown>[];
imap?: Record<string, unknown>[]; imap?: Record<string, unknown>[];
postbox?: Record<string, unknown>[]; postbox?: Record<string, unknown>[];
print?: Record<string, unknown>[];
}; };
}; };
@@ -771,6 +807,7 @@ export type AggregateCampaignReport = {
outcomes: { outcomes: {
smtp_accepted: AggregateReportCount; smtp_accepted: AggregateReportCount;
postbox_accepted: AggregateReportCount; postbox_accepted: AggregateReportCount;
print_accepted: AggregateReportCount;
delivered: AggregateReportCount; delivered: AggregateReportCount;
partially_accepted: AggregateReportCount; partially_accepted: AggregateReportCount;
failed: AggregateReportCount; failed: AggregateReportCount;
@@ -908,6 +945,15 @@ campaignId: string)
return apiFetch<CampaignPostboxCatalog>(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`); return apiFetch<CampaignPostboxCatalog>(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`);
} }
export async function listCampaignPrintTemplates(
settings: ApiSettings,
campaignId: string,
query = "")
: Promise<CampaignPrintTemplatesResponse> {
const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : "";
return apiFetch<CampaignPrintTemplatesResponse>(settings, `/api/v1/campaigns/${campaignId}/print-templates${suffix}`);
}
export async function snapshotCampaignRecipientAddressSource( export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -1242,6 +1288,20 @@ versionId?: string)
); );
} }
export async function downloadCampaignPrintArtifact(
settings: ApiSettings,
downloadPath: string,
filename: string)
: Promise<void> {
if (![
"/api/v1/campaigns/",
"/api/v1/files/"
].some((prefix) => downloadPath.startsWith(prefix))) {
throw new Error("The printable output download path is invalid.");
}
await apiDownload(settings, downloadPath, filename || "campaign-print-output.html");
}
export async function emailCampaignReport( export async function emailCampaignReport(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -45,6 +45,7 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"sending", "sending",
"smtp_accepted", "smtp_accepted",
"postbox_accepted", "postbox_accepted",
"print_accepted",
"delivered", "delivered",
"partially_accepted", "partially_accepted",
"sent", "sent",
@@ -54,6 +55,14 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"cancelled"]. "cancelled"].
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) })); map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const PRINT_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"ready",
"accepted",
"failed",
"skipped"].
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [ const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested", "not_requested",
"pending", "pending",
@@ -162,6 +171,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [ const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
{ label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" }, { label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" },
{ label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" }, { label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" },
{ label: "Postbox accepted", value: cards?.postbox_accepted ?? 0, shortcutId: "postbox_accepted" },
{ label: "Print accepted", value: cards?.print_accepted ?? 0, shortcutId: "print_accepted" },
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" }, { label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" },
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: cards?.outcome_unknown ?? 0, shortcutId: "outcome_unknown" }, { label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: cards?.outcome_unknown ?? 0, shortcutId: "outcome_unknown" },
{ label: "i18n:govoplan-campaign.not_attempted.e1be3c69", value: cards?.not_attempted ?? 0, shortcutId: "not_attempted" }, { label: "i18n:govoplan-campaign.not_attempted.e1be3c69", value: cards?.not_attempted ?? 0, shortcutId: "not_attempted" },
@@ -274,7 +285,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}); });
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result); const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
const status = String(sendResult.status ?? "submitted"); const status = String(sendResult.status ?? "submitted");
if (["smtp_accepted", "postbox_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1; if (["smtp_accepted", "postbox_accepted", "print_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1;
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`); else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
} catch (err) { } catch (err) {
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`); failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
@@ -377,6 +388,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{ 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: "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: "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: "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: "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: "print", header: "Print", width: 135, sortable: true, filterable: true, columnType: "from-list", list: { options: PRINT_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.print_status ?? "unknown")} label={deliveryStatusLabel(String(row.print_status ?? "unknown"))} />, value: (row) => String(row.print_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: "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), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_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)) },
{ {
@@ -591,6 +603,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<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>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 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>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</dd></div>
<div><dt>Print state</dt><dd><StatusBadge status={String(detail.job.print_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.print_status ?? "unknown"))} /></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.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> <div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
<div><dt>Message SHA-256</dt><dd><code>{String(detail.job.eml_sha256 ?? "—")}</code></dd></div> <div><dt>Message SHA-256</dt><dd><code>{String(detail.job.eml_sha256 ?? "—")}</code></dd></div>
@@ -605,6 +618,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<AttachmentEvidenceSection attachments={Array.isArray(detail.job.attachments) ? detail.job.attachments : []} /> <AttachmentEvidenceSection attachments={Array.isArray(detail.job.attachments) ? detail.job.attachments : []} />
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} /> <AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} /> <AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} />
<AttemptHistoryTable kind="print" rows={detail.attempts.print ?? []} />
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} /> <AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
</div> </div>
} }
@@ -918,12 +932,14 @@ function shortEvidenceId(value: string): string {
return value.length > 16 ? `${value.slice(0, 16)}...` : value; return value.length > 16 ? `${value.slice(0, 16)}...` : value;
} }
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";rows: Record<string, unknown>[];}) { function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox" | "print";rows: Record<string, unknown>[];}) {
const title = kind === "smtp" const title = kind === "smtp"
? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
: kind === "postbox" : kind === "postbox"
? "Postbox delivery attempts" ? "Postbox delivery attempts"
: "i18n:govoplan-campaign.imap_append_attempts.b30e980a"; : kind === "print"
? "Printable output attempts"
: "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<section className="attempt-history-section"> <section className="attempt-history-section">
@@ -938,6 +954,8 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";
{ 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")} /> }, { 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" ? 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 ?? "—") } : { 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 === "print" ?
{ id: "render", header: "Render", width: 220, sortable: true, filterable: true, value: (row) => String(row.render_id ?? "—"), render: (row) => String(row.render_id ?? "—") } :
kind === "postbox" ? kind === "postbox" ?
{ id: "target", header: "Postbox", width: 220, sortable: true, filterable: true, value: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—"), render: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—") } : { id: "target", header: "Postbox", width: 220, sortable: true, filterable: true, value: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—"), render: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—") } :
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") }, { id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
@@ -1005,10 +1023,12 @@ function initialReportGridFilters(): Record<string, string | string[]> {
const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS); const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS);
const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS); const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS);
const postbox = statusParameters(params, "postbox_status", POSTBOX_STATUS_OPTIONS); const postbox = statusParameters(params, "postbox_status", POSTBOX_STATUS_OPTIONS);
const print = statusParameters(params, "print_status", PRINT_STATUS_OPTIONS);
const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS); const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS);
if (send.length > 0) result.send = send; if (send.length > 0) result.send = send;
if (imap.length > 0) result.imap = imap; if (imap.length > 0) result.imap = imap;
if (postbox.length > 0) result.postbox = postbox; if (postbox.length > 0) result.postbox = postbox;
if (print.length > 0) result.print = print;
if (validation.length > 0) result.validation = validation; if (validation.length > 0) result.validation = validation;
return result; return result;
} }
@@ -1040,7 +1060,7 @@ function serializeInitialGridFilters(filters: Record<string, string | string[]>)
} }
function campaignJobSortColumn(value?: string): CampaignJobSortColumn { function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "postbox" || value === "imap" || value === "attempts" || value === "updated") { if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "postbox" || value === "print" || value === "imap" || value === "attempts" || value === "updated") {
return value; return value;
} }
return "number"; return "number";
@@ -44,7 +44,8 @@ import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
import DistributionListImportDialog from "./recipients/DistributionListImportDialog"; import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
import { import {
distributionListDrift, distributionListDrift,
materializeDistributionListExpansion materializeDistributionListExpansion,
type DistributionRouteSelections
} from "./utils/distributionListImport"; } from "./utils/distributionListImport";
import { import {
AddressHeaderControl, AddressHeaderControl,
@@ -72,6 +73,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesModuleInstalled = usePlatformModuleInstalled("files");
const postboxModuleInstalled = usePlatformModuleInstalled("postbox"); const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
const templatesModuleInstalled = usePlatformModuleInstalled("templates");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false); const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
@@ -359,9 +361,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
setAddressSourceImportOpen(false); setAddressSourceImportOpen(false);
} }
function applyDistributionListImport(snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) { function applyDistributionListImport(
snapshot: CampaignDistributionListExpansion,
mode: RecipientImportMode,
routeSelections: DistributionRouteSelections
) {
if (locked || !draft) return; if (locked || !draft) return;
setDraft(materializeDistributionListExpansion(draft, snapshot, mode)); setDraft(materializeDistributionListExpansion(draft, snapshot, mode, routeSelections));
markDirty(); markDirty();
setDistributionListImportOpen(false); setDistributionListImportOpen(false);
} }
@@ -541,6 +547,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
locked, locked,
filesModuleInstalled, filesModuleInstalled,
postboxModuleInstalled, postboxModuleInstalled,
templatesModuleInstalled,
postboxCatalog, postboxCatalog,
entries: inlineEntries, entries: inlineEntries,
fieldDefinitions, fieldDefinitions,
@@ -12,6 +12,7 @@ import {
appendSent, appendSent,
buildVersion, buildVersion,
cancelCampaign, cancelCampaign,
downloadCampaignPrintArtifact,
getCampaignDeliveryOptions, getCampaignDeliveryOptions,
getCampaignJobs, getCampaignJobs,
getCampaignJobsDelta, getCampaignJobsDelta,
@@ -151,6 +152,8 @@ export default function ReviewSendPage({
); );
const validation = asRecord(version?.validation_summary); const validation = asRecord(version?.validation_summary);
const build = asRecord(version?.build_summary); const build = asRecord(version?.build_summary);
const printOutput = asRecord(build.print_output);
const printArtifact = asRecord(printOutput.artifact);
const summary = liveSummary ?? data.summary; const summary = liveSummary ?? data.summary;
const cards = summary?.cards; const cards = summary?.cards;
const attachmentSummary = asRecord(summary?.attachments); const attachmentSummary = asRecord(summary?.attachments);
@@ -1449,6 +1452,34 @@ export default function ReviewSendPage({
<WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} /> <WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
</div> </div>
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p> <p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
{getText(printOutput, "render_id") && (
<div className="review-flow-data-section">
<div className="page-heading split">
<div>
<h3>Printable output</h3>
<p className="muted small-note">
Template revision {String(printOutput.template_revision ?? "—")} · {String(printOutput.item_count ?? 0)} recipient item(s) · {String(printOutput.page_count ?? 0)} page(s) · {String(printOutput.output_size_bytes ?? 0)} B
</p>
</div>
{getText(printArtifact, "download_path") && (
<Button
onClick={() => void downloadCampaignPrintArtifact(
settings,
getText(printArtifact, "download_path"),
getText(printArtifact, "filename", "campaign-print-output.html")
).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)))}
>
Download output
</Button>
)}
</div>
<dl className="detail-list">
<div><dt>Template hash</dt><dd><code>{getText(printOutput, "template_hash") || "—"}</code></dd></div>
<div><dt>Input hash</dt><dd><code>{getText(printOutput, "input_hash") || "—"}</code></dd></div>
<div><dt>Output hash</dt><dd><code>{getText(printOutput, "output_sha256") || "—"}</code></dd></div>
</dl>
</div>
)}
<div className="button-row compact-actions review-flow-stage-actions"> <div className="button-row compact-actions review-flow-stage-actions">
<Button variant="primary" onClick={() => void runBuild()} disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted}> <Button variant="primary" onClick={() => void runBuild()} disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted}>
{busy === "build" ? "i18n:govoplan-campaign.building.7cc766ce" : hasBuild ? "i18n:govoplan-campaign.build_again.bd018b93" : "i18n:govoplan-campaign.build_exact_messages.bc53f55e"} {busy === "build" ? "i18n:govoplan-campaign.building.7cc766ce" : hasBuild ? "i18n:govoplan-campaign.build_again.bd018b93" : "i18n:govoplan-campaign.build_exact_messages.bc53f55e"}
@@ -1,13 +1,18 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns"; import {
listCampaignPrintTemplates,
previewCampaignAttachments,
type CampaignAttachmentPreviewRule,
type CampaignPrintTemplate
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui"; import { FieldLabel } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui"; import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg"; import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
import LockedVersionNotice from "./components/LockedVersionNotice"; import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
@@ -35,6 +40,10 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
const [attachmentPreviewRules, setAttachmentPreviewRules] = useState<CampaignAttachmentPreviewRule[]>([]); const [attachmentPreviewRules, setAttachmentPreviewRules] = useState<CampaignAttachmentPreviewRule[]>([]);
const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false); const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false);
const [attachmentPreviewError, setAttachmentPreviewError] = useState(""); const [attachmentPreviewError, setAttachmentPreviewError] = useState("");
const [printTemplatesAvailable, setPrintTemplatesAvailable] = useState(false);
const [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
const [printTemplatesError, setPrintTemplatesError] = useState("");
const subjectRef = useRef<HTMLInputElement | null>(null); const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null); const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<WysiwygEditorHandle | null>(null); const htmlRef = useRef<WysiwygEditorHandle | null>(null);
@@ -55,6 +64,9 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
onLoaded: () => setPreviewIndex(0) onLoaded: () => setPreviewIndex(0)
}); });
const template = asRecord(displayDraft.template); const template = asRecord(displayDraft.template);
const delivery = asRecord(displayDraft.delivery);
const printConfig = asRecord(delivery.print);
const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null;
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both")); const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor; const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]); const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
@@ -145,6 +157,28 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
}; };
}, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]); }, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]);
useEffect(() => {
let cancelled = false;
setPrintTemplatesLoading(true);
setPrintTemplatesError("");
void listCampaignPrintTemplates(settings, campaignId)
.then((response) => {
if (cancelled) return;
setPrintTemplatesAvailable(response.available);
setPrintTemplates(response.templates);
})
.catch((reason: unknown) => {
if (cancelled) return;
setPrintTemplatesAvailable(false);
setPrintTemplates([]);
setPrintTemplatesError(reason instanceof Error ? reason.message : String(reason));
})
.finally(() => {
if (!cancelled) setPrintTemplatesLoading(false);
});
return () => { cancelled = true; };
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchTemplateText(target: EditorTarget, value: string) { function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value); patch(["template", target], value);
@@ -159,6 +193,22 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
} }
} }
function patchPrintConfig(values: Record<string, unknown>) {
if (locked) return;
patch(["delivery", "print"], { ...printConfig, ...values });
}
function selectPrintTemplate(templateId: string) {
const selected = printTemplates.find((item) => item.id === templateId) ?? null;
const profile = selected?.revision?.output_profiles[0] ?? null;
patchPrintConfig({
template_id: selected?.id ?? null,
template_revision: selected?.revision?.revision ?? selected?.current_revision ?? null,
output_format: profile?.output_format ?? getText(printConfig, "output_format", "html"),
profile_id: profile?.id ?? null
});
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) { function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return; if (locked) return;
const target = activeEditor === "subject" ? "subject" : visibleBodyEditor; const target = activeEditor === "subject" ? "subject" : visibleBodyEditor;
@@ -328,6 +378,64 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
</Card> </Card>
<div className="template-side-stack"> <div className="template-side-stack">
<Card title="Printable output">
<LoadingFrame loading={printTemplatesLoading} label="Loading printable templates">
<div className="form-grid">
{printTemplatesError && <DismissibleAlert tone="danger" compact resetKey={printTemplatesError}>{printTemplatesError}</DismissibleAlert>}
{!printTemplatesError && !printTemplatesAvailable && (
<DismissibleAlert tone="info" compact dismissible={false}>
Printable delivery becomes available when the Templates module is enabled. Mail-only Campaigns remain unaffected.
</DismissibleAlert>
)}
{printTemplatesAvailable && (
<>
<FormField label="Print template" help="Used for recipients whose frozen route is postal or internal mail, including safe fallbacks.">
<select
value={getText(printConfig, "template_id")}
disabled={locked}
onChange={(event) => selectPrintTemplate(event.target.value)}
>
<option value="">Select a published template</option>
{printTemplates.map((item) => (
<option key={item.id} value={item.id} disabled={!item.published_revision_id}>
{item.name} · {item.template_type} · r{item.revision?.revision ?? item.current_revision}
</option>
))}
</select>
</FormField>
{selectedPrintTemplate && (
<p className="muted small-note">
{selectedPrintTemplate.description || `${selectedPrintTemplate.template_type} template`}
{selectedPrintTemplate.revision?.required_fields.length
? ` · Required fields: ${selectedPrintTemplate.revision.required_fields.map((field) => field.label || field.path).join(", ")}`
: " · No additional fields required"}
</p>
)}
<FormField label="Output format">
<SegmentedControl
ariaLabel="Printable output format"
value={getText(printConfig, "output_format", "html") as "html" | "text"}
disabled={locked || !selectedPrintTemplate}
size="content"
width="inline"
onChange={(outputFormat) => patchPrintConfig({ output_format: outputFormat })}
options={[
{ id: "html", label: "HTML" },
{ id: "text", label: "Text" }
]}
/>
</FormField>
<ToggleSwitch
label="Store generated output in Files"
checked={getBool(printConfig, "persist_to_files", true)}
disabled={locked || !selectedPrintTemplate}
onChange={(checked) => patchPrintConfig({ persist_to_files: checked })}
/>
</>
)}
</div>
</LoadingFrame>
</Card>
<Card title="i18n:govoplan-campaign.fields.e8b68527"> <Card title="i18n:govoplan-campaign.fields.e8b68527">
{invalidNamespacePlaceholders.length > 0 && {invalidNamespacePlaceholders.length > 0 &&
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282 {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert> <DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282 {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
@@ -21,7 +21,11 @@ import {
type CampaignDistributionRecipient type CampaignDistributionRecipient
} from "../../../api/campaigns"; } from "../../../api/campaigns";
import type { RecipientImportMode } from "../utils/bulkImport"; import type { RecipientImportMode } from "../utils/bulkImport";
import { usableChannelSummary } from "../utils/distributionListImport"; import {
usableChannelSummary,
type DistributionRouteSelection,
type DistributionRouteSelections
} from "../utils/distributionListImport";
type RequestedChannel = "email" | "postal" | "internal_mail" | "portal"; type RequestedChannel = "email" | "postal" | "internal_mail" | "portal";
type PreviewRow = CampaignDistributionRecipient & { included: boolean }; type PreviewRow = CampaignDistributionRecipient & { included: boolean };
@@ -46,7 +50,11 @@ export default function DistributionListImportDialog({
sources: CampaignDistributionListSource[]; sources: CampaignDistributionListSource[];
initialSourceId?: string; initialSourceId?: string;
onCancel: () => void; onCancel: () => void;
onImport: (snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) => void; onImport: (
snapshot: CampaignDistributionListExpansion,
mode: RecipientImportMode,
routeSelections: DistributionRouteSelections
) => void;
}) { }) {
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.id || ""); const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.id || "");
const [sourceQuery, setSourceQuery] = useState(""); const [sourceQuery, setSourceQuery] = useState("");
@@ -54,6 +62,9 @@ export default function DistributionListImportDialog({
const [requestedChannels, setRequestedChannels] = useState<RequestedChannel[]>(channelOptions.map((item) => item.id)); const [requestedChannels, setRequestedChannels] = useState<RequestedChannel[]>(channelOptions.map((item) => item.id));
const [parameters, setParameters] = useState<Record<string, unknown>>({}); const [parameters, setParameters] = useState<Record<string, unknown>>({});
const [preview, setPreview] = useState<CampaignDistributionListExpansion | null>(null); const [preview, setPreview] = useState<CampaignDistributionListExpansion | null>(null);
const [routeSelections, setRouteSelections] = useState<DistributionRouteSelections>({});
const [previewPage, setPreviewPage] = useState(1);
const [previewPageSize, setPreviewPageSize] = useState(50);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const selectedSource = useMemo( const selectedSource = useMemo(
@@ -71,6 +82,10 @@ export default function DistributionListImportDialog({
...(preview?.recipients ?? []).map((recipient) => ({ ...recipient, included: true })), ...(preview?.recipients ?? []).map((recipient) => ({ ...recipient, included: true })),
...(preview?.excluded ?? []).map((recipient) => ({ ...recipient, included: false })) ...(preview?.excluded ?? []).map((recipient) => ({ ...recipient, included: false }))
], [preview]); ], [preview]);
const unresolvedRoutes = useMemo(
() => preview?.recipients.filter((recipient) => !validRouteSelection(recipient, routeSelections[recipient.recipient_key])) ?? [],
[preview, routeSelections]
);
useEffect(() => { useEffect(() => {
if (filteredSources.some((source) => source.id === selectedSourceId)) return; if (filteredSources.some((source) => source.id === selectedSourceId)) return;
@@ -81,6 +96,7 @@ export default function DistributionListImportDialog({
if (!selectedSource) { if (!selectedSource) {
setParameters({}); setParameters({});
setPreview(null); setPreview(null);
setRouteSelections({});
return; return;
} }
setParameters(Object.fromEntries( setParameters(Object.fromEntries(
@@ -89,6 +105,8 @@ export default function DistributionListImportDialog({
.map((parameter) => [parameter.key, parameter.default]) .map((parameter) => [parameter.key, parameter.default])
)); ));
setPreview(null); setPreview(null);
setRouteSelections({});
setPreviewPage(1);
setError(""); setError("");
}, [selectedSource?.id, selectedSource?.revision_id]); }, [selectedSource?.id, selectedSource?.revision_id]);
@@ -97,11 +115,13 @@ export default function DistributionListImportDialog({
? [...new Set([...current, channel])] ? [...new Set([...current, channel])]
: current.filter((item) => item !== channel)); : current.filter((item) => item !== channel));
setPreview(null); setPreview(null);
setRouteSelections({});
} }
function updateParameter(parameter: CampaignDistributionListParameter, value: unknown) { function updateParameter(parameter: CampaignDistributionListParameter, value: unknown) {
setParameters((current) => ({ ...current, [parameter.key]: normalizeParameterValue(parameter, value) })); setParameters((current) => ({ ...current, [parameter.key]: normalizeParameterValue(parameter, value) }));
setPreview(null); setPreview(null);
setRouteSelections({});
} }
function requestPayload(idempotencyKey?: string): CampaignDistributionListExpansionInput { function requestPayload(idempotencyKey?: string): CampaignDistributionListExpansionInput {
@@ -120,7 +140,10 @@ export default function DistributionListImportDialog({
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
setPreview(await previewCampaignRecipientDistributionList(settings, campaignId, requestPayload())); const result = await previewCampaignRecipientDistributionList(settings, campaignId, requestPayload());
setPreview(result);
setRouteSelections(defaultRouteSelections(result.recipients));
setPreviewPage(1);
} catch (reason) { } catch (reason) {
setPreview(null); setPreview(null);
setError(reason instanceof Error ? reason.message : String(reason)); setError(reason instanceof Error ? reason.message : String(reason));
@@ -140,7 +163,15 @@ export default function DistributionListImportDialog({
campaignId, campaignId,
requestPayload(idempotencyKey) requestPayload(idempotencyKey)
); );
onImport(snapshot, mode); const unresolved = snapshot.recipients.filter(
(recipient) => !validRouteSelection(recipient, routeSelections[recipient.recipient_key])
);
if (unresolved.length > 0) {
setPreview(snapshot);
setError(`${unresolved.length} recipient route${unresolved.length === 1 ? "" : "s"} must be selected again because the frozen expansion changed.`);
return;
}
onImport(snapshot, mode, routeSelections);
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason)); setError(reason instanceof Error ? reason.message : String(reason));
} finally { } finally {
@@ -162,7 +193,7 @@ export default function DistributionListImportDialog({
<Button onClick={onCancel} disabled={loading}>Cancel</Button> <Button onClick={onCancel} disabled={loading}>Cancel</Button>
<Button <Button
variant="primary" variant="primary"
disabled={loading || !preview || preview.recipients.length === 0 || preview.truncated} disabled={loading || !preview || preview.recipients.length === 0 || preview.truncated || unresolvedRoutes.length > 0}
onClick={() => void freezeAndImport()} onClick={() => void freezeAndImport()}
> >
Freeze and import Freeze and import
@@ -269,6 +300,7 @@ export default function DistributionListImportDialog({
<div><dt>Included</dt><dd>{preview.recipients.length}</dd></div> <div><dt>Included</dt><dd>{preview.recipients.length}</dd></div>
<div><dt>Excluded</dt><dd>{preview.excluded.length}</dd></div> <div><dt>Excluded</dt><dd>{preview.excluded.length}</dd></div>
<div><dt>Providers</dt><dd>{preview.provider_evidence.length}</dd></div> <div><dt>Providers</dt><dd>{preview.provider_evidence.length}</dd></div>
<div><dt>Route decisions</dt><dd>{unresolvedRoutes.length ? `${unresolvedRoutes.length} required` : "Complete"}</dd></div>
<div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div> <div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div>
</dl> </dl>
{preview.stale && ( {preview.stale && (
@@ -281,6 +313,11 @@ export default function DistributionListImportDialog({
The expansion reached a safety limit and cannot be frozen from this dialog. The expansion reached a safety limit and cannot be frozen from this dialog.
</DismissibleAlert> </DismissibleAlert>
)} )}
{unresolvedRoutes.length > 0 && (
<DismissibleAlert tone="warning" compact dismissible={false}>
Select one delivery route for every included recipient. A fallback is optional and is only used when the primary channel rejects before accepting the delivery.
</DismissibleAlert>
)}
{preview.diagnostics.map((diagnostic) => ( {preview.diagnostics.map((diagnostic) => (
<DismissibleAlert <DismissibleAlert
key={`${diagnostic.code}:${diagnostic.message}`} key={`${diagnostic.code}:${diagnostic.message}`}
@@ -291,10 +328,21 @@ export default function DistributionListImportDialog({
{diagnostic.message} {diagnostic.message}
</DismissibleAlert> </DismissibleAlert>
))} ))}
<DistributionPreviewGrid rows={previewRows.slice(0, 100)} /> <DistributionPreviewGrid
{previewRows.length > 100 && ( rows={previewRows}
<p className="muted small-note">{previewRows.length - 100} more decisions are included in the frozen evidence.</p> routeSelections={routeSelections}
)} onRouteChange={(recipientKey, selection) => setRouteSelections((current) => ({
...current,
[recipientKey]: selection
}))}
page={previewPage}
pageSize={previewPageSize}
onPageChange={setPreviewPage}
onPageSizeChange={(pageSize) => {
setPreviewPageSize(pageSize);
setPreviewPage(1);
}}
/>
</> </>
)} )}
</Dialog> </Dialog>
@@ -351,11 +399,62 @@ function DistributionParameterField({
); );
} }
function DistributionPreviewGrid({ rows }: {rows: PreviewRow[];}) { function DistributionPreviewGrid({
rows,
routeSelections,
onRouteChange,
page,
pageSize,
onPageChange,
onPageSizeChange
}: {
rows: PreviewRow[];
routeSelections: DistributionRouteSelections;
onRouteChange: (recipientKey: string, selection: DistributionRouteSelection) => void;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}) {
const columns: DataGridColumn<PreviewRow>[] = [ const columns: DataGridColumn<PreviewRow>[] = [
{ id: "name", header: "Recipient", width: "minmax(180px, 1fr)", value: (row) => row.display_name || row.recipient_key }, { id: "name", header: "Recipient", width: "minmax(180px, 1fr)", value: (row) => row.display_name || row.recipient_key },
{ id: "result", header: "Decision", width: 120, value: (row) => row.included ? "Included" : row.status }, { id: "result", header: "Decision", width: 120, value: (row) => row.included ? "Included" : row.status },
{ id: "channels", header: "Usable channels", width: "minmax(160px, 0.8fr)", value: (row) => usableChannelSummary(row.channels) }, {
id: "primary",
header: "Primary route",
width: "minmax(210px, 1fr)",
render: (row) => (
<RouteSelect
row={row}
value={routeSelections[row.recipient_key]?.primaryTargetKey ?? ""}
onChange={(value) => onRouteChange(row.recipient_key, {
primaryTargetKey: value,
fallbackTargetKey: ""
})}
/>
),
filterValue: (row) => usableChannelSummary(row.channels)
},
{
id: "fallback",
header: "Fallback",
width: "minmax(210px, 1fr)",
render: (row) => {
const selection = routeSelections[row.recipient_key];
return (
<RouteSelect
row={row}
value={selection?.fallbackTargetKey ?? ""}
primaryTargetKey={selection?.primaryTargetKey ?? ""}
fallback
onChange={(value) => onRouteChange(row.recipient_key, {
primaryTargetKey: selection?.primaryTargetKey ?? "",
fallbackTargetKey: value
})}
/>
);
}
},
{ id: "source", header: "Source entries", width: "minmax(160px, 0.8fr)", value: (row) => row.source_entry_ids.join(", ") }, { id: "source", header: "Source entries", width: "minmax(160px, 0.8fr)", value: (row) => row.source_entry_ids.join(", ") },
{ id: "reason", header: "Explanation", width: "minmax(220px, 1.2fr)", value: (row) => row.explanations.map((item) => item.message).join(" · ") } { id: "reason", header: "Explanation", width: "minmax(220px, 1.2fr)", value: (row) => row.explanations.map((item) => item.message).join(" · ") }
]; ];
@@ -367,10 +466,87 @@ function DistributionPreviewGrid({ rows }: {rows: PreviewRow[];}) {
getRowKey={(row) => `${row.included ? "included" : "excluded"}:${row.recipient_key}`} getRowKey={(row) => `${row.included ? "included" : "excluded"}:${row.recipient_key}`}
emptyText="No recipients resolved from this Distribution List." emptyText="No recipients resolved from this Distribution List."
className="recipient-table-wrap" className="recipient-table-wrap"
pagination={{
mode: "client",
page,
pageSize,
pageSizeOptions: [25, 50, 100, 250],
onPageChange,
onPageSizeChange
}}
/> />
); );
} }
function RouteSelect({
row,
value,
primaryTargetKey = "",
fallback = false,
onChange
}: {
row: PreviewRow;
value: string;
primaryTargetKey?: string;
fallback?: boolean;
onChange: (value: string) => void;
}) {
const usable = row.channels.filter((candidate) => candidate.status === "usable");
const primary = usable.find((candidate) => candidate.target_key === primaryTargetKey) ?? null;
const options = fallback ? usable.filter((candidate) => fallbackAllowed(primary?.channel, candidate.channel)) : usable;
return (
<select
value={value}
disabled={!row.included || (fallback && !primary)}
aria-label={`${fallback ? "Fallback" : "Primary route"} for ${row.display_name || row.recipient_key}`}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{fallback ? "No fallback" : "Select route"}</option>
{options.map((candidate) => (
<option key={candidate.target_key} value={candidate.target_key}>
{routeLabel(candidate.channel)}: {candidate.target}{candidate.preferred ? " (preferred)" : ""}
</option>
))}
</select>
);
}
function defaultRouteSelections(recipients: CampaignDistributionRecipient[]): DistributionRouteSelections {
return Object.fromEntries(recipients.map((recipient) => {
const usable = recipient.channels.filter((candidate) => candidate.status === "usable");
const preferred = usable.filter((candidate) => candidate.preferred);
const primary = preferred.length === 1 ? preferred[0] : usable.length === 1 ? usable[0] : null;
return [recipient.recipient_key, { primaryTargetKey: primary?.target_key ?? "", fallbackTargetKey: "" }];
}));
}
function validRouteSelection(
recipient: CampaignDistributionRecipient,
selection: DistributionRouteSelection | undefined
): boolean {
if (!selection?.primaryTargetKey) return false;
const usable = recipient.channels.filter((candidate) => candidate.status === "usable");
const primary = usable.find((candidate) => candidate.target_key === selection.primaryTargetKey);
if (!primary) return false;
if (!selection.fallbackTargetKey) return true;
const fallback = usable.find((candidate) => candidate.target_key === selection.fallbackTargetKey);
return Boolean(fallback && fallbackAllowed(primary.channel, fallback.channel));
}
function fallbackAllowed(primary: string | undefined, fallback: string): boolean {
if (primary === "email") return fallback === "portal" || fallback === "postal" || fallback === "internal_mail";
if (primary === "portal") return fallback === "email" || fallback === "postal" || fallback === "internal_mail";
return false;
}
function routeLabel(channel: string): string {
if (channel === "email") return "Mail";
if (channel === "portal") return "Postbox";
if (channel === "internal_mail") return "Internal mail";
if (channel === "postal") return "Postal";
return channel;
}
function normalizeParameterValue(parameter: CampaignDistributionListParameter, value: unknown): unknown { function normalizeParameterValue(parameter: CampaignDistributionListParameter, value: unknown): unknown {
if (value === "" || value === null || value === undefined) return null; if (value === "" || value === null || value === undefined) return null;
if (parameter.value_type === "integer") return Number.parseInt(String(value), 10); if (parameter.value_type === "integer") return Number.parseInt(String(value), 10);
@@ -36,6 +36,7 @@ export type RecipientProfileColumnContext = {
locked: boolean; locked: boolean;
filesModuleInstalled: boolean; filesModuleInstalled: boolean;
postboxModuleInstalled: boolean; postboxModuleInstalled: boolean;
templatesModuleInstalled: boolean;
postboxCatalog: CampaignPostboxCatalog; postboxCatalog: CampaignPostboxCatalog;
entries: Record<string, unknown>[]; entries: Record<string, unknown>[];
fieldDefinitions: ReturnType<typeof getDraftFields>; fieldDefinitions: ReturnType<typeof getDraftFields>;
@@ -53,7 +54,7 @@ export type RecipientProfileColumnContext = {
removeEntry: (index: number) => void; removeEntry: (index: number) => void;
}; };
export function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, addressFilter, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] { export function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, templatesModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, addressFilter, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [ return [
{ {
id: "number", id: "number",
@@ -109,7 +110,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
value: recipientAddressFilterValue 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" }, { 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 ? [{ ...(postboxModuleInstalled || templatesModuleInstalled || entries.some((entry) => Boolean(entry.channel_policy || entry.print_target || normalizePostboxTargets(entry.postbox_targets).length)) ? [{
id: "delivery", id: "delivery",
header: "Delivery", header: "Delivery",
width: "minmax(260px, 0.9fr)", width: "minmax(260px, 0.9fr)",
@@ -118,6 +119,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
filterable: true, filterable: true,
render: (entry, index) => { render: (entry, index) => {
const targets = normalizePostboxTargets(entry.postbox_targets); const targets = normalizePostboxTargets(entry.postbox_targets);
const printTarget = asRecord(entry.print_target);
return ( return (
<div className="campaign-recipient-delivery-cell"> <div className="campaign-recipient-delivery-cell">
<select <select
@@ -133,21 +135,31 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
> >
<option value="">Campaign default</option> <option value="">Campaign default</option>
<option value="mail">Mail</option> <option value="mail">Mail</option>
<option value="postbox">Postbox</option> <option value="postbox" disabled={!postboxModuleInstalled}>Postbox</option>
<option value="mail_and_postbox">Mail and Postbox</option> <option value="print" disabled={!templatesModuleInstalled}>Print</option>
<option value="mail_then_postbox">Mail, then Postbox fallback</option> <option value="mail_and_postbox" disabled={!postboxModuleInstalled}>Mail and Postbox</option>
<option value="postbox_then_mail">Postbox, then Mail fallback</option> <option value="mail_then_postbox" disabled={!postboxModuleInstalled}>Mail, then Postbox fallback</option>
<option value="postbox_then_mail" disabled={!postboxModuleInstalled}>Postbox, then Mail fallback</option>
<option value="mail_then_print" disabled={!templatesModuleInstalled}>Mail, then print fallback</option>
<option value="postbox_then_print" disabled={!postboxModuleInstalled || !templatesModuleInstalled}>Postbox, then print fallback</option>
</select> </select>
<Button {postboxModuleInstalled && (
disabled={locked || !postboxCatalog.available} <Button
onClick={() => openPostboxTargetEditor(index)} disabled={locked || !postboxCatalog.available}
> onClick={() => openPostboxTargetEditor(index)}
Postboxes ({targets.length}) >
</Button> Postboxes ({targets.length})
</Button>
)}
{printTarget.target && (
<span className="muted small-note" title={String(printTarget.target)}>
{printTarget.channel === "internal_mail" ? "Internal mail" : "Postal"}: {String(printTarget.target)}
</span>
)}
</div> </div>
); );
}, },
value: (entry) => `${String(entry.channel_policy ?? "default")} ${normalizePostboxTargets(entry.postbox_targets).map((target) => target.label ?? target.postbox_id ?? target.template_id ?? "").join(" ")}` value: (entry) => `${String(entry.channel_policy ?? "default")} ${normalizePostboxTargets(entry.postbox_targets).map((target) => target.label ?? target.postbox_id ?? target.template_id ?? "").join(" ")} ${String(asRecord(entry.print_target).target ?? "")}`
} as DataGridColumn<Record<string, unknown>>] : []), } as DataGridColumn<Record<string, unknown>>] : []),
...(individualAttachmentBasePaths.length > 0 ? [{ ...(individualAttachmentBasePaths.length > 0 ? [{
id: "attachments", id: "attachments",
@@ -61,17 +61,31 @@ export type DistributionListExpansionSnapshot = {
truncated: boolean; truncated: boolean;
}; };
export type DistributionRouteSelection = {
primaryTargetKey: string;
fallbackTargetKey?: string;
};
export type DistributionRouteSelections = Record<string, DistributionRouteSelection>;
export function materializeDistributionListExpansion( export function materializeDistributionListExpansion(
draft: JsonRecord, draft: JsonRecord,
expansion: DistributionListExpansionSnapshot, expansion: DistributionListExpansionSnapshot,
mode: RecipientImportMode mode: RecipientImportMode,
routeSelections?: DistributionRouteSelections
): JsonRecord { ): JsonRecord {
const currentEntries = asRecord(draft.entries); const currentEntries = asRecord(draft.entries);
const existingEntries = asArray(currentEntries.inline).map(asRecord); const existingEntries = asArray(currentEntries.inline).map(asRecord);
const usedIds = new Set(existingEntries.map((entry) => text(entry.id)).filter(Boolean)); const usedIds = new Set(existingEntries.map((entry) => text(entry.id)).filter(Boolean));
const fieldNames = new Set<string>(); const fieldNames = new Set<string>();
const importedEntries = expansion.recipients.map((recipient) => { const importedEntries = expansion.recipients.map((recipient) => {
const entry = recipientEntry(expansion, recipient, usedIds); const entry = recipientEntry(
expansion,
recipient,
usedIds,
routeSelections?.[recipient.recipient_key],
routeSelections !== undefined
);
Object.keys(asRecord(entry.fields)).forEach((name) => fieldNames.add(name)); Object.keys(asRecord(entry.fields)).forEach((name) => fieldNames.add(name));
return entry; return entry;
}); });
@@ -145,26 +159,42 @@ export function distributionListDrift(
function recipientEntry( function recipientEntry(
expansion: DistributionListExpansionSnapshot, expansion: DistributionListExpansionSnapshot,
recipient: DistributionRecipientSnapshot, recipient: DistributionRecipientSnapshot,
usedIds: Set<string> usedIds: Set<string>,
routeSelection?: DistributionRouteSelection,
explicitRouting = false
): JsonRecord { ): JsonRecord {
const usableChannels = recipient.channels.filter((candidate) => candidate.status === "usable"); const usableChannels = recipient.channels.filter((candidate) => candidate.status === "usable");
const preferredChannels = usableChannels.filter((candidate) => candidate.preferred); const selectedRoute = routeSelection
const selectedRoute = preferredChannels.length === 1 ? usableChannels.find((candidate) => candidate.target_key === routeSelection.primaryTargetKey) ?? null
? preferredChannels[0] : explicitRouting
: usableChannels.length === 1 ? null
? usableChannels[0] : inferredRoute(usableChannels);
: null; const fallbackRoute = routeSelection?.fallbackTargetKey
const email = selectedRoute?.channel === "email" ? selectedRoute.target.trim() : ""; ? usableChannels.find((candidate) => candidate.target_key === routeSelection.fallbackTargetKey) ?? null
: null;
const emailRoute = [selectedRoute, fallbackRoute].find((candidate) => candidate?.channel === "email") ?? null;
const postboxRoute = explicitRouting
? [selectedRoute, fallbackRoute].find((candidate) => candidate?.channel === "portal") ?? null
: null;
const printRoute = explicitRouting
? [selectedRoute, fallbackRoute].find((candidate) => isPrintChannel(candidate?.channel)) ?? null
: null;
const email = emailRoute?.target.trim() ?? "";
const fields = stringFields(recipient.attributes); const fields = stringFields(recipient.attributes);
const routeReason = selectedRoute const routeReason = selectedRoute
? (selectedRoute.preferred ? "preferred_channel" : "single_usable_channel") ? (routeSelection ? "explicit_user_selection" : selectedRoute.preferred ? "preferred_channel" : "single_usable_channel")
: usableChannels.length > 1 : usableChannels.length > 1
? "explicit_route_required" ? "explicit_route_required"
: "no_usable_channel"; : "no_usable_channel";
const channelPolicy = explicitRouting
? routePolicy(selectedRoute, fallbackRoute)
: selectedRoute?.channel === "email"
? "mail"
: null;
return { return {
id: uniqueRecipientId(recipient.recipient_key, usedIds), id: uniqueRecipientId(recipient.recipient_key, usedIds),
active: recipient.status === "usable" && Boolean(email), active: recipient.status === "usable" && Boolean(selectedRoute && channelPolicy),
name: recipient.display_name, name: recipient.display_name,
email, email,
from: [], from: [],
@@ -176,6 +206,10 @@ function recipientEntry(
merge_cc: true, merge_cc: true,
merge_bcc: true, merge_bcc: true,
merge_reply_to: true, merge_reply_to: true,
channel_policy: channelPolicy,
postbox_targets: postboxRoute ? [postboxTarget(postboxRoute, recipient.display_name)] : [],
merge_postbox_targets: false,
print_target: printRoute ? printTarget(printRoute) : null,
fields, fields,
attachments: [], attachments: [],
combine_attachments: true, combine_attachments: true,
@@ -196,9 +230,7 @@ function recipientEntry(
function_id: recipient.function_id ?? null, function_id: recipient.function_id ?? null,
channels: recipient.channels, channels: recipient.channels,
selected_route: selectedRoute, selected_route: selectedRoute,
fallback_routes: selectedRoute fallback_routes: fallbackRoute ? [fallbackRoute] : [],
? usableChannels.filter((candidate) => candidate.target_key !== selectedRoute.target_key)
: [],
route_reason: routeReason, route_reason: routeReason,
explanations: recipient.explanations, explanations: recipient.explanations,
attributes: recipient.attributes, attributes: recipient.attributes,
@@ -207,6 +239,54 @@ function recipientEntry(
}; };
} }
function inferredRoute(usableChannels: DistributionChannelCandidateSnapshot[]): DistributionChannelCandidateSnapshot | null {
const preferredChannels = usableChannels.filter((candidate) => candidate.preferred);
if (preferredChannels.length === 1) return preferredChannels[0];
return usableChannels.length === 1 ? usableChannels[0] : null;
}
function routePolicy(
primary: DistributionChannelCandidateSnapshot | null,
fallback: DistributionChannelCandidateSnapshot | null
): string | null {
if (!primary) return null;
if (primary.channel === "email") {
if (fallback?.channel === "portal") return "mail_then_postbox";
if (isPrintChannel(fallback?.channel)) return "mail_then_print";
return fallback ? null : "mail";
}
if (primary.channel === "portal") {
if (fallback?.channel === "email") return "postbox_then_mail";
if (isPrintChannel(fallback?.channel)) return "postbox_then_print";
return fallback ? null : "postbox";
}
return isPrintChannel(primary.channel) && !fallback ? "print" : null;
}
function isPrintChannel(channel: string | undefined): channel is "postal" | "internal_mail" {
return channel === "postal" || channel === "internal_mail";
}
function postboxTarget(candidate: DistributionChannelCandidateSnapshot, label: string): JsonRecord {
return {
id: `distribution-${safeId(candidate.target_key).slice(0, 95)}`,
mode: "direct",
label: label || candidate.target,
address_key: candidate.target
};
}
function printTarget(candidate: DistributionChannelCandidateSnapshot): JsonRecord {
return {
channel: candidate.channel,
target: candidate.target,
target_key: candidate.target_key,
contact_point_id: candidate.contact_point_id ?? null,
locale: candidate.locale ?? null,
decision_provenance: candidate.decision_provenance ?? {}
};
}
function distributionListImportProvenance( function distributionListImportProvenance(
expansion: DistributionListExpansionSnapshot, expansion: DistributionListExpansionSnapshot,
mode: RecipientImportMode, mode: RecipientImportMode,
@@ -6,6 +6,7 @@ export type CampaignJobSortColumn =
| "queue" | "queue"
| "send" | "send"
| "postbox" | "postbox"
| "print"
| "imap" | "imap"
| "attempts" | "attempts"
| "updated"; | "updated";
@@ -33,6 +34,7 @@ const FILTER_PARAMETERS: Record<string, string> = {
queue: "filter_queue", queue: "filter_queue",
send: "filter_send", send: "filter_send",
postbox: "filter_postbox", postbox: "filter_postbox",
print: "filter_print",
imap: "filter_imap", imap: "filter_imap",
attempts: "filter_attempts", attempts: "filter_attempts",
evidence: "filter_evidence" evidence: "filter_evidence"
@@ -8,6 +8,8 @@ export const DEFAULT_REPORT_GRID_SORT = { columnId: "number", direction: "asc" a
export type ReportGridShortcutId = export type ReportGridShortcutId =
| "all" | "all"
| "smtp_accepted" | "smtp_accepted"
| "postbox_accepted"
| "print_accepted"
| "failed" | "failed"
| "outcome_unknown" | "outcome_unknown"
| "not_attempted" | "not_attempted"
@@ -20,6 +22,8 @@ export type ReportGridShortcutId =
const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = { const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = {
all: {}, all: {},
smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) }, smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) },
postbox_accepted: { send: listFilter(["postbox_accepted"]) },
print_accepted: { send: listFilter(["print_accepted"]) },
failed: { send: listFilter(["failed_temporary", "failed_permanent"]) }, failed: { send: listFilter(["failed_temporary", "failed_permanent"]) },
outcome_unknown: { send: listFilter(["outcome_unknown"]) }, outcome_unknown: { send: listFilter(["outcome_unknown"]) },
not_attempted: { send: listFilter(["not_queued"]) }, not_attempted: { send: listFilter(["not_queued"]) },
@@ -216,6 +216,7 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
<div className="dashboard-grid"> <div className="dashboard-grid">
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" /> <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="Postbox accepted" value={countValue(outcomes.postbox_accepted)} tone="good" />
<MetricCard label="Print accepted" value={countValue(outcomes.print_accepted)} tone="good" />
<MetricCard label="Both channels accepted" value={countValue(outcomes.delivered)} 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="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.failed.09fef5d8" value={countValue(outcomes.failed)} tone="danger" />
+18
View File
@@ -237,6 +237,24 @@ assert(distributionImports[0].source_type === "distribution_list", "Campaign sto
assert((asRecord(distributionImports[0].source_provenance).exclusions as unknown[]).length === 1, "excluded recipients remain in immutable import evidence"); assert((asRecord(distributionImports[0].source_provenance).exclusions as unknown[]).length === 1, "excluded recipients remain in immutable import evidence");
assert(distributionListDrift(distributionEntries.imports, [{ id: "list-1", revision: 4, revision_id: "revision-4", definition_hash: "definition-hash-4" }]).length === 1, "list revision drift is detected without changing frozen recipients"); assert(distributionListDrift(distributionEntries.imports, [{ id: "list-1", revision: 4, revision_id: "revision-4", definition_hash: "definition-hash-4" }]).length === 1, "list revision drift is detected without changing frozen recipients");
const explicitlyRoutedDraft = materializeDistributionListExpansion(
draft,
distributionExpansion,
"replace",
{
"contact:ada": {
primaryTargetKey: "email:ada@example.org",
fallbackTargetKey: "postal:ada"
},
"contact:postal": { primaryTargetKey: "postal:only" }
}
);
const explicitlyRoutedEntries = asRecord(explicitlyRoutedDraft.entries).inline as Record<string, unknown>[];
assert(explicitlyRoutedEntries[0].channel_policy === "mail_then_print", "an explicit postal fallback is frozen into the delivery policy");
assert(asRecord(explicitlyRoutedEntries[0].print_target).target_key === "postal:ada", "the exact printable fallback target is retained");
assert(explicitlyRoutedEntries[1].active === true && explicitlyRoutedEntries[1].channel_policy === "print", "print-only recipients remain active without a Mail address");
assert(asRecord(explicitlyRoutedEntries[1].distribution_source).route_reason === "explicit_user_selection", "route provenance records the user decision");
void runXlsxImportAssertions(); void runXlsxImportAssertions();
async function runXlsxImportAssertions(): Promise<void> { async function runXlsxImportAssertions(): Promise<void> {