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) auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False) password_hash: Mapped[str | None] = mapped_column(String(500)) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) tenant: Mapped[Tenant] = relationship(back_populates="users") api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan") auth_sessions: Mapped[list[AuthSession]] = 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 UserGroupMembership(Base, TimestampMixin): __tablename__ = "user_group_memberships" __table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),) 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) group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True) class UserRoleAssignment(Base, TimestampMixin): __tablename__ = "user_role_assignments" __table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),) 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) role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True) class GroupRoleAssignment(Base, TimestampMixin): __tablename__ = "group_role_assignments" __table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),) 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) group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True) role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True) 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 AuthSession(Base, TimestampMixin): __tablename__ = "auth_sessions" 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) token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) user_agent: Mapped[str | None] = mapped_column(String(500)) ip_address: Mapped[str | None] = mapped_column(String(100)) user: Mapped[User] = relationship(back_populates="auth_sessions") 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 FileBlob(Base, TimestampMixin): __tablename__ = "file_blobs" __table_args__ = (UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),) 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) storage_backend: Mapped[str] = mapped_column(String(50), nullable=False) storage_bucket: Mapped[str | None] = mapped_column(String(255)) storage_key: Mapped[str] = mapped_column(String(1000), nullable=False) checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) content_type: Mapped[str | None] = mapped_column(String(255)) ref_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False) retained_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class FileFolder(Base, TimestampMixin): __tablename__ = "file_folders" 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_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) class FileAsset(Base, TimestampMixin): __tablename__ = "file_assets" 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_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) display_path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True) filename: Mapped[str] = mapped_column(String(500), nullable=False, index=True) description: Mapped[str | None] = mapped_column(Text) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) class FileVersion(Base, TimestampMixin): __tablename__ = "file_versions" __table_args__ = (UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),) 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) file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True) blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True) version_number: Mapped[int] = mapped_column(Integer, nullable=False) filename_at_upload: Mapped[str] = mapped_column(String(500), nullable=False) display_path_at_upload: Mapped[str] = mapped_column(String(1000), nullable=False) content_type: Mapped[str | None] = mapped_column(String(255)) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) class FileShare(Base, TimestampMixin): __tablename__ = "file_shares" __table_args__ = (UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),) 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) file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True) target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) class CampaignAttachmentUse(Base, TimestampMixin): __tablename__ = "campaign_attachment_uses" __table_args__ = (UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),) 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) campaign_job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="SET NULL"), nullable=True, index=True) entry_index: Mapped[int | None] = mapped_column(Integer) entry_id: Mapped[str | None] = mapped_column(String(255), index=True) file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="RESTRICT"), nullable=False, index=True) file_version_id: Mapped[str] = mapped_column(ForeignKey("file_versions.id", ondelete="RESTRICT"), nullable=False, index=True) file_blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True) filename_used: Mapped[str] = mapped_column(String(500), nullable=False) checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) content_type: Mapped[str | None] = mapped_column(String(255)) use_stage: Mapped[str] = mapped_column(String(20), default="built", nullable=False, index=True) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=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)