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,200 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""index messages(user_id, message_id_hdr)
|
||||
|
||||
Speeds the per-user Message-ID lookups used by JWZ threading (§7.2 candidate
|
||||
set) and by the duplicate-Message-ID resolution in the indexer (§7.1).
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-07-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: str | None = "0001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE INDEX ix_messages_user_msgid ON messages (user_id, message_id_hdr)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_messages_user_msgid")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add messages.has_remote_images
|
||||
|
||||
Surfaces whether the sanitizer blocked remote images so the UI can show the
|
||||
"load images" bar (SPEC §9.4).
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-07-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: str | None = "0002"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE messages ADD COLUMN has_remote_images boolean NOT NULL DEFAULT false")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS has_remote_images")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add threads.pinned
|
||||
|
||||
Denormalized "pinned" state for a thread (SPEC extension for the UI pin action):
|
||||
true when any message in the thread carries the \\Flagged flag.
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-07-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE threads ADD COLUMN pinned boolean NOT NULL DEFAULT false")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE threads DROP COLUMN IF EXISTS pinned")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add compose_attachments
|
||||
|
||||
Files uploaded for outgoing mail (the compose "Attach" action). Referenced by a
|
||||
draft's payload.attachment_ids and assembled into the MIME at send time.
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-07-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005"
|
||||
down_revision: str | None = "0004"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"compose_attachments",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.dialects.postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.dialects.postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("filename", sa.Text(), nullable=False),
|
||||
sa.Column("mime_type", sa.Text(), nullable=False),
|
||||
sa.Column("size_bytes", sa.Integer(), nullable=False),
|
||||
sa.Column("blob_sha256", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("compose_attachments")
|
||||
Reference in New Issue
Block a user