inital commit

This commit is contained in:
2026-06-08 15:57:11 +02:00
parent aaf8729663
commit d9ca48addc
114 changed files with 12172 additions and 1 deletions

View File

@@ -0,0 +1,309 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import copy
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignStatus,
CampaignVersion,
JobBuildStatus,
JobImapStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
)
from app.mailer.campaign.loader import load_campaign_config
from app.mailer.campaign.validation import Severity, validate_campaign_config
from app.mailer.messages.builder import build_campaign_messages
from app.mailer.messages.models import MessageDraft
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
BUILD_OUTPUT_DIR = RUNTIME_DIR / "generated_eml"
class CampaignPersistenceError(RuntimeError):
pass
def _ensure_dirs() -> None:
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def _write_campaign_snapshot(version: CampaignVersion) -> Path:
_ensure_dirs()
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def _next_version_number(session: Session, campaign_id: str) -> int:
current = session.query(func.max(CampaignVersion.version_number)).filter(CampaignVersion.campaign_id == campaign_id).scalar()
return int(current or 0) + 1
def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | None:
if not value or base_path is None:
return value
path = Path(value).expanduser()
if path.is_absolute():
return str(path)
return str((base_path / path).resolve())
def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]:
"""Return a DB/runtime-safe campaign JSON snapshot.
The CLI naturally resolves relative paths against the campaign.json file.
Once the campaign is stored in the database, the JSON snapshot lives in
app/mailer/runtime/campaign_snapshots. To keep existing file-based
campaigns working, relative file paths are normalized to absolute paths at
import time when a source_base_path is known.
"""
base = Path(source_base_path).expanduser().resolve() if source_base_path else None
data = copy.deepcopy(raw_json)
template_source = data.get("template", {}).get("source") if isinstance(data.get("template"), dict) else None
if isinstance(template_source, dict):
for key in ("subject_path", "text_path", "html_path"):
template_source[key] = _resolve_runtime_path(base, template_source.get(key))
entries_source = data.get("entries", {}).get("source") if isinstance(data.get("entries"), dict) else None
if isinstance(entries_source, dict):
entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path"))
attachments = data.get("attachments")
if isinstance(attachments, dict):
attachments["base_path"] = _resolve_runtime_path(base, attachments.get("base_path")) or "."
return data
def create_campaign_version_from_json(
session: Session,
*,
tenant_id: str,
user_id: str | None,
raw_json: dict[str, Any],
source_filename: str | None = None,
source_base_path: str | None = None,
) -> tuple[Campaign, CampaignVersion]:
if source_base_path is None and source_filename:
source_path = Path(source_filename).expanduser()
source_base_path = str(source_path.parent if source_path.suffix else source_path)
runtime_json = normalize_campaign_paths(raw_json, source_base_path)
# load_campaign_config is file-oriented. Use a temporary snapshot for schema/Pydantic validation.
_ensure_dirs()
tmp_path = CAMPAIGN_SNAPSHOT_DIR / "_incoming_campaign.json"
tmp_path.write_text(json.dumps(runtime_json, ensure_ascii=False, indent=2), encoding="utf-8")
config = load_campaign_config(tmp_path)
campaign = (
session.query(Campaign)
.filter(Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id)
.one_or_none()
)
if campaign is None:
campaign = Campaign(
tenant_id=tenant_id,
created_by_user_id=user_id,
external_id=config.campaign.id,
name=config.campaign.name,
description=config.campaign.description,
status=CampaignStatus.DRAFT.value,
)
session.add(campaign)
session.flush()
else:
campaign.name = config.campaign.name
campaign.description = config.campaign.description
version = CampaignVersion(
campaign_id=campaign.id,
version_number=_next_version_number(session, campaign.id),
raw_json=runtime_json,
schema_version=raw_json.get("version", "1.0"),
source_filename=source_filename,
source_base_path=source_base_path,
)
session.add(version)
session.flush()
campaign.current_version_id = version.id
session.add(campaign)
_write_campaign_snapshot(version)
session.commit()
return campaign, version
def load_version_config(session: Session, version_id: str):
version = session.get(CampaignVersion, version_id)
if not version:
raise CampaignPersistenceError(f"Campaign version not found: {version_id}")
path = _write_campaign_snapshot(version)
return version, path, load_campaign_config(path)
def validate_campaign_version(session: Session, *, tenant_id: str, version_id: str, check_files: bool = False) -> dict[str, Any]:
version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=check_files)
report_json = report.model_dump(mode="json")
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
version.validation_summary = report_json
# Replace version-level semantic issues from previous validations.
(
session.query(CampaignIssue)
.filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_(None))
.delete(synchronize_session=False)
)
for issue in report.issues:
session.add(
CampaignIssue(
tenant_id=tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
severity=issue.severity.value,
code=issue.code,
message=issue.message,
source=issue.path,
)
)
campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value
if report.ok:
version.workflow_state = "under_review"
version.is_complete = True
session.add(version)
session.add(campaign)
session.commit()
return report_json
def _job_validation_status(value: str) -> str:
allowed = {item.value for item in JobValidationStatus}
return value if value in allowed else JobValidationStatus.NEEDS_REVIEW.value
def _job_from_message(
*,
tenant_id: str,
campaign_id: str,
version_id: str,
message: MessageDraft,
) -> CampaignJob:
recipient_email = message.to[0].email if message.to else None
return CampaignJob(
tenant_id=tenant_id,
campaign_id=campaign_id,
campaign_version_id=version_id,
entry_index=message.entry_index,
entry_id=message.entry_id,
recipient_email=recipient_email,
subject=message.subject,
eml_local_path=message.eml_path,
eml_size_bytes=message.eml_size_bytes,
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status),
validation_status=_job_validation_status(message.validation_status.value),
queue_status=JobQueueStatus.DRAFT.value,
send_status=JobSendStatus.NOT_QUEUED.value,
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
resolved_recipients={
"from": message.from_.model_dump(mode="json") if message.from_ else None,
"to": [item.model_dump(mode="json") for item in message.to],
"cc": [item.model_dump(mode="json") for item in message.cc],
"bcc": [item.model_dump(mode="json") for item in message.bcc],
"reply_to": [item.model_dump(mode="json") for item in message.reply_to],
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
},
resolved_attachments=[item.model_dump(mode="json") for item in message.attachments],
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
)
def build_campaign_version(
session: Session,
*,
tenant_id: str,
version_id: str,
write_eml: bool = True,
) -> dict[str, Any]:
version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
result = build_campaign_messages(config, campaign_file=snapshot_path, output_dir=output_dir, write_eml=write_eml)
report_json = result.report.model_dump(mode="json", by_alias=True)
report_json.update({
"built_count": result.report.built_count,
"build_failed_count": result.report.build_failed_count,
"ready_count": result.report.ready_count,
"warning_count": result.report.warning_count,
"needs_review_count": result.report.needs_review_count,
"blocked_count": result.report.blocked_count,
"excluded_count": result.report.excluded_count,
"inactive_count": result.report.inactive_count,
"queueable_count": result.report.queueable_count,
})
version.build_summary = report_json
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds.
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False)
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
session.flush()
for built in result.built_messages:
job = _job_from_message(
tenant_id=tenant_id,
campaign_id=campaign.id,
version_id=version.id,
message=built.draft,
)
session.add(job)
session.flush()
for issue in built.draft.issues:
session.add(
CampaignIssue(
tenant_id=tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
job_id=job.id,
severity=issue.severity,
code=issue.code,
message=issue.message,
source=issue.source,
behavior=issue.behavior,
)
)
if result.report.needs_review_count or result.report.blocked_count:
campaign.status = CampaignStatus.NEEDS_REVIEW.value
version.workflow_state = "under_review"
elif result.report.queueable_count > 0:
campaign.status = CampaignStatus.READY_TO_QUEUE.value
version.workflow_state = "built"
else:
campaign.status = CampaignStatus.VALIDATED.value
session.add(version)
session.add(campaign)
session.commit()
return report_json

View File

@@ -0,0 +1,346 @@
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,
}