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, }