attachment backend use

This commit is contained in:
2026-06-13 04:14:10 +02:00
parent 36e9211ee6
commit fe5ac084b7
11 changed files with 696 additions and 145 deletions

View File

@@ -211,16 +211,29 @@ def _base_path_by_path(config: CampaignConfig, rendered_base_dir: str) -> Attach
return None
def _base_path_by_id(config: CampaignConfig, base_path_id: str | None) -> AttachmentBasePathConfig | None:
if not base_path_id:
return None
return next((base_path for base_path in config.attachments.base_paths if base_path.id == base_path_id), None)
def _default_base_path(config: CampaignConfig) -> AttachmentBasePathConfig:
return config.attachments.base_paths[0]
def _selected_base_path(config: CampaignConfig, rendered_base_dir: str) -> AttachmentBasePathConfig | None:
if config.attachments.base_paths:
if rendered_base_dir in {"", "."}:
return _default_base_path(config)
return _base_path_by_path(config, rendered_base_dir)
return None
def _selected_base_path(
config: CampaignConfig,
attachment_config: AttachmentConfig,
rendered_base_dir: str,
) -> AttachmentBasePathConfig | None:
if not config.attachments.base_paths:
return None
selected_by_id = _base_path_by_id(config, attachment_config.base_path_id)
if selected_by_id is not None:
return selected_by_id
if rendered_base_dir in {"", "."}:
return _default_base_path(config)
return _base_path_by_path(config, rendered_base_dir)
def _rule_allows_multiple(config: AttachmentConfig, rendered_file_filter: str) -> bool:
@@ -248,9 +261,9 @@ def _ambiguous_behavior(campaign_config: CampaignConfig, config: AttachmentConfi
def _entry_attachment_allowed(config: CampaignConfig, attachment_config: AttachmentConfig, values: dict[str, Any]) -> bool:
rendered_base_dir = _rendered_base_dir(attachment_config, values)
individual_paths = config.attachments.individual_base_path_values
if individual_paths:
return rendered_base_dir in individual_paths
selected = _selected_base_path(config, attachment_config, rendered_base_dir)
if config.attachments.base_paths:
return bool(selected and selected.allow_individual)
return config.attachments.allow_individual
@@ -271,6 +284,7 @@ def _resolve_attachment_directory(
*,
campaign_file: str | Path,
campaign_config: CampaignConfig,
attachment_config: AttachmentConfig,
rendered_base_dir: str,
) -> tuple[Path, AttachmentBasePathConfig | None]:
"""Resolve the directory for an attachment rule.
@@ -281,7 +295,7 @@ def _resolve_attachment_directory(
e.g. attachments/base_path + base_dir twice.
"""
selected_base_path = _selected_base_path(campaign_config, rendered_base_dir)
selected_base_path = _selected_base_path(campaign_config, attachment_config, rendered_base_dir)
if selected_base_path is not None:
return _resolve_path(campaign_file, selected_base_path.path), selected_base_path
@@ -337,6 +351,7 @@ def _resolve_one_config(
directory, selected_base_path = _resolve_attachment_directory(
campaign_file=campaign_file,
campaign_config=campaign_config,
attachment_config=config,
rendered_base_dir=rendered_base_dir,
)
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)

View File

@@ -223,6 +223,7 @@ class AttachmentBasePathConfig(StrictModel):
class AttachmentConfig(StrictModel):
id: str | None = None
label: str | None = None
base_path_id: str | None = None
# Legacy UI helper. Current attachment resolution ignores this value and
# treats direct files as plain file_filter patterns without wildcards.
# Keep accepting it so existing drafts with {"type": ""}, "direct"

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import json
import tempfile
from email import policy
from email.message import EmailMessage
from pathlib import Path
@@ -14,6 +12,10 @@ from app.mailer.campaign.loader import load_campaign_config
from app.mailer.campaign.validation import validate_campaign_config
from app.mailer.messages.builder import build_campaign_messages
from app.mailer.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
from app.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
prepared_campaign_snapshot,
)
from app.mailer.dev.mock_mailbox import (
clear_records,
consume_fail_next_imap,
@@ -147,13 +149,18 @@ def run_mock_campaign_send(
if clear_mailbox:
clear_records()
with tempfile.TemporaryDirectory(prefix="multimailer-mock-send-") as temp_dir:
temp_path = Path(temp_dir)
campaign_path = temp_path / f"campaign-{version.id}.json"
campaign_path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
config = load_campaign_config(campaign_path)
validation_report = validate_campaign_config(config, campaign_file=campaign_path, check_files=check_files)
build_result = build_campaign_messages(config, campaign_file=campaign_path, write_eml=False)
with prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="multimailer-mock-send-",
) as prepared:
config = load_campaign_config(prepared.path)
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
send_results: list[dict[str, Any]] = []
sent_count = 0

View File

@@ -57,6 +57,7 @@ class MessageAttachmentSummary(BaseModel):
file_filter: str
directory: str
matches: list[str] = Field(default_factory=list)
managed_matches: list[dict[str, object]] = Field(default_factory=list)
class MessageDraft(BaseModel):

View File

@@ -26,6 +26,10 @@ from app.mailer.campaign.validation import Severity, validate_campaign_config
from app.mailer.messages.builder import build_campaign_messages
from app.mailer.messages.models import MessageDraft
from app.storage.services import record_campaign_attachment_uses_for_job
from app.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
prepared_campaign_snapshot,
)
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
@@ -195,7 +199,19 @@ def validate_campaign_version(
}:
raise CampaignPersistenceError("Audit-safe/final campaign versions cannot be validated. Create an editable copy instead.")
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=check_files)
if check_files:
with prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=False,
prefix="multimailer-managed-validate-",
) as prepared:
managed_config = load_campaign_config(prepared.path)
report = validate_campaign_config(managed_config, campaign_file=prepared.path, check_files=True)
else:
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=False)
report_json = report.model_dump(mode="json")
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
version.validation_summary = report_json
@@ -298,7 +314,17 @@ def build_campaign_version(
_ensure_version_validated_and_locked(version)
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
result = build_campaign_messages(config, campaign_file=snapshot_path, output_dir=output_dir, write_eml=write_eml)
with prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="multimailer-managed-build-",
) as prepared:
managed_config = load_campaign_config(prepared.path)
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
report_json = result.report.model_dump(mode="json", by_alias=True)
report_json.update({
"built_count": result.report.built_count,

View File

@@ -648,6 +648,13 @@
"label": {
"type": "string"
},
"base_path_id": {
"type": [
"string",
"null"
],
"description": "Stable reference to attachments.base_paths[].id. Preferred over path matching when multiple managed spaces use the same logical path."
},
"base_dir": {
"type": "string",
"description": "Selected attachment base path for current WebUI campaigns, or a directory relative to attachments.base_path for legacy campaigns."