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
+72
View File
@@ -3,8 +3,10 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
import uuid
from typing import Any
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -274,3 +276,73 @@ async def live_loop(
await apply_delta(provider, account_obj, mailbox_obj, delta)
finally:
await provider.close()
async def poll_folders_once(account_id: uuid.UUID, *, include_inbox: bool) -> None:
"""One CONDSTORE poll pass over an account's special-use folders.
``include_inbox`` also polls INBOX (used by the manual Refresh); the periodic
secondary loop leaves INBOX to its dedicated IDLE loop. Custom (non special-use)
folders are not polled. Drafts are polled so drafts made in other clients appear
(persisted un-threaded).
"""
from app.db import session_scope
async with session_scope() as session:
account = await _load_account(session, account_id)
account_obj = account
stmt = select(Mailbox.id).where(
Mailbox.account_id == account_id, Mailbox.special_use.isnot(None)
)
if not include_inbox:
stmt = stmt.where(Mailbox.special_use != "inbox")
mailbox_ids = list(await session.scalars(stmt))
if not mailbox_ids:
return
provider = build_provider(account_obj)
try:
for mailbox_id in mailbox_ids:
async with session_scope() as session:
mailbox = await session.get(Mailbox, mailbox_id)
mailbox_obj = mailbox
if mailbox_obj is None:
continue
try:
delta = await provider.poll(
mailbox_obj.path,
last_seen_uid=mailbox_obj.last_seen_uid,
highestmodseq=mailbox_obj.highestmodseq,
)
await apply_delta(provider, account_obj, mailbox_obj, delta)
except Exception:
logger.exception("poll failed: account=%s mailbox=%s", account_id, mailbox_obj.path)
finally:
await provider.close()
async def _poll_secondary_once(account_id: uuid.UUID) -> None:
await poll_folders_once(account_id, include_inbox=False)
async def refresh_account_job(ctx: dict[str, Any], account_id: str) -> None:
"""Poll every special-use folder (incl. INBOX) once — the manual Refresh action."""
await poll_folders_once(uuid.UUID(account_id), include_inbox=True)
async def secondary_poll_loop(account_id: uuid.UUID, stop: asyncio.Event | None = None) -> None:
"""Periodically CONDSTORE-poll non-INBOX folders (Sent/Drafts/Archive/Trash/Junk).
INBOX has its own IDLE loop (real-time). This keeps the other folders in step
with server-side changes (e.g. Gmail-filed sent mail) and changes made by other
clients. CONDSTORE/QRESYNC makes each pass a cheap MODSEQ delta.
"""
stop = stop or asyncio.Event()
interval = get_settings().poll_interval
while not stop.is_set():
# Wait first: the initial state was already covered by the backfill.
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=interval)
if stop.is_set():
break
await _poll_secondary_once(account_id)