From bc11450f3f3088eed40e146ccced01b114cd3dba Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:01:22 +0200 Subject: [PATCH] feat(jmap): keep unified/All-Mail counters current for shared accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stalwart's JMAP EventSource only pushes StateChange for the session's *primary* account — a background change in a shared/delegated (secondary) account is never pushed — so the shared folder's counters, and the unified/All-Mail badge that aggregates them, went stale until a full reload. (Other *login* accounts already update live because each login has its own SSE.) Extend the client's state poll to every account in the session: - buildStatePollingRequest emits a Mailbox/Email `get` per account, with the accountId encoded in the callId (`mbx:` / `eml:`); - checkForStateChanges / fetchCurrentStates key polling state per account and report a per-account `changed` map, which handleStateChange already treats as "some mailbox changed" and refetches the full (own + delegated) mailbox list from — the badge is a live projection over that list; - a slow (20s) secondary-account poll runs alongside SSE (paused while hidden) so shared counters stay current between focus events. --- lib/jmap/client.ts | 103 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 23 deletions(-) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 04aabcdc..4df7fa86 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -5676,6 +5676,7 @@ export class JMAPClient implements IJMAPClient { } private pollingInterval: NodeJS.Timeout | null = null; + private secondaryPollInterval: NodeJS.Timeout | null = null; private pollingStates: { [key: string]: string } = {}; private sseAbortController: AbortController | null = null; private sseReconnectTimeout: NodeJS.Timeout | null = null; @@ -5693,6 +5694,10 @@ export class JMAPClient implements IJMAPClient { }; private static readonly POLLING_INTERVAL = 3_000; + // Shared/secondary accounts get no SSE push (Stalwart pushes the primary + // account only), so poll them on a slow cadence alongside SSE to keep their + // folder + unified/All-Mail counters from going stale between focus events. + private static readonly SECONDARY_POLL_INTERVAL = 20_000; private static readonly SSE_RECONNECT_DELAY = 3_000; private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval @@ -5700,13 +5705,34 @@ export class JMAPClient implements IJMAPClient { const eventSourceUrl = this.getEventSourceUrl(); if (eventSourceUrl) { this.connectSSE(eventSourceUrl); + // SSE covers the primary account only; keep shared accounts fresh too. + this.startSecondaryAccountPoll(); } else { + // The fallback poll already covers every session account. this.startPollingFallback(); } this.setupBrowserEventListeners(); return true; } + /** + * Slow poll of the session's shared/secondary accounts, run in parallel with + * SSE (which never reports them). Skipped when there are no shared accounts, + * and paused while the tab is hidden (visibilitychange forces a check on + * return). Reuses checkForStateChanges, which already reports per-account. + */ + private startSecondaryAccountPoll(): void { + if (this.secondaryPollInterval) return; + const hasSecondary = this.pollAccountIds().some((id) => id !== this.accountId); + if (!hasSecondary) return; + // Prime the per-account baseline so the first tick doesn't false-fire. + void this.fetchCurrentStates(); + this.secondaryPollInterval = setInterval(() => { + if (typeof document !== 'undefined' && document.hidden) return; + void this.checkForStateChanges(); + }, JMAPClient.SECONDARY_POLL_INTERVAL); + } + private connectSSE(templateUrl: string): void { if (this.isRateLimited()) { this.scheduleSSEReconnect(); @@ -5833,12 +5859,30 @@ export class JMAPClient implements IJMAPClient { }, JMAPClient.POLLING_INTERVAL); } + /** + * Accounts whose Mailbox/Email state the poll should track. Stalwart's SSE + * only pushes StateChange for the primary account, never for delegated/shared + * (secondary) accounts, so their folder counters — and the unified/All-Mail + * badges that aggregate them — would otherwise never refresh from a background + * change. Polling every session account (primary + shared) closes that gap on + * the visibility/interval reconcile path. Mailbox/Email get callIds are tagged + * with the accountId (`mbx:` / `eml:`) so each account is compared + * independently. (#shared-counter-push) + */ + private pollAccountIds(): string[] { + const ids = Object.keys(this.accounts || {}); + return ids.length > 0 ? ids : [this.accountId]; + } + private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } { const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; - const methodCalls: JMAPMethodCall[] = [ - ['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'], - ['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'], - ]; + const methodCalls: JMAPMethodCall[] = []; + for (const acctId of this.pollAccountIds()) { + methodCalls.push( + ['Mailbox/get', { accountId: acctId, ids: null, properties: ['id'] }, `mbx:${acctId}`], + ['Email/get', { accountId: acctId, ids: [], properties: ['id'] }, `eml:${acctId}`], + ); + } if (this.supportsCalendars()) { using.push('urn:ietf:params:jmap:calendars'); @@ -5859,6 +5903,16 @@ export class JMAPClient implements IJMAPClient { return { using, methodCalls }; } + /** Map a polled method response back to its (accountId, stateKey). */ + private resolvePolledState(method: string, callId: unknown): { accountId: string; stateKey: string } | null { + if (typeof callId === 'string') { + if (callId.startsWith('mbx:')) return { accountId: callId.slice(4), stateKey: 'Mailbox' }; + if (callId.startsWith('eml:')) return { accountId: callId.slice(4), stateKey: 'Email' }; + } + const stateKey = JMAPClient.STATE_TYPE_MAP[method]; + return stateKey ? { accountId: this.accountId, stateKey } : null; + } + private async fetchCurrentStates(): Promise { if (this.isRateLimited()) { return; @@ -5873,10 +5927,10 @@ export class JMAPClient implements IJMAPClient { if (response.ok) { const data = await response.json(); - for (const [method, result] of data.methodResponses) { - const stateKey = JMAPClient.STATE_TYPE_MAP[method]; - if (stateKey && result.state) { - this.pollingStates[stateKey] = result.state; + for (const [method, result, callId] of data.methodResponses) { + const resolved = this.resolvePolledState(method, callId); + if (resolved && result?.state) { + this.pollingStates[`${resolved.accountId}:${resolved.stateKey}`] = result.state; } } } @@ -5899,25 +5953,24 @@ export class JMAPClient implements IJMAPClient { if (response.ok) { const data = await response.json(); - const changes: { [key: string]: string } = {}; - let hasChanges = false; + // Build a per-account changed map so a background change in a shared + // (secondary) account is reported under its own accountId — which + // handleStateChange treats as "some mailbox changed" and refetches the + // full (own + delegated) mailbox list from. + const changedByAccount: Record> = {}; - for (const [method, result] of data.methodResponses) { - const stateKey = JMAPClient.STATE_TYPE_MAP[method]; - if (stateKey && result.state) { - if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) { - changes[stateKey] = result.state; - hasChanges = true; - } - this.pollingStates[stateKey] = result.state; + for (const [method, result, callId] of data.methodResponses) { + const resolved = this.resolvePolledState(method, callId); + if (!resolved || !result?.state) continue; + const key = `${resolved.accountId}:${resolved.stateKey}`; + if (this.pollingStates[key] && this.pollingStates[key] !== result.state) { + (changedByAccount[resolved.accountId] ??= {})[resolved.stateKey] = result.state; } + this.pollingStates[key] = result.state; } - if (hasChanges && this.stateChangeCallback) { - this.stateChangeCallback({ - '@type': 'StateChange', - changed: { [this.accountId]: changes }, - }); + if (Object.keys(changedByAccount).length > 0 && this.stateChangeCallback) { + this.stateChangeCallback({ '@type': 'StateChange', changed: changedByAccount }); } } } catch { @@ -5930,6 +5983,10 @@ export class JMAPClient implements IJMAPClient { clearInterval(this.pollingInterval); this.pollingInterval = null; } + if (this.secondaryPollInterval) { + clearInterval(this.secondaryPollInterval); + this.secondaryPollInterval = null; + } if (this.sseAbortController) { this.sseAbortController.abort(); this.sseAbortController = null;