Two-way draft sync, signatures, sent/bcc fixes, and mail-list UI

Backend
- Sent copies: persist locally on send + periodic multi-folder CONDSTORE poll
  (Sent/Archive/Trash/Junk) so they thread and show without an IDLE-only INBOX.
- Bcc: transmitted copy omits Bcc, Sent copy keeps it; store/display bcc_addr.
- Drafts: mirror local drafts to the IMAP Drafts folder (append/replace/expunge);
  inbound sync of foreign drafts (un-threaded) with adopt-to-edit + dedup by UID.
- Per-user storage quota (5 GiB default) and manual "sync all folders" action.
- Account editing (display/color/server/password) and per-account HTML signature.
- Account view scoped to that account's inbox; new migrations 0006–0009.

Frontend
- Mail-list pagination, folder filters (All/Unread/Pinned), sort toggle, refresh.
- Aligned + widened mail-list margins; reading-pane bars fixed height.
- Rich-text composer: color palette, highlight, image; attachments; expand/pop-out.
- Signatures: auto-insert for new mail, replace on From change, reply insert.
- Restore/Delete actions per folder; drafts view merges local + remote, date-sorted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 15:27:09 +02:00
co-authored by Claude Opus 4.8
parent a5ec0d21cb
commit 3951df9755
39 changed files with 1917 additions and 139 deletions
+188
View File
@@ -0,0 +1,188 @@
"""Mirroring local drafts to the IMAP Drafts folder (continue on any client)."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
import pytest_asyncio
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.db import session_scope
from app.identity.passwords import hash_password
from app.models import Account, Draft, Mailbox, User
from app.outbox import draft_sync
@dataclass
class Ctx:
session: AsyncSession
user_id: uuid.UUID
account_id: uuid.UUID
@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"drf{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",
display_name="Me",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
session.add(
Mailbox(account_id=account.id, user_id=user.id, path="DRAFTS", special_use="drafts")
)
await session.commit()
try:
yield Ctx(session=session, user_id=user.id, account_id=account.id)
finally:
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
class FakeProvider:
def __init__(self) -> None:
self.appended: list[tuple[str, frozenset[str]]] = []
self.deleted: list[tuple[str, list[int]]] = []
self._uid = 41
async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int:
self.appended.append((mailbox, flags))
self._uid += 1
return self._uid
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
self.deleted.append((mailbox, list(uids)))
async def close(self) -> None:
return None
async def _make_draft(ctx: Ctx) -> uuid.UUID:
draft = Draft(
user_id=ctx.user_id,
account_id=ctx.account_id,
kind="new",
payload={"to": ["bob@example.com"], "subject": "Hi", "html": "<p>hi</p>", "text": ""},
)
ctx.session.add(draft)
await ctx.session.commit()
return draft.id
async def test_push_draft_appends_with_draft_flag_and_replaces(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
did = await _make_draft(ctx)
fake = FakeProvider()
monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake)
await draft_sync.push_draft_to_imap_job({}, str(did))
# appended once to DRAFTS with the \Draft flag; UID recorded on the draft
assert len(fake.appended) == 1
assert fake.appended[0][0] == "DRAFTS"
assert "\\Draft" in fake.appended[0][1]
assert fake.deleted == [] # nothing to replace yet
async with session_scope() as s:
assert (await s.get(Draft, did)).imap_uid == 42 # type: ignore[union-attr]
# A second push (edit) appends a new copy and expunges the previous UID.
await draft_sync.push_draft_to_imap_job({}, str(did))
assert len(fake.appended) == 2
assert fake.deleted == [("DRAFTS", [42])]
async with session_scope() as s:
assert (await s.get(Draft, did)).imap_uid == 43 # type: ignore[union-attr]
async def test_draft_messages_are_not_threaded(ctx: Ctx) -> None:
from datetime import UTC, datetime
from email.message import EmailMessage
from app.indexer.indexer import persist_message
from app.providers.base import RemoteMessage
m = EmailMessage()
m["Subject"] = "A draft reply"
m["From"] = "me@example.com"
m["To"] = "bob@example.com"
m["Message-ID"] = "<d1@x.com>"
m["In-Reply-To"] = "<orig@x.com>" # would normally thread it
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
m.set_content("body")
drafts_mb = await ctx.session.scalar(
select(Mailbox).where(
Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts"
)
)
assert drafts_mb is not None
remote = RemoteMessage(
uid=71, raw=m.as_bytes(), flags=frozenset({"\\Draft"}), internal_date=datetime.now(UTC)
)
result = await persist_message(
ctx.session,
user_id=ctx.user_id,
account_id=ctx.account_id,
mailbox_id=drafts_mb.id,
mailbox_special_use="drafts",
remote=remote,
)
await ctx.session.commit()
assert result is not None
assert result.message.thread_id is None # drafts stay out of conversations
async def test_remove_imap_draft_expunges(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
fake = FakeProvider()
monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake)
await draft_sync.remove_imap_draft_job({}, str(ctx.account_id), 7)
assert fake.deleted == [("DRAFTS", [7])]
async def test_no_drafts_mailbox_creates_one(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
# Drop the drafts mailbox so the job has to create it.
await ctx.session.execute(
delete(Mailbox).where(Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts")
)
await ctx.session.commit()
did = await _make_draft(ctx)
created: list[str] = []
class CreatingProvider(FakeProvider):
async def create_mailbox(self, path: str) -> None:
created.append(path)
async def list_mailboxes(self): # type: ignore[no-untyped-def]
from app.providers.base import RemoteMailbox
return [RemoteMailbox(path="Drafts", special_use="drafts", uidvalidity=1)]
fake = CreatingProvider()
monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake)
await draft_sync.push_draft_to_imap_job({}, str(did))
assert created == ["Drafts"]
async with session_scope() as s:
mb = await s.scalar(
select(Mailbox).where(
Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts"
)
)
assert mb is not None and mb.path == "Drafts"
+92 -4
View File
@@ -65,14 +65,18 @@ def test_build_mime_sets_threading_and_alternative() -> None:
"text": "",
},
)
raw, env_from, env_to = build_mime(account, draft, parent)
transmit_raw, sent_raw, env_from, env_to = build_mime(account, draft, parent)
assert env_from == "me@example.com"
assert set(env_to) == {"bob@example.com", "c@example.com", "secret@example.com"}
msg = email.message_from_bytes(raw, policy=default_policy)
msg = email.message_from_bytes(transmit_raw, policy=default_policy)
assert msg["From"] == "Me <me@example.com>"
assert msg["To"] == "bob@example.com"
assert "secret@example.com" not in (raw.decode()) # bcc not in headers
assert msg["Bcc"] is None # transmitted copy carries no Bcc
assert "secret@example.com" not in transmit_raw.decode() # bcc not transmitted
# ...but the Sent copy records the Bcc so the sender keeps the blind recipient.
sent_msg = email.message_from_bytes(sent_raw, policy=default_policy)
assert sent_msg["Bcc"] == "secret@example.com"
assert msg["In-Reply-To"] == "<orig@x.com>"
assert "<root@x.com>" in msg["References"] and "<orig@x.com>" in msg["References"]
# multipart/alternative with an auto-generated text part
@@ -96,7 +100,7 @@ def test_build_mime_includes_attachments() -> None:
kind="new",
payload={"to": ["bob@example.com"], "subject": "Files", "html": "<p>hi</p>", "text": ""},
)
raw, _from, _to = build_mime(
raw, _sent, _from, _to = build_mime(
account, draft, None, [("report.pdf", "application/pdf", b"%PDF-1.4 data")]
)
msg = email.message_from_bytes(raw, policy=default_policy)
@@ -219,6 +223,79 @@ async def test_draft_crud(ctx: Ctx) -> None:
assert deleted.status_code == 200
async def test_remote_drafts_listed_and_adopted(ctx: Ctx) -> None:
from datetime import UTC, datetime
from app.models import BodyPart
# A "foreign" draft (as if created in another client) in the Drafts folder.
drafts_mb = Mailbox(
account_id=ctx.account_id, user_id=ctx.user_id, path="DRAFTS", special_use="drafts"
)
ctx.session.add(drafts_mb)
await ctx.session.flush()
msg = Message(
user_id=ctx.user_id,
account_id=ctx.account_id,
mailbox_id=drafts_mb.id,
uid=94,
subject="Home draft",
to_addr=[{"name": "", "email": "bob@example.com"}],
cc_addr=[],
bcc_addr=[],
internal_date=datetime.now(UTC),
blob_sha256="x" * 64,
snippet="hello there",
)
ctx.session.add(msg)
await ctx.session.flush()
ctx.session.add(BodyPart(message_id=msg.id, kind="html_sanitized", content="<p>hello</p>"))
ctx.session.add(BodyPart(message_id=msg.id, kind="text", content="hello there"))
await ctx.session.commit()
# It shows in the remote drafts list.
listing = await ctx.client.get("/api/drafts/remote")
assert listing.status_code == 200
assert any(i["message_id"] == str(msg.id) and i["imap_uid"] == 94 for i in listing.json())
# Adopting turns it into an editable local draft (linked by UID).
adopted = await ctx.client.post(f"/api/drafts/adopt/{msg.id}", headers=_hdr())
assert adopted.status_code == 200, adopted.text
draft = adopted.json()
assert draft["payload"]["subject"] == "Home draft"
assert draft["payload"]["to"] == ["bob@example.com"]
assert draft["payload"]["html"] == "<p>hello</p>"
# Now it's excluded from the remote list (we own that UID)...
listing2 = await ctx.client.get("/api/drafts/remote")
assert all(i["message_id"] != str(msg.id) for i in listing2.json())
# ...and adopting again returns the same local draft (idempotent).
again = await ctx.client.post(f"/api/drafts/adopt/{msg.id}", headers=_hdr())
assert again.json()["id"] == draft["id"]
async def test_publish_draft_enqueues_imap_mirror(ctx: Ctx) -> None:
did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"]
ctx.enqueued.clear()
resp = await ctx.client.post(f"/api/drafts/{did}/publish", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued[-1][0] == ("push_draft_to_imap_job", did)
async def test_delete_draft_expunges_mirror_when_present(ctx: Ctx) -> None:
did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"]
# Simulate a previously mirrored copy (imap_uid set).
draft = await ctx.session.get(Draft, uuid.UUID(did))
assert draft is not None
draft.imap_uid = 88
await ctx.session.commit()
ctx.enqueued.clear()
resp = await ctx.client.request("DELETE", f"/api/drafts/{did}", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued[-1][0] == ("remove_imap_draft_job", str(ctx.account_id), 88)
async def test_upload_and_list_compose_attachment(ctx: Ctx) -> None:
resp = await ctx.client.post(
"/api/attachments",
@@ -332,6 +409,17 @@ async def test_send_job_success(ctx: Ctx, monkeypatch) -> None: # type: ignore[
ctx.session.expunge_all()
async with session_scope() as verify:
assert await verify.get(Draft, draft_id) is None
# The sent copy is persisted into the Sent mailbox (append UID 99).
sent_mb = await verify.scalar(
select(Mailbox).where(
Mailbox.account_id == ctx.account_id, Mailbox.special_use == "sent"
)
)
assert sent_mb is not None
sent_msg = await verify.scalar(
select(Message).where(Message.mailbox_id == sent_mb.id, Message.uid == 99)
)
assert sent_msg is not None and sent_msg.subject == "Re: Hi"
kr = await ctx.session.scalar(
select(KnownRecipient).where(
KnownRecipient.user_id == ctx.user_id, KnownRecipient.email == "bob@example.com"