mock server, file and folder management

This commit is contained in:
2026-06-12 02:18:30 +02:00
parent b67c8abdc5
commit f3db5fc5cf
28 changed files with 3049 additions and 6 deletions

View File

@@ -1,7 +1,11 @@
from __future__ import annotations
import copy
import re
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from app.api.v1.schemas import (
BuildCampaignRequest,
@@ -34,12 +38,14 @@ from app.mailer.persistence.campaigns import (
create_campaign_version_from_json,
validate_campaign_version,
)
from app.storage.services import list_assets_for_user, resolve_patterns
from app.mailer.persistence.versions import (
LockedCampaignVersionError,
create_minimal_campaign,
fork_campaign_version_for_edit,
is_version_final_locked,
is_user_locked_version,
is_version_locked,
get_campaign_version_for_tenant,
publish_campaign_version,
unlock_validated_campaign_version,
@@ -67,6 +73,37 @@ def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -
return version
def _sync_campaign_metadata_to_current_version(session: Session, campaign: Campaign) -> None:
"""Keep editable version JSON aligned with version-independent campaign metadata.
Campaign metadata can be edited from the overview while individual campaign
sections save the current version JSON later. Without this sync, a later
version save can re-apply stale `campaign.name` / `campaign.id` values from
raw_json and make the old overview metadata appear to come back. Audit-safe
or validation-locked versions are left untouched.
"""
if not campaign.current_version_id:
return
version = session.get(CampaignVersion, campaign.current_version_id)
if not version or version.campaign_id != campaign.id or is_version_locked(version):
return
raw_json = copy.deepcopy(version.raw_json if isinstance(version.raw_json, dict) else {})
campaign_section = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
raw_json["campaign"] = {
**campaign_section,
"id": campaign.external_id,
"name": campaign.name,
"description": campaign.description or "",
}
version.raw_json = raw_json
session.add(version)
@router.post("", response_model=CampaignCreateResponse)
def create_campaign(
payload: CampaignCreateRequest,
@@ -187,6 +224,8 @@ def update_campaign_metadata_endpoint(
campaign.status = payload.status
if payload.description is not None:
campaign.description = payload.description
_sync_campaign_metadata_to_current_version(session, campaign)
session.add(campaign)
session.commit()
session.refresh(campaign)
@@ -690,7 +729,10 @@ from app.api.v1.schemas import (
QueueCampaignResponse,
SendCampaignNowRequest,
SendCampaignNowResponse,
MockCampaignSendRequest,
MockCampaignSendResponse,
)
from app.mailer.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
from app.mailer.sending.jobs import (
QueueingError,
cancel_campaign_jobs,
@@ -734,6 +776,55 @@ def queue_campaign(
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)
def mock_send_campaign(
campaign_id: str,
payload: MockCampaignSendRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:send")),
):
"""Run a fully visible mock delivery flow without mutating campaign state.
The route validates and builds the selected version, then optionally records
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
real SMTP/IMAP servers and it does not mark the version sent/final.
"""
payload = payload or MockCampaignSendRequest()
try:
result = run_mock_campaign_send(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=payload.version_id,
send=payload.send,
include_warnings=payload.include_warnings,
include_needs_review=payload.include_needs_review,
append_sent=payload.append_sent,
clear_mailbox=payload.clear_mailbox,
check_files=payload.check_files,
)
audit_from_principal(
session,
principal,
action="campaign.mock_send" if payload.send else "campaign.mock_send_review",
object_type="campaign",
object_id=campaign_id,
details={
"version_id": result.get("version_id"),
"send_requested": payload.send,
"sent_count": result.get("send", {}).get("sent_count"),
"failed_count": result.get("send", {}).get("failed_count"),
},
commit=True,
)
return MockCampaignSendResponse(result=result)
except MockCampaignSendError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse)
def send_campaign_now_endpoint(
campaign_id: str,
@@ -756,7 +847,7 @@ def send_campaign_now_endpoint(
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
validation_result: dict[str, object] | None = version.validation_summary if isinstance(version.validation_summary, dict) else None
build_result: dict[str, object] | None = None
build_result: dict[str, object] | None = version.build_summary if isinstance(version.build_summary, dict) else None
if is_user_locked_version(version):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
@@ -767,7 +858,7 @@ def send_campaign_now_endpoint(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Campaign version must be validated and locked before dry-run or sending.",
)
if not version.build_summary:
if not build_result:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Campaign version must be built before dry-run or sending.",
@@ -874,3 +965,129 @@ def append_sent(
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
class CampaignAttachmentPreviewRequest(BaseModel):
include_unmatched: bool = True
class CampaignAttachmentPreviewResponse(BaseModel):
campaign_id: str
version_id: str
shared_file_count: int
rules: list[dict[str, object]] = Field(default_factory=list)
unused_shared_files: list[dict[str, object]] = Field(default_factory=list)
_BRACE_PLACEHOLDER_RE = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
_DOLLAR_PLACEHOLDER_RE = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
def _preview_render_template(template: str, *, global_values: dict[str, object], entry_fields: dict[str, object]) -> str:
def value_for(raw_key: str) -> str:
key = raw_key.strip()
if key.startswith("local:"):
value = entry_fields.get(key.removeprefix("local:"), "")
elif key.startswith("local."):
value = entry_fields.get(key.removeprefix("local."), "")
elif key.startswith("global:"):
value = global_values.get(key.removeprefix("global:"), "")
elif key.startswith("global."):
value = global_values.get(key.removeprefix("global."), "")
else:
value = entry_fields.get(key, global_values.get(key, ""))
return "" if value is None else str(value)
rendered = _BRACE_PLACEHOLDER_RE.sub(lambda match: value_for(match.group(1)), template)
return _DOLLAR_PLACEHOLDER_RE.sub(lambda match: value_for(match.group(1)), rendered)
def _rule_pattern(rule: dict[str, object], base_path_names: set[str], *, global_values: dict[str, object], entry_fields: dict[str, object]) -> str:
base_dir = _preview_render_template(str(rule.get("base_dir") or "."), global_values=global_values, entry_fields=entry_fields).strip().strip("/")
file_filter = _preview_render_template(str(rule.get("file_filter") or "*"), global_values=global_values, entry_fields=entry_fields).strip() or "*"
if not base_dir or base_dir == "." or base_dir in base_path_names:
return file_filter
return f"{base_dir}/{file_filter}"
def _file_preview(asset) -> dict[str, object]:
return {
"id": asset.id,
"display_path": asset.display_path,
"filename": asset.filename,
"owner_type": asset.owner_type,
"owner_id": asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id,
}
@router.post("/{campaign_id}/versions/{version_id}/attachments/preview", response_model=CampaignAttachmentPreviewResponse)
def preview_campaign_attachments(
campaign_id: str,
version_id: str,
payload: CampaignAttachmentPreviewRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if version.campaign_id != campaign.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
attachments = raw.get("attachments") if isinstance(raw.get("attachments"), dict) else {}
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
base_path_names = {str(item.get("name")) for item in base_paths if isinstance(item, dict) and item.get("name")}
global_values = raw.get("global_values") if isinstance(raw.get("global_values"), dict) else {}
rules: list[dict[str, object]] = []
global_rules = attachments.get("global") if isinstance(attachments.get("global"), list) else []
for index, rule in enumerate(global_rules):
if not isinstance(rule, dict):
continue
rules.append({
"source": "global",
"index": index,
"label": rule.get("label"),
"required": bool(rule.get("required", True)),
"pattern": _rule_pattern(rule, base_path_names, global_values=global_values, entry_fields={}),
})
entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {}
inline_entries = entries.get("inline") if isinstance(entries.get("inline"), list) else []
for entry_index, entry in enumerate(inline_entries, start=1):
if not isinstance(entry, dict):
continue
entry_fields = entry.get("fields") if isinstance(entry.get("fields"), dict) else {}
entry_rules = entry.get("attachments") if isinstance(entry.get("attachments"), list) else []
for rule_index, rule in enumerate(entry_rules):
if not isinstance(rule, dict):
continue
rules.append({
"source": "entry",
"entry_index": entry_index,
"entry_id": entry.get("id"),
"index": rule_index,
"label": rule.get("label"),
"required": bool(rule.get("required", True)),
"pattern": _rule_pattern(rule, base_path_names, global_values=global_values, entry_fields=entry_fields),
})
shared_assets = list_assets_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
campaign_id=campaign.id,
is_admin=principal.user.is_tenant_admin or "*" in set(principal.scopes or []),
)
resolved, unmatched = resolve_patterns(shared_assets, [str(rule["pattern"]) for rule in rules])
for rule, result in zip(rules, resolved, strict=False):
rule["matches"] = [_file_preview(asset) for asset in result.matches]
rule["match_count"] = len(result.matches)
return CampaignAttachmentPreviewResponse(
campaign_id=campaign.id,
version_id=version.id,
shared_file_count=len(shared_assets),
rules=rules,
unused_shared_files=[_file_preview(asset) for asset in unmatched] if payload is None or payload.include_unmatched else [],
)