fix(campaign): sanitize synchronous send results

This commit is contained in:
2026-07-22 09:21:44 +02:00
parent 1225802c5d
commit f095a3e2c7
3 changed files with 241 additions and 5 deletions

View File

@@ -40,6 +40,51 @@ CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS = frozenset(
}
)
_SEND_NOW_RESULT_KEYS = (
"campaign_id",
"version_id",
"attempted_count",
"sent_count",
"failed_count",
"outcome_unknown_count",
"skipped_count",
"preflight_count",
"delivery_mode",
"dry_run",
)
_SEND_NOW_JOB_RESULT_KEYS = (
"campaign_id",
"version_id",
"job_id",
"status",
"attempt_number",
"dry_run",
"queued_count",
"skipped_count",
"blocked_count",
"enqueued_count",
"delivery_mode",
"worker_queue_available",
)
_SYNCHRONOUS_POLICY_KEYS = (
"max_recipient_jobs",
"source",
"deployment_max_recipient_jobs",
"tenant_max_recipient_jobs",
)
_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count")
_BUILD_SUMMARY_KEYS = (
"built_count",
"build_failed_count",
"ready_count",
"warning_count",
"needs_review_count",
"blocked_count",
"excluded_count",
"inactive_count",
"queueable_count",
)
def public_campaign_payload(value: Any, *, include_diagnostics: bool = False) -> Any:
"""Return a detached payload without infrastructure-only locators."""
@@ -83,6 +128,59 @@ def public_delivery_result_message(
return "Delivery recorded a warning; an operator can inspect restricted diagnostics."
def public_send_campaign_now_result(
value: dict[str, Any],
*,
validation_summary: dict[str, Any],
build_summary: dict[str, Any],
) -> dict[str, Any]:
"""Project synchronous delivery into its recipient-authorized public contract.
Per-job provider messages are deliberately omitted. They can contain SMTP
diagnostics or refused envelope addresses and belong only in restricted
diagnostics backed by persisted job state.
"""
result = _selected_payload(value, _SEND_NOW_RESULT_KEYS)
policy = value.get("synchronous_send_policy")
result["synchronous_send_policy"] = _selected_payload(
policy if isinstance(policy, dict) else {},
_SYNCHRONOUS_POLICY_KEYS,
)
rows = value.get("results")
if isinstance(rows, list):
result["results"] = [
_selected_payload(row, _SEND_NOW_JOB_RESULT_KEYS)
for row in rows
if isinstance(row, dict)
]
else:
result["results"] = []
result["validation"] = _selected_payload(validation_summary, _VALIDATION_SUMMARY_KEYS)
result["build"] = _selected_payload(build_summary, _BUILD_SUMMARY_KEYS)
return result
def send_campaign_now_audit_details(value: dict[str, Any]) -> dict[str, Any]:
"""Return aggregate-only evidence for a synchronous Campaign send audit."""
details = _selected_payload(value, _SEND_NOW_RESULT_KEYS)
policy = value.get("synchronous_send_policy")
details["synchronous_send_policy"] = _selected_payload(
policy if isinstance(policy, dict) else {},
_SYNCHRONOUS_POLICY_KEYS,
)
return details
def _selected_payload(value: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
return {
key: copy.deepcopy(value[key])
for key in keys
if key in value
}
def public_campaign_configuration(value: Any) -> Any:
"""Return campaign JSON without infrastructure locators or mail secrets.

View File

@@ -117,6 +117,8 @@ from govoplan_campaign.backend.reports.aggregate import (
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
public_delivery_result_message,
public_send_campaign_now_result,
send_campaign_now_audit_details,
)
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from govoplan_campaign.backend.persistence.campaigns import (
@@ -3580,7 +3582,7 @@ def send_campaign_now_endpoint(
detail="Campaign version must be built before dry-run or sending.",
)
result = send_campaign_now(
delivery_result = send_campaign_now(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
@@ -3590,18 +3592,21 @@ def send_campaign_now_endpoint(
use_rate_limit=payload.use_rate_limit,
enqueue_imap_task=payload.enqueue_imap_task,
).as_dict()
result["validation"] = validation_result
result["build"] = build_result
response_result = public_send_campaign_now_result(
delivery_result,
validation_summary=validation_result,
build_summary=build_result,
)
audit_from_principal(
session,
principal,
action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
details=send_campaign_now_audit_details(delivery_result),
commit=True,
)
return SendCampaignNowResponse(result=result)
return SendCampaignNowResponse(result=response_result)
except SynchronousSendRejected as exc:
audit_from_principal(
session,

View File

@@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock, patch
from govoplan_campaign.backend import router
from govoplan_campaign.backend.schemas import SendCampaignNowRequest
from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult
def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() -> None:
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
version = SimpleNamespace(
id="version-1",
raw_json={},
locked_at=datetime(2026, 7, 22, tzinfo=UTC),
validation_summary={
"ok": True,
"error_count": 0,
"warning_count": 1,
"issues": [{"message": "provider-secret validation detail"}],
},
build_summary={
"built_count": 2,
"build_failed_count": 0,
"ready_count": 2,
"warning_count": 1,
"messages": [{"recipient": "refused-recipient@example.test"}],
"build_token": "provider-secret-build-token",
},
)
delivery = SendCampaignNowResult(
campaign_id="campaign-1",
version_id="version-1",
attempted_count=2,
sent_count=1,
failed_count=1,
outcome_unknown_count=0,
skipped_count=0,
preflight_count=2,
synchronous_send_policy={
"max_recipient_jobs": 25,
"source": "deployment_default",
"deployment_max_recipient_jobs": 25,
"tenant_max_recipient_jobs": None,
"provider_diagnostic": "provider-secret-policy",
},
results=[
{
"job_id": "job-failed",
"status": "failed",
"attempt_number": 1,
"message": "provider-secret smtp.internal.example",
"recipient_email": "private@example.test",
},
{
"job_id": "job-refused",
"status": "smtp_accepted",
"attempt_number": 1,
"message": (
"SMTP accepted 1/2 envelope recipient(s); refused recipients: "
'{"refused-recipient@example.test": [550, "not allowed"]}'
),
},
],
)
principal = SimpleNamespace(tenant_id="tenant-1", user=SimpleNamespace(id="user-1"), api_key=None)
audit = Mock()
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_require_permission"),
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
patch.object(router, "_get_version_for_tenant", return_value=version),
patch.object(router, "_require_mail_profile_use_if_needed"),
patch.object(router, "is_user_locked_version", return_value=False),
patch.object(router, "send_campaign_now", return_value=delivery),
patch.object(router, "audit_from_principal", audit),
):
response = router.send_campaign_now_endpoint(
"campaign-1",
SendCampaignNowRequest(),
session=Mock(),
principal=principal, # type: ignore[arg-type]
)
payload = response.model_dump(mode="json")["result"]
assert payload["results"] == [
{"job_id": "job-failed", "status": "failed", "attempt_number": 1},
{"job_id": "job-refused", "status": "smtp_accepted", "attempt_number": 1},
]
assert payload["validation"] == {"ok": True, "error_count": 0, "warning_count": 1}
assert payload["build"] == {
"built_count": 2,
"build_failed_count": 0,
"ready_count": 2,
"warning_count": 1,
}
assert payload["synchronous_send_policy"] == {
"max_recipient_jobs": 25,
"source": "deployment_default",
"deployment_max_recipient_jobs": 25,
"tenant_max_recipient_jobs": None,
}
audit_details = audit.call_args.kwargs["details"]
assert set(audit_details) == {
"campaign_id",
"version_id",
"attempted_count",
"sent_count",
"failed_count",
"outcome_unknown_count",
"skipped_count",
"preflight_count",
"delivery_mode",
"dry_run",
"synchronous_send_policy",
}
assert "results" not in audit_details
assert "validation" not in audit_details
assert "build" not in audit_details
serialized = repr({"response": payload, "audit": audit_details})
for forbidden in (
"provider-secret",
"smtp.internal.example",
"private@example.test",
"refused-recipient@example.test",
"not allowed",
):
assert forbidden not in serialized