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,124 @@
|
||||
"""Duplicate Message-ID handling in the indexer (SPEC §7.1).
|
||||
|
||||
Real-world trigger: home.pl's welcome mailer reuses one static Message-ID for
|
||||
every recipient, so the same id lands in different accounts as different messages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.message import EmailMessage
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.models import Account, Mailbox, Message, User
|
||||
from app.providers.base import RemoteMessage
|
||||
|
||||
|
||||
class Dupenv:
|
||||
def __init__(self, session: AsyncSession, user_id: uuid.UUID) -> None:
|
||||
self.session = session
|
||||
self.user_id = user_id
|
||||
self.accounts: dict[str, uuid.UUID] = {}
|
||||
self.mailboxes: dict[str, uuid.UUID] = {}
|
||||
self._uid = 0
|
||||
|
||||
async def add_account(self, label: str) -> None:
|
||||
acc = Account(
|
||||
user_id=self.user_id,
|
||||
email=f"{label}@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
self.session.add(acc)
|
||||
await self.session.flush()
|
||||
self.accounts[label] = acc.id
|
||||
|
||||
async def add_mailbox(self, account_label: str, path: str) -> None:
|
||||
mb = Mailbox(
|
||||
account_id=self.accounts[account_label],
|
||||
user_id=self.user_id,
|
||||
path=path,
|
||||
special_use="inbox",
|
||||
)
|
||||
self.session.add(mb)
|
||||
await self.session.flush()
|
||||
self.mailboxes[f"{account_label}/{path}"] = mb.id
|
||||
|
||||
async def ingest(
|
||||
self, account_label: str, path: str, *, mid: str, minutes: int = 0
|
||||
) -> Message | None:
|
||||
self._uid += 1
|
||||
m = EmailMessage()
|
||||
m["Subject"] = "Pierwsze kroki z pocztą w home.pl"
|
||||
m["From"] = "info@home.pl"
|
||||
m["Message-ID"] = mid
|
||||
m["Date"] = "Sun, 23 Nov 2025 12:47:30 +0000"
|
||||
m.set_content("welcome template body")
|
||||
remote = RemoteMessage(
|
||||
uid=self._uid,
|
||||
raw=m.as_bytes(),
|
||||
flags=frozenset(),
|
||||
internal_date=datetime(2026, 1, 1, tzinfo=UTC) + timedelta(minutes=minutes),
|
||||
)
|
||||
result = await persist_message(
|
||||
self.session,
|
||||
user_id=self.user_id,
|
||||
account_id=self.accounts[account_label],
|
||||
mailbox_id=self.mailboxes[f"{account_label}/{path}"],
|
||||
mailbox_special_use="inbox",
|
||||
remote=remote,
|
||||
)
|
||||
return result.message if result else None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def dup() -> AsyncIterator[Dupenv]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"dup{uuid.uuid4().hex[:8]}@test.local", password_hash="x")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
env = Dupenv(session, user.id)
|
||||
await env.add_account("a")
|
||||
await env.add_account("b")
|
||||
await env.add_mailbox("a", "INBOX")
|
||||
await env.add_mailbox("a", "Archive")
|
||||
await env.add_mailbox("b", "INBOX")
|
||||
try:
|
||||
yield env
|
||||
finally:
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_cross_account_duplicate_isolates(dup: Dupenv) -> None:
|
||||
# Same static Message-ID delivered to two different accounts -> two threads.
|
||||
a = await dup.ingest("a", "INBOX", mid="<static@home.pl>", minutes=0)
|
||||
b = await dup.ingest("b", "INBOX", mid="<static@home.pl>", minutes=60)
|
||||
assert a is not None and b is not None
|
||||
assert a.thread_id != b.thread_id
|
||||
# The second one was stored with a synthesized, non-authoritative id.
|
||||
assert a.message_id_hdr == "static@home.pl"
|
||||
assert b.message_id_hdr.endswith("@synthetic.local")
|
||||
|
||||
|
||||
async def test_same_account_duplicate_merges(dup: Dupenv) -> None:
|
||||
# Same message in INBOX + Archive of ONE account (Gmail-style) -> one thread.
|
||||
a = await dup.ingest("a", "INBOX", mid="<msg@x.com>", minutes=0)
|
||||
b = await dup.ingest("a", "Archive", mid="<msg@x.com>", minutes=1)
|
||||
assert a is not None and b is not None
|
||||
assert a.thread_id == b.thread_id
|
||||
assert b.message_id_hdr == "msg@x.com"
|
||||
Reference in New Issue
Block a user