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