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:
2026-08-05 18:04:49 +02:00
co-authored by Claude Opus 4.8
parent e5a87b37b1
commit 4c200ab94a
38 changed files with 863 additions and 106 deletions
+20 -2
View File
@@ -4,14 +4,14 @@ from __future__ import annotations
import uuid
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.schemas import CreateAccountRequest
from app.accounts.types import ServerConfig
from app.indexer import blobstore
from app.models import Account, Attachment, Message
from app.models import Account, Attachment, Message, Thread
from app.security.crypto import encrypt_secret
@@ -40,6 +40,7 @@ async def create_account(
discovery_src="manual",
state="backfilling",
sync_window=req.sync_window,
sync_spam=req.sync_spam,
)
session.add(account)
try:
@@ -81,6 +82,23 @@ async def delete_account(
await session.execute(delete(Account).where(Account.id == account_id))
await session.flush()
# Threads are user-scoped and can span accounts (Thread.account_ids), so the
# account cascade only removes its messages — not the threads. Clean up: drop
# threads left with no messages (else they linger as empty rows the unified list
# and label views still show but can't open), and de-list the account from
# threads that survive on another account's messages.
await session.execute(
delete(Thread).where(
Thread.user_id == user_id,
~select(Message.id).where(Message.thread_id == Thread.id).exists(),
)
)
await session.execute(
update(Thread)
.where(Thread.user_id == user_id, Thread.account_ids.contains([account_id]))
.values(account_ids=func.array_remove(Thread.account_ids, account_id))
)
# Deregister from sync:live and drop any lease so the owning sync worker
# stops the account's runner on its next renewal (refactor §2 fencing).
from app.events import get_event_bus