fix(unified-mailbox): single-source unified counters, unified id space, background push

The unified-section sidebar badges (per-role unified folders + cross-view
All mail/Unread/Starred) failed to count down when messages were deleted/moved/
read from the unified views, and failed to count up for incoming mail - while
the underlying per-account folder counters updated correctly. Root cause: the
badges were a separate counter representation, recomputed only by a fresh server
fetch, completely decoupled from the optimistically-patched mailbox lists.

Three coordinated changes:

V1 - single source of truth: derive `unifiedCounts`/`crossUnreadCount` as a pure
live projection of `mailboxes` + `accountMailboxes` (the lists every mutation
already patches and push refreshes), over the last-known unified scope. A store
subscription re-projects whenever those lists change, so optimistic deletes and
push refreshes flow into the badges with no server round trip and no
eventual-consistency snap-back.

V3 - unified id space: searchEmails/advancedSearchEmails now namespace shared/
delegated mailboxIds (`${ownerId}:${id}`) like getEmails already did. The
cross-account views browse via advancedSearchEmails, so shared emails there
previously carried bare owner ids; now every fetch path is consistent and
emailInMailbox hits the `ids[mailbox.id]` fast path (originalId branches kept as
a defensive fallback). resolveSourceFolderName matches `m.id` first (also fixes
a latent missing source-folder name for shared emails).

Background push: bind push notifications for every connected login, not just the
active one - background accounts now drive the unified counters by rebuilding the
unified scope on their state changes. handleStateChange also refreshes the
mailbox list on a Mailbox change for ANY changed account key, so delegated
shared-folder activity arriving via the active client updates counters too.

Tests: unified-badge live projection on delete; client-level namespacing for
searchEmails/advancedSearchEmails (shared vs own account).
This commit is contained in:
Stefan Hildebrandt
2026-07-11 21:14:50 +02:00
parent fdad60cf03
commit 2e42693228
6 changed files with 300 additions and 36 deletions
+14
View File
@@ -1885,6 +1885,13 @@ export class JMAPClient implements IJMAPClient {
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
// Mirror getEmails: emails fetched from a delegated/shared account carry
// bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line
// up with the namespaced ids the store holds for shared mailboxes. (#281 V3)
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return { emails, hasMore, total };
} catch (error) {
console.error('Search failed:', error);
@@ -1925,6 +1932,13 @@ export class JMAPClient implements IJMAPClient {
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
// Namespace shared/delegated-account mailbox ids (see searchEmails). The
// cross-account views (All mail / Unread / Starred) browse via this method,
// so without it shared emails would carry bare owner ids there. (#281 V3)
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return { emails, hasMore, total };
} catch (error) {
console.error('Advanced search failed:', error);