Files
multi-seal-mail/server/app/api/v1/campaigns.py

1094 lines
44 KiB
Python

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,
CampaignCreateRequest,
CampaignUpdateRequest,
CampaignCreateResponse,
CampaignCreateMinimalRequest,
CampaignJobsResponse,
CampaignListResponse,
CampaignResponse,
CampaignVersionDetailResponse,
CampaignVersionResponse,
CampaignVersionSetStepRequest,
CampaignVersionUpdateRequest,
CampaignPartialValidationRequest,
CampaignPartialValidationResponse,
ValidateCampaignRequest,
ReportEmailRequest,
ReportEmailResponse,
)
from app.auth.dependencies import ApiPrincipal, require_scope
from app.audit.logging import audit_from_principal
from app.db.models import Campaign, CampaignJob, CampaignVersion
from app.db.session import get_session
from app.mailer.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from app.mailer.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from app.mailer.persistence.campaigns import (
CampaignPersistenceError,
build_campaign_version,
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,
update_campaign_version,
validate_campaign_partial,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _get_campaign_for_tenant(session: Session, campaign_id: str, tenant_id: str) -> Campaign:
campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
return campaign
def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -> CampaignVersion:
version = session.get(CampaignVersion, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
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,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
campaign, version = create_campaign_version_from_json(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
raw_json=payload.config,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
)
audit_from_principal(
session,
principal,
action="campaign.created",
object_type="campaign",
object_id=campaign.id,
details={"version_id": version.id, "external_id": campaign.external_id},
commit=True,
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version))
@router.post("/new", response_model=CampaignCreateResponse)
def create_minimal_campaign_endpoint(
payload: CampaignCreateMinimalRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Create a minimal editable campaign/version for the WebUI wizard.
This is intentionally different from importing a complete campaign JSON. It
returns a normal Campaign + CampaignVersion whose version is a working copy
and can be autosaved while incomplete.
"""
try:
campaign, version = create_minimal_campaign(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
external_id=payload.external_id,
name=payload.name,
description=payload.description,
current_flow=payload.current_flow,
current_step=payload.current_step,
)
audit_from_principal(
session,
principal,
action="campaign.created_minimal",
object_type="campaign",
object_id=campaign.id,
details={"version_id": version.id, "external_id": campaign.external_id},
commit=True,
)
return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version))
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.get("", response_model=CampaignListResponse)
def list_campaigns(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
campaigns = (
session.query(Campaign)
.filter(Campaign.tenant_id == principal.tenant_id)
.order_by(Campaign.updated_at.desc())
.all()
)
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
@router.get("/{campaign_id}", response_model=CampaignResponse)
def get_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
return CampaignResponse.model_validate(_get_campaign_for_tenant(session, campaign_id, principal.tenant_id))
@router.put("/{campaign_id}", response_model=CampaignResponse)
def update_campaign_metadata_endpoint(
campaign_id: str,
payload: CampaignUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
if payload.external_id is not None:
value = payload.external_id.strip()
if not value:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign ID cannot be empty")
duplicate = (
session.query(Campaign)
.filter(Campaign.tenant_id == principal.tenant_id, Campaign.external_id == value, Campaign.id != campaign.id)
.one_or_none()
)
if duplicate:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Campaign ID already exists for this tenant")
campaign.external_id = value
if payload.name is not None:
value = payload.name.strip()
if not value:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign name cannot be empty")
campaign.name = value
if payload.status is not None:
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)
audit_from_principal(
session,
principal,
action="campaign.metadata_updated",
object_type="campaign",
object_id=campaign.id,
details={"external_id": campaign.external_id, "name": campaign.name},
commit=True,
)
return CampaignResponse.model_validate(campaign)
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
def list_versions(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
versions = (
session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.desc())
.all()
)
return [CampaignVersionResponse.model_validate(item) for item in versions]
@router.get("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
def get_version_detail(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
try:
version = get_campaign_version_for_tenant(
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id
)
return CampaignVersionDetailResponse.model_validate(version)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse)
def fork_version_for_edit(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Create a new editable campaign version from a locked/validated/sent version.
Versions that were validated, built, queued or sent are immutable audit
snapshots. This endpoint makes an explicit editable copy and makes that
new copy the campaign's current version.
"""
payload = payload or CampaignVersionUpdateRequest()
try:
version = fork_campaign_version_for_edit(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=payload.campaign_json,
current_flow=payload.current_flow or "manual",
current_step=payload.current_step,
editor_state=payload.editor_state,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=True,
)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
audit_from_principal(
session,
principal,
action="campaign.version_forked_for_edit",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id, "source_version_id": version_id, "version_number": version.version_number},
commit=True,
)
return CampaignCreateResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.model_validate(version),
)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/{campaign_id}/versions/{version_id}/unlock-validation", response_model=CampaignVersionDetailResponse)
def unlock_version_validation(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Unlock a successfully validated version before delivery starts.
Unlocking invalidates validation/build state and removes generated jobs for
that version. Sent/final versions cannot be unlocked and must be copied.
"""
try:
version = unlock_validated_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
audit_from_principal(
session,
principal,
action="campaign.version_validation_unlocked",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id},
commit=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except LockedCampaignVersionError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
def update_version_detail(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
version = update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=payload.campaign_json,
current_flow=payload.current_flow,
current_step=payload.current_step,
workflow_state=payload.workflow_state,
is_complete=payload.is_complete,
editor_state=payload.editor_state,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=False,
)
audit_from_principal(
session,
principal,
action="campaign.version_updated",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step},
commit=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except LockedCampaignVersionError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 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}/versions/{version_id}/autosave", response_model=CampaignVersionDetailResponse)
def autosave_version(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
version = update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=payload.campaign_json,
current_flow=payload.current_flow,
current_step=payload.current_step,
workflow_state=payload.workflow_state,
is_complete=payload.is_complete,
editor_state=payload.editor_state,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=True,
)
audit_from_principal(
session,
principal,
action="campaign.version_autosaved",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step},
commit=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except LockedCampaignVersionError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 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}/versions/{version_id}/set-step", response_model=CampaignVersionDetailResponse)
def set_version_step(
campaign_id: str,
version_id: str,
payload: CampaignVersionSetStepRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
version = update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
current_flow=payload.current_flow,
current_step=payload.current_step,
autosave=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except LockedCampaignVersionError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse)
def validate_version_partial(
campaign_id: str,
version_id: str,
payload: CampaignPartialValidationRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:validate")),
):
try:
version = get_campaign_version_for_tenant(
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id
)
campaign_json = payload.campaign_json if payload and payload.campaign_json is not None else version.raw_json
result = validate_campaign_partial(campaign_json, section=payload.section if payload else None)
audit_from_principal(
session,
principal,
action="campaign.version_partially_validated",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id, "section": result.get("section"), "ok": result.get("ok")},
commit=True,
)
return CampaignPartialValidationResponse(**result)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/{campaign_id}/versions/{version_id}/publish", response_model=CampaignVersionDetailResponse)
def publish_version(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
version = publish_campaign_version(session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id)
audit_from_principal(
session,
principal,
action="campaign.version_published",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id},
commit=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/versions/{version_id}/validate")
def validate_version(
version_id: str,
payload: ValidateCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:validate")),
):
try:
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if is_user_locked_version(version) or is_version_final_locked(version):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This version is audit-safe/final and cannot be validated again. Create an editable copy instead.",
)
result = validate_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
check_files=payload.check_files if payload else False,
user_id=principal.user.id,
)
audit_from_principal(
session,
principal,
action="campaign.validated",
object_type="campaign_version",
object_id=version_id,
details={"check_files": payload.check_files if payload else False, "ok": result.get("ok")},
commit=True,
)
return result
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 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("/versions/{version_id}/build")
def build_version(
version_id: str,
payload: BuildCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:build")),
):
try:
result = build_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
write_eml=payload.write_eml if payload else True,
)
audit_from_principal(
session,
principal,
action="campaign.messages_built",
object_type="campaign_version",
object_id=version_id,
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
commit=True,
)
return result
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
def list_jobs(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_id == campaign.id)
.order_by(CampaignJob.entry_index.asc())
.all()
)
return CampaignJobsResponse(
jobs=[
{
"id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"subject": job.subject,
"build_status": job.build_status,
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"imap_status": job.imap_status,
"eml_local_path": job.eml_local_path,
"eml_size_bytes": job.eml_size_bytes,
"attempt_count": job.attempt_count,
"last_error": job.last_error,
"queued_at": job.queued_at,
"sent_at": job.sent_at,
"issues": job.issues_snapshot,
"attachments": job.resolved_attachments,
}
for job in jobs
]
)
@router.get("/{campaign_id}/summary")
def campaign_summary(
campaign_id: str,
include_jobs: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
"""Return dashboard-friendly campaign status counters and summaries."""
try:
return generate_campaign_report(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
include_jobs=include_jobs,
)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get("/{campaign_id}/report")
def campaign_report(
campaign_id: str,
include_jobs: bool = True,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
"""Return the full JSON report for one campaign."""
try:
return generate_campaign_report(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
include_jobs=include_jobs,
)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get("/{campaign_id}/report/jobs.csv")
def campaign_jobs_csv(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
"""Export per-job campaign status as CSV."""
try:
csv_text = generate_jobs_csv(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return Response(
content=csv_text,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"'},
)
@router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse)
def email_campaign_report(
campaign_id: str,
payload: ReportEmailRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("reports:send")),
):
"""Generate a campaign report and send it to one or more email addresses."""
try:
result = send_campaign_report_email(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
to=payload.to,
include_jobs=payload.include_jobs,
attach_jobs_csv=payload.attach_jobs_csv,
attach_report_json=payload.attach_report_json,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="report.email_sent" if not payload.dry_run else "report.email_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result.as_dict(),
commit=True,
)
return ReportEmailResponse(result=result.as_dict())
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except (CampaignReportEmailError, Exception) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
# Queue / delivery control -------------------------------------------------
from app.api.v1.schemas import (
AppendSentRequest,
CampaignActionResponse,
QueueCampaignRequest,
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,
enqueue_pending_imap_appends,
pause_campaign_jobs,
queue_campaign_jobs,
resume_campaign_jobs,
send_campaign_now,
)
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
def queue_campaign(
campaign_id: str,
payload: QueueCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:queue")),
):
payload = payload or QueueCampaignRequest()
try:
result = queue_campaign_jobs(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=payload.version_id,
include_warnings=payload.include_warnings,
enqueue_celery=payload.enqueue_celery,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="campaign.queued" if not payload.dry_run else "campaign.queue_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result.as_dict(),
commit=True,
)
return QueueCampaignResponse(**result.as_dict())
except QueueingError as exc:
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,
payload: SendCampaignNowRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:send")),
):
"""Validate/build/queue and synchronously send a small campaign version.
This endpoint is intentionally conservative and suitable for a first small
test campaign. Larger campaigns should use the queue/Celery flow.
"""
payload = payload or SendCampaignNowRequest()
try:
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
version_id = payload.version_id or campaign.current_version_id
if not version_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign has no current version")
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 = 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,
detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.",
)
if not version.locked_at or not validation_result or validation_result.get("ok") is not True:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Campaign version must be validated and locked before dry-run or sending.",
)
if not build_result:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Campaign version must be built before dry-run or sending.",
)
result = send_campaign_now(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
include_warnings=payload.include_warnings,
dry_run=payload.dry_run,
use_rate_limit=payload.use_rate_limit,
enqueue_imap_task=payload.enqueue_imap_task,
).as_dict()
result["validation"] = validation_result
result["build"] = build_result
audit_from_principal(
session,
principal,
action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
commit=True,
)
return SendCampaignNowResponse(result=result)
except HTTPException:
raise
except (CampaignPersistenceError, QueueingError) 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}/pause", response_model=CampaignActionResponse)
def pause_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:queue")),
):
try:
result = pause_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
audit_from_principal(session, principal, action="campaign.paused", object_type="campaign", object_id=campaign_id, details=result, commit=True)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse)
def resume_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:queue")),
):
try:
result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse)
def cancel_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:queue")),
):
try:
result = cancel_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
audit_from_principal(session, principal, action="campaign.cancelled", object_type="campaign", object_id=campaign_id, details=result, commit=True)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse)
def append_sent(
campaign_id: str,
payload: AppendSentRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:send")),
):
payload = payload or AppendSentRequest()
try:
result = enqueue_pending_imap_appends(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
enqueue_celery=payload.enqueue_celery,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="campaign.append_sent_enqueued" if not payload.dry_run else "campaign.append_sent_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
commit=True,
)
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 [],
)