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:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
"""Alembic environment — async engine, config sourced from app settings."""
from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy.pool import NullPool
# Import models so Base.metadata is populated for autogenerate.
import app.models # noqa: F401
from app.config import get_settings
from app.db import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=get_settings().database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: object) -> None:
context.configure(connection=connection, target_metadata=target_metadata) # type: ignore[arg-type]
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+27
View File
@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+200
View File
@@ -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")