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
+15
View File
@@ -0,0 +1,15 @@
"""Fixtures for threading tests: the shared rollback session + a threading env."""
from __future__ import annotations
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from tests.threading.env import ThreadingEnv
@pytest_asyncio.fixture
async def env(db_session: AsyncSession) -> ThreadingEnv:
e = ThreadingEnv(db_session)
await e.setup()
return e
+87
View File
@@ -0,0 +1,87 @@
"""Test helper: a user with two accounts/mailboxes + a message factory.
Kept out of ``conftest.py`` so tests can import :class:`ThreadingEnv` without
re-importing the conftest module.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Account, Mailbox, Message, User
from app.threading_.threader import thread_message
BASE = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
class ThreadingEnv:
"""A user with two accounts + one mailbox each, plus a message factory."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.user_id: uuid.UUID
self.account_a: uuid.UUID
self.account_b: uuid.UUID
self.mailbox_a: uuid.UUID
self.mailbox_b: uuid.UUID
self._uid = 0
async def setup(self) -> None:
user = User(handle=f"t{uuid.uuid4().hex[:8]}@test.local", password_hash="x")
self.session.add(user)
await self.session.flush()
self.user_id = user.id
for label in ("a", "b"):
account = Account(
user_id=self.user_id,
email=f"{label}@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
self.session.add(account)
await self.session.flush()
mailbox = Mailbox(account_id=account.id, user_id=self.user_id, path="INBOX")
self.session.add(mailbox)
await self.session.flush()
setattr(self, f"account_{label}", account.id)
setattr(self, f"mailbox_{label}", mailbox.id)
async def add(
self,
*,
message_id: str | None,
subject: str = "Subject",
references: list[str] | None = None,
in_reply_to: str | None = None,
from_email: str = "alice@example.com",
to_email: str = "bob@example.com",
minutes: int = 0,
account: str = "a",
flags: list[str] | None = None,
) -> Message:
self._uid += 1
msg = Message(
user_id=self.user_id,
account_id=getattr(self, f"account_{account}"),
mailbox_id=getattr(self, f"mailbox_{account}"),
uid=self._uid,
message_id_hdr=message_id,
in_reply_to=in_reply_to,
references_hdr=references or [],
subject=subject,
from_addr=[{"name": "", "email": from_email}],
to_addr=[{"name": "", "email": to_email}],
internal_date=BASE + timedelta(minutes=minutes),
flags=flags or [],
blob_sha256="0" * 64,
snippet=subject,
)
self.session.add(msg)
await self.session.flush()
await thread_message(self.session, msg)
return msg
+55
View File
@@ -0,0 +1,55 @@
"""Message-ID and subject normalization (SPEC §7.1, §7.3)."""
from __future__ import annotations
from app.threading_.normalize import (
is_synthetic,
normalize_message_id,
normalize_reference_list,
normalize_subject,
synthesize_message_id,
)
def test_normalize_message_id_strips_brackets_and_lowercases_domain() -> None:
assert normalize_message_id("<Abc.123@Example.COM>") == "Abc.123@example.com"
assert normalize_message_id(" <x@Y.Z> ") == "x@y.z"
# local part case is preserved, domain lowercased
assert normalize_message_id("<CaseSensitive@Domain.Com>") == "CaseSensitive@domain.com"
def test_normalize_message_id_rejects_invalid() -> None:
assert normalize_message_id(None) is None
assert normalize_message_id("no-at-sign") is None
assert normalize_message_id("<>") is None
assert normalize_message_id("@nolocal.com") is None
def test_normalize_reference_list_order_and_dedupe() -> None:
raw = "<a@x.com> <b@y.com> <a@X.com>"
assert normalize_reference_list(raw) == ["a@x.com", "b@y.com"]
assert normalize_reference_list("") == []
assert normalize_reference_list(None) == []
def test_normalize_reference_list_whitespace_separated_without_brackets() -> None:
assert normalize_reference_list("a@x.com b@y.com") == ["a@x.com", "b@y.com"]
def test_synthetic_id() -> None:
sid = synthesize_message_id(b"raw bytes here")
assert sid.endswith("@synthetic.local")
assert is_synthetic(sid)
assert not is_synthetic("real@example.com")
assert not is_synthetic(None)
# deterministic
assert synthesize_message_id(b"raw bytes here") == sid
def test_normalize_subject_strips_prefixes() -> None:
assert normalize_subject("Re: Hello") == "Hello"
assert normalize_subject("RE: FW: Fwd: Hello world") == "Hello world"
assert normalize_subject("Odp: Cześć") == "Cześć" # Polish reply prefix
assert normalize_subject("PD: X") == "X"
assert normalize_subject("No prefix") == "No prefix"
assert normalize_subject("") == ""
+137
View File
@@ -0,0 +1,137 @@
"""JWZ threading test vectors (SPEC §7.4). All must pass."""
from __future__ import annotations
from tests.threading.env import ThreadingEnv
async def test_linear_chain(env: ThreadingEnv) -> None:
a = await env.add(message_id="a@x.com", subject="Hello")
b = await env.add(
message_id="b@x.com",
subject="Re: Hello",
in_reply_to="a@x.com",
references=["a@x.com"],
minutes=1,
)
c = await env.add(
message_id="c@x.com",
subject="Re: Hello",
in_reply_to="b@x.com",
references=["a@x.com", "b@x.com"],
minutes=2,
)
assert a.thread_id == b.thread_id == c.thread_id
async def test_chain_out_of_order(env: ThreadingEnv) -> None:
# C arrives first, then A, then B — all must end in one thread.
c = await env.add(
message_id="c@x.com", subject="Re: Hi", references=["a@x.com", "b@x.com"], minutes=2
)
a = await env.add(message_id="a@x.com", subject="Hi", minutes=0)
b = await env.add(message_id="b@x.com", subject="Re: Hi", references=["a@x.com"], minutes=1)
assert a.thread_id == b.thread_id == c.thread_id
async def test_reply_missing_in_reply_to_but_has_references(env: ThreadingEnv) -> None:
a = await env.add(message_id="a@x.com", subject="Topic")
b = await env.add(
message_id="b@x.com", subject="Re: Topic", references=["a@x.com"], minutes=1
) # no In-Reply-To
assert a.thread_id == b.thread_id
async def test_thread_merge_on_late_parent(env: ThreadingEnv) -> None:
# Two children of A arrive first (each referencing only A) forming separate
# threads; the late parent A must merge them into one.
b = await env.add(message_id="b@x.com", subject="Re: T", references=["a@x.com"], minutes=1)
c = await env.add(message_id="c@x.com", subject="Re: T", references=["a@x.com"], minutes=2)
# b and c both reference a but not each other -> initially could be separate threads
a = await env.add(message_id="a@x.com", subject="T", minutes=0)
await env.session.refresh(b)
await env.session.refresh(c)
assert a.thread_id == b.thread_id == c.thread_id
async def test_broken_client_subject_only_joins(env: ThreadingEnv) -> None:
# A client that drops References; joins by subject + shared participant within 7 days.
a = await env.add(
message_id="a@x.com",
subject="Project update",
from_email="alice@example.com",
to_email="bob@example.com",
)
b = await env.add(
message_id="b@x.com",
subject="Re: Project update",
from_email="bob@example.com",
to_email="alice@example.com",
minutes=60,
) # no references, shares participants
assert a.thread_id == b.thread_id
async def test_distinct_topic_same_subject_does_not_join(env: ThreadingEnv) -> None:
a = await env.add(
message_id="a@x.com",
subject="hi",
from_email="alice@example.com",
to_email="bob@example.com",
)
b = await env.add(
message_id="b@x.com",
subject="hi",
from_email="carol@example.com",
to_email="dave@example.com",
minutes=5,
)
assert a.thread_id != b.thread_id
async def test_cross_account_merge(env: ThreadingEnv) -> None:
# A message in account A references a message in account B -> one thread (I-3).
a = await env.add(message_id="a@x.com", subject="Shared", account="a")
b = await env.add(
message_id="b@x.com", subject="Re: Shared", references=["a@x.com"], account="b", minutes=1
)
assert a.thread_id == b.thread_id
assert a.account_id != b.account_id
async def test_duplicate_message_id_same_account_merges(env: ThreadingEnv) -> None:
# Same message in two mailboxes of ONE account (e.g. Gmail INBOX + All Mail):
# the shared Message-ID is authoritative -> one thread. (Threading-level view;
# the indexer decides authoritative vs synthetic per SPEC §7.1 — see indexer tests.)
a1 = await env.add(message_id="dup@x.com", subject="Dup", account="a")
a2 = await env.add(message_id="dup@x.com", subject="Dup", account="a", minutes=1)
assert a1.thread_id == a2.thread_id
async def test_synthetic_ids_do_not_join(env: ThreadingEnv) -> None:
# Two messages that were given synthetic ids (§7.1 duplicate handling) with no
# references must not merge, even with the same subject far apart in time.
a = await env.add(message_id="deadbeef@synthetic.local", subject="Welcome", minutes=0)
b = await env.add(
message_id="cafebabe@synthetic.local", subject="Welcome", account="b", minutes=60 * 24 * 30
)
assert a.thread_id != b.thread_id
async def test_synthetic_id_isolation(env: ThreadingEnv) -> None:
# Two messages lacking Message-ID and References with empty subjects never join.
a = await env.add(message_id=None, subject="")
b = await env.add(message_id=None, subject="", minutes=1)
assert a.thread_id != b.thread_id
async def test_denormalization_counts(env: ThreadingEnv) -> None:
from app.models import Thread
a = await env.add(message_id="a@x.com", subject="Count", flags=["\\Seen"])
await env.add(message_id="b@x.com", subject="Re: Count", references=["a@x.com"], minutes=1)
thread = await env.session.get(Thread, a.thread_id)
assert thread is not None
assert thread.message_count == 2
assert thread.unread_count == 1 # only b is unread
assert thread.snippet == "Re: Count" # newest message's snippet