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).
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { useEmailStore } from '../email-store';
|
||
import { useAuthStore } from '../auth-store';
|
||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||
import type { UnifiedAccountClient } from '@/lib/unified-mailbox';
|
||
|
||
// Regression coverage for issue #281: single-email actions performed in the
|
||
// unified inbox must be routed to the *email's own account* client, not the
|
||
// active account's. Sending them to the active account silently no-ops
|
||
// server-side (JMAP returns notUpdated without throwing), so the change is lost
|
||
// on the next reload.
|
||
|
||
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
|
||
return {
|
||
id: 'inbox',
|
||
name: 'Inbox',
|
||
sortOrder: 0,
|
||
totalEmails: 0,
|
||
unreadEmails: 0,
|
||
totalThreads: 0,
|
||
unreadThreads: 0,
|
||
myRights: {
|
||
mayReadItems: true,
|
||
mayAddItems: true,
|
||
mayRemoveItems: true,
|
||
maySetSeen: true,
|
||
maySetKeywords: true,
|
||
mayCreateChild: true,
|
||
mayRename: true,
|
||
mayDelete: true,
|
||
maySubmit: true,
|
||
},
|
||
isSubscribed: true,
|
||
isShared: false,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function makeEmail(overrides: Partial<Email> = {}): Email {
|
||
return {
|
||
id: 'email-1',
|
||
threadId: 'thread-1',
|
||
subject: 'Hi',
|
||
receivedAt: new Date().toISOString(),
|
||
keywords: {},
|
||
mailboxIds: {},
|
||
...overrides,
|
||
} as Email;
|
||
}
|
||
|
||
function makeClient() {
|
||
return {
|
||
markAsRead: vi.fn().mockResolvedValue(undefined),
|
||
toggleStar: vi.fn().mockResolvedValue(undefined),
|
||
moveEmail: vi.fn().mockResolvedValue(undefined),
|
||
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
|
||
batchDeleteEmails: vi.fn().mockResolvedValue(undefined),
|
||
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
|
||
} as unknown as IJMAPClient;
|
||
}
|
||
|
||
describe('unified-view single-email action routing (#281)', () => {
|
||
let activeClient: IJMAPClient; // account-a, also the "passed" client
|
||
let accountBClient: IJMAPClient;
|
||
|
||
beforeEach(() => {
|
||
activeClient = makeClient();
|
||
accountBClient = makeClient();
|
||
|
||
// Route each login by its AccountEntry.id (the `sourceClientAccountId` key).
|
||
// account-a is the active login; account-b is a second direct login; the
|
||
// active login (account-a) also delegates access to the shared owner 'owner-x'.
|
||
useAuthStore.setState({
|
||
activeAccountId: 'account-a',
|
||
getClientForAccount: (id: string) =>
|
||
(id === 'account-b' ? accountBClient : id === 'account-a' ? activeClient : undefined) as never,
|
||
} as never);
|
||
|
||
useEmailStore.setState({
|
||
isUnifiedView: true,
|
||
unifiedRole: 'inbox',
|
||
viewingAccountId: null,
|
||
selectedMailbox: '',
|
||
mailboxes: [makeMailbox({ id: 'a-inbox', role: 'inbox' })],
|
||
// Owner mailbox lists are cached by their JMAP id (`sourceAccountId`).
|
||
accountMailboxes: {
|
||
'account-a': [makeMailbox({ id: 'a-inbox', role: 'inbox' })],
|
||
'account-b': [
|
||
makeMailbox({ id: 'b-inbox', role: 'inbox' }),
|
||
makeMailbox({ id: 'b-archive', name: 'Archive', role: 'archive' }),
|
||
],
|
||
// Shared owner reached through account-a's client.
|
||
'owner-x': [
|
||
makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }),
|
||
makeMailbox({ id: 'owner-x:x-trash', originalId: 'x-trash', name: 'Trash', role: 'trash', isShared: true, accountId: 'owner-x' }),
|
||
],
|
||
},
|
||
processingReadStatus: new Set(),
|
||
selectedEmail: null,
|
||
selectedEmailIds: new Set(),
|
||
unifiedScope: [],
|
||
unifiedCounts: [],
|
||
crossUnreadCount: 0,
|
||
emails: [
|
||
// Second direct login: sourceClientAccountId === sourceAccountId === 'account-b'.
|
||
makeEmail({ id: 'email-b', accountId: 'account-b', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b', keywords: {}, mailboxIds: { 'b-inbox': true } }),
|
||
// Shared/group source: reached via account-a's client, owned by 'owner-x'.
|
||
makeEmail({ id: 'email-shared', accountId: 'owner-x', sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
|
||
],
|
||
});
|
||
});
|
||
|
||
it('routes markAsRead to the email’s account client', async () => {
|
||
await useEmailStore.getState().markAsRead(activeClient, 'email-b', true);
|
||
|
||
expect(accountBClient.markAsRead).toHaveBeenCalledWith('email-b', true, 'account-b');
|
||
expect(activeClient.markAsRead).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('routes toggleStar to the email’s account client with the owner accountId', async () => {
|
||
await useEmailStore.getState().toggleStar(activeClient, 'email-b');
|
||
|
||
expect(accountBClient.toggleStar).toHaveBeenCalledWith('email-b', true, 'account-b');
|
||
expect(activeClient.toggleStar).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('routes moveToMailbox to the email’s account client with that account’s destination', async () => {
|
||
await useEmailStore.getState().moveToMailbox(activeClient, 'email-b', 'b-archive');
|
||
|
||
expect(accountBClient.moveEmail).toHaveBeenCalledWith('email-b', 'b-archive', 'account-b');
|
||
expect(activeClient.moveEmail).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('updates the unread counter on the email’s own account, not the active one (id collision)', async () => {
|
||
// Real per-account JMAP ids can collide; here both inboxes use the same id.
|
||
useEmailStore.setState({
|
||
mailboxes: [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 5 })],
|
||
accountMailboxes: {
|
||
'account-a': [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 5 })],
|
||
'account-b': [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 3 })],
|
||
},
|
||
emails: [
|
||
makeEmail({ id: 'b1', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b', keywords: {}, mailboxIds: { inbox: true } }),
|
||
],
|
||
});
|
||
|
||
await useEmailStore.getState().markAsRead(activeClient, 'b1', true);
|
||
|
||
const s = useEmailStore.getState();
|
||
expect(s.accountMailboxes['account-b'][0].unreadEmails).toBe(2); // account-b decremented
|
||
expect(s.mailboxes[0].unreadEmails).toBe(5); // active account untouched
|
||
expect(s.accountMailboxes['account-a'][0].unreadEmails).toBe(5); // active list untouched
|
||
});
|
||
|
||
it('batchMarkAsRead adjusts each email’s own account counter (cross-account)', async () => {
|
||
useEmailStore.setState({
|
||
mailboxes: [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 5 })],
|
||
accountMailboxes: {
|
||
'account-a': [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 5 })],
|
||
'account-b': [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 3 })],
|
||
},
|
||
emails: [
|
||
makeEmail({ id: 'a1', sourceClientAccountId: 'account-a', sourceAccountId: 'account-a', keywords: {}, mailboxIds: { inbox: true } }),
|
||
makeEmail({ id: 'b1', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b', keywords: {}, mailboxIds: { inbox: true } }),
|
||
],
|
||
selectedEmailIds: new Set(['a1', 'b1']),
|
||
});
|
||
|
||
await useEmailStore.getState().batchMarkAsRead(activeClient, true);
|
||
|
||
const s = useEmailStore.getState();
|
||
expect(s.mailboxes[0].unreadEmails).toBe(4); // active account: -1
|
||
expect(s.accountMailboxes['account-b'][0].unreadEmails).toBe(2); // account-b: -1
|
||
});
|
||
|
||
it('routes a shared/group email through the delegating login client + owner accountId', async () => {
|
||
await useEmailStore.getState().markAsRead(activeClient, 'email-shared', true);
|
||
// Reached via account-a's client (the active one), targeting the owner account.
|
||
expect(activeClient.markAsRead).toHaveBeenCalledWith('email-shared', true, 'owner-x');
|
||
expect(accountBClient.markAsRead).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('stars a shared/group email via the delegating client + owner accountId', async () => {
|
||
await useEmailStore.getState().toggleStar(activeClient, 'email-shared');
|
||
expect(activeClient.toggleStar).toHaveBeenCalledWith('email-shared', true, 'owner-x');
|
||
});
|
||
|
||
it('decrements a shared/group folder counter when deleting from the unified view', async () => {
|
||
// Real app: the active account's `mailboxes` includes its delegated shared
|
||
// folders (namespaced id + originalId + owner accountId). Unified-fetched
|
||
// shared emails carry the owner's BARE mailboxIds and sourceAccountId=owner.
|
||
// Regression: emailInMailbox missed these, so the shared folder's badge
|
||
// stayed at its old value after deleting in All mail / All unread.
|
||
useEmailStore.setState({
|
||
mailboxes: [
|
||
makeMailbox({ id: 'a-inbox', role: 'inbox', unreadEmails: 2, totalEmails: 5 }),
|
||
makeMailbox({
|
||
id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox',
|
||
role: 'inbox', isShared: true, accountId: 'owner-x',
|
||
unreadEmails: 4, totalEmails: 10,
|
||
}),
|
||
],
|
||
emails: [
|
||
makeEmail({
|
||
id: 'email-shared', accountId: 'owner-x',
|
||
sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x',
|
||
keywords: {}, // unread
|
||
mailboxIds: { 'x-inbox': true }, // BARE owner id (not namespaced)
|
||
}),
|
||
],
|
||
selectedEmailIds: new Set(['email-shared']),
|
||
});
|
||
|
||
await useEmailStore.getState().batchDelete(activeClient, true);
|
||
|
||
expect(activeClient.batchDeleteEmails).toHaveBeenCalledWith(['email-shared'], 'owner-x');
|
||
const shared = useEmailStore.getState().mailboxes.find(m => m.id === 'owner-x:x-inbox')!;
|
||
expect(shared.unreadEmails).toBe(3); // was 4
|
||
expect(shared.totalEmails).toBe(9); // was 10
|
||
});
|
||
|
||
it('decrements the unified-section badges when deleting from the unified view (live projection)', async () => {
|
||
// The unified-section badges (unifiedCounts / crossUnreadCount) must be a
|
||
// live projection of the per-account mailbox lists, NOT a stale server
|
||
// snapshot. Deleting a message in the unified view patches the folder's
|
||
// counter; the badge must follow in lockstep without a re-fetch.
|
||
const scope: UnifiedAccountClient[] = [
|
||
{
|
||
accountId: 'account-a', accountLabel: 'A', client: activeClient,
|
||
clientAccountId: 'account-a', jmapAccountId: 'account-a', isShared: false,
|
||
mailboxes: [makeMailbox({ id: 'a-inbox', role: 'inbox' })],
|
||
},
|
||
{
|
||
accountId: 'owner-x', accountLabel: 'Shared', client: activeClient,
|
||
clientAccountId: 'account-a', jmapAccountId: 'owner-x', isShared: true,
|
||
mailboxes: [makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', role: 'inbox', isShared: true, accountId: 'owner-x' })],
|
||
},
|
||
];
|
||
|
||
useEmailStore.setState({
|
||
mailboxes: [
|
||
makeMailbox({ id: 'a-inbox', role: 'inbox', unreadEmails: 2, totalEmails: 5 }),
|
||
makeMailbox({
|
||
id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox',
|
||
role: 'inbox', isShared: true, accountId: 'owner-x',
|
||
unreadEmails: 4, totalEmails: 10,
|
||
}),
|
||
],
|
||
emails: [
|
||
makeEmail({
|
||
id: 'email-shared', accountId: 'owner-x',
|
||
sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x',
|
||
keywords: {}, // unread
|
||
mailboxIds: { 'x-inbox': true }, // BARE owner id (not namespaced)
|
||
}),
|
||
],
|
||
selectedEmailIds: new Set(['email-shared']),
|
||
});
|
||
|
||
// Seed the badges from the scope (also stores unifiedScope).
|
||
useEmailStore.getState().refreshUnifiedCounts(scope);
|
||
useEmailStore.getState().refreshCrossCounts(scope);
|
||
|
||
const before = useEmailStore.getState();
|
||
expect(before.unifiedCounts.find(c => c.role === 'inbox')).toMatchObject({ unreadEmails: 6, totalEmails: 15 });
|
||
expect(before.crossUnreadCount).toBe(6);
|
||
|
||
await useEmailStore.getState().batchDelete(activeClient, true);
|
||
|
||
const after = useEmailStore.getState();
|
||
// Underlying folder counter dropped...
|
||
expect(after.mailboxes.find(m => m.id === 'owner-x:x-inbox')!.unreadEmails).toBe(3);
|
||
// ...and the unified-section badges followed via the live projection.
|
||
expect(after.unifiedCounts.find(c => c.role === 'inbox')).toMatchObject({ unreadEmails: 5, totalEmails: 14 });
|
||
expect(after.crossUnreadCount).toBe(5);
|
||
});
|
||
|
||
it('still uses the active/passed client outside unified view', async () => {
|
||
useEmailStore.setState({
|
||
isUnifiedView: false,
|
||
emails: [makeEmail({ id: 'email-a', accountId: 'account-a', mailboxIds: { 'a-inbox': true } })],
|
||
});
|
||
|
||
await useEmailStore.getState().markAsRead(activeClient, 'email-a', true);
|
||
|
||
expect(activeClient.markAsRead).toHaveBeenCalledWith('email-a', true, undefined);
|
||
expect(accountBClient.markAsRead).not.toHaveBeenCalled();
|
||
});
|
||
});
|