185 lines
6.3 KiB
Python
185 lines
6.3 KiB
Python
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())
|