from __future__ import annotations import copy from datetime import UTC, datetime from pathlib import Path from typing import Any from uuid import uuid4 from sqlalchemy import func from sqlalchemy.orm import Session from app.db.models import ( Campaign, CampaignIssue, CampaignStatus, CampaignVersion, CampaignVersionFlow, CampaignVersionWorkflowState, CampaignJob, JobSendStatus, ) 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, ) class LockedCampaignVersionError(CampaignPersistenceError): """Raised when a caller tries to edit an immutable campaign version.""" USER_LOCK_TEMPORARY = "temporary" USER_LOCK_PERMANENT = "permanent" USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT} def campaign_version_user_lock_state(version: CampaignVersion) -> str | None: """Return the explicit user-lock state with backwards compatibility. Older databases represented a permanent user lock only through published_at. Treat those rows as permanent until the migration has backfilled the explicit state. """ state = getattr(version, "user_lock_state", None) if state in USER_LOCK_STATES: return state if version.published_at: return USER_LOCK_PERMANENT return None 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": ".", "base_paths": [ { "id": "default", "name": "Campaign files", "path": ".", "allow_individual": True, "unsent_warning": False, } ], "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", "unsent_attachment_files": "warn", "ignore_empty_fields": False, "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 LOCKED_WORKFLOW_STATES = { CampaignVersionWorkflowState.APPROVED.value, CampaignVersionWorkflowState.BUILT.value, CampaignVersionWorkflowState.QUEUED.value, CampaignVersionWorkflowState.SENDING.value, CampaignVersionWorkflowState.COMPLETED.value, CampaignVersionWorkflowState.CANCELLED.value, CampaignVersionWorkflowState.ARCHIVED.value, } def is_version_locked(version: CampaignVersion) -> bool: """Return True when a version is immutable and edits must fork/unlock.""" return bool( version.locked_at or campaign_version_user_lock_state(version) or version.workflow_state in LOCKED_WORKFLOW_STATES ) def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None: campaign_meta = raw_json.get("campaign") if isinstance(raw_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 def fork_campaign_version_for_edit( 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, editor_state: dict[str, Any] | None = None, source_filename: str | None = None, source_base_path: str | None = None, autosave: bool = True, ) -> CampaignVersion: """Create a new editable working version from a locked/validated version. This preserves the audit value of the validated/sent version while allowing users to continue editing a campaign. New content starts with the supplied raw_json when provided, otherwise with a clone of the source version. """ source = 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 base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json) runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json) new_version = CampaignVersion( campaign_id=campaign.id, version_number=_next_version_number(session, campaign.id), raw_json=runtime_json, schema_version=str(runtime_json.get("version", source.schema_version or "1.0")), source_filename=source_filename if source_filename is not None else source.source_filename, source_base_path=source_base_path if source_base_path is not None else source.source_base_path, workflow_state=CampaignVersionWorkflowState.EDITING.value, current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value), current_step=current_step if current_step is not None else source.current_step, is_complete=False, editor_state=editor_state if editor_state is not None else copy.deepcopy(source.editor_state or {}), autosaved_at=datetime.now(UTC) if autosave else None, ) session.add(new_version) session.flush() _apply_campaign_metadata(campaign, runtime_json) campaign.current_version_id = new_version.id campaign.status = CampaignStatus.DRAFT.value session.add(campaign) _write_campaign_snapshot(new_version) session.commit() return new_version def lock_validated_version(version: CampaignVersion, *, user_id: str | None = None) -> None: if version.locked_at is None: version.locked_at = datetime.now(UTC) version.locked_by_user_id = user_id def is_version_final_locked(version: CampaignVersion) -> bool: """Return True when a version is part of or past delivery and must stay immutable.""" return version.workflow_state in { CampaignVersionWorkflowState.QUEUED.value, CampaignVersionWorkflowState.SENDING.value, CampaignVersionWorkflowState.COMPLETED.value, CampaignVersionWorkflowState.CANCELLED.value, CampaignVersionWorkflowState.ARCHIVED.value, } def is_temporary_user_locked_version(version: CampaignVersion) -> bool: return campaign_version_user_lock_state(version) == USER_LOCK_TEMPORARY def is_permanent_user_locked_version(version: CampaignVersion) -> bool: return campaign_version_user_lock_state(version) == USER_LOCK_PERMANENT def is_user_locked_version(version: CampaignVersion) -> bool: """Return True for either reversible or permanent user-requested locks.""" return campaign_version_user_lock_state(version) is not None def is_audit_safe_version(version: CampaignVersion) -> bool: """Return True when a version is immutable and cannot be unlocked.""" return is_permanent_user_locked_version(version) or is_version_final_locked(version) def is_version_validated_and_locked(version: CampaignVersion) -> bool: """Return True when the version was successfully validated and locked as a review snapshot.""" validation = version.validation_summary if isinstance(version.validation_summary, dict) else {} return bool(version.locked_at and validation.get("ok") is True) def unlock_validated_campaign_version( session: Session, *, tenant_id: str, campaign_id: str, version_id: str, ) -> CampaignVersion: """Unlock a validation snapshot so it can be edited again. This is only allowed before delivery starts. Unlocking invalidates validation, build output and queued job records for that version. Sent/final versions must be copied instead. """ 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 is_temporary_user_locked_version(version): raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.") if is_permanent_user_locked_version(version): raise LockedCampaignVersionError("This version is permanently locked and cannot be unlocked. Create an editable copy instead.") if is_version_final_locked(version): raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.") # A version with sent jobs is final even if workflow_state was not updated for some reason. sent_jobs = ( session.query(CampaignJob) .filter( CampaignJob.campaign_version_id == version.id, CampaignJob.send_status == JobSendStatus.SENT.value, ) .count() ) if sent_jobs: raise LockedCampaignVersionError("This version has sent messages and cannot be unlocked. Create an editable copy instead.") version.locked_at = None version.locked_by_user_id = None version.validation_summary = None version.build_summary = None editor_state = copy.deepcopy(version.editor_state or {}) editor_state.pop("review_send", None) version.editor_state = editor_state version.workflow_state = CampaignVersionWorkflowState.EDITING.value version.is_complete = False session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False) session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False) campaign.current_version_id = version.id campaign.status = CampaignStatus.DRAFT.value session.add(version) session.add(campaign) session.commit() 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 is_version_locked(version): raise LockedCampaignVersionError( "Campaign version is locked. Create an editable copy before changing campaign data." ) 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")) _apply_campaign_metadata(campaign, runtime_json) 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 version.locked_at = None version.locked_by_user_id = None if version.workflow_state != CampaignVersionWorkflowState.EDITING.value: version.workflow_state = CampaignVersionWorkflowState.EDITING.value campaign.status = CampaignStatus.DRAFT.value 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 update_campaign_review_state( session: Session, *, tenant_id: str, campaign_id: str, version_id: str, inspection_complete: bool, reviewed_message_keys: list[str], user_id: str | None, ) -> CampaignVersion: """Persist review acknowledgement without mutating the locked campaign data. Validation locks make the campaign JSON immutable, but review metadata is operational state attached to a specific build. It is therefore stored in editor_state and tied to the current build token so a rebuild invalidates it. """ version = get_campaign_version_for_tenant( session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id, ) if is_version_final_locked(version): raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.") build_summary = version.build_summary if isinstance(version.build_summary, dict) else {} if not build_summary: raise CampaignPersistenceError("Build messages before recording review state.") build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip() if not build_token: # Backwards-compatible upgrade for build summaries created before # review-state tokens were introduced. build_token = uuid4().hex build_summary = copy.deepcopy(build_summary) build_summary["build_token"] = build_token version.build_summary = build_summary editor_state = copy.deepcopy(version.editor_state or {}) editor_state["review_send"] = { "build_token": build_token, "inspection_complete": bool(inspection_complete), "reviewed_message_keys": list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip())), "updated_at": datetime.now(UTC).isoformat(), "updated_by_user_id": user_id, } version.editor_state = editor_state session.add(version) session.commit() return version def lock_campaign_version_temporarily( session: Session, *, tenant_id: str, campaign_id: str, version_id: str, user_id: str | None, ) -> CampaignVersion: """Apply a reversible user-requested lock without changing workflow state.""" version = get_campaign_version_for_tenant( session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id, ) if is_version_final_locked(version): raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.") if is_permanent_user_locked_version(version): raise LockedCampaignVersionError("This version is already permanently locked.") if is_temporary_user_locked_version(version): return version if version.locked_at: raise LockedCampaignVersionError("This version is already temporarily locked by validation. Unlock validation before applying a user lock.") version.user_lock_state = USER_LOCK_TEMPORARY version.user_locked_at = datetime.now(UTC) version.user_locked_by_user_id = user_id session.add(version) session.commit() return version def unlock_user_locked_campaign_version( session: Session, *, tenant_id: str, campaign_id: str, version_id: str, ) -> CampaignVersion: """Remove a reversible user lock without invalidating campaign data.""" version = get_campaign_version_for_tenant( session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id, ) state = campaign_version_user_lock_state(version) if state == USER_LOCK_PERMANENT: raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.") if state != USER_LOCK_TEMPORARY: raise LockedCampaignVersionError("This version does not have a temporary user lock.") if is_version_final_locked(version): raise LockedCampaignVersionError("Delivery/final versions cannot be unlocked. Create an editable copy instead.") version.user_lock_state = None version.user_locked_at = None version.user_locked_by_user_id = None session.add(version) session.commit() return version def permanently_lock_campaign_version( session: Session, *, tenant_id: str, campaign_id: str, version_id: str, user_id: str | None, ) -> CampaignVersion: """Apply an irreversible user lock. The version remains in its current workflow state so the campaign itself is not silently archived. Future changes must be made in an editable copy. """ version = get_campaign_version_for_tenant( session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id, ) if is_version_final_locked(version): raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.") if is_permanent_user_locked_version(version): return version now = datetime.now(UTC) version.user_lock_state = USER_LOCK_PERMANENT version.user_locked_at = now version.user_locked_by_user_id = user_id # Retain published_at as a compatibility marker for existing integrations. version.published_at = version.published_at or now session.add(version) session.commit() return version def publish_campaign_version( session: Session, *, tenant_id: str, campaign_id: str, version_id: str, user_id: str | None = None, ) -> CampaignVersion: """Backwards-compatible alias for the permanent user lock.""" return permanently_lock_campaign_version( session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id, user_id=user_id, ) 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 {} base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else [] has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths) if not has_named_base_path and 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, }