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,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
|
||||
@@ -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
|
||||
@@ -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("") == ""
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user