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>
201 lines
6.8 KiB
Python
201 lines
6.8 KiB
Python
"""initial schema (SPEC §4)
|
|
|
|
Revision ID: 0001
|
|
Revises:
|
|
Create Date: 2026-07-16
|
|
|
|
The authoritative DDL lives in SPEC §4; this migration reproduces it verbatim.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
|
|
from alembic import op
|
|
|
|
revision: str = "0001"
|
|
down_revision: str | None = None
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
UPGRADE_STMTS: list[str] = [
|
|
"CREATE EXTENSION IF NOT EXISTS citext",
|
|
"""
|
|
CREATE TABLE users (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
handle citext UNIQUE NOT NULL,
|
|
password_hash text NOT NULL,
|
|
totp_secret bytea,
|
|
recovery_email citext,
|
|
settings jsonb NOT NULL DEFAULT '{}',
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE accounts (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
email citext NOT NULL,
|
|
display_name text NOT NULL DEFAULT '',
|
|
color text NOT NULL DEFAULT '#888888',
|
|
imap_config jsonb NOT NULL,
|
|
smtp_config jsonb NOT NULL,
|
|
secret_ct bytea NOT NULL,
|
|
discovery_src text NOT NULL,
|
|
state text NOT NULL DEFAULT 'backfilling',
|
|
state_detail text NOT NULL DEFAULT '',
|
|
last_sync_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE (user_id, email)
|
|
)
|
|
""",
|
|
"CREATE INDEX ix_accounts_user_id ON accounts (user_id)",
|
|
"""
|
|
CREATE TABLE mailboxes (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
path text NOT NULL,
|
|
special_use text,
|
|
uidvalidity bigint NOT NULL DEFAULT 0,
|
|
highestmodseq bigint,
|
|
last_seen_uid bigint NOT NULL DEFAULT 0,
|
|
UNIQUE (account_id, path)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE threads (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
subject_norm text NOT NULL,
|
|
snippet text NOT NULL DEFAULT '',
|
|
participants jsonb NOT NULL DEFAULT '[]',
|
|
account_ids uuid[] NOT NULL DEFAULT '{}',
|
|
message_count int NOT NULL DEFAULT 0,
|
|
unread_count int NOT NULL DEFAULT 0,
|
|
has_attachments boolean NOT NULL DEFAULT false,
|
|
last_message_at timestamptz NOT NULL
|
|
)
|
|
""",
|
|
"CREATE INDEX ix_threads_user_last ON threads (user_id, last_message_at DESC)",
|
|
"""
|
|
CREATE TABLE messages (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
mailbox_id uuid NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
|
uid bigint NOT NULL,
|
|
thread_id uuid REFERENCES threads(id),
|
|
message_id_hdr text,
|
|
in_reply_to text,
|
|
references_hdr text[] NOT NULL DEFAULT '{}',
|
|
subject text NOT NULL DEFAULT '',
|
|
from_addr jsonb NOT NULL DEFAULT '[]',
|
|
to_addr jsonb NOT NULL DEFAULT '[]',
|
|
cc_addr jsonb NOT NULL DEFAULT '[]',
|
|
date_hdr timestamptz,
|
|
internal_date timestamptz NOT NULL,
|
|
flags text[] NOT NULL DEFAULT '{}',
|
|
size_bytes int NOT NULL DEFAULT 0,
|
|
has_attachments boolean NOT NULL DEFAULT false,
|
|
snippet text NOT NULL DEFAULT '',
|
|
blob_sha256 text NOT NULL,
|
|
body_indexed tsvector,
|
|
UNIQUE (mailbox_id, uid)
|
|
)
|
|
""",
|
|
"CREATE INDEX ix_messages_user_thread ON messages (user_id, thread_id)",
|
|
"CREATE INDEX ix_messages_account_msgid ON messages (account_id, message_id_hdr)",
|
|
"CREATE INDEX ix_messages_body_indexed ON messages USING gin (body_indexed)",
|
|
"""
|
|
CREATE TABLE body_parts (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
kind text NOT NULL,
|
|
content text NOT NULL,
|
|
sanitizer_v int NOT NULL DEFAULT 0
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE attachments (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
user_id uuid NOT NULL,
|
|
filename text NOT NULL,
|
|
mime_type text NOT NULL,
|
|
size_bytes int NOT NULL,
|
|
content_id text,
|
|
blob_sha256 text NOT NULL
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE drafts (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
thread_id uuid REFERENCES threads(id),
|
|
reply_to_message_id uuid REFERENCES messages(id),
|
|
kind text NOT NULL DEFAULT 'reply',
|
|
payload jsonb NOT NULL,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE outbox (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL,
|
|
draft_id uuid NOT NULL,
|
|
state text NOT NULL DEFAULT 'undo_window',
|
|
send_after timestamptz NOT NULL,
|
|
error text
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE saved_searches (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name text NOT NULL,
|
|
query text NOT NULL,
|
|
pinned boolean NOT NULL DEFAULT true,
|
|
UNIQUE (user_id, name)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE known_recipients (
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
email citext NOT NULL,
|
|
name text NOT NULL DEFAULT '',
|
|
times_used int NOT NULL DEFAULT 0,
|
|
last_used_at timestamptz NOT NULL,
|
|
PRIMARY KEY (user_id, email)
|
|
)
|
|
""",
|
|
"CREATE INDEX ix_known_recipients_user_email "
|
|
"ON known_recipients (user_id, email text_pattern_ops)",
|
|
]
|
|
|
|
DOWNGRADE_STMTS: list[str] = [
|
|
"DROP TABLE IF EXISTS known_recipients",
|
|
"DROP TABLE IF EXISTS saved_searches",
|
|
"DROP TABLE IF EXISTS outbox",
|
|
"DROP TABLE IF EXISTS drafts",
|
|
"DROP TABLE IF EXISTS attachments",
|
|
"DROP TABLE IF EXISTS body_parts",
|
|
"DROP TABLE IF EXISTS messages",
|
|
"DROP TABLE IF EXISTS threads",
|
|
"DROP TABLE IF EXISTS mailboxes",
|
|
"DROP TABLE IF EXISTS accounts",
|
|
"DROP TABLE IF EXISTS users",
|
|
]
|
|
|
|
|
|
def upgrade() -> None:
|
|
for stmt in UPGRADE_STMTS:
|
|
op.execute(stmt)
|
|
|
|
|
|
def downgrade() -> None:
|
|
for stmt in DOWNGRADE_STMTS:
|
|
op.execute(stmt)
|