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())