Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
"""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 (
|
||||
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("'{}'")
|
||||
)
|
||||
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'"))
|
||||
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("'[]'")
|
||||
)
|
||||
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)
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user