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
+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"