fix: keep shared folders from other servers when they reuse an account id #1108

This commit is contained in:
Linus Rath
2026-10-18 14:58:53 +02:00
parent 4f9271e25b
commit 99cf78a280
3 changed files with 37 additions and 8 deletions
@@ -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]);
});
});
+14 -5
View File
@@ -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<IJMAPClient, 'getServerUrl'> | 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<string>();
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;
+3 -3
View File
@@ -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<string>();
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 {