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,303 @@
|
||||
"""Flag / move actions (SPEC §6.3, §9.1): DB-first + enqueue + revert."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.api import actions as actions_api
|
||||
from app.config import get_settings
|
||||
from app.db import session_scope
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.main import create_app
|
||||
from app.models import Account, Mailbox, Message, Thread, User
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.sync import actions as sync_actions
|
||||
|
||||
CSRF = "csrf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
client: httpx.AsyncClient
|
||||
session: AsyncSession
|
||||
user_id: uuid.UUID
|
||||
inbox_id: uuid.UUID
|
||||
archive_id: uuid.UUID
|
||||
message_ids: list[uuid.UUID] = field(default_factory=list)
|
||||
thread_id: uuid.UUID | None = None
|
||||
enqueued: list[tuple] = field(default_factory=list)
|
||||
|
||||
|
||||
def _hdr() -> dict[str, str]:
|
||||
return {"X-CSRF": CSRF}
|
||||
|
||||
|
||||
def _raw(subject: str, mid: str, refs: str | None = None) -> bytes:
|
||||
m = EmailMessage()
|
||||
m["Subject"] = subject
|
||||
m["From"] = "a@x.com"
|
||||
m["To"] = "me@example.com"
|
||||
m["Message-ID"] = mid
|
||||
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
||||
if refs:
|
||||
m["References"] = refs
|
||||
m.set_content("body")
|
||||
return m.as_bytes()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def]
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"act{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
|
||||
session.add_all([inbox, archive])
|
||||
await session.commit()
|
||||
|
||||
c = Ctx(
|
||||
client=None,
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
inbox_id=inbox.id, # type: ignore[arg-type]
|
||||
archive_id=archive.id,
|
||||
)
|
||||
for i, (subj, mid, refs) in enumerate(
|
||||
[
|
||||
("Topic", "<a@x.com>", None),
|
||||
("Re: Topic", "<b@x.com>", "<a@x.com>"),
|
||||
]
|
||||
):
|
||||
remote = RemoteMessage(
|
||||
uid=i + 1, raw=_raw(subj, mid, refs), flags=frozenset(), internal_date=datetime.now(UTC)
|
||||
)
|
||||
result = await persist_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
mailbox_id=inbox.id,
|
||||
mailbox_special_use="inbox",
|
||||
remote=remote,
|
||||
)
|
||||
await session.commit()
|
||||
assert result is not None
|
||||
c.message_ids.append(result.message.id)
|
||||
c.thread_id = result.message.thread_id
|
||||
|
||||
# capture enqueues instead of hitting a real arq pool
|
||||
class FakePool:
|
||||
async def enqueue_job(self, *args: object) -> None:
|
||||
c.enqueued.append(args)
|
||||
|
||||
async def fake_pool() -> FakePool:
|
||||
return FakePool()
|
||||
|
||||
monkeypatch.setattr(actions_api, "get_arq_pool", fake_pool)
|
||||
|
||||
token = await create_session(user.id, "pytest")
|
||||
c.client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
await c.client.aclose()
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_mark_message_read_enqueues_push(ctx: Ctx) -> None:
|
||||
mid = ctx.message_ids[0]
|
||||
resp = await ctx.client.post(
|
||||
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "\\Seen" in resp.json()["flags"]
|
||||
|
||||
await ctx.session.refresh(await ctx.session.get(Message, mid))
|
||||
msg = await ctx.session.get(Message, mid)
|
||||
assert msg is not None and "\\Seen" in msg.flags
|
||||
assert ctx.enqueued == [("push_flags_job", str(mid), ["\\Seen"], [])]
|
||||
|
||||
|
||||
async def test_noop_flag_change_does_not_enqueue(ctx: Ctx) -> None:
|
||||
mid = ctx.message_ids[0]
|
||||
await ctx.client.post(f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr())
|
||||
ctx.enqueued.clear()
|
||||
# already \Seen -> no-op
|
||||
resp = await ctx.client.post(
|
||||
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert ctx.enqueued == []
|
||||
|
||||
|
||||
async def test_thread_flags_mark_all_read(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(
|
||||
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
thread = await ctx.session.get(Thread, ctx.thread_id)
|
||||
await ctx.session.refresh(thread)
|
||||
assert thread is not None and thread.unread_count == 0
|
||||
assert len(ctx.enqueued) == 2 # both messages pushed
|
||||
|
||||
|
||||
async def test_thread_flag_sets_pinned(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(
|
||||
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Flagged"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
thread = await ctx.session.get(Thread, ctx.thread_id)
|
||||
await ctx.session.refresh(thread)
|
||||
assert thread is not None and thread.pinned is True
|
||||
|
||||
# Unpinning clears it again.
|
||||
await ctx.client.post(
|
||||
f"/api/threads/{ctx.thread_id}/flags", json={"remove": ["\\Flagged"]}, headers=_hdr()
|
||||
)
|
||||
await ctx.session.refresh(thread)
|
||||
assert thread.pinned is False
|
||||
|
||||
|
||||
async def test_move_message(ctx: Ctx) -> None:
|
||||
mid = ctx.message_ids[0]
|
||||
resp = await ctx.client.post(
|
||||
f"/api/messages/{mid}/move", json={"mailbox_id": str(ctx.archive_id)}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = await ctx.session.get(Message, mid)
|
||||
await ctx.session.refresh(msg)
|
||||
assert msg is not None and msg.mailbox_id == ctx.archive_id
|
||||
assert ctx.enqueued[0][0] == "move_job"
|
||||
|
||||
|
||||
async def test_archive_thread_enqueues_move(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/archive", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "queued"}
|
||||
assert ctx.enqueued == [
|
||||
("move_thread_to_folder_job", str(ctx.thread_id), "archive"),
|
||||
]
|
||||
|
||||
|
||||
async def test_trash_thread_enqueues_move(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/trash", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
assert ctx.enqueued == [
|
||||
("move_thread_to_folder_job", str(ctx.thread_id), "trash"),
|
||||
]
|
||||
|
||||
|
||||
async def test_archive_unknown_thread_404(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{uuid.uuid4()}/archive", headers=_hdr())
|
||||
assert resp.status_code == 404
|
||||
assert ctx.enqueued == []
|
||||
|
||||
|
||||
async def test_restore_thread_enqueues_move_to_inbox(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/restore", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
assert ctx.enqueued == [
|
||||
("move_thread_to_folder_job", str(ctx.thread_id), "inbox"),
|
||||
]
|
||||
|
||||
|
||||
async def test_delete_thread_enqueues_permanent_delete(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/delete", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
assert ctx.enqueued == [("delete_thread_job", str(ctx.thread_id))]
|
||||
|
||||
|
||||
async def test_ignores_unknown_flags(ctx: Ctx) -> None:
|
||||
mid = ctx.message_ids[0]
|
||||
resp = await ctx.client.post(
|
||||
f"/api/messages/{mid}/flags", json={"add": ["\\Deleted", "bogus"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = await ctx.session.get(Message, mid)
|
||||
await ctx.session.refresh(msg)
|
||||
assert msg is not None and "\\Deleted" not in msg.flags # not user-settable here
|
||||
|
||||
|
||||
async def test_delete_thread_job_expunges_and_removes_rows(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
tid = ctx.thread_id
|
||||
assert tid is not None
|
||||
deleted: list[tuple[str, list[int]]] = []
|
||||
|
||||
class FakeProvider:
|
||||
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
|
||||
deleted.append((mailbox, uids))
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FakeProvider())
|
||||
|
||||
await sync_actions.delete_thread_job({}, str(tid))
|
||||
|
||||
# server expunge happened for the thread's INBOX uids
|
||||
assert deleted and deleted[0][0] == "INBOX"
|
||||
# DB rows for the thread's messages are gone (verify in a fresh session; the
|
||||
# job deleted them in its own session, so drop ctx's stale identity map).
|
||||
ctx.session.expunge_all()
|
||||
async with session_scope() as verify:
|
||||
remaining = await verify.scalar(
|
||||
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
||||
)
|
||||
assert remaining == 0
|
||||
assert await verify.scalar(select(Thread).where(Thread.id == tid)) is None
|
||||
|
||||
|
||||
async def test_push_flags_job_reverts_on_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
mid = ctx.message_ids[0]
|
||||
# mark read in DB first (as the endpoint would)
|
||||
msg = await ctx.session.get(Message, mid)
|
||||
assert msg is not None
|
||||
msg.flags = ["\\Seen"]
|
||||
await ctx.session.commit()
|
||||
|
||||
class FailingProvider:
|
||||
async def push_flags(self, *a: object, **k: object) -> None:
|
||||
raise RuntimeError("imap down")
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FailingProvider())
|
||||
|
||||
# final try -> should revert (remove the \Seen we added)
|
||||
await sync_actions.push_flags_job({"job_try": 5}, str(mid), ["\\Seen"], [])
|
||||
reverted = await ctx.session.get(Message, mid)
|
||||
await ctx.session.refresh(reverted)
|
||||
assert reverted is not None and "\\Seen" not in reverted.flags
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Recipient suggest, settings, and per-sender remote-image control (SPEC §9.1/§9.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
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.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.main import create_app
|
||||
from app.models import Account, KnownRecipient, Mailbox, User
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.search.pg import PgSearchBackend
|
||||
|
||||
CSRF = "csrf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
client: httpx.AsyncClient
|
||||
session: AsyncSession
|
||||
user_id: uuid.UUID
|
||||
account_id: uuid.UUID
|
||||
mailbox_id: uuid.UUID
|
||||
|
||||
|
||||
def _hdr() -> dict[str, str]:
|
||||
return {"X-CSRF": CSRF}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx() -> AsyncIterator[Ctx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"rs{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
session.add(mailbox)
|
||||
await session.commit()
|
||||
token = await create_session(user.id, "pytest")
|
||||
client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield Ctx(
|
||||
client=client,
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
mailbox_id=mailbox.id,
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_recipient_suggest_prefix_and_ranking(ctx: Ctx) -> None:
|
||||
now = datetime.now(UTC)
|
||||
ctx.session.add_all(
|
||||
[
|
||||
KnownRecipient(
|
||||
user_id=ctx.user_id,
|
||||
email="alice@example.com",
|
||||
name="Alice",
|
||||
times_used=10,
|
||||
last_used_at=now,
|
||||
),
|
||||
KnownRecipient(
|
||||
user_id=ctx.user_id,
|
||||
email="alan@example.com",
|
||||
name="Alan",
|
||||
times_used=1,
|
||||
last_used_at=now - timedelta(days=200),
|
||||
),
|
||||
KnownRecipient(
|
||||
user_id=ctx.user_id,
|
||||
email="bob@example.com",
|
||||
name="Bob",
|
||||
times_used=99,
|
||||
last_used_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
await ctx.session.commit()
|
||||
|
||||
resp = await ctx.client.get("/api/recipients/suggest", params={"q": "al"})
|
||||
assert resp.status_code == 200
|
||||
emails = [r["email"] for r in resp.json()]
|
||||
assert set(emails) == {"alice@example.com", "alan@example.com"} # prefix match, bob excluded
|
||||
assert emails[0] == "alice@example.com" # higher score (recent + frequent) ranks first
|
||||
|
||||
|
||||
async def test_recipient_suggest_empty_query(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.get("/api/recipients/suggest", params={"q": ""})
|
||||
assert resp.status_code == 200 and resp.json() == []
|
||||
|
||||
|
||||
async def test_settings_get_and_patch(ctx: Ctx) -> None:
|
||||
got = await ctx.client.get("/api/settings")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["thread_order"] == "asc"
|
||||
|
||||
patched = await ctx.client.patch(
|
||||
"/api/settings", json={"thread_order": "desc", "tz": "Europe/Warsaw"}, headers=_hdr()
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["thread_order"] == "desc"
|
||||
assert patched.json()["tz"] == "Europe/Warsaw"
|
||||
|
||||
# invalid order is ignored
|
||||
patched2 = await ctx.client.patch(
|
||||
"/api/settings", json={"thread_order": "sideways"}, headers=_hdr()
|
||||
)
|
||||
assert patched2.json()["thread_order"] == "desc"
|
||||
|
||||
|
||||
async def _ingest_html(ctx: Ctx, *, sender: str, html: str) -> uuid.UUID:
|
||||
m = EmailMessage()
|
||||
m["Subject"] = "Newsletter"
|
||||
m["From"] = sender
|
||||
m["To"] = "me@example.com"
|
||||
m["Message-ID"] = f"<{uuid.uuid4().hex}@x.com>"
|
||||
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
||||
m.set_content("text fallback")
|
||||
m.add_alternative(html, subtype="html")
|
||||
result = await persist_message(
|
||||
ctx.session,
|
||||
user_id=ctx.user_id,
|
||||
account_id=ctx.account_id,
|
||||
mailbox_id=ctx.mailbox_id,
|
||||
mailbox_special_use="inbox",
|
||||
remote=RemoteMessage(
|
||||
uid=1, raw=m.as_bytes(), flags=frozenset(), internal_date=datetime.now(UTC)
|
||||
),
|
||||
)
|
||||
await ctx.session.commit()
|
||||
assert result is not None
|
||||
await PgSearchBackend().index(result.search_doc)
|
||||
return result.message.id
|
||||
|
||||
|
||||
async def test_remote_images_blocked_then_allowed(ctx: Ctx) -> None:
|
||||
mid = await _ingest_html(
|
||||
ctx,
|
||||
sender="News <news@sender.com>",
|
||||
html='<p>Hi<img src="http://tracker.example/pixel.gif"></p>',
|
||||
)
|
||||
# blocked by default
|
||||
before = await ctx.client.get(f"/api/messages/{mid}/html")
|
||||
assert "tracker.example" not in before.text
|
||||
|
||||
# approve the sender
|
||||
allow = await ctx.client.post(f"/api/messages/{mid}/allow-remote-images", headers=_hdr())
|
||||
assert allow.status_code == 200
|
||||
assert allow.json()["sender"] == "news@sender.com"
|
||||
|
||||
# now the remote image is rendered
|
||||
after = await ctx.client.get(f"/api/messages/{mid}/html")
|
||||
assert "http://tracker.example/pixel.gif" in after.text
|
||||
Reference in New Issue
Block a user