fix: route unread-counter update to the email's own account in aggregate views

In a cross-account view, marking a second account's email read/unread updated
the *active* account's folder counter instead of the email's. Two causes: the
optimistic counter update only touched `state.mailboxes` (the active account),
and JMAP mailbox ids can collide across accounts so the id match hit the wrong
folder.

Add applyMailboxCounterUpdate(): route the counter delta to the list that holds
the email's folders — the active account's `mailboxes` (incl. its shared
folders) for active-account/shared emails, otherwise that account's
`accountMailboxes[sourceClientAccountId]` entry. Use it in markAsRead.

Regression test: a 2nd-account email with a colliding inbox id decrements that
account's counter and leaves the active account's untouched.
This commit is contained in:
Stefan Hildebrandt
2026-06-23 19:16:22 +02:00
parent a29c33b50a
commit befee332d2
2 changed files with 63 additions and 16 deletions
@@ -68,6 +68,7 @@ describe('unified-view single-email action routing (#281)', () => {
// 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);
@@ -124,6 +125,27 @@ describe('unified-view single-email action routing (#281)', () => {
expect(activeClient.moveEmail).not.toHaveBeenCalled();
});
it('updates the unread counter on the emails 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('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.
+41 -16
View File
@@ -522,6 +522,34 @@ function emailInMailbox(
return false;
}
// Apply a per-mailbox counter adjustment to the mailbox list that actually holds
// the email's folders, and return the matching `set()` partial.
//
// In aggregate views an email may belong to another logged-in account. The
// sidebar shows the active account's folders from `mailboxes` (its
// getAllMailboxes, incl. its delegated shared folders) and every *other*
// account's folders from `accountMailboxes[<AccountEntry.id>]`. Updating the
// wrong list silently corrupts counters - and because JMAP mailbox ids can
// collide across accounts, blindly matching `mailboxes` would even decrement the
// *active* account's folder for a different account's email. So route by the
// email's source login: a different account → its `accountMailboxes` entry,
// otherwise (active account, its shared folders, or a non-aggregate view) →
// `mailboxes`. (#281)
function applyMailboxCounterUpdate(
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
email: { sourceClientAccountId?: string },
adjust: (mb: Mailbox) => Mailbox,
): { mailboxes?: Mailbox[]; accountMailboxes?: Record<string, Mailbox[]> } {
const srcClient = email.sourceClientAccountId;
const activeId = useAuthStore.getState().activeAccountId;
if (srcClient && srcClient !== activeId) {
const list = state.accountMailboxes[srcClient];
if (!list) return {};
return { accountMailboxes: { ...state.accountMailboxes, [srcClient]: list.map(adjust) } };
}
return { mailboxes: state.mailboxes.map(adjust) };
}
// 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.
@@ -1327,21 +1355,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
}
const updatedMailboxes = state.mailboxes.map(mailbox => {
// Check if this email belongs to this mailbox. Shared mailboxes are
// stored under a namespaced id but the email's mailboxIds are keyed by
// the owner-side JMAP id (originalId), so match on originalId first.
if (emailInMailbox(emailInState, mailbox)) {
// Adjust unread counter: -1 if marking as read, +1 if marking as unread
const delta = read ? -1 : 1;
return {
...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
unreadThreads: Math.max(0, mailbox.unreadThreads + delta)
};
}
return mailbox;
});
// Adjust the unread counter on the folder(s) holding this email, in the
// email's *own* account's mailbox list (#281). -1 marking read, +1 unread.
const delta = read ? -1 : 1;
const mailboxPatch = applyMailboxCounterUpdate(state, emailInState, (mailbox) =>
emailInMailbox(emailInState, mailbox)
? {
...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
unreadThreads: Math.max(0, mailbox.unreadThreads + delta),
}
: mailbox,
);
return {
emails: state.emails.map(e =>
@@ -1350,7 +1375,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
: state.selectedEmail,
mailboxes: updatedMailboxes,
...mailboxPatch,
processingReadStatus: newProcessingSet,
// Also update threadEmailsCache so expanded dropdowns reflect the change
threadEmailsCache: (() => {