"""SQLAlchemy ORM models mirroring the authoritative DDL in SPEC §4. The Alembic initial migration is generated to match these models exactly. Invariant I-7: every table carrying user data has an indexed ``user_id``. """ from __future__ import annotations import datetime as dt import uuid from sqlalchemy import ( BigInteger, ForeignKey, Index, Integer, Text, UniqueConstraint, text, ) from sqlalchemy.dialects.postgresql import ( ARRAY, BYTEA, CITEXT, JSONB, TIMESTAMP, TSVECTOR, UUID, ) from sqlalchemy.orm import Mapped, mapped_column from app.db import Base def _uuid_pk() -> Mapped[uuid.UUID]: return mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) def _now() -> Mapped[dt.datetime]: return mapped_column(TIMESTAMP(timezone=True), nullable=False, server_default=text("now()")) class User(Base): __tablename__ = "users" id: Mapped[uuid.UUID] = _uuid_pk() handle: Mapped[str] = mapped_column(CITEXT, unique=True, nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False) totp_secret: Mapped[bytes | None] = mapped_column(BYTEA) recovery_email: Mapped[str | None] = mapped_column(CITEXT) settings: Mapped[dict[str, object]] = mapped_column( JSONB, nullable=False, server_default=text("'{}'") ) # Storage quota in bytes; per-user so an admin can override it later. # Default 5 GiB (5 * 1024^3). quota_bytes: Mapped[int] = mapped_column( BigInteger, nullable=False, server_default=text("5368709120") ) created_at: Mapped[dt.datetime] = _now() class Account(Base): __tablename__ = "accounts" __table_args__ = (UniqueConstraint("user_id", "email", name="uq_accounts_user_email"),) id: Mapped[uuid.UUID] = _uuid_pk() user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) email: Mapped[str] = mapped_column(CITEXT, nullable=False) display_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) color: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'#888888'")) # Per-account HTML signature, inserted into new messages (and via compose menu). signature: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False) discovery_src: Mapped[str] = mapped_column(Text, nullable=False) state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'backfilling'")) state_detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) last_sync_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True)) created_at: Mapped[dt.datetime] = _now() class Mailbox(Base): __tablename__ = "mailboxes" __table_args__ = (UniqueConstraint("account_id", "path", name="uq_mailboxes_account_path"),) id: Mapped[uuid.UUID] = _uuid_pk() account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) path: Mapped[str] = mapped_column(Text, nullable=False) special_use: Mapped[str | None] = mapped_column(Text) uidvalidity: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) highestmodseq: Mapped[int | None] = mapped_column(Integer) last_seen_uid: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) class Thread(Base): __tablename__ = "threads" __table_args__ = (Index("ix_threads_user_last", "user_id", text("last_message_at DESC")),) id: Mapped[uuid.UUID] = _uuid_pk() user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) subject_norm: Mapped[str] = mapped_column(Text, nullable=False) snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) participants: Mapped[list[dict[str, str]]] = mapped_column( JSONB, nullable=False, server_default=text("'[]'") ) account_ids: Mapped[list[uuid.UUID]] = mapped_column( ARRAY(UUID(as_uuid=True)), nullable=False, server_default=text("'{}'") ) message_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) unread_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) last_message_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) class Message(Base): __tablename__ = "messages" __table_args__ = ( UniqueConstraint("mailbox_id", "uid", name="uq_messages_mailbox_uid"), Index("ix_messages_user_thread", "user_id", "thread_id"), Index("ix_messages_account_msgid", "account_id", "message_id_hdr"), Index("ix_messages_user_msgid", "user_id", "message_id_hdr"), Index("ix_messages_body_indexed", "body_indexed", postgresql_using="gin"), ) id: Mapped[uuid.UUID] = _uuid_pk() user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False ) mailbox_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False ) uid: Mapped[int] = mapped_column(Integer, nullable=False) thread_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("threads.id") ) message_id_hdr: Mapped[str | None] = mapped_column(Text) in_reply_to: Mapped[str | None] = mapped_column(Text) references_hdr: Mapped[list[str]] = mapped_column( ARRAY(Text), nullable=False, server_default=text("'{}'") ) subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) from_addr: Mapped[list[dict[str, str]]] = mapped_column( JSONB, nullable=False, server_default=text("'[]'") ) to_addr: Mapped[list[dict[str, str]]] = mapped_column( JSONB, nullable=False, server_default=text("'[]'") ) cc_addr: Mapped[list[dict[str, str]]] = mapped_column( JSONB, nullable=False, server_default=text("'[]'") ) bcc_addr: Mapped[list[dict[str, str]]] = mapped_column( JSONB, nullable=False, server_default=text("'[]'") ) date_hdr: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True)) internal_date: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) flags: Mapped[list[str]] = mapped_column( ARRAY(Text), nullable=False, server_default=text("'{}'") ) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) has_remote_images: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) blob_sha256: Mapped[str] = mapped_column(Text, nullable=False) body_indexed: Mapped[str | None] = mapped_column(TSVECTOR) class BodyPart(Base): __tablename__ = "body_parts" id: Mapped[uuid.UUID] = _uuid_pk() message_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False ) kind: Mapped[str] = mapped_column(Text, nullable=False) # text | html_sanitized content: Mapped[str] = mapped_column(Text, nullable=False) # SPEC §10.4: re-sanitize on render if the sanitizer version changed. sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) class Attachment(Base): __tablename__ = "attachments" id: Mapped[uuid.UUID] = _uuid_pk() message_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False ) user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) filename: Mapped[str] = mapped_column(Text, nullable=False) mime_type: Mapped[str] = mapped_column(Text, nullable=False) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) content_id: Mapped[str | None] = mapped_column(Text) blob_sha256: Mapped[str] = mapped_column(Text, nullable=False) class ComposeAttachment(Base): """A file uploaded for an outgoing message, referenced by a draft's payload.""" __tablename__ = "compose_attachments" id: Mapped[uuid.UUID] = _uuid_pk() user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) filename: Mapped[str] = mapped_column(Text, nullable=False) mime_type: Mapped[str] = mapped_column(Text, nullable=False) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) blob_sha256: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[dt.datetime] = _now() class Draft(Base): __tablename__ = "drafts" id: Mapped[uuid.UUID] = _uuid_pk() user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False ) thread_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("threads.id") ) reply_to_message_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("messages.id") ) kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'reply'")) payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) # UID of the mirrored copy in the account's IMAP Drafts folder (so the draft # is visible/continuable on other mail clients). Replaced on each edit. imap_uid: Mapped[int | None] = mapped_column(Integer) updated_at: Mapped[dt.datetime] = _now() class Outbox(Base): __tablename__ = "outbox" id: Mapped[uuid.UUID] = _uuid_pk() user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) draft_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'undo_window'")) send_after: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) error: Mapped[str | None] = mapped_column(Text) class SavedSearch(Base): __tablename__ = "saved_searches" __table_args__ = (UniqueConstraint("user_id", "name", name="uq_saved_searches_user_name"),) id: Mapped[uuid.UUID] = _uuid_pk() user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) name: Mapped[str] = mapped_column(Text, nullable=False) query: Mapped[str] = mapped_column(Text, nullable=False) pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true")) class KnownRecipient(Base): __tablename__ = "known_recipients" __table_args__ = ( Index( "ix_known_recipients_user_email", "user_id", text("email text_pattern_ops"), ), ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, ) email: Mapped[str] = mapped_column(CITEXT, primary_key=True) name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) times_used: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) last_used_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) __all__ = [ "Account", "Attachment", "BodyPart", "Draft", "KnownRecipient", "Mailbox", "Message", "Outbox", "SavedSearch", "Thread", "User", ]