feat(campaign): enforce attachment delivery policies

This commit is contained in:
2026-07-20 20:07:12 +02:00
parent 724ca779d6
commit ad34365f6c
9 changed files with 613 additions and 15 deletions

View File

@@ -390,6 +390,27 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
)
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
return config.attachments.send_without_attachments_behavior or (
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
)
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
messages = {
Behavior.BLOCK: "No attachment file was resolved for this message, and campaign policy blocks sending without attachments.",
Behavior.ASK: "No attachment file was resolved for this message. Confirm the message should still be sent.",
Behavior.DROP: "No attachment file was resolved for this message. Campaign policy excludes messages without attachments.",
Behavior.WARN: "No attachment file was resolved for this message. Campaign policy allows sending with a warning.",
}
return AttachmentIssue(
severity=ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING,
code="missing_attachment_coverage",
message=messages.get(behavior, "No attachment file was resolved for this message."),
behavior=behavior,
)
def _resolve_one_config(
*,
campaign_file: str | Path,
@@ -495,6 +516,15 @@ def resolve_entry_attachments(
)
issues = [issue for item in resolved for issue in item.issues]
missing_coverage_behavior = _send_without_attachments_behavior(config)
if (
entry.active
and resolved
and missing_coverage_behavior != Behavior.CONTINUE
and sum(len(item.matches) for item in resolved) == 0
and not any(issue.behavior == Behavior.BLOCK for issue in issues)
):
issues.append(_issue_for_missing_attachment_coverage(missing_coverage_behavior))
return EntryAttachmentResolution(
entry_index=entry_index,
entry_id=entry.id,

View File

@@ -404,11 +404,20 @@ class AttachmentsConfig(StrictModel):
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
allow_individual: bool = False
send_without_attachments: bool = True
send_without_attachments_behavior: Behavior | None = None
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
missing_behavior: Behavior = Behavior.ASK
ambiguous_behavior: Behavior = Behavior.ASK
@model_validator(mode="after")
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
if self.send_without_attachments_behavior is None:
self.send_without_attachments_behavior = Behavior.CONTINUE if self.send_without_attachments else Behavior.BLOCK
else:
self.send_without_attachments = self.send_without_attachments_behavior != Behavior.BLOCK
return self
@property
def individual_base_path_values(self) -> set[str]:
return {base_path.path for base_path in self.base_paths if base_path.allow_individual}

View File

@@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field
from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
from ..attachments.resolver import resolve_campaign_attachments
class Severity(StrEnum):
@@ -507,6 +508,7 @@ def _entries_source_file_issues(config: CampaignConfig, campaign_path: Path, map
def _file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
issues.extend(_attachment_file_check_issues(config, campaign_path))
issues.extend(_attachment_resolution_check_issues(config, campaign_path))
issues.extend(_template_source_file_issues(config, campaign_path))
return issues
@@ -536,6 +538,37 @@ def _attachment_file_check_issues(config: CampaignConfig, campaign_path: Path) -
]
def _attachment_resolution_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
try:
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
except Exception as exc:
return [
_issue(
Severity.ERROR,
"attachment_resolution_failed",
f"attachment rules could not be resolved: {exc}",
"/attachments",
)
]
issues: list[SemanticIssue] = []
for entry in report.entries:
entry_path = f"/entries/{entry.entry_id or entry.entry_index}"
for issue in entry.issues:
if any(issue is attachment_issue for attachment in entry.attachments for attachment_issue in attachment.issues):
continue
issues.append(_issue(Severity(issue.severity.value), issue.code, issue.message, entry_path))
for attachment in entry.attachments:
attachment_path = (
f"/attachments/global/{attachment.index}"
if attachment.scope.value == "global"
else f"{entry_path}/attachments/{attachment.index}"
)
for issue in attachment.issues:
issues.append(_issue(Severity(issue.severity.value), issue.code, issue.message, attachment_path))
return issues
def _template_source_file_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
for schema_path, raw_path in _iter_template_source_paths(config):

View File

@@ -245,6 +245,26 @@ def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolu
]
def _append_no_attachment_coverage_issue(issues: list[MessageIssue]) -> None:
if any(issue.code == "missing_attachment_coverage" for issue in issues):
return
issues.append(
MessageIssue(
severity="error",
code="missing_attachment_coverage",
message="No attachment file was resolved for this message, and sending without attachments is not allowed.",
behavior=Behavior.BLOCK.value,
source="attachments",
)
)
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
return config.attachments.send_without_attachments_behavior or (
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
)
def _safe_filename(value: str | None, fallback: str) -> str:
raw = value or fallback
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
@@ -391,6 +411,7 @@ def _attach_files(
for attachment in resolution.attachments:
attachment.message_filenames = []
attachment.zip_entry_names = []
attachment.zip_filename = None
for attachment in resolution.attachments:
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
@@ -435,6 +456,7 @@ def _attach_files(
work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename,
members,
password,
archive.method.value,
)
data, maintype, subtype = _attachment_bytes(archive_path)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
@@ -740,6 +762,14 @@ def _build_mime_message(
values=rendered.values,
work_dir=work_dir,
)
if attachment_count == 0 and context.resolution.attachments and _send_without_attachments_behavior(config) == Behavior.BLOCK:
_append_no_attachment_coverage_issue(context.issues)
return _MimeBuildResult(
message=None,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.BLOCKED,
attachment_count=0,
)
return _MimeBuildResult(
message=message,
build_status=BuildStatus.BUILT,

View File

@@ -158,6 +158,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
],
"allow_individual": True,
"send_without_attachments": False,
"send_without_attachments_behavior": "block",
"global": [],
"zip": {"enabled": False, "archives": []},
"missing_behavior": "ask",

View File

@@ -419,7 +419,19 @@
"send_without_attachments": {
"type": "boolean",
"default": true,
"description": "Legacy compatibility flag. Prefer validation_policy and per-config missing_behavior for new campaigns."
"description": "Legacy compatibility flag. Use send_without_attachments_behavior for new campaigns."
},
"send_without_attachments_behavior": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "continue",
"description": "Campaign-wide behavior when no attachment file is resolved for an active message."
},
"zip": {
"$ref": "#/$defs/zip_collection_config",

View File

@@ -1,8 +1,14 @@
from __future__ import annotations
import binascii
from datetime import datetime
import secrets
import stat
import struct
import zipfile
from pathlib import Path
from typing import Iterable
import zlib
try:
import pyzipper
@@ -10,6 +16,8 @@ except ImportError: # pragma: no cover
pyzipper = None
ArchiveMember = tuple[Path, str]
ZIP_METHOD_AES = "aes"
ZIP_METHOD_STANDARD = "zip_standard"
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
@@ -34,23 +42,17 @@ def create_zip_archive(
output_path: Path,
files: Iterable[Path | ArchiveMember],
password: str = "",
method: str = ZIP_METHOD_AES,
) -> Path:
"""Create a ZIP archive, using AES encryption when a password is supplied."""
"""Create a ZIP archive, optionally using AES or legacy ZipCrypto encryption."""
output_path.parent.mkdir(parents=True, exist_ok=True)
members = _normalized_members(files)
if password:
if pyzipper is None:
raise RuntimeError("pyzipper is required for writing password-protected ZIP files")
with pyzipper.AESZipFile(
output_path,
"w",
compression=pyzipper.ZIP_DEFLATED,
encryption=pyzipper.WZ_AES,
) as zip_file:
zip_file.setpassword(password.encode("utf-8"))
for file_path, archive_name in members:
zip_file.write(file_path, arcname=archive_name)
if method == ZIP_METHOD_STANDARD:
_create_zipcrypto_archive(output_path, members, password)
return output_path
_create_aes_archive(output_path, members, password)
return output_path
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
@@ -59,7 +61,214 @@ def create_zip_archive(
return output_path
def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path:
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
return create_zip_archive(output_path, files, password)
return create_zip_archive(output_path, files, password, method)
def _create_aes_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None:
if pyzipper is None:
raise RuntimeError("pyzipper is required for writing AES password-protected ZIP files")
with pyzipper.AESZipFile(
output_path,
"w",
compression=pyzipper.ZIP_DEFLATED,
encryption=pyzipper.WZ_AES,
) as zip_file:
zip_file.setpassword(password.encode("utf-8"))
for file_path, archive_name in members:
zip_file.write(file_path, arcname=archive_name)
def _create_zipcrypto_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None:
entries: list[_CentralDirectoryEntry] = []
password_bytes = password.encode("utf-8")
with output_path.open("wb") as zip_file:
for file_path, archive_name in members:
local_header_offset = zip_file.tell()
file_data = file_path.read_bytes()
crc = binascii.crc32(file_data) & 0xFFFFFFFF
compressed_data = _deflate(file_data)
encrypted_data = _zipcrypto_encrypt(compressed_data, password_bytes, crc)
compressed_size = len(encrypted_data)
uncompressed_size = len(file_data)
modified_at = datetime.fromtimestamp(file_path.stat().st_mtime)
dos_time, dos_date = _dos_timestamp(modified_at)
filename_bytes, flags = _filename_bytes(archive_name, encrypted=True)
zip_file.write(
struct.pack(
"<IHHHHHIIIHH",
0x04034B50,
20,
flags,
zipfile.ZIP_DEFLATED,
dos_time,
dos_date,
crc,
compressed_size,
uncompressed_size,
len(filename_bytes),
0,
)
)
zip_file.write(filename_bytes)
zip_file.write(encrypted_data)
entries.append(
_CentralDirectoryEntry(
filename_bytes=filename_bytes,
flags=flags,
dos_time=dos_time,
dos_date=dos_date,
crc=crc,
compressed_size=compressed_size,
uncompressed_size=uncompressed_size,
external_attributes=_external_attributes(file_path),
local_header_offset=local_header_offset,
)
)
central_directory_offset = zip_file.tell()
for entry in entries:
zip_file.write(
struct.pack(
"<IHHHHHHIIIHHHHHII",
0x02014B50,
20,
20,
entry.flags,
zipfile.ZIP_DEFLATED,
entry.dos_time,
entry.dos_date,
entry.crc,
entry.compressed_size,
entry.uncompressed_size,
len(entry.filename_bytes),
0,
0,
0,
0,
entry.external_attributes,
entry.local_header_offset,
)
)
zip_file.write(entry.filename_bytes)
central_directory_size = zip_file.tell() - central_directory_offset
zip_file.write(
struct.pack(
"<IHHHHIIH",
0x06054B50,
0,
0,
len(entries),
len(entries),
central_directory_size,
central_directory_offset,
0,
)
)
class _CentralDirectoryEntry:
def __init__(
self,
*,
filename_bytes: bytes,
flags: int,
dos_time: int,
dos_date: int,
crc: int,
compressed_size: int,
uncompressed_size: int,
external_attributes: int,
local_header_offset: int,
) -> None:
self.filename_bytes = filename_bytes
self.flags = flags
self.dos_time = dos_time
self.dos_date = dos_date
self.crc = crc
self.compressed_size = compressed_size
self.uncompressed_size = uncompressed_size
self.external_attributes = external_attributes
self.local_header_offset = local_header_offset
def _deflate(data: bytes) -> bytes:
compressor = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -zlib.MAX_WBITS)
return compressor.compress(data) + compressor.flush()
def _zipcrypto_encrypt(data: bytes, password: bytes, crc: int) -> bytes:
cipher = _ZipCrypto(password)
header = secrets.token_bytes(11) + bytes([(crc >> 24) & 0xFF])
return cipher.encrypt(header + data)
class _ZipCrypto:
def __init__(self, password: bytes) -> None:
self.key0 = 0x12345678
self.key1 = 0x23456789
self.key2 = 0x34567890
for value in password:
self._update_keys(value)
def encrypt(self, data: bytes) -> bytes:
encrypted = bytearray()
for value in data:
encrypted.append(value ^ self._decrypt_byte())
self._update_keys(value)
return bytes(encrypted)
def _decrypt_byte(self) -> int:
temp = self.key2 | 2
return ((temp * (temp ^ 1)) >> 8) & 0xFF
def _update_keys(self, value: int) -> None:
self.key0 = _zipcrypto_crc32_byte(value, self.key0)
self.key1 = (self.key1 + (self.key0 & 0xFF)) & 0xFFFFFFFF
self.key1 = (self.key1 * 134775813 + 1) & 0xFFFFFFFF
self.key2 = _zipcrypto_crc32_byte((self.key1 >> 24) & 0xFF, self.key2)
def _build_zipcrypto_crc(value: int) -> int:
for _ in range(8):
if value & 1:
value = 0xEDB88320 ^ (value >> 1)
else:
value >>= 1
return value
_ZIPCRYPTO_CRC_TABLE = [_build_zipcrypto_crc(value) for value in range(256)]
def _zipcrypto_crc32_byte(value: int, crc: int) -> int:
return ((crc >> 8) ^ _ZIPCRYPTO_CRC_TABLE[(crc ^ value) & 0xFF]) & 0xFFFFFFFF
def _filename_bytes(filename: str, *, encrypted: bool) -> tuple[bytes, int]:
flags = 0x1 if encrypted else 0
try:
return filename.encode("ascii"), flags
except UnicodeEncodeError:
return filename.encode("utf-8"), flags | 0x800
def _dos_timestamp(value: datetime) -> tuple[int, int]:
year = min(max(value.year, 1980), 2107)
month = value.month if value.year == year else 1
day = value.day if value.year == year else 1
dos_time = value.hour << 11 | value.minute << 5 | value.second // 2
dos_date = (year - 1980) << 9 | month << 5 | day
return dos_time, dos_date
def _external_attributes(path: Path) -> int:
try:
permissions = stat.S_IMODE(path.stat().st_mode)
except OSError:
return 0
return permissions << 16