diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index 0765251..6418887 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -456,7 +456,7 @@ CAMPAIGN_USER_DOCUMENTATION = ( topic_id="campaigns.workflow.route-unassigned-files", title="Review and route unassigned campaign files", summary="Turn files left in a watched source into an explicit report or reviewed attachment message instead of silently overlooking them.", - body="Campaign already compares watched attachment sources with the exact files assigned to built recipient messages. An optional residual-file disposition turns the remaining set into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.", + body="Campaign compares watched attachment sources with the exact files assigned to built recipient messages. The configurable validation behavior can block, require review, or explicitly ignore the remaining set. An optional residual-file disposition instead turns it into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The normalized action, observed file and source counts, routing mode, and configured recipient are visible in build review and retained in the campaign protocol; the audit event records the same policy and counts without copying the recipient address. A routed row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.", order=35, audience=("campaign_manager", "campaign_author", "campaign_reviewer"), required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"), @@ -481,7 +481,7 @@ CAMPAIGN_USER_DOCUMENTATION = ( "de": { "title": "Nicht zugeordnete Kampagnendateien prüfen und weiterleiten", "summary": "Übrig gebliebene Dateien aus überwachten Quellen ausdrücklich melden oder als geprüfte Nachricht vorbereiten, statt sie unbemerkt zu übergehen.", - "body": "Campaign vergleicht überwachte Anhangsquellen bereits mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Eine optionale Restdatei-Behandlung erzeugt aus der verbleibenden Menge eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.", + "body": "Campaign vergleicht überwachte Anhangsquellen mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Das konfigurierbare Validierungsverhalten kann die Restmenge sperren, zur Prüfung vorlegen oder ausdrücklich ignorieren. Eine optionale Restdatei-Behandlung erzeugt stattdessen eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die normalisierte Aktion, Datei- und Quellenanzahl, Routingart und der konfigurierte Empfänger sind in der Build-Prüfung sichtbar und bleiben im Kampagnenprotokoll erhalten; das Audit-Ereignis speichert Richtlinie und Anzahlen ohne die Empfängeradresse zu kopieren. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.", "outcome": "Jede überwachte Datei ist zugeordnet, bewusst gemeldet oder durch einen sichtbaren Richtlinienbefund erfasst.", "verification": "Der Build enthält keine verborgene Restmenge: Er zeigt entweder den konfigurierten Warn- oder Sperrbefund oder eine zu prüfende Zeile mit Restdatei-Provenienz und dem konfigurierten Empfänger.", } diff --git a/src/govoplan_campaign/backend/messages/builder.py b/src/govoplan_campaign/backend/messages/builder.py index 8467336..a0e922e 100644 --- a/src/govoplan_campaign/backend/messages/builder.py +++ b/src/govoplan_campaign/backend/messages/builder.py @@ -1103,6 +1103,39 @@ def _build_residual_file_message( ) +def _residual_file_disposition_evidence( + *, + config: CampaignConfig, + residual_groups: list[_ResidualFileGroup], +) -> dict[str, object]: + disposition = config.attachments.residual_files + behavior = config.validation_policy.unsent_attachment_files.value + if disposition.mode == ResidualFileMode.REPORT: + action = "route_report" + elif disposition.mode == ResidualFileMode.ATTACH: + action = "route_with_files" + elif behavior == Behavior.BLOCK.value: + action = "block" + elif behavior in {Behavior.CONTINUE.value, Behavior.DROP.value}: + action = "ignore" + else: + action = "review" + recipient = ( + disposition.recipient.model_dump(mode="json", by_alias=True) + if disposition.recipient is not None + else None + ) + return { + "contract_version": "1", + "action": action, + "routing_mode": disposition.mode.value, + "validation_behavior": behavior, + "watched_source_count": len(residual_groups), + "residual_file_count": sum(len(group.files) for group in residual_groups), + "recipient": recipient, + } + + def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None: if not issues: return @@ -1183,5 +1216,9 @@ def build_campaign_messages( duration_ms=(time.perf_counter() - started) * 1000, rules_resolved=rules_resolved, ), + residual_file_disposition=_residual_file_disposition_evidence( + config=config, + residual_groups=residual_groups, + ), ) return CampaignBuildResult(report=report, built_messages=built_messages) diff --git a/src/govoplan_campaign/backend/messages/models.py b/src/govoplan_campaign/backend/messages/models.py index cd5be4d..3997cbb 100644 --- a/src/govoplan_campaign/backend/messages/models.py +++ b/src/govoplan_campaign/backend/messages/models.py @@ -117,6 +117,7 @@ class CampaignBuildReport(BaseModel): inactive_entries_count: int = 0 messages: list[MessageDraft] = Field(default_factory=list) attachment_resolution_profile: dict[str, object] = Field(default_factory=dict) + residual_file_disposition: dict[str, object] = Field(default_factory=dict) @property def built_count(self) -> int: diff --git a/src/govoplan_campaign/backend/routes/versions.py b/src/govoplan_campaign/backend/routes/versions.py index 03659e9..9fe0417 100644 --- a/src/govoplan_campaign/backend/routes/versions.py +++ b/src/govoplan_campaign/backend/routes/versions.py @@ -876,6 +876,9 @@ def build_version( "write_eml": write_eml, "built_count": result.get("built_count"), "recovery_operation_id": recovery_start.operation_id, + "residual_file_disposition": _residual_file_audit_evidence( + result.get("residual_file_disposition") + ), }, commit=True, ) @@ -906,3 +909,19 @@ def build_version( raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc + + +def _residual_file_audit_evidence(value: object) -> dict[str, object]: + if not isinstance(value, dict): + return {} + return { + key: value.get(key) + for key in ( + "contract_version", + "action", + "routing_mode", + "validation_behavior", + "watched_source_count", + "residual_file_count", + ) + } diff --git a/tests/test_attachment_building.py b/tests/test_attachment_building.py index 543651e..d9efa4c 100644 --- a/tests/test_attachment_building.py +++ b/tests/test_attachment_building.py @@ -340,6 +340,22 @@ class CampaignAttachmentBuildTests(unittest.TestCase): ) self.assertEqual(len(result.report.messages), 2) + self.assertEqual( + { + "contract_version": "1", + "action": "route_report" if mode == "report" else "route_with_files", + "routing_mode": mode, + "validation_behavior": "block", + "watched_source_count": 1, + "residual_file_count": 1, + "recipient": { + "email": "operator@example.org", + "name": "Operator", + "type": "to", + }, + }, + result.report.residual_file_disposition, + ) normal, residual = result.report.messages self.assertEqual(normal.validation_status.value, "ready") self.assertEqual(residual.entry_id, "__residual_files__") @@ -361,6 +377,61 @@ class CampaignAttachmentBuildTests(unittest.TestCase): filenames = [part.get_filename() for part in mime.iter_attachments()] self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else []) + def test_residual_file_policy_evidence_normalizes_block_and_ignore(self) -> None: + for behavior, action in (("block", "block"), ("continue", "ignore")): + with self.subTest(behavior=behavior), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + watched = root / "watched" + watched.mkdir() + (watched / "residual.txt").write_text("residual", encoding="utf-8") + campaign_file = root / "campaign.json" + campaign_file.write_text("{}", encoding="utf-8") + config = CampaignConfig.model_validate({ + "version": "1.0", + "campaign": {"id": f"residual-{behavior}", "name": "Residual", "mode": "test"}, + "fields": [], + "global_values": {}, + "server": { + "mail_profile_id": "profile-1", + "profile_capabilities": {"smtp_available": True}, + }, + "recipients": { + "from": {"email": "sender@example.org", "type": "to"}, + "allow_individual_to": True, + }, + "template": {"subject": "Normal", "text": "Body"}, + "attachments": { + "base_paths": [{ + "id": "watched", + "name": "Watched folder", + "path": "watched", + "unsent_warning": True, + }], + }, + "entries": {"inline": [{ + "id": "recipient-1", + "to": [{"email": "recipient@example.org", "type": "to"}], + }]}, + "validation_policy": {"unsent_attachment_files": behavior}, + "delivery": {"imap_append_sent": {"enabled": False}}, + }) + + result = build_campaign_messages( + config, + campaign_file=campaign_file, + output_dir=root / "out", + write_eml=False, + ) + + self.assertEqual(action, result.report.residual_file_disposition["action"]) + self.assertEqual(1, result.report.residual_file_disposition["residual_file_count"]) + issue_codes = { + issue.code + for message in result.report.messages + for issue in message.issues + } + self.assertEqual(behavior == "block", "unsent_attachment_files" in issue_codes) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_campaign_build_recovery.py b/tests/test_campaign_build_recovery.py index 6a8ae60..53a0732 100644 --- a/tests/test_campaign_build_recovery.py +++ b/tests/test_campaign_build_recovery.py @@ -15,7 +15,10 @@ from govoplan_campaign.backend.persistence.campaigns import ( _verify_build_storage_manifest, _verify_storage_keys_absent, ) -from govoplan_campaign.backend.routes.versions import _campaign_build_recovery_plan +from govoplan_campaign.backend.routes.versions import ( + _campaign_build_recovery_plan, + _residual_file_audit_evidence, +) def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None: @@ -34,6 +37,25 @@ def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> ) +def test_residual_file_audit_evidence_omits_recipient_identity() -> None: + assert _residual_file_audit_evidence({ + "contract_version": "1", + "action": "route_report", + "routing_mode": "report", + "validation_behavior": "warn", + "watched_source_count": 2, + "residual_file_count": 3, + "recipient": {"email": "operator@example.org"}, + }) == { + "contract_version": "1", + "action": "route_report", + "routing_mode": "report", + "validation_behavior": "warn", + "watched_source_count": 2, + "residual_file_count": 3, + } + + def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None: storage = LocalFilesystemStorageBackend(tmp_path) payload = b"Message-ID: \r\n\r\nbody" diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx index c711f00..aad5cb4 100644 --- a/webui/src/features/campaigns/ReviewSendPage.tsx +++ b/webui/src/features/campaigns/ReviewSendPage.tsx @@ -158,6 +158,8 @@ export default function ReviewSendPage({ ); const validation = asRecord(version?.validation_summary); const build = asRecord(version?.build_summary); + const residualFileDisposition = asRecord(build.residual_file_disposition); + const residualFileRecipient = asRecord(residualFileDisposition.recipient); const printOutput = asRecord(build.print_output); const printArtifact = asRecord(printOutput.artifact); const summary = liveSummary ?? data.summary; @@ -1490,6 +1492,18 @@ export default function ReviewSendPage({ {hasBuild && } {hasBuild && }

i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170

+ {hasBuild && Object.keys(residualFileDisposition).length > 0 && ( +
+

i18n:govoplan-campaign.unassigned_file_policy

+ +
i18n:govoplan-campaign.action.97c89a4d
{humanize(String(residualFileDisposition.action ?? "review"))}
+
i18n:govoplan-campaign.validation_policy.57dcc756
{humanize(String(residualFileDisposition.validation_behavior ?? "warn"))}
+
i18n:govoplan-campaign.unassigned_files_detected
{String(residualFileDisposition.residual_file_count ?? 0)}
+
i18n:govoplan-campaign.watched_sources
{String(residualFileDisposition.watched_source_count ?? 0)}
+ {residualFileRecipient.email ?
i18n:govoplan-campaign.recipient.90343260
{String(residualFileRecipient.email)}
: null} +
+
+ )} {getText(printOutput, "render_id") && (
diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 9e87473..bffac32 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,9 @@ import type { PlatformTranslations } from "@govoplan/core-webui"; export const generatedTranslations: PlatformTranslations = { "en": { + "i18n:govoplan-campaign.unassigned_file_policy": "Unassigned file policy", + "i18n:govoplan-campaign.unassigned_files_detected": "Unassigned files detected", + "i18n:govoplan-campaign.watched_sources": "Watched sources", "i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Activate all ({value0})", "i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Deactivate all ({value0})", "i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Activate all recipients", @@ -1343,6 +1346,9 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto" }, "de": { + "i18n:govoplan-campaign.unassigned_file_policy": "Richtlinie für nicht zugeordnete Dateien", + "i18n:govoplan-campaign.unassigned_files_detected": "Erkannte nicht zugeordnete Dateien", + "i18n:govoplan-campaign.watched_sources": "Überwachte Quellen", "i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Alle aktivieren ({value0})", "i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Alle deaktivieren ({value0})", "i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Alle Empfänger aktivieren", diff --git a/webui/tests/review-workflow-guidance-ui-structure.test.mjs b/webui/tests/review-workflow-guidance-ui-structure.test.mjs index ebe0e9c..b4535f5 100644 --- a/webui/tests/review-workflow-guidance-ui-structure.test.mjs +++ b/webui/tests/review-workflow-guidance-ui-structure.test.mjs @@ -19,5 +19,9 @@ assert.match(guidance, /campaigns\.workflow\.complete-review/); assert.match(page, /calculateBuildReviewProgress/); assert.match(page, /]+label="i18n:govoplan-campaign\.remaining\.cc632b5e"/); assert.match(page, /showBuiltDetails\(\{ reviewed: 'list:\["no"\]' \}\)/); +assert.match(page, /data-residual-file-policy/); +assert.match(page, /build\.residual_file_disposition/); +assert.match(page, /residual_file_count/); +assert.match(page, /watched_source_count/); assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/); assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);