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
+133 -20
View File
@@ -82,9 +82,23 @@ interface EmailStore {
// isUnifiedView.
crossView: CrossView | null;
unifiedErrors: Map<string, string>; // accountId -> error message
// Unified-section sidebar badges. These are NOT an independent source of
// truth: they are a pure projection of the live per-account mailbox lists
// (`mailboxes` + `accountMailboxes`) over the last-known unified scope
// (`unifiedScope`). Recomputed automatically whenever those lists change (see
// the store subscription below), so optimistic delete/move/markRead patches and
// push-driven mailbox refreshes flow into the badges without a server round
// trip. (#281 follow-up: single source of truth for unified counters.)
unifiedCounts: UnifiedMailboxCounts[];
// Unread total across the cross-view included folders (badge for unread/all).
crossUnreadCount: number;
// The account/folder structure (which accounts, role mailboxes, cross-include
// selection) that the unified badges are projected over. Set by
// refreshUnifiedCounts/refreshCrossCounts from the freshly-built
// UnifiedAccountClient[]. The COUNTER values it carries are ignored at
// projection time - live counters are read from `mailboxes`/`accountMailboxes`
// instead - so a stale snapshot here only affects structure, never numbers.
unifiedScope: UnifiedAccountClient[];
// Scheduled send state
scheduledEmails: ScheduledEmail[];
@@ -508,15 +522,14 @@ async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): P
// Whether an email belongs to a given mailbox, for local counter math.
// Own-account emails carry bare ids (equal to both `id` and `originalId`).
// Shared/group emails fetched for the unified/cross views are decorated by
// lib/unified-mailbox.ts WITHOUT namespacing their `mailboxIds`, so they carry
// the owner's BARE JMAP ids while the shared mailbox is stored with a namespaced
// `id` (`${ownerId}:${origId}`), `isShared: true` and `originalId`/`accountId`
// (the owner). Matching only `mailbox.id` therefore missed every shared email,
// so a shared folder's counter never moved when mail was deleted/moved/read from
// the unified views. We match shared mailboxes via `originalId` too, but scope it
// to the owning account (`sourceAccountId === mailbox.accountId`) so a bare owner
// id can't collide with another account's folder. (#281, shared-counter fix)
// As of #281 V3, EVERY client fetch path (getEmails/getEmail/getThreadEmails/
// searchEmails/advancedSearchEmails) namespaces shared/delegated emails'
// `mailboxIds` to the store id (`${ownerId}:${origId}`), so the `ids[mailbox.id]`
// fast path below matches own and shared mailboxes alike - one id space.
// The `originalId` branches remain as a defensive fallback for any email that
// still carries a bare owner id (scoped to the owning account via
// `sourceAccountId === mailbox.accountId` so a bare owner id can't collide with
// another account's folder). (#281)
function emailInMailbox(
email: { mailboxIds?: Record<string, boolean>; sourceAccountId?: string },
mailbox: Mailbox,
@@ -618,6 +631,62 @@ function applyDeleteCounters(
};
}
// ─── Unified-badge live projection ────────────────────────────────────────────
//
// The unified-section badges (unifiedCounts / crossUnreadCount) are a pure
// projection of the live per-account mailbox lists - the SAME lists the
// optimistic delete/move/markRead paths patch and that push refreshes. Rather
// than trusting the counter snapshot baked into the UnifiedAccountClient[] (which
// came from a server fetch and goes stale the moment a local mutation runs), we
// look each scope mailbox up by id in the live store list and use its current
// counters. This keeps the badges in lockstep with the per-folder counters - one
// source of truth, no server round trip, no eventual-consistency snap-back.
// The live store list that holds a unified-scope account's folders. Mirrors
// applyMailboxCounterUpdate's routing exactly: the active client's folders (incl.
// its delegated shared folders) live in `mailboxes`; every other logged-in
// account's folders live in `accountMailboxes[clientAccountId]`. Falls back to
// the account's own (snapshot) list so an unknown account still contributes its
// last-known counters instead of vanishing.
function liveListForAccount(
account: UnifiedAccountClient,
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
): Mailbox[] {
const activeId = useAuthStore.getState().activeAccountId;
if (account.clientAccountId === activeId) return state.mailboxes;
return state.accountMailboxes[account.clientAccountId] ?? account.mailboxes;
}
// Returns a shallow copy of the scope account whose mailboxes carry LIVE counter
// values (matched by id against the live store list). Structure - which
// mailboxes, their roles, originalId, crossIncludedMailboxIds - is preserved from
// the scope snapshot; only the counter numbers are refreshed. This lets the
// existing lib aggregators (fetchUnifiedMailboxCounts / getCrossUnreadTotal) run
// unchanged over live data.
function accountWithLiveCounters(
account: UnifiedAccountClient,
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
): UnifiedAccountClient {
const live = liveListForAccount(account, state);
const byId = new Map(live.map((m) => [m.id, m]));
return { ...account, mailboxes: account.mailboxes.map((m) => byId.get(m.id) ?? m) };
}
// Project the live mailbox state over the current unified scope into the two
// badge values. Pure function of (unifiedScope, mailboxes, accountMailboxes).
function projectUnifiedCounts(
state: { unifiedScope: UnifiedAccountClient[]; mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
): { unifiedCounts: UnifiedMailboxCounts[]; crossUnreadCount: number } {
if (state.unifiedScope.length === 0) {
return { unifiedCounts: [], crossUnreadCount: 0 };
}
const live = state.unifiedScope.map((a) => accountWithLiveCounters(a, state));
return {
unifiedCounts: fetchUnifiedMailboxCounts(live),
crossUnreadCount: getCrossUnreadTotal(live),
};
}
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
// falls back to name matching ("trash" / "deleted") so users with custom or
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
@@ -685,6 +754,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
unifiedErrors: new Map(),
unifiedCounts: [],
crossUnreadCount: 0,
unifiedScope: [],
// Scheduled send state
scheduledEmails: [],
@@ -2522,30 +2592,37 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get the current account ID from the client (assuming primary account)
const accountId = client.getAccountId();
// Check if there are changes for this account
// Changes may arrive for the client's primary account OR a delegated
// shared/group owner it has access to. Active-account *view* concerns
// (current email list, scheduled, calendar, filters) key off the primary
// account only, but the mailbox-COUNT refresh must react to any changed
// account: the active client's getAllMailboxes returns own + delegated
// folders, and the unified-section counts project from that list. (#281)
const accountChanges = change.changed[accountId];
if (!accountChanges) return;
const anyMailboxChanged = Object.values(change.changed).some((c) => c?.Mailbox);
// Handle Email state changes - refresh current mailbox
if (accountChanges.Email) {
if (accountChanges?.Email) {
await get().refreshCurrentMailbox(client);
get().fetchTagCounts(client);
}
if (accountChanges.EmailSubmission) {
if (accountChanges?.EmailSubmission) {
await get().refreshScheduledMetadata(client);
if (get().isScheduledView) {
await get().fetchScheduledEmails(client);
}
}
// Handle Mailbox state changes - refresh mailbox list
if (accountChanges.Mailbox) {
// Handle Mailbox state changes - refresh mailbox list (own + delegated
// shared folders), so both the active account's and its shared folders'
// counters follow background activity.
if (anyMailboxChanged) {
await get().fetchMailboxes(client);
}
// Handle Calendar/CalendarEvent state changes - refresh calendar data
if (accountChanges.Calendar || accountChanges.CalendarEvent) {
if (accountChanges?.Calendar || accountChanges?.CalendarEvent) {
const calendarStore = useCalendarStore.getState();
if (calendarStore.supportsCalendar) {
calendarStore.fetchCalendars(client);
@@ -2563,7 +2640,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
// Handle SieveScript state changes - refresh filter rules
if (accountChanges.SieveScript) {
if (accountChanges?.SieveScript) {
const { useFilterStore } = await import('./filter-store');
const filterStore = useFilterStore.getState();
if (filterStore.isSupported) {
@@ -3183,8 +3260,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
refreshUnifiedCounts: async (accounts) => {
try {
const counts = fetchUnifiedMailboxCounts(accounts);
set({ unifiedCounts: counts });
// Store the scope and project live counters over it. The badges then track
// the live mailbox lists via the store subscription (below), so subsequent
// optimistic mutations update them without another build/fetch.
set((state) => {
const next = { ...state, unifiedScope: accounts };
return { unifiedScope: accounts, ...projectUnifiedCounts(next) };
});
} catch (error) {
console.error('Failed to refresh unified counts:', error);
}
@@ -3223,7 +3305,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
refreshCrossCounts: (accounts) => {
try {
set({ crossUnreadCount: getCrossUnreadTotal(accounts) });
set((state) => {
const next = { ...state, unifiedScope: accounts };
return { unifiedScope: accounts, ...projectUnifiedCounts(next) };
});
} catch (error) {
console.error('Failed to refresh cross-account counts:', error);
}
@@ -3623,3 +3708,31 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
},
}));
// Keep the unified-section badges in lockstep with the live per-account mailbox
// lists. Whenever `mailboxes`, `accountMailboxes`, or the unified scope change -
// i.e. after any optimistic delete/move/markRead patch or a push-driven mailbox
// refresh - re-project the badges from that single source of truth. The guard
// short-circuits on every unrelated state change (emails, loading flags, …) by
// reference equality, and our own counter writes don't re-enter the projection
// because they leave the three watched lists untouched (no loop). (#281)
useEmailStore.subscribe((state, prev) => {
if (
state.mailboxes === prev.mailboxes &&
state.accountMailboxes === prev.accountMailboxes &&
state.unifiedScope === prev.unifiedScope
) {
return;
}
if (state.unifiedScope.length === 0) return;
const projected = projectUnifiedCounts(state);
const sameCross = projected.crossUnreadCount === state.crossUnreadCount;
const sameUnified =
projected.unifiedCounts.length === state.unifiedCounts.length &&
projected.unifiedCounts.every((c, i) => {
const cur = state.unifiedCounts[i];
return cur && cur.role === c.role && cur.unreadEmails === c.unreadEmails && cur.totalEmails === c.totalEmails;
});
if (sameCross && sameUnified) return;
useEmailStore.setState(projected);
});