feat(campaign): govern attachment reuse

This commit is contained in:
2026-08-20 05:20:07 +02:00
parent f4fe534ee1
commit 112ef9dc31
16 changed files with 705 additions and 2 deletions
@@ -0,0 +1,184 @@
from __future__ import annotations
import hashlib
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from govoplan_campaign.backend.campaign.models import (
AttachmentReuseAction,
AttachmentReuseAllowance,
AttachmentReusePolicy,
)
from govoplan_campaign.backend.messages.models import MessageDraft, MessageIssue
@dataclass(frozen=True, slots=True)
class _AttachmentUse:
message: MessageDraft
message_key: str
recipient_key: tuple[str, ...]
source_identity: str
file_name: str
@dataclass(slots=True)
class AttachmentReuseEvaluation:
report: dict[str, object]
issues_by_entry_index: dict[int, list[MessageIssue]]
def evaluate_attachment_reuse(
messages: list[MessageDraft],
*,
policy: AttachmentReusePolicy,
) -> AttachmentReuseEvaluation:
"""Evaluate repeated resolved-file use without exposing source paths.
A use is one resolved file occurrence in one attachment rule. The same
source file can therefore be detected both across built messages and when
two rules add it to one message. Allowed findings remain in the build
protocol; policy violations additionally become recipient-level issues.
"""
uses_by_source: dict[str, list[_AttachmentUse]] = defaultdict(list)
for message in messages:
if not message.active:
continue
message_key = str(message.entry_id or message.entry_index)
recipient_key = _recipient_key(message, fallback=message_key)
for attachment in message.attachments:
for match in attachment.matches:
source_identity = _source_identity(match)
uses_by_source[source_identity].append(
_AttachmentUse(
message=message,
message_key=message_key,
recipient_key=recipient_key,
source_identity=source_identity,
file_name=Path(match).name,
)
)
findings: list[dict[str, object]] = []
issues_by_entry_index: dict[int, list[MessageIssue]] = defaultdict(list)
affected_entry_indexes: set[int] = set()
allowed_count = 0
violation_count = 0
for source_identity, uses in sorted(uses_by_source.items()):
if len(uses) < 2:
continue
fingerprint = hashlib.sha256(source_identity.encode("utf-8")).hexdigest()
message_keys = {item.message_key for item in uses}
recipient_keys = {item.recipient_key for item in uses}
allowed, explanation = _is_allowed(
policy,
message_count=len(message_keys),
recipient_count=len(recipient_keys),
)
disposition = "allowed" if allowed else policy.action.value
finding = {
"file_fingerprint": fingerprint,
"file_name": uses[0].file_name,
"use_count": len(uses),
"message_count": len(message_keys),
"recipient_count": len(recipient_keys),
"disposition": disposition,
"explanation": explanation,
}
findings.append(finding)
if allowed:
allowed_count += 1
continue
violation_count += 1
behavior = _issue_behavior(policy.action)
severity = (
"error" if policy.action == AttachmentReuseAction.BLOCK else "warning"
)
for use in _unique_message_uses(uses):
affected_entry_indexes.add(use.message.entry_index)
issues_by_entry_index[use.message.entry_index].append(
MessageIssue(
severity=severity,
code="duplicate_attachment_reuse",
message=(
f"Attachment {use.file_name!r} is reused {len(uses)} times "
f"across {len(message_keys)} built message(s); the configured "
f"policy requires {disposition}."
),
behavior=behavior,
source="attachments:reuse_policy",
details={
**finding,
"policy": policy.model_dump(mode="json"),
},
)
)
return AttachmentReuseEvaluation(
report={
"contract_version": "1",
"policy": policy.model_dump(mode="json"),
"duplicate_file_count": len(findings),
"allowed_file_count": allowed_count,
"violation_file_count": violation_count,
"affected_message_count": len(affected_entry_indexes),
"findings": findings,
},
issues_by_entry_index=dict(issues_by_entry_index),
)
def _source_identity(value: str) -> str:
return str(Path(value).resolve(strict=False))
def _recipient_key(message: MessageDraft, *, fallback: str) -> tuple[str, ...]:
addresses = message.to or message.bcc or message.cc
normalized = sorted(
{
item.email.strip().casefold()
for item in addresses
if item.email and item.email.strip()
}
)
return tuple(normalized) if normalized else (f"entry:{fallback}",)
def _is_allowed(
policy: AttachmentReusePolicy,
*,
message_count: int,
recipient_count: int,
) -> tuple[bool, str]:
if policy.action == AttachmentReuseAction.ALLOW:
return True, "The campaign policy explicitly allows attachment reuse."
if (
policy.allow_within == AttachmentReuseAllowance.SAME_MESSAGE
and message_count == 1
):
return True, "Reuse is confined to one built message as allowed by policy."
if (
policy.allow_within == AttachmentReuseAllowance.SAME_RECIPIENT
and recipient_count == 1
):
return True, "Reuse is confined to one recipient as allowed by policy."
return False, (
"Reuse crosses the configured allowance and is handled by the "
f"{policy.action.value} policy."
)
def _issue_behavior(action: AttachmentReuseAction) -> str:
if action == AttachmentReuseAction.REVIEW:
return "ask"
return action.value
def _unique_message_uses(uses: list[_AttachmentUse]) -> list[_AttachmentUse]:
unique: dict[int, _AttachmentUse] = {}
for use in uses:
unique.setdefault(use.message.entry_index, use)
return list(unique.values())
@@ -474,6 +474,24 @@ class ResidualFileMode(StrEnum):
ATTACH = "attach" ATTACH = "attach"
class AttachmentReuseAction(StrEnum):
ALLOW = "allow"
WARN = "warn"
REVIEW = "review"
BLOCK = "block"
class AttachmentReuseAllowance(StrEnum):
NONE = "none"
SAME_RECIPIENT = "same_recipient"
SAME_MESSAGE = "same_message"
class AttachmentReusePolicy(StrictModel):
action: AttachmentReuseAction = AttachmentReuseAction.ALLOW
allow_within: AttachmentReuseAllowance = AttachmentReuseAllowance.NONE
class ResidualFileDispositionConfig(StrictModel): class ResidualFileDispositionConfig(StrictModel):
mode: ResidualFileMode = ResidualFileMode.NONE mode: ResidualFileMode = ResidualFileMode.NONE
recipient: RecipientConfig | None = None recipient: RecipientConfig | None = None
@@ -533,6 +551,9 @@ class AttachmentsConfig(StrictModel):
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global") global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
missing_behavior: Behavior = Behavior.WARN missing_behavior: Behavior = Behavior.WARN
ambiguous_behavior: Behavior = Behavior.ASK ambiguous_behavior: Behavior = Behavior.ASK
reuse_policy: AttachmentReusePolicy = Field(
default_factory=AttachmentReusePolicy
)
residual_files: ResidualFileDispositionConfig = Field( residual_files: ResidualFileDispositionConfig = Field(
default_factory=ResidualFileDispositionConfig default_factory=ResidualFileDispositionConfig
) )
@@ -452,6 +452,40 @@ CAMPAIGN_USER_DOCUMENTATION = (
), ),
related_modules=("files",), related_modules=("files",),
), ),
_workflow_topic(
topic_id="campaigns.workflow.control-attachment-reuse",
title="Control repeated campaign attachment use",
summary="Choose whether one resolved file may be reused, produces a warning, requires a reasoned review decision, or blocks delivery.",
body="Attachment reuse is a campaign-owned policy. The action can allow and record every repeated file, warn, require explicit review, or block affected messages. An optional exception permits reuse confined to one recipient or one built message. Every repeated-file finding is represented in the build protocol by a path-safe fingerprint, display filename, use count, message count, disposition, and explanation. Review decisions require a reason, bind to the exact message and build fingerprint, and remain available in campaign protocol and audit evidence. Changing the policy requires a new validation and build; it never rewrites historical evidence.",
order=35,
audience=("campaign_manager", "campaign_author", "campaign_reviewer", "administrator"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build", "campaigns:campaign:review"),
route="/campaigns/{campaign_id}/files",
screen="Attachments",
help_contexts=("campaign.attachments", "campaign.attachments.reuse-policy"),
prerequisites=(
"Decide which repeated use is acceptable for the campaign's purpose and recipients.",
"The current campaign version is editable when changing the policy.",
),
steps=(
"Open Attachments and choose Allow, Warn, Require explicit review, or Block delivery.",
"Optionally allow reuse only within the same recipient or the same built message.",
"Save, validate, and build the campaign, then inspect the repeated-file summary and affected messages.",
"For Review findings, open every affected message and record an explicit reason; for Block findings, correct the rules or policy and rebuild.",
),
outcome="A campaign build whose repeated attachment use is governed and reviewable under an explicit policy.",
verification="Review and send shows the configured action and boundary, repeated-file counts and dispositions; affected messages show warning, review, or blocked state as configured.",
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Wiederholte Verwendung von Kampagnenanhängen steuern",
"summary": "Festlegen, ob dieselbe aufgelöste Datei wiederverwendet werden darf, eine Warnung erzeugt, eine begründete Prüfentscheidung erfordert oder den Versand sperrt.",
"body": "Die Wiederverwendung von Anhängen wird durch eine kampagneneigene Richtlinie gesteuert. Die Aktion kann jede Wiederverwendung zulassen und protokollieren, warnen, eine ausdrückliche Prüfung verlangen oder betroffene Nachrichten sperren. Optional darf die Wiederverwendung innerhalb desselben Empfängers oder derselben erzeugten Nachricht ausgenommen werden. Jeder Befund erscheint mit pfadsicherem Fingerabdruck, Anzeigename, Verwendungs- und Nachrichtenanzahl, Ergebnis und Begründung im Build-Protokoll. Prüfentscheidungen benötigen eine Begründung, sind an Nachricht und Build-Fingerabdruck gebunden und bleiben in Protokoll und Auditnachweis erhalten. Eine Richtlinienänderung erfordert eine neue Validierung und einen neuen Build und verändert keine historischen Nachweise.",
"outcome": "Ein Kampagnen-Build, dessen wiederholte Anhangsverwendung durch eine ausdrückliche Richtlinie gesteuert und prüfbar ist.",
"verification": "Prüfen und senden zeigt Aktion, Ausnahmegrenze, Anzahlen und Ergebnisse; betroffene Nachrichten tragen entsprechend Warn-, Prüf- oder Sperrstatus.",
}
},
),
_workflow_topic( _workflow_topic(
topic_id="campaigns.workflow.route-unassigned-files", topic_id="campaigns.workflow.route-unassigned-files",
title="Review and route unassigned campaign files", title="Review and route unassigned campaign files",
@@ -19,6 +19,7 @@ from govoplan_campaign.backend.attachments.resolver import (
effective_send_without_attachments_behavior, effective_send_without_attachments_behavior,
resolve_entry_attachments, resolve_entry_attachments,
) )
from govoplan_campaign.backend.attachments.reuse import evaluate_attachment_reuse
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
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.field_values import ignored_entry_field_overrides from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
@@ -1149,6 +1150,28 @@ def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: lis
status = _apply_behavior(status, issue.behavior) status = _apply_behavior(status, issue.behavior)
built.draft.validation_status = status built.draft.validation_status = status
def _apply_attachment_reuse_policy(
config: CampaignConfig,
built_messages: list[BuiltMessage],
) -> dict[str, object]:
evaluation = evaluate_attachment_reuse(
[built.draft for built in built_messages],
policy=config.attachments.reuse_policy,
)
for built in built_messages:
issues = evaluation.issues_by_entry_index.get(built.draft.entry_index, [])
if not issues:
continue
built.draft.issues.extend(issues)
for issue in issues:
if issue.behavior:
built.draft.validation_status = _apply_behavior(
built.draft.validation_status,
issue.behavior,
)
return evaluation.report
def build_campaign_messages( def build_campaign_messages(
config: CampaignConfig, config: CampaignConfig,
*, *,
@@ -1203,6 +1226,10 @@ def build_campaign_messages(
) )
if residual_message is not None: if residual_message is not None:
built_messages.append(residual_message) built_messages.append(residual_message)
attachment_reuse = _apply_attachment_reuse_policy(
config,
built_messages,
)
rules_resolved = sum(len(built.draft.attachments) for built in built_messages) rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
report = CampaignBuildReport( report = CampaignBuildReport(
@@ -1216,6 +1243,7 @@ def build_campaign_messages(
duration_ms=(time.perf_counter() - started) * 1000, duration_ms=(time.perf_counter() - started) * 1000,
rules_resolved=rules_resolved, rules_resolved=rules_resolved,
), ),
attachment_reuse=attachment_reuse,
residual_file_disposition=_residual_file_disposition_evidence( residual_file_disposition=_residual_file_disposition_evidence(
config=config, config=config,
residual_groups=residual_groups, residual_groups=residual_groups,
@@ -117,6 +117,7 @@ class CampaignBuildReport(BaseModel):
inactive_entries_count: int = 0 inactive_entries_count: int = 0
messages: list[MessageDraft] = Field(default_factory=list) messages: list[MessageDraft] = Field(default_factory=list)
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict) attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
attachment_reuse: dict[str, object] = Field(default_factory=dict)
residual_file_disposition: dict[str, object] = Field(default_factory=dict) residual_file_disposition: dict[str, object] = Field(default_factory=dict)
@property @property
@@ -594,6 +594,7 @@ def set_version_review_state(
"inspection_complete": payload.inspection_complete, "inspection_complete": payload.inspection_complete,
"reviewed_message_count": len(payload.reviewed_message_keys), "reviewed_message_count": len(payload.reviewed_message_keys),
"issue_decision_count": len(payload.issue_decisions), "issue_decision_count": len(payload.issue_decisions),
"issue_decisions": _review_decision_audit_evidence(version),
}, },
commit=True, commit=True,
) )
@@ -879,6 +880,9 @@ def build_version(
"residual_file_disposition": _residual_file_audit_evidence( "residual_file_disposition": _residual_file_audit_evidence(
result.get("residual_file_disposition") result.get("residual_file_disposition")
), ),
"attachment_reuse": _attachment_reuse_audit_evidence(
result.get("attachment_reuse")
),
}, },
commit=True, commit=True,
) )
@@ -925,3 +929,54 @@ def _residual_file_audit_evidence(value: object) -> dict[str, object]:
"residual_file_count", "residual_file_count",
) )
} }
def _attachment_reuse_audit_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict):
return {}
policy = value.get("policy")
return {
"contract_version": value.get("contract_version"),
"policy": dict(policy) if isinstance(policy, dict) else {},
"duplicate_file_count": value.get("duplicate_file_count"),
"allowed_file_count": value.get("allowed_file_count"),
"violation_file_count": value.get("violation_file_count"),
"affected_message_count": value.get("affected_message_count"),
}
def _review_decision_audit_evidence(
version: CampaignVersion,
) -> dict[str, object]:
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
review_state = editor_state.get("review_send")
if not isinstance(review_state, dict):
return {}
raw_decisions = review_state.get("issue_decisions")
decisions = [item for item in raw_decisions or [] if isinstance(item, dict)]
evidence = [
{
"decision": item.get("decision"),
"issue_codes": sorted(
str(code) for code in item.get("issue_codes") or [] if code
),
"issue_fingerprint": item.get("issue_fingerprint"),
"message_sha256": item.get("message_sha256"),
"reason_recorded": bool(str(item.get("reason") or "").strip()),
}
for item in decisions
]
return {
"count": len(evidence),
"with_reason_count": sum(
1 for item in evidence if item["reason_recorded"]
),
"issue_codes": sorted(
{
code
for item in evidence
for code in item["issue_codes"]
}
),
"evidence_sha256": _canonical_sha256(evidence),
}
@@ -372,6 +372,10 @@
], ],
"default": "ask" "default": "ask"
}, },
"reuse_policy": {
"$ref": "#/$defs/attachment_reuse_policy",
"description": "Controls whether resolving the same source file more than once is allowed, warned, reviewed, or blocked, with an optional same-recipient or same-message allowance."
},
"residual_files": { "residual_files": {
"$ref": "#/$defs/residual_file_disposition" "$ref": "#/$defs/residual_file_disposition"
} }
@@ -1458,6 +1462,25 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"attachment_reuse_policy": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["allow", "warn", "review", "block"],
"default": "allow",
"description": "Action when one resolved source file is used more than once outside the configured allowance. Review creates an explicit, reasoned review decision."
},
"allow_within": {
"type": "string",
"enum": ["none", "same_recipient", "same_message"],
"default": "none",
"description": "Optional exception that permits reuse confined to one recipient or one built message."
}
},
"additionalProperties": false,
"default": {"action": "allow", "allow_within": "none"}
},
"residual_file_disposition": { "residual_file_disposition": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -61,6 +61,16 @@
} }
], ],
"fields": { "fields": {
"/attachments/reuse_policy/action": {
"label": "Duplicate-file action",
"control": "select",
"description": "Allow and record, warn, require a reasoned review decision, or block repeated use of the same resolved file."
},
"/attachments/reuse_policy/allow_within": {
"label": "Allowed reuse boundary",
"control": "select",
"description": "Optionally exempt reuse confined to one recipient or one built message."
},
"/attachments/global[]/message_filename_template": { "/attachments/global[]/message_filename_template": {
"label": "Direct attachment filename", "label": "Direct attachment filename",
"control": "text", "control": "text",
+160
View File
@@ -7,6 +7,7 @@ import zipfile
from pathlib import Path from pathlib import Path
from govoplan_campaign.backend.campaign.models import CampaignConfig from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.loader import validate_against_schema
from govoplan_campaign.backend.campaign.validation import validate_campaign_config from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.builder import build_campaign_messages
@@ -56,6 +57,64 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
"delivery": {"imap_append_sent": {"enabled": False}}, "delivery": {"imap_append_sent": {"enabled": False}},
}) })
def _attachment_reuse_config(
self,
*,
action: str,
allow_within: str = "none",
recipient_emails: tuple[str, ...] = (
"first@example.org",
"second@example.org",
),
duplicate_rules: bool = False,
) -> CampaignConfig:
rules = [{
"id": "shared-file",
"base_dir": "documents",
"file_filter": "shared.pdf",
"required": True,
}]
if duplicate_rules:
rules.append({**rules[0], "id": "shared-file-again"})
return CampaignConfig.model_validate({
"version": "1.0",
"campaign": {
"id": f"reuse-{action}-{allow_within}",
"name": "Attachment reuse",
"mode": "test",
},
"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": "Subject", "text": "Body"},
"attachments": {
"global": rules,
"reuse_policy": {
"action": action,
"allow_within": allow_within,
},
},
"entries": {
"inline": [
{
"id": f"recipient-{index}",
"to": [{"email": email, "type": "to"}],
}
for index, email in enumerate(recipient_emails, start=1)
]
},
"validation_policy": {
"missing_email": "block",
"template_error": "block",
},
"delivery": {"imap_append_sent": {"enabled": False}},
})
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None: def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
@@ -275,6 +334,107 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
self.assertEqual(archive.namelist(), ["matched.xlsx"]) self.assertEqual(archive.namelist(), ["matched.xlsx"])
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook") self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
def test_attachment_reuse_action_controls_message_validation(self) -> None:
expected = {
"allow": ("ready", None, 0, 1),
"warn": ("warning", "warn", 1, 0),
"review": ("needs_review", "ask", 1, 0),
"block": ("blocked", "block", 1, 0),
}
for action, (status, behavior, violation_count, allowed_count) in expected.items():
with self.subTest(action=action), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
documents = root / "documents"
documents.mkdir()
(documents / "shared.pdf").write_bytes(b"shared")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = self._attachment_reuse_config(action=action)
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(
[status, status],
[message.validation_status.value for message in result.report.messages],
)
report = result.report.attachment_reuse
self.assertEqual(1, report["duplicate_file_count"])
self.assertEqual(violation_count, report["violation_file_count"])
self.assertEqual(allowed_count, report["allowed_file_count"])
finding = report["findings"][0]
self.assertEqual("shared.pdf", finding["file_name"])
self.assertNotIn(str(root), str(report))
issues = [
issue
for message in result.report.messages
for issue in message.issues
if issue.code == "duplicate_attachment_reuse"
]
if behavior is None:
self.assertEqual([], issues)
else:
self.assertEqual([behavior, behavior], [issue.behavior for issue in issues])
self.assertEqual(
{"action": action, "allow_within": "none"},
issues[0].details["policy"],
)
def test_attachment_reuse_can_be_allowed_within_recipient_or_message(self) -> None:
cases = (
("same_recipient", ("same@example.org", "same@example.org"), False, 2),
("same_message", ("same@example.org",), True, 2),
)
for allow_within, recipients, duplicate_rules, expected_use_count in cases:
with self.subTest(allow_within=allow_within), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
documents = root / "documents"
documents.mkdir()
(documents / "shared.pdf").write_bytes(b"shared")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
result = build_campaign_messages(
self._attachment_reuse_config(
action="block",
allow_within=allow_within,
recipient_emails=recipients,
duplicate_rules=duplicate_rules,
),
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(
["ready"] * len(recipients),
[message.validation_status.value for message in result.report.messages],
)
report = result.report.attachment_reuse
self.assertEqual(1, report["allowed_file_count"])
self.assertEqual(0, report["violation_file_count"])
self.assertEqual(expected_use_count, report["findings"][0]["use_count"])
def test_attachment_reuse_policy_is_part_of_the_json_schema(self) -> None:
config = self._attachment_reuse_config(
action="review",
allow_within="same_recipient",
)
payload = config.model_dump(
mode="json",
by_alias=True,
exclude_none=True,
exclude_defaults=True,
)
payload["server"].pop("profile_capabilities", None)
validate_against_schema(payload)
def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None: def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None:
for mode, expected_attachment_count in (("report", 0), ("attach", 1)): for mode, expected_attachment_count in (("report", 0), ("attach", 1)):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp: with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
+45
View File
@@ -16,8 +16,10 @@ from govoplan_campaign.backend.persistence.campaigns import (
_verify_storage_keys_absent, _verify_storage_keys_absent,
) )
from govoplan_campaign.backend.routes.versions import ( from govoplan_campaign.backend.routes.versions import (
_attachment_reuse_audit_evidence,
_campaign_build_recovery_plan, _campaign_build_recovery_plan,
_residual_file_audit_evidence, _residual_file_audit_evidence,
_review_decision_audit_evidence,
) )
@@ -56,6 +58,49 @@ def test_residual_file_audit_evidence_omits_recipient_identity() -> None:
} }
def test_attachment_reuse_audit_evidence_keeps_policy_but_not_file_names() -> None:
assert _attachment_reuse_audit_evidence({
"contract_version": "1",
"policy": {"action": "review", "allow_within": "same_recipient"},
"duplicate_file_count": 4,
"allowed_file_count": 1,
"violation_file_count": 3,
"affected_message_count": 5,
"findings": [{"file_name": "personal.pdf"}],
}) == {
"contract_version": "1",
"policy": {"action": "review", "allow_within": "same_recipient"},
"duplicate_file_count": 4,
"allowed_file_count": 1,
"violation_file_count": 3,
"affected_message_count": 5,
}
def test_review_decision_audit_evidence_is_aggregate_and_integrity_sealed() -> None:
version = type("Version", (), {
"editor_state": {
"review_send": {
"issue_decisions": [{
"decision": "accept",
"reason": "Approved exception",
"issue_codes": ["duplicate_attachment_reuse"],
"issue_fingerprint": "f" * 64,
"message_sha256": "m" * 64,
}]
}
}
})()
evidence = _review_decision_audit_evidence(version)
assert evidence["count"] == 1
assert evidence["with_reason_count"] == 1
assert evidence["issue_codes"] == ["duplicate_attachment_reuse"]
assert len(str(evidence["evidence_sha256"])) == 64
assert "Approved exception" not in str(evidence)
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None: def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
storage = LocalFilesystemStorageBackend(tmp_path) storage = LocalFilesystemStorageBackend(tmp_path)
payload = b"Message-ID: <build@example.test>\r\n\r\nbody" payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
+30 -2
View File
@@ -78,6 +78,33 @@ def test_complete_review_workflow_documents_each_attention_class() -> None:
assert topic.metadata["help_contexts"] == ["campaign.review-send"] assert topic.metadata["help_contexts"] == ["campaign.review-send"]
def test_attachment_reuse_workflow_documents_policy_and_evidence() -> None:
topic = next(
item
for item in CAMPAIGN_USER_DOCUMENTATION
if item.id == "campaigns.workflow.control-attachment-reuse"
)
rendered = "\n".join(
(
topic.summary,
topic.body,
*topic.metadata["steps"],
topic.metadata["verification"],
)
)
assert "allow" in rendered.lower()
assert "warn" in rendered.lower()
assert "review" in rendered.lower()
assert "block" in rendered.lower()
assert "fingerprint" in rendered.lower()
assert "reason" in rendered.lower()
assert topic.metadata["help_contexts"] == [
"campaign.attachments",
"campaign.attachments.reuse-policy",
]
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None: def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
assert _topics({"docs:documentation:read"}) == () assert _topics({"docs:documentation:read"}) == ()
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == () assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
@@ -418,8 +445,9 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
"campaign.template", "campaign.template",
"campaign.template.content-library", "campaign.template.content-library",
"campaigns.action.schedule-drafts", "campaigns.action.schedule-drafts",
"campaign.attachments", "campaign.attachments",
"campaign.attachments.residual-files", "campaign.attachments.reuse-policy",
"campaign.attachments.residual-files",
"campaign.recipients", "campaign.recipients",
"campaign.recipient-data", "campaign.recipient-data",
"campaign.server-settings", "campaign.server-settings",
+37
View File
@@ -92,6 +92,43 @@ def test_attachment_block_cannot_be_overridden_by_review_decision() -> None:
assert decisions == [] assert decisions == []
def test_attachment_reuse_review_requires_reason_and_captures_policy() -> None:
job = _job(
issues=[{
"code": "duplicate_attachment_reuse",
"behavior": "ask",
"source": "attachments:reuse_policy",
"details": {
"file_fingerprint": "f" * 64,
"policy": {"action": "review", "allow_within": "none"},
},
}]
)
with pytest.raises(CampaignPersistenceError, match="require an explicit reason"):
_normalize_review_issue_decisions(
[job],
[],
user_id="reviewer-1",
build_token="build-1",
)
decisions = _normalize_review_issue_decisions(
[job],
[{
"job_id": job.id,
"decision": "accept",
"reason": "The shared statutory notice is intentionally identical.",
}],
user_id="reviewer-1",
build_token="build-1",
)
assert decisions[0]["issue_codes"] == ["duplicate_attachment_reuse"]
assert decisions[0]["reason"] == "The shared statutory notice is intentionally identical."
assert len(decisions[0]["issue_fingerprint"]) == 64
def _job(*, issues: list[dict[str, object]]) -> SimpleNamespace: def _job(*, issues: list[dict[str, object]]) -> SimpleNamespace:
return SimpleNamespace( return SimpleNamespace(
id="job-1", id="job-1",
@@ -58,6 +58,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]); const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]); const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]); const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
const reusePolicy = asRecord(attachments.reuse_policy);
const reuseAction = ["allow", "warn", "review", "block"].includes(String(reusePolicy.action)) ? String(reusePolicy.action) : "allow";
const reuseAllowance = ["same_recipient", "same_message"].includes(String(reusePolicy.allow_within)) ? String(reusePolicy.allow_within) : "none";
const residualFiles = asRecord(attachments.residual_files); const residualFiles = asRecord(attachments.residual_files);
const residualMode = ["report", "attach"].includes(String(residualFiles.mode)) ? String(residualFiles.mode) : "none"; const residualMode = ["report", "attach"].includes(String(residualFiles.mode)) ? String(residualFiles.mode) : "none";
const residualRecipient = asRecord(residualFiles.recipient); const residualRecipient = asRecord(residualFiles.recipient);
@@ -113,6 +116,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
}); });
} }
function patchReusePolicy(next: Record<string, unknown>) {
if (locked) return;
patch(["attachments", "reuse_policy"], {
action: "allow",
allow_within: "none",
...reusePolicy,
...next
});
}
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) { function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath)); patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
} }
@@ -306,6 +319,29 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
</div> </div>
</Card> </Card>
<Card title="Attachment reuse policy" collapsible>
<div className="campaign-residual-file-form">
<FormField label="Duplicate-file action" help="Applied when the same resolved source file appears more than once outside the configured allowance.">
<select value={reuseAction} disabled={locked} onChange={(event) => patchReusePolicy({ action: event.target.value })}>
<option value="allow">Allow and record</option>
<option value="warn">Allow with warning</option>
<option value="review">Require explicit review</option>
<option value="block">Block delivery</option>
</select>
</FormField>
<FormField label="Allowed reuse boundary" help="A boundary is an exception to the selected action. No exception applies the action to every repeated use.">
<select value={reuseAllowance} disabled={locked} onChange={(event) => patchReusePolicy({ allow_within: event.target.value })}>
<option value="none">No exception</option>
<option value="same_recipient">Within the same recipient</option>
<option value="same_message">Within the same built message</option>
</select>
</FormField>
</div>
<DismissibleAlert tone={reuseAction === "block" ? "warning" : "info"} dismissible={false} compact>
Every repeated-file finding is retained in the build protocol. Review requires a reason bound to the exact build; Block prevents affected messages from being queued.
</DismissibleAlert>
</Card>
<Card title="Unassigned file disposition" collapsible> <Card title="Unassigned file disposition" collapsible>
<div className="campaign-residual-file-form"> <div className="campaign-residual-file-form">
<FormField label="Action" help="Only sources with Unsent enabled are inspected. The existing warning policy remains active when no disposition is selected."> <FormField label="Action" help="Only sources with Unsent enabled are inspected. The existing warning policy remains active when no disposition is selected.">
@@ -158,6 +158,9 @@ 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 attachmentReuse = asRecord(build.attachment_reuse);
const attachmentReusePolicy = asRecord(attachmentReuse.policy);
const attachmentReuseFindings = asArray(attachmentReuse.findings).map(asRecord);
const residualFileDisposition = asRecord(build.residual_file_disposition); const residualFileDisposition = asRecord(build.residual_file_disposition);
const residualFileRecipient = asRecord(residualFileDisposition.recipient); const residualFileRecipient = asRecord(residualFileDisposition.recipient);
const printOutput = asRecord(build.print_output); const printOutput = asRecord(build.print_output);
@@ -1504,6 +1507,29 @@ export default function ReviewSendPage({
</DescriptionList> </DescriptionList>
</div> </div>
)} )}
{hasBuild && Object.keys(attachmentReuse).length > 0 && (
<div className="review-flow-data-section" data-attachment-reuse-policy>
<h3>Attachment reuse policy</h3>
<DescriptionList variant="inline">
<div><dt>Action</dt><dd>{humanize(String(attachmentReusePolicy.action ?? "allow"))}</dd></div>
<div><dt>Allowed boundary</dt><dd>{humanize(String(attachmentReusePolicy.allow_within ?? "none"))}</dd></div>
<div><dt>Repeated files</dt><dd>{String(attachmentReuse.duplicate_file_count ?? 0)}</dd></div>
<div><dt>Allowed</dt><dd>{String(attachmentReuse.allowed_file_count ?? 0)}</dd></div>
<div><dt>Policy findings</dt><dd>{String(attachmentReuse.violation_file_count ?? 0)}</dd></div>
<div><dt>Affected messages</dt><dd>{String(attachmentReuse.affected_message_count ?? 0)}</dd></div>
</DescriptionList>
{attachmentReuseFindings.length > 0 && (
<ul className="small-note">
{attachmentReuseFindings.slice(0, 10).map((finding) => (
<li key={String(finding.file_fingerprint)}>
{String(finding.file_name ?? "Attachment")} · {String(finding.use_count ?? 0)} uses · {humanize(String(finding.disposition ?? "allowed"))}
</li>
))}
</ul>
)}
{attachmentReuseFindings.length > 10 && <p className="muted small-note">Showing 10 of {attachmentReuseFindings.length} repeated files.</p>}
</div>
)}
{getText(printOutput, "render_id") && ( {getText(printOutput, "render_id") && (
<div className="review-flow-data-section"> <div className="review-flow-data-section">
<div className="page-heading split"> <div className="page-heading split">
@@ -28,6 +28,10 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
allow_individual: false, allow_individual: false,
send_without_attachments: true, send_without_attachments: true,
send_without_attachments_behavior: "continue", send_without_attachments_behavior: "continue",
reuse_policy: {
action: "allow",
allow_within: "none"
},
global: [], global: [],
residual_files: { residual_files: {
mode: "none", mode: "none",
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const guidance = readFileSync(resolve(here, "../src/features/campaigns/review/ReviewWorkflowGuidance.tsx"), "utf8"); const guidance = readFileSync(resolve(here, "../src/features/campaigns/review/ReviewWorkflowGuidance.tsx"), "utf8");
const page = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8"); const page = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8");
const attachmentsPage = readFileSync(resolve(here, "../src/features/campaigns/AttachmentsDataPage.tsx"), "utf8");
assert.match(guidance, /<ActionBlockerHint/); assert.match(guidance, /<ActionBlockerHint/);
assert.match(guidance, /<GuidedReviewList/); assert.match(guidance, /<GuidedReviewList/);
@@ -23,5 +24,15 @@ assert.match(page, /data-residual-file-policy/);
assert.match(page, /build\.residual_file_disposition/); assert.match(page, /build\.residual_file_disposition/);
assert.match(page, /residual_file_count/); assert.match(page, /residual_file_count/);
assert.match(page, /watched_source_count/); assert.match(page, /watched_source_count/);
assert.match(page, /data-attachment-reuse-policy/);
assert.match(page, /build\.attachment_reuse/);
assert.match(page, /attachmentReusePolicy\.allow_within/);
assert.match(attachmentsPage, /patch\(\["attachments", "reuse_policy"\]/);
for (const action of ["allow", "warn", "review", "block"]) {
assert.match(attachmentsPage, new RegExp(`<option value="${action}">`));
}
for (const allowance of ["none", "same_recipient", "same_message"]) {
assert.match(attachmentsPage, new RegExp(`<option value="${allowance}">`));
}
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/); assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/); assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);