From 99cf78a2803cf48472464dc5f43283ffc9e4367c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:10:22 +0200 Subject: [PATCH] fix: keep shared folders from other servers when they reuse an account id #1108 --- lib/__tests__/unified-mailbox-dedupe.test.ts | 20 ++++++++++++++++++++ lib/unified-mailbox.ts | 19 ++++++++++++++----- stores/email-store.ts | 6 +++--- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/lib/__tests__/unified-mailbox-dedupe.test.ts b/lib/__tests__/unified-mailbox-dedupe.test.ts index 4499a0b96..4eeb42bcc 100644 --- a/lib/__tests__/unified-mailbox-dedupe.test.ts +++ b/lib/__tests__/unified-mailbox-dedupe.test.ts @@ -54,4 +54,24 @@ describe('dedupeUnifiedAccounts', () => { const group = sharedEntry('G', 'login-a', [mb('G:inbox', 'inbox', 1, 'inbox')]); expect(dedupeUnifiedAccounts([a, b, group])).toEqual([a, b, group]); }); + it('does not merge equal JMAP ids that live on different servers', () => { + // Stalwart hands out the same short account and mailbox ids on every + // server, so login A's own folder and a folder shared to login B on + // another server can both be account "c", mailbox "f1". + const server = (url: string) => ({ getServerUrl: () => url } as unknown as IJMAPClient); + const own = makeAccount({ accountId: 'login-a', jmapAccountId: 'c', client: server('https://one.example'), mailboxes: [mb('f1', undefined, 1)] }); + const sharedElsewhere = sharedEntry('c', 'login-b', [mb('c:f1', undefined, 2, 'f1')]); + sharedElsewhere.client = server('https://two.example'); + const out = dedupeUnifiedAccounts([own, sharedElsewhere]); + expect(out).toEqual([own, sharedElsewhere]); + expect(getCrossUnreadTotal(out)).toBe(3); + }); + + it('still merges the same folder reached through two logins of one server', () => { + const server = (url: string) => ({ getServerUrl: () => url } as unknown as IJMAPClient); + const own = makeAccount({ accountId: 'login-a', jmapAccountId: 'c', client: server('https://one.example'), mailboxes: [mb('f1', undefined, 1)] }); + const viaGrantee = sharedEntry('c', 'login-b', [mb('c:f1', undefined, 1, 'f1')]); + viaGrantee.client = server('https://ONE.example/'); + expect(dedupeUnifiedAccounts([viaGrantee, own])).toEqual([own]); + }); }); diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index 2be114442..203b70940 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -768,6 +768,11 @@ export function connectedAccountsGrew(previous: string | null, current: string): return current.split(',').some((id) => id !== '' && !before.has(id)); } +/** The server a client talks to, compared without case or a trailing slash. */ +export function unifiedServerKey(client: Pick | undefined): string { + return (client?.getServerUrl?.() ?? '').trim().replace(/\/+$/, '').toLowerCase(); +} + /** * How far the unified scope has got while logins are still reconnecting: * `{ loaded, total }` while some logins expected in it are not there yet, @@ -795,18 +800,22 @@ export function unifiedLoadProgress(input: { * shared with two logins at once, otherwise yields one entry per reaching * login: its messages list twice and its unread count is summed twice. * - * A mailbox is identified by its owning JMAP account + server-side id (a shared - * entry's `originalId`). Personal entries always win and are never trimmed, so + * A mailbox is identified by its server, its owning JMAP account and its + * server-side id (a shared entry's `originalId`). JMAP account and mailbox ids + * are only unique on one server (Stalwart hands out short ids like "b" or "c" + * everywhere), so logins on different servers never cover each other's + * folders. Personal entries always win and are never trimmed, so * an owner's folder is read through the owner's own login and honors their * folder selection. Shared entries keep only mailboxes not already covered and * are dropped when none remain. Order is preserved. */ export function dedupeUnifiedAccounts(accounts: UnifiedAccountClient[]): UnifiedAccountClient[] { const seen = new Set(); - const key = (jmapAccountId: string, mailboxId: string) => `${jmapAccountId}\u0000${mailboxId}`; + const key = (account: UnifiedAccountClient, mailboxId: string) => + `${unifiedServerKey(account.client)}\u0000${account.jmapAccountId}\u0000${mailboxId}`; for (const account of accounts) { if (account.isShared) continue; - for (const m of account.mailboxes) seen.add(key(account.jmapAccountId, m.id)); + for (const m of account.mailboxes) seen.add(key(account, m.id)); } const out: UnifiedAccountClient[] = []; for (const account of accounts) { @@ -815,7 +824,7 @@ export function dedupeUnifiedAccounts(accounts: UnifiedAccountClient[]): Unified continue; } const mailboxes = account.mailboxes.filter((m) => { - const k = key(account.jmapAccountId, m.originalId ?? m.id); + const k = key(account, m.originalId ?? m.id); if (seen.has(k)) return false; seen.add(k); return true; diff --git a/stores/email-store.ts b/stores/email-store.ts index c5ec41781..67d765405 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -18,7 +18,7 @@ import { emailSummary, emailToReadView } from "@/lib/plugin-projection"; import { resolveThreadRoute } from "@/lib/thread-routing"; import { emailKeyFor, threadKeyFor, threadIdFromKey, KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; import type { ExternalSearchResult } from "@/lib/plugin-types"; -import { positionsByAccount, fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, trashAndJunkIds, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, dedupeUnifiedAccounts, type AcrossAccountsSearchOptions, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; +import { positionsByAccount, fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, trashAndJunkIds, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, dedupeUnifiedAccounts, unifiedServerKey, type AcrossAccountsSearchOptions, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; import { defaultSearchScopeFor, isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { useAuthStore } from "@/stores/auth-store"; import { pathNamesMailFolder } from "@/lib/deep-links"; @@ -1492,7 +1492,7 @@ export async function buildUnifiedAccountClients( const ownLoginJmapIds = new Set(); for (const a of authAccounts) { const c = allClients.get(a.id); - if (c) ownLoginJmapIds.add(c.getAccountId()); + if (c) ownLoginJmapIds.add(`${unifiedServerKey(c)} ${c.getAccountId()}`); } // Every login at once rather than one after the other: the slowest login, // not the sum of all of them, bounds how long the scope takes. @@ -1548,7 +1548,7 @@ export async function buildUnifiedAccountClients( // in the owner account instead of falling back to the active account. // When the owner is logged in too, its own (un-namespaced) list wins: // dedupeUnifiedAccounts routes that folder through the owner's login. - if (!ownLoginJmapIds.has(ownerId)) publish(ownerId, ownerMailboxes); + if (!ownLoginJmapIds.has(`${unifiedServerKey(c)} ${ownerId}`)) publish(ownerId, ownerMailboxes); } } } catch {