mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
fix: keep shared folders from other servers when they reuse an account id #1108
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user