347 lines
13 KiB
Python
347 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.db.models import (
|
|
Campaign,
|
|
CampaignIssue,
|
|
CampaignStatus,
|
|
CampaignVersion,
|
|
CampaignVersionFlow,
|
|
CampaignVersionWorkflowState,
|
|
)
|
|
from app.mailer.campaign.loader import load_campaign_config
|
|
from app.mailer.persistence.campaigns import (
|
|
CAMPAIGN_SNAPSHOT_DIR,
|
|
CampaignPersistenceError,
|
|
_ensure_dirs,
|
|
_next_version_number,
|
|
_write_campaign_snapshot,
|
|
normalize_campaign_paths,
|
|
)
|
|
|
|
|
|
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
|
|
"""Return a WebUI-friendly starter campaign JSON.
|
|
|
|
It is intentionally usable as an editable working copy. It contains the
|
|
main sections the UI expects, but it may still be incomplete from the
|
|
strict send/build perspective until the user configures recipients,
|
|
template and sender details.
|
|
"""
|
|
|
|
return {
|
|
"version": "1.0",
|
|
"campaign": {
|
|
"id": external_id,
|
|
"name": name,
|
|
"description": description or "",
|
|
"mode": "draft",
|
|
},
|
|
"fields": [],
|
|
"global_values": {},
|
|
"server": {
|
|
"smtp": {
|
|
"host": "",
|
|
"port": 587,
|
|
"username": "",
|
|
"password": "",
|
|
"security": "starttls",
|
|
},
|
|
"imap": {
|
|
"enabled": False,
|
|
"host": "",
|
|
"port": 993,
|
|
"username": "",
|
|
"password": "",
|
|
"security": "tls",
|
|
"sent_folder": "auto",
|
|
},
|
|
},
|
|
"recipients": {
|
|
"from": {"name": "", "email": ""},
|
|
"allow_individual_from": False,
|
|
"to": [],
|
|
"allow_individual_to": True,
|
|
"cc": [],
|
|
"allow_individual_cc": False,
|
|
"bcc": [],
|
|
"allow_individual_bcc": False,
|
|
"reply_to": [],
|
|
"allow_individual_reply_to": False,
|
|
"bounce_to": [],
|
|
"allow_individual_bounce_to": False,
|
|
"disposition_notification_to": [],
|
|
"allow_individual_disposition_notification_to": False,
|
|
},
|
|
"template": {
|
|
"subject": "",
|
|
"text": "",
|
|
"html": None,
|
|
},
|
|
"attachments": {
|
|
"base_path": ".",
|
|
"allow_individual": True,
|
|
"send_without_attachments": False,
|
|
"global": [],
|
|
"missing_behavior": "ask",
|
|
"ambiguous_behavior": "ask",
|
|
},
|
|
"entries": {
|
|
"inline": [],
|
|
"defaults": {
|
|
"active": True,
|
|
"combine_to": False,
|
|
"combine_cc": True,
|
|
"combine_bcc": True,
|
|
"combine_reply_to": True,
|
|
"combine_bounce_to": True,
|
|
"combine_disposition_notification_to": True,
|
|
"combine_attachments": True,
|
|
"attachments": [],
|
|
},
|
|
},
|
|
"validation_policy": {
|
|
"missing_required_attachment": "ask",
|
|
"missing_optional_attachment": "warn",
|
|
"ambiguous_attachment_match": "ask",
|
|
"missing_email": "block",
|
|
"template_error": "block",
|
|
},
|
|
"delivery": {
|
|
"rate_limit": {
|
|
"messages_per_minute": 5,
|
|
"concurrency": 1,
|
|
},
|
|
"imap_append_sent": {
|
|
"enabled": False,
|
|
"folder": "auto",
|
|
},
|
|
"retry": {
|
|
"max_attempts": 3,
|
|
"backoff_seconds": [60, 300, 900],
|
|
},
|
|
},
|
|
"status_tracking": {
|
|
"enabled": True,
|
|
},
|
|
}
|
|
|
|
|
|
def create_minimal_campaign(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
user_id: str | None,
|
|
external_id: str,
|
|
name: str,
|
|
description: str | None = None,
|
|
current_flow: str = CampaignVersionFlow.CREATE.value,
|
|
current_step: str = "basics",
|
|
) -> tuple[Campaign, CampaignVersion]:
|
|
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
|
|
if existing:
|
|
raise CampaignPersistenceError(f"Campaign with id '{external_id}' already exists for this tenant")
|
|
|
|
campaign = Campaign(
|
|
tenant_id=tenant_id,
|
|
created_by_user_id=user_id,
|
|
external_id=external_id,
|
|
name=name,
|
|
description=description,
|
|
status=CampaignStatus.DRAFT.value,
|
|
)
|
|
session.add(campaign)
|
|
session.flush()
|
|
|
|
version = CampaignVersion(
|
|
campaign_id=campaign.id,
|
|
version_number=1,
|
|
raw_json=minimal_campaign_json(external_id=external_id, name=name, description=description),
|
|
schema_version="1.0",
|
|
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
|
current_flow=current_flow,
|
|
current_step=current_step,
|
|
is_complete=False,
|
|
editor_state={"created_from": "minimal_campaign"},
|
|
autosaved_at=datetime.now(UTC),
|
|
)
|
|
session.add(version)
|
|
session.flush()
|
|
campaign.current_version_id = version.id
|
|
session.add(campaign)
|
|
_write_campaign_snapshot(version)
|
|
session.commit()
|
|
return campaign, version
|
|
|
|
|
|
def get_campaign_version_for_tenant(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
campaign_id: str,
|
|
version_id: str,
|
|
) -> CampaignVersion:
|
|
campaign = session.get(Campaign, campaign_id)
|
|
version = session.get(CampaignVersion, version_id)
|
|
if not campaign or campaign.tenant_id != tenant_id or not version or version.campaign_id != campaign.id:
|
|
raise CampaignPersistenceError("Campaign version not found")
|
|
return version
|
|
|
|
|
|
def update_campaign_version(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
campaign_id: str,
|
|
version_id: str,
|
|
raw_json: dict[str, Any] | None = None,
|
|
current_flow: str | None = None,
|
|
current_step: str | None = None,
|
|
workflow_state: str | None = None,
|
|
is_complete: bool | None = None,
|
|
editor_state: dict[str, Any] | None = None,
|
|
source_filename: str | None = None,
|
|
source_base_path: str | None = None,
|
|
autosave: bool = False,
|
|
) -> CampaignVersion:
|
|
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
|
campaign = session.get(Campaign, campaign_id)
|
|
assert campaign is not None
|
|
|
|
if raw_json is not None:
|
|
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
|
version.raw_json = runtime_json
|
|
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
|
campaign_meta = runtime_json.get("campaign") if isinstance(runtime_json.get("campaign"), dict) else {}
|
|
if campaign_meta:
|
|
campaign.name = campaign_meta.get("name") or campaign.name
|
|
campaign.description = campaign_meta.get("description", campaign.description)
|
|
campaign.external_id = campaign_meta.get("id") or campaign.external_id
|
|
|
|
if current_flow is not None:
|
|
version.current_flow = current_flow
|
|
if current_step is not None:
|
|
version.current_step = current_step
|
|
if workflow_state is not None:
|
|
version.workflow_state = workflow_state
|
|
if is_complete is not None:
|
|
version.is_complete = is_complete
|
|
if editor_state is not None:
|
|
version.editor_state = editor_state
|
|
if source_filename is not None:
|
|
version.source_filename = source_filename
|
|
if source_base_path is not None:
|
|
version.source_base_path = source_base_path
|
|
if autosave:
|
|
version.autosaved_at = datetime.now(UTC)
|
|
|
|
# Changes invalidate previous build and validation summaries.
|
|
if raw_json is not None:
|
|
version.validation_summary = None
|
|
version.build_summary = None
|
|
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
|
|
|
session.add(version)
|
|
session.add(campaign)
|
|
session.flush()
|
|
_write_campaign_snapshot(version)
|
|
session.commit()
|
|
return version
|
|
|
|
|
|
def publish_campaign_version(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
campaign_id: str,
|
|
version_id: str,
|
|
) -> CampaignVersion:
|
|
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
|
campaign = session.get(Campaign, campaign_id)
|
|
assert campaign is not None
|
|
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
|
version.published_at = datetime.now(UTC)
|
|
campaign.current_version_id = version.id
|
|
campaign.status = CampaignStatus.VALIDATED.value
|
|
session.add(version)
|
|
session.add(campaign)
|
|
session.commit()
|
|
return version
|
|
|
|
|
|
def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]:
|
|
"""Lightweight UI-facing validation for incomplete campaign working copies.
|
|
|
|
This is intentionally less strict than campaign.schema.json validation. It
|
|
lets the WebUI autosave and validate one wizard step at a time.
|
|
"""
|
|
|
|
issues: list[dict[str, Any]] = []
|
|
|
|
def issue(severity: str, sec: str, field: str, code: str, message: str) -> None:
|
|
if section is None or section == sec:
|
|
issues.append({
|
|
"severity": severity,
|
|
"section": sec,
|
|
"field": field,
|
|
"code": code,
|
|
"message": message,
|
|
})
|
|
|
|
campaign = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
|
if not campaign.get("id"):
|
|
issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
|
|
if not campaign.get("name"):
|
|
issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
|
|
|
|
recipients = raw_json.get("recipients") if isinstance(raw_json.get("recipients"), dict) else {}
|
|
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
|
|
if not sender.get("email"):
|
|
issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
|
|
|
|
entries = raw_json.get("entries") if isinstance(raw_json.get("entries"), dict) else {}
|
|
has_inline = bool(entries.get("inline"))
|
|
has_source = isinstance(entries.get("source"), dict)
|
|
if not has_inline and not has_source:
|
|
issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
|
|
if has_source:
|
|
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
|
|
if not any(key in mapping for key in ("to.0.email", "to.email", "email")):
|
|
issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
|
|
|
|
template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {}
|
|
if not template.get("subject") and not (isinstance(template.get("source"), dict) and template["source"].get("subject_path")):
|
|
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
|
|
if not template.get("text") and not template.get("html") and not isinstance(template.get("source"), dict):
|
|
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
|
|
|
|
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}
|
|
if not attachments.get("base_path"):
|
|
issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
|
|
|
|
delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {}
|
|
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
|
|
messages_per_minute = rate_limit.get("messages_per_minute")
|
|
if messages_per_minute is not None:
|
|
try:
|
|
if int(messages_per_minute) < 1:
|
|
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
|
|
except (TypeError, ValueError):
|
|
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")
|
|
|
|
return {
|
|
"ok": not any(item["severity"] == "error" for item in issues),
|
|
"section": section,
|
|
"error_count": sum(1 for item in issues if item["severity"] == "error"),
|
|
"warning_count": sum(1 for item in issues if item["severity"] == "warning"),
|
|
"info_count": sum(1 for item in issues if item["severity"] == "info"),
|
|
"issues": issues,
|
|
}
|