Gmail label sync + no-dup backfill, live label/UI updates, account-delete cleanup, Sync-SPAM toggle, sidebar logo
Gmail sync (SPEC: Gmail sync): - Provider fetches X-GM-LABELS when X-GM-EXT-1 is advertised; parses custom labels (system labels dropped, mUTF-7 decoded) onto RemoteMessage. - Engine syncs only system folders + All Mail for Gmail, skips every label folder, and dedupes the All Mail pass by Message-ID — kills the INBOX/All-Mail duplication. - Custom labels ride the message.new event; the labels plugin subscribes and maps them to chips as "<name> [Gmail]" (per-message + thread aggregate + known set), so labels fill in gradually with the sync window. Non-ASCII labels decode right. Labels self-healing (message-driven reconcile): - Per-message keys are the source of truth; t: aggregates and _labels are derived. Reconcile drops labels of deleted messages, regroups survivors by their current thread (fixing JWZ thread merges), rewrites changed t: keys, and rebuilds _labels from live messages — keeping user labels, dropping orphaned Gmail ones. - New SDK reads: sdk.threads.existing / sdk.messages.threads_of. Sidebar refreshes live via a plugin.labels.changed WS event on new labels. Account deletion: prune threads left empty after the cascade (they were user-scoped, so only messages cascaded) + de-list the account from surviving cross-account threads. Sync SPAM: opt-in per-account sync_spam (migration 0015, default off) — the engine skips the Junk folder in backfill and poll unless enabled; wizard checkbox added. Robustness / UX: - WebSocket auto-reconnects with backoff and resyncs on (re)connect, and account add/delete invalidates threads/labels — no more hard refresh for live updates. - Backfill banner + sidebar ring also require done < total, so a completed account can't leave a stuck "X of X". - Scrub lone UTF-16 surrogates from parsed headers/bodies so bad messages no longer crash the backfill on Postgres text/JSONB. - Ultimo signet logo in the Mail/Calendar/Contacts sidebar header (and at the top of the collapsed rail), via the shared plugin-ui.css + MailLayout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -31,6 +31,7 @@ from app.providers.base import (
|
||||
RemoteMessage,
|
||||
)
|
||||
from app.providers.imap_smtp.parsing import (
|
||||
encode_mailbox_name,
|
||||
parse_fetch_lines,
|
||||
parse_list_line,
|
||||
parse_search_uids,
|
||||
@@ -39,6 +40,9 @@ from app.providers.imap_smtp.parsing import (
|
||||
)
|
||||
|
||||
_FETCH_ITEMS = "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[])"
|
||||
# Gmail (X-GM-EXT-1): also pull each message's labels so custom labels can map to
|
||||
# chips without visiting every label folder (SPEC: Gmail sync / no-duplication).
|
||||
_FETCH_ITEMS_GM = "(UID FLAGS INTERNALDATE RFC822.SIZE X-GM-LABELS BODY.PEEK[])"
|
||||
_IDLE_TIMEOUT = 25 * 60 # SPEC §5.1: re-issue IDLE at least every 25 min
|
||||
_CONNECT_TIMEOUT = 20
|
||||
_PER_ACCOUNT_CONN_CAP = 4 # SPEC §6.3 / §4: IDLE + transient + short-lived jobs
|
||||
@@ -81,6 +85,13 @@ class ImapSmtpProvider(MailProvider):
|
||||
self._uidvalidity_seen: dict[str, int] = {}
|
||||
self._conn_slots: list[asyncio.Semaphore] = []
|
||||
self._qresync_enabled = False
|
||||
self._gm_ext = False
|
||||
|
||||
@property
|
||||
def gmail_labels_supported(self) -> bool:
|
||||
"""True when the server advertises ``X-GM-EXT-1`` (Gmail), so FETCH can pull
|
||||
``X-GM-LABELS`` and the engine can use the no-duplication Gmail sync path."""
|
||||
return self._gm_ext
|
||||
|
||||
@property
|
||||
def qresync_enabled(self) -> bool:
|
||||
@@ -148,6 +159,8 @@ class ImapSmtpProvider(MailProvider):
|
||||
self._release_conn_slots() # never hold a slot without an open socket
|
||||
raise
|
||||
self._imap = imap
|
||||
caps = {c.upper() for c in (imap.protocol.capabilities or set())}
|
||||
self._gm_ext = "X-GM-EXT-1" in caps
|
||||
await self._maybe_enable_qresync(imap)
|
||||
return imap
|
||||
|
||||
@@ -165,9 +178,16 @@ class ImapSmtpProvider(MailProvider):
|
||||
except Exception: # noqa: BLE001 - degrade gracefully to the reconcile path
|
||||
self._qresync_enabled = False
|
||||
|
||||
@staticmethod
|
||||
def _wire(mailbox: str) -> str:
|
||||
"""Server wire form of a mailbox name: mUTF-7 encoded and quoted. Handles
|
||||
non-ASCII names (Gmail's localized ``[Gmail]/Wysłane``) and names with
|
||||
spaces (``[Gmail]/All Mail``); ASCII names are unchanged bar the quotes."""
|
||||
return str(aioimaplib.quoted(encode_mailbox_name(mailbox)))
|
||||
|
||||
async def _select(self, mailbox: str) -> dict[str, int]:
|
||||
imap = await self._connect()
|
||||
resp = await imap.select(mailbox)
|
||||
resp = await imap.select(self._wire(mailbox))
|
||||
if resp.result != "OK":
|
||||
raise ProviderConnectError(f"cannot select {mailbox!r}")
|
||||
self._selected = mailbox
|
||||
@@ -212,7 +232,7 @@ class ImapSmtpProvider(MailProvider):
|
||||
async def _mailbox_uidvalidity(self, path: str) -> int:
|
||||
imap = await self._connect()
|
||||
try:
|
||||
resp = await imap.status(path, "(UIDVALIDITY)")
|
||||
resp = await imap.status(self._wire(path), "(UIDVALIDITY)")
|
||||
except Exception:
|
||||
return 0
|
||||
if resp.result != "OK":
|
||||
@@ -280,7 +300,8 @@ class ImapSmtpProvider(MailProvider):
|
||||
if not uids:
|
||||
return []
|
||||
uid_set = ",".join(str(u) for u in uids)
|
||||
resp = await imap.uid("fetch", uid_set, _FETCH_ITEMS)
|
||||
items = _FETCH_ITEMS_GM if self._gm_ext else _FETCH_ITEMS
|
||||
resp = await imap.uid("fetch", uid_set, items)
|
||||
if resp.result != "OK":
|
||||
raise ProviderConnectError("FETCH failed")
|
||||
out: list[RemoteMessage] = []
|
||||
@@ -289,7 +310,11 @@ class ImapSmtpProvider(MailProvider):
|
||||
continue
|
||||
out.append(
|
||||
RemoteMessage(
|
||||
uid=pf.uid, raw=pf.raw, flags=pf.flags, internal_date=pf.internal_date
|
||||
uid=pf.uid,
|
||||
raw=pf.raw,
|
||||
flags=pf.flags,
|
||||
internal_date=pf.internal_date,
|
||||
gmail_labels=pf.gmail_labels,
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -355,7 +380,7 @@ class ImapSmtpProvider(MailProvider):
|
||||
proto = imap.protocol
|
||||
param = f"(QRESYNC ({uidvalidity} {modseq}))"
|
||||
cmd = aioimaplib.Command(
|
||||
"SELECT", proto.new_tag(), aioimaplib.quoted(mailbox), param, loop=proto.loop
|
||||
"SELECT", proto.new_tag(), self._wire(mailbox), param, loop=proto.loop
|
||||
)
|
||||
return await proto.execute(cmd)
|
||||
|
||||
@@ -419,7 +444,7 @@ class ImapSmtpProvider(MailProvider):
|
||||
async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int:
|
||||
imap = await self._connect()
|
||||
flag_str = f"({' '.join(sorted(flags))})" if flags else None
|
||||
resp = await imap.append(raw, mailbox=mailbox, flags=flag_str)
|
||||
resp = await imap.append(raw, mailbox=self._wire(mailbox), flags=flag_str)
|
||||
if resp.result != "OK":
|
||||
raise ProviderConnectError(f"APPEND to {mailbox} failed")
|
||||
for line in resp.lines:
|
||||
|
||||
Reference in New Issue
Block a user