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:
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user