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:
+57
-8
@@ -37,15 +37,29 @@ export function subscribeWs(listener: WsListener): () => void {
|
||||
|
||||
export function connectWs(queryClient: QueryClient): () => void {
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||
const url = `${proto}://${window.location.host}/api/ws`;
|
||||
|
||||
const ping = window.setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ping" }));
|
||||
let socket: WebSocket | null = null;
|
||||
let ping: number | undefined;
|
||||
let reconnectTimer: number | undefined;
|
||||
let attempts = 0;
|
||||
let stopped = false;
|
||||
|
||||
// Refetch the live-updated lists — used on every (re)connect so a dropped socket
|
||||
// (server restart, sleep, network blip) self-heals and catches up on missed events.
|
||||
const resync = () => {
|
||||
for (const key of [
|
||||
["threads"],
|
||||
["accounts"],
|
||||
["folder-counts"],
|
||||
["quota"],
|
||||
["drafts-remote"],
|
||||
]) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(event.data);
|
||||
@@ -87,7 +101,15 @@ export function connectWs(queryClient: QueryClient): () => void {
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
}
|
||||
if (name === "account.state_changed") {
|
||||
// Adding/deleting an account changes threads and (Gmail) labels wholesale —
|
||||
// refetch the list, chips, and sidebar so nothing stale lingers.
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["thread-labels"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["message-labels"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["labels-all"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
window.dispatchEvent(new Event("webmail:labels-changed"));
|
||||
}
|
||||
if (name === "message.sent") {
|
||||
// The sent message's draft is deleted server-side; refresh the Drafts list.
|
||||
@@ -96,8 +118,35 @@ export function connectWs(queryClient: QueryClient): () => void {
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
socket = new WebSocket(url);
|
||||
socket.onopen = () => {
|
||||
attempts = 0;
|
||||
ping = window.setInterval(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
resync(); // catch up on anything published while we were disconnected
|
||||
};
|
||||
socket.onmessage = handleMessage;
|
||||
socket.onclose = () => {
|
||||
if (ping !== undefined) window.clearInterval(ping);
|
||||
if (stopped) return;
|
||||
// Exponential backoff, capped at 30s, so a downed server is retried politely.
|
||||
const delay = Math.min(30000, 1000 * 2 ** attempts);
|
||||
attempts += 1;
|
||||
reconnectTimer = window.setTimeout(connect, delay);
|
||||
};
|
||||
socket.onerror = () => socket?.close(); // route errors through onclose -> reconnect
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
window.clearInterval(ping);
|
||||
socket.close();
|
||||
stopped = true;
|
||||
if (ping !== undefined) window.clearInterval(ping);
|
||||
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user