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

36
server/app/db/base.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import DateTime
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
type_annotation_map = {
dict[str, Any]: __import__("sqlalchemy").JSON,
list[dict[str, Any]]: __import__("sqlalchemy").JSON,
list[str]: __import__("sqlalchemy").JSON,
}
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.db.base import Base
from app.db.models import Role, Tenant, User
from app.db.session import engine
from app.security.api_keys import CreatedApiKey, create_api_key
DEFAULT_SCOPES = [
"campaign:read",
"campaign:write",
"campaign:validate",
"campaign:build",
"campaign:queue",
"campaign:send_test",
"campaign:send",
"attachments:read",
"attachments:write",
"reports:read",
"reports:send",
"audit:read",
"admin:users",
"admin:settings",
]
DEFAULT_ROLES = {
"owner": ["*"],
"admin": [
"campaign:read", "campaign:write", "campaign:validate", "campaign:build",
"campaign:queue", "campaign:send_test", "campaign:send",
"attachments:read", "attachments:write", "reports:read", "reports:send", "audit:read",
"admin:users", "admin:settings",
],
"campaign_manager": ["campaign:read", "campaign:write", "campaign:validate", "campaign:build", "reports:read"],
"sender": ["campaign:read", "campaign:queue", "campaign:send_test", "campaign:send", "reports:read", "reports:send"],
"reviewer": ["campaign:read", "campaign:validate", "reports:read"],
"viewer": ["campaign:read", "reports:read"],
"auditor": ["campaign:read", "reports:read", "audit:read"],
}
@dataclass(slots=True)
class BootstrapResult:
tenant: Tenant
user: User
created_api_key: CreatedApiKey | None
def create_all_tables() -> None:
# Import models so SQLAlchemy sees all tables.
from app.db import models # noqa: F401
Base.metadata.create_all(bind=engine)
def bootstrap_dev_data(
session: Session,
*,
api_key_secret: str | None = None,
tenant_slug: str = "default",
user_email: str = "admin@example.local",
) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None:
tenant = Tenant(slug=tenant_slug, name="Default Tenant")
session.add(tenant)
session.flush()
for slug, permissions in DEFAULT_ROLES.items():
role = session.query(Role).filter(Role.tenant_id == tenant.id, Role.slug == slug).one_or_none()
if role is None:
session.add(Role(tenant_id=tenant.id, slug=slug, name=slug.replace("_", " ").title(), permissions=permissions))
user = session.query(User).filter(User.tenant_id == tenant.id, User.email == user_email).one_or_none()
if user is None:
user = User(tenant_id=tenant.id, email=user_email, display_name="Development Admin", is_tenant_admin=True)
session.add(user)
session.flush()
created_api_key = None
if api_key_secret:
existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None]
if not existing:
created_api_key = create_api_key(
session,
user=user,
name="Development API key",
scopes=["*"],
secret=api_key_secret,
)
session.commit()
return BootstrapResult(tenant=tenant, user=user, created_api_key=created_api_key)

335
server/app/db/models.py Normal file
View File

@@ -0,0 +1,335 @@
from __future__ import annotations
import uuid
from datetime import datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class CampaignStatus(StrEnum):
DRAFT = "draft"
VALIDATED = "validated"
NEEDS_REVIEW = "needs_review"
READY_TO_QUEUE = "ready_to_queue"
QUEUED = "queued"
SENDING = "sending"
SENT = "sent"
FAILED = "failed"
CANCELLED = "cancelled"
ARCHIVED = "archived"
class CampaignVersionWorkflowState(StrEnum):
EDITING = "editing"
UNDER_REVIEW = "under_review"
APPROVED = "approved"
BUILT = "built"
QUEUED = "queued"
SENDING = "sending"
COMPLETED = "completed"
CANCELLED = "cancelled"
ARCHIVED = "archived"
class CampaignVersionFlow(StrEnum):
CREATE = "create"
REVIEW = "review"
SEND = "send"
MANUAL = "manual"
JSON = "json"
class JobBuildStatus(StrEnum):
PENDING = "pending"
BUILT = "built"
BUILD_FAILED = "build_failed"
class JobValidationStatus(StrEnum):
READY = "ready"
WARNING = "warning"
NEEDS_REVIEW = "needs_review"
BLOCKED = "blocked"
EXCLUDED = "excluded"
INACTIVE = "inactive"
class JobQueueStatus(StrEnum):
DRAFT = "draft"
QUEUED = "queued"
SENDING = "sending"
PAUSED = "paused"
CANCELLED = "cancelled"
class JobSendStatus(StrEnum):
NOT_QUEUED = "not_queued"
QUEUED = "queued"
SENDING = "sending"
SENT = "sent"
FAILED_TEMPORARY = "failed_temporary"
FAILED_PERMANENT = "failed_permanent"
CANCELLED = "cancelled"
class JobImapStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
APPENDED = "appended"
FAILED = "failed"
SKIPPED = "skipped"
class IssueSeverity(StrEnum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
class Tenant(Base, TimestampMixin):
__tablename__ = "tenants"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
users: Mapped[list[User]] = relationship(back_populates="tenant", cascade="all, delete-orphan")
campaigns: Mapped[list[Campaign]] = relationship(back_populates="tenant", cascade="all, delete-orphan")
class User(Base, TimestampMixin):
__tablename__ = "users"
__table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_tenant_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
tenant: Mapped[Tenant] = relationship(back_populates="users")
api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan")
class Group(Base, TimestampMixin):
__tablename__ = "groups"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
class Role(Base, TimestampMixin):
__tablename__ = "roles"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
class ApiKey(Base, TimestampMixin):
__tablename__ = "api_keys"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
key_hash: Mapped[str] = mapped_column(String(128), nullable=False)
scopes: Mapped[list[str]] = mapped_column(JSON, default=list)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user: Mapped[User] = relationship(back_populates="api_keys")
class Campaign(Base, TimestampMixin):
__tablename__ = "campaigns"
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(50), default=CampaignStatus.DRAFT.value, nullable=False, index=True)
current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
tenant: Mapped[Tenant] = relationship(back_populates="campaigns")
versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
class CampaignVersion(Base, TimestampMixin):
__tablename__ = "campaign_versions"
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False)
source_filename: Mapped[str | None] = mapped_column(String(500))
source_base_path: Mapped[str | None] = mapped_column(String(1000))
# Editor/workflow metadata used by the WebUI and future desktop clients.
# A campaign version can be the autosaved working copy of a new or existing
# campaign, so no separate CampaignDraft entity is needed.
workflow_state: Mapped[str] = mapped_column(
String(50),
default=CampaignVersionWorkflowState.EDITING.value,
nullable=False,
index=True,
)
current_flow: Mapped[str] = mapped_column(
String(50),
default=CampaignVersionFlow.MANUAL.value,
nullable=False,
index=True,
)
current_step: Mapped[str | None] = mapped_column(String(100))
is_complete: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
editor_state: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
campaign: Mapped[Campaign] = relationship(back_populates="versions")
class CampaignJob(Base, TimestampMixin):
__tablename__ = "campaign_jobs"
__table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
entry_index: Mapped[int] = mapped_column(Integer, nullable=False)
entry_id: Mapped[str | None] = mapped_column(String(255), index=True)
recipient_email: Mapped[str | None] = mapped_column(String(320), index=True)
subject: Mapped[str | None] = mapped_column(String(998))
message_id_header: Mapped[str | None] = mapped_column(String(255))
eml_storage_key: Mapped[str | None] = mapped_column(String(1000))
eml_local_path: Mapped[str | None] = mapped_column(String(1000))
eml_size_bytes: Mapped[int | None] = mapped_column(Integer)
build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True)
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True)
send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True)
imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_error: Mapped[str | None] = mapped_column(Text)
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
campaign: Mapped[Campaign] = relationship(back_populates="jobs")
class CampaignIssue(Base, TimestampMixin):
__tablename__ = "campaign_issues"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True)
job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True)
severity: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
message: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[str | None] = mapped_column(String(255))
behavior: Mapped[str | None] = mapped_column(String(50))
class AttachmentBlob(Base, TimestampMixin):
__tablename__ = "attachment_blobs"
__table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
mime_type: Mapped[str | None] = mapped_column(String(255))
storage_bucket: Mapped[str] = mapped_column(String(255), nullable=False)
storage_key: Mapped[str] = mapped_column(String(1000), nullable=False)
class AttachmentInstance(Base, TimestampMixin):
__tablename__ = "attachment_instances"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True)
blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True)
logical_name: Mapped[str | None] = mapped_column(String(500))
filename: Mapped[str] = mapped_column(String(500), nullable=False)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
class SendAttempt(Base, TimestampMixin):
__tablename__ = "send_attempts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
smtp_status_code: Mapped[int | None] = mapped_column(Integer)
smtp_response: Mapped[str | None] = mapped_column(Text)
error_type: Mapped[str | None] = mapped_column(String(255))
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ImapAppendAttempt(Base, TimestampMixin):
__tablename__ = "imap_append_attempts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
folder: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(50), nullable=False)
error_message: Mapped[str | None] = mapped_column(Text)
class AuditLog(Base, TimestampMixin):
__tablename__ = "audit_log"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
api_key_id: Mapped[str | None] = mapped_column(ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, index=True)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
object_type: Mapped[str | None] = mapped_column(String(100), index=True)
object_id: Mapped[str | None] = mapped_column(String(100), index=True)
details: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)

17
server/app/db/session.py Normal file
View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.settings import settings
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_session() -> Generator[Session, None, None]:
with SessionLocal() as session:
yield session