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:
@@ -397,4 +397,53 @@ describe('JMAPClient resilience', () => {
|
||||
).rejects.toThrow('Failed to fetch blob: 404');
|
||||
});
|
||||
});
|
||||
|
||||
// #281 V3: every email fetch path must namespace mailboxIds for shared/
|
||||
// delegated accounts (`${ownerId}:${id}`) so they line up with the store's
|
||||
// namespaced shared-mailbox ids. searchEmails/advancedSearchEmails are the
|
||||
// cross-view (All mail / Unread / Starred) browse paths and previously did not.
|
||||
describe('shared-account mailboxId namespacing', () => {
|
||||
function queryAndGet(email: Record<string, unknown>) {
|
||||
return {
|
||||
methodResponses: [
|
||||
['Email/query', { total: 1, ids: ['e1'] }, '0'],
|
||||
['Email/get', { list: [email] }, '1'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('advancedSearchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
|
||||
const client = await createConnectedClient(); // primary acct-1
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
|
||||
);
|
||||
|
||||
const { emails } = await client.advancedSearchEmails({ inMailbox: 'owner-x:x-inbox' }, 'owner-x');
|
||||
|
||||
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
|
||||
expect(emails[0].mailboxIds['x-inbox']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('searchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
|
||||
const client = await createConnectedClient();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
|
||||
);
|
||||
|
||||
const { emails } = await client.searchEmails('hello', undefined, 'owner-x');
|
||||
|
||||
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
|
||||
});
|
||||
|
||||
it('leaves own-account mailboxIds untouched (no foreign accountId)', async () => {
|
||||
const client = await createConnectedClient(); // primary acct-1
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { inbox: true } })),
|
||||
);
|
||||
|
||||
const { emails } = await client.advancedSearchEmails({ inMailbox: 'inbox' });
|
||||
|
||||
expect(emails[0].mailboxIds).toEqual({ inbox: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user