feat(jmap): keep unified/All-Mail counters current for shared accounts
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:<id>` / `eml:<id>`); - 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.
This commit is contained in:
+80
-23
@@ -5676,6 +5676,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private pollingInterval: NodeJS.Timeout | null = null;
|
private pollingInterval: NodeJS.Timeout | null = null;
|
||||||
|
private secondaryPollInterval: NodeJS.Timeout | null = null;
|
||||||
private pollingStates: { [key: string]: string } = {};
|
private pollingStates: { [key: string]: string } = {};
|
||||||
private sseAbortController: AbortController | null = null;
|
private sseAbortController: AbortController | null = null;
|
||||||
private sseReconnectTimeout: NodeJS.Timeout | null = null;
|
private sseReconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
@@ -5693,6 +5694,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static readonly POLLING_INTERVAL = 3_000;
|
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_RECONNECT_DELAY = 3_000;
|
||||||
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
|
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();
|
const eventSourceUrl = this.getEventSourceUrl();
|
||||||
if (eventSourceUrl) {
|
if (eventSourceUrl) {
|
||||||
this.connectSSE(eventSourceUrl);
|
this.connectSSE(eventSourceUrl);
|
||||||
|
// SSE covers the primary account only; keep shared accounts fresh too.
|
||||||
|
this.startSecondaryAccountPoll();
|
||||||
} else {
|
} else {
|
||||||
|
// The fallback poll already covers every session account.
|
||||||
this.startPollingFallback();
|
this.startPollingFallback();
|
||||||
}
|
}
|
||||||
this.setupBrowserEventListeners();
|
this.setupBrowserEventListeners();
|
||||||
return true;
|
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 {
|
private connectSSE(templateUrl: string): void {
|
||||||
if (this.isRateLimited()) {
|
if (this.isRateLimited()) {
|
||||||
this.scheduleSSEReconnect();
|
this.scheduleSSEReconnect();
|
||||||
@@ -5833,12 +5859,30 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}, JMAPClient.POLLING_INTERVAL);
|
}, 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:<id>` / `eml:<id>`) 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[] } {
|
private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } {
|
||||||
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
|
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
|
||||||
const methodCalls: JMAPMethodCall[] = [
|
const methodCalls: JMAPMethodCall[] = [];
|
||||||
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
|
for (const acctId of this.pollAccountIds()) {
|
||||||
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
|
methodCalls.push(
|
||||||
];
|
['Mailbox/get', { accountId: acctId, ids: null, properties: ['id'] }, `mbx:${acctId}`],
|
||||||
|
['Email/get', { accountId: acctId, ids: [], properties: ['id'] }, `eml:${acctId}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.supportsCalendars()) {
|
if (this.supportsCalendars()) {
|
||||||
using.push('urn:ietf:params:jmap:calendars');
|
using.push('urn:ietf:params:jmap:calendars');
|
||||||
@@ -5859,6 +5903,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return { using, methodCalls };
|
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<void> {
|
private async fetchCurrentStates(): Promise<void> {
|
||||||
if (this.isRateLimited()) {
|
if (this.isRateLimited()) {
|
||||||
return;
|
return;
|
||||||
@@ -5873,10 +5927,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
for (const [method, result] of data.methodResponses) {
|
for (const [method, result, callId] of data.methodResponses) {
|
||||||
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
|
const resolved = this.resolvePolledState(method, callId);
|
||||||
if (stateKey && result.state) {
|
if (resolved && result?.state) {
|
||||||
this.pollingStates[stateKey] = result.state;
|
this.pollingStates[`${resolved.accountId}:${resolved.stateKey}`] = result.state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5899,25 +5953,24 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const changes: { [key: string]: string } = {};
|
// Build a per-account changed map so a background change in a shared
|
||||||
let hasChanges = false;
|
// (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<string, Record<string, string>> = {};
|
||||||
|
|
||||||
for (const [method, result] of data.methodResponses) {
|
for (const [method, result, callId] of data.methodResponses) {
|
||||||
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
|
const resolved = this.resolvePolledState(method, callId);
|
||||||
if (stateKey && result.state) {
|
if (!resolved || !result?.state) continue;
|
||||||
if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) {
|
const key = `${resolved.accountId}:${resolved.stateKey}`;
|
||||||
changes[stateKey] = result.state;
|
if (this.pollingStates[key] && this.pollingStates[key] !== result.state) {
|
||||||
hasChanges = true;
|
(changedByAccount[resolved.accountId] ??= {})[resolved.stateKey] = result.state;
|
||||||
}
|
|
||||||
this.pollingStates[stateKey] = result.state;
|
|
||||||
}
|
}
|
||||||
|
this.pollingStates[key] = result.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasChanges && this.stateChangeCallback) {
|
if (Object.keys(changedByAccount).length > 0 && this.stateChangeCallback) {
|
||||||
this.stateChangeCallback({
|
this.stateChangeCallback({ '@type': 'StateChange', changed: changedByAccount });
|
||||||
'@type': 'StateChange',
|
|
||||||
changed: { [this.accountId]: changes },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -5930,6 +5983,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
clearInterval(this.pollingInterval);
|
clearInterval(this.pollingInterval);
|
||||||
this.pollingInterval = null;
|
this.pollingInterval = null;
|
||||||
}
|
}
|
||||||
|
if (this.secondaryPollInterval) {
|
||||||
|
clearInterval(this.secondaryPollInterval);
|
||||||
|
this.secondaryPollInterval = null;
|
||||||
|
}
|
||||||
if (this.sseAbortController) {
|
if (this.sseAbortController) {
|
||||||
this.sseAbortController.abort();
|
this.sseAbortController.abort();
|
||||||
this.sseAbortController = null;
|
this.sseAbortController = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user