feat: add governed postbox delivery and report hardening

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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