Files
webmail-client/plugins/webmail_plugin_labels/webmail_plugin_labels/router.py
T
lukasandClaude Opus 4.8 4c200ab94a 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
2026-08-05 18:04:49 +02:00

183 lines
7.0 KiB
Python

"""HTTP surface for the labels plugin, mounted at /api/plugins/labels (SPEC §11).
State is the namespaced KV store: key ``<message_id>`` -> that message's labels,
and key ``_labels`` -> the user's known label set. The KV store is scoped to
(plugin, user_id) by construction, so users never see each other's labels.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
_SDK = sdk("labels")
_ALL_KEY = "_labels"
_BUNDLES = {"actions.js", "sidebar.js"}
class LabelsIn(BaseModel):
labels: list[str]
class IdsIn(BaseModel):
ids: list[str]
class NameIn(BaseModel):
name: str
async def _remember(kv: object, labels: list[str]) -> None:
"""Merge ``labels`` into the user's known-label set."""
known = set(await kv.get(_ALL_KEY, [])) # type: ignore[attr-defined]
if not labels or set(labels) <= known:
return
known.update(labels)
await kv.set(_ALL_KEY, sorted(known)) # type: ignore[attr-defined]
@router.get("/message/{message_id}/labels", response_model=list[str])
async def get_labels(message_id: str, user_id: CurrentUserId) -> list[str]:
return await _SDK.kv(user_id).get(message_id, [])
@router.put("/message/{message_id}/labels", dependencies=[Depends(require_csrf)])
async def set_labels(message_id: str, body: LabelsIn, user_id: CurrentUserId) -> dict[str, object]:
labels = sorted({name.strip() for name in body.labels if name.strip()})
kv = _SDK.kv(user_id)
await kv.set(message_id, labels)
await _remember(kv, labels)
return {"status": "ok", "labels": labels}
@router.post("/messages/labels")
async def batch_message_labels(body: IdsIn, user_id: CurrentUserId) -> dict[str, list[str]]:
"""Labels for a set of messages (the reading pane aggregates the thread union)."""
kv = _SDK.kv(user_id)
return {mid: await kv.get(mid, []) for mid in body.ids}
@router.put("/thread/{thread_id}/labels", dependencies=[Depends(require_csrf)])
async def set_thread_labels(
thread_id: str, body: LabelsIn, user_id: CurrentUserId
) -> dict[str, object]:
"""The thread's aggregated labels (union of its messages), written by the reading
pane so the message list — which has no message ids — can display them."""
labels = sorted({name.strip() for name in body.labels if name.strip()})
kv = _SDK.kv(user_id)
await kv.set(f"t:{thread_id}", labels)
await _remember(kv, labels)
return {"status": "ok", "labels": labels}
@router.post("/threads/labels")
async def batch_thread_labels(body: IdsIn, user_id: CurrentUserId) -> dict[str, list[str]]:
"""Aggregated labels for a set of threads (list-view chips). Reconciles first so
the chips reflect live messages — stale ones from deleted mail are dropped."""
live = await _reconcile(user_id)
return {tid: live[tid] for tid in body.ids if live.get(tid)}
_GMAIL_SUFFIX = " [Gmail]"
async def _reconcile(user_id: object) -> dict[str, list[str]]:
"""Self-heal label state from the per-message keys (the source of truth) and
return the live ``thread_id -> labels`` map.
Per-message keys (``<message_id>``) are authoritative; the ``t:<thread_id>``
aggregates and the ``_labels`` set are derived. Messages get deleted (account
removal) and re-threaded (JWZ merges) out from under us, which orphans thread
aggregates — so rebuild them here: drop labels of deleted messages, regroup the
survivors by their *current* thread, rewrite changed/stale ``t:`` keys, and
rebuild ``_labels`` from labels on live messages. User-created labels (no
``… [Gmail]`` suffix) are preserved even when unused; orphaned Gmail labels drop."""
kv = _SDK.kv(user_id)
items = await kv.items()
msg_keys = {
k: v
for k, v in items.items()
if k != _ALL_KEY and not k.startswith("t:") and isinstance(v, list)
}
homes = await _SDK.messages.threads_of(user_id, list(msg_keys))
thread_labels: dict[str, set[str]] = {}
live_labels: set[str] = set()
for message_id, labels in msg_keys.items():
if message_id not in homes: # message deleted -> drop its orphaned key
await kv.delete(message_id)
continue
live_labels.update(labels)
thread_id = homes[message_id]
if thread_id is not None:
thread_labels.setdefault(thread_id, set()).update(labels)
# Rewrite only the thread aggregates that changed; delete ones with no messages.
old_thread_keys = {k[2:] for k in items if k.startswith("t:")}
result: dict[str, list[str]] = {}
for thread_id, names in thread_labels.items():
value = sorted(names)
result[thread_id] = value
if items.get(f"t:{thread_id}") != value:
await kv.set(f"t:{thread_id}", value)
for thread_id in old_thread_keys - set(thread_labels):
await kv.delete(f"t:{thread_id}")
known = set(items.get(_ALL_KEY, []) or [])
new_known = {n for n in known if not n.endswith(_GMAIL_SUFFIX)} | live_labels
if new_known != known:
await kv.set(_ALL_KEY, sorted(new_known))
return result
@router.get("/labels", response_model=list[str])
async def all_labels(user_id: CurrentUserId) -> list[str]:
await _reconcile(user_id) # self-heal from live messages, then return the known set
return await _SDK.kv(user_id).get(_ALL_KEY, [])
@router.get("/labels/{name}/threads", response_model=list[str])
async def label_threads(name: str, user_id: CurrentUserId) -> list[str]:
"""Thread ids whose aggregate labels include ``name`` — powers the label view in
the mail list. Reconciles first, so it reflects live messages' current threads."""
live = await _reconcile(user_id)
return [tid for tid, labels in live.items() if name in labels]
@router.post("/labels", dependencies=[Depends(require_csrf)])
async def create_label(body: NameIn, user_id: CurrentUserId) -> list[str]:
name = body.name.strip()
kv = _SDK.kv(user_id)
if name:
await _remember(kv, [name])
return await kv.get(_ALL_KEY, [])
@router.delete("/labels/{name}", dependencies=[Depends(require_csrf)])
async def delete_label(name: str, user_id: CurrentUserId) -> list[str]:
"""Delete a label everywhere: drop it from the known set and unhook it from every
message (``<message_id>``) and thread (``t:<thread_id>``) it was attached to."""
kv = _SDK.kv(user_id)
for key, labels in (await kv.items()).items():
if key == _ALL_KEY or not isinstance(labels, list) or name not in labels:
continue
await kv.set(key, [n for n in labels if n != name])
known = [n for n in await kv.get(_ALL_KEY, []) if n != name]
await kv.set(_ALL_KEY, known)
return known
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
if filename not in _BUNDLES:
raise HTTPException(status_code=404)
return FileResponse(_UI / filename, media_type="text/javascript")