From b76ce0d11981da729ad5e2525bf13b18661cedb4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:11:26 +0200 Subject: [PATCH] fix: folders containing a system folder name disappear from the sidebar #771 --- lib/__tests__/mailbox-deep-nesting.test.ts | 71 +++++++++++++++++++++- lib/utils.ts | 12 ++-- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/lib/__tests__/mailbox-deep-nesting.test.ts b/lib/__tests__/mailbox-deep-nesting.test.ts index 6daa99367..91bf9bf0c 100644 --- a/lib/__tests__/mailbox-deep-nesting.test.ts +++ b/lib/__tests__/mailbox-deep-nesting.test.ts @@ -349,7 +349,7 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => const mailboxes = [ makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }), makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }), - makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate - OK to remove + makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level, name only *contains* "Sent" - must keep (#771) makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }), makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested - must keep makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }), @@ -358,8 +358,10 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => const tree = buildMailboxTree(mailboxes); const flat = flattenMailboxTree(tree); - // "Sent Mail" at root (no parentId) can be deduped - that's fine - // But "Sent" nested under Projects must be kept + // "Sent Mail" at root is a distinct folder, not a duplicate of role "Sent" + expect(flat.find(n => n.id === 'sent-dup')).toBeDefined(); + + // "Sent" nested under Projects must be kept const sentNested = flat.find(n => n.id === 'sent-nested'); expect(sentNested).toBeDefined(); expect(sentNested!.depth).toBe(2); @@ -370,6 +372,69 @@ describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => }); }); +describe('GitHub #771: folders whose name contains a role folder name disappear', () => { + it('keeps root-level folders that merely contain a role folder name', () => { + const mailboxes = [ + makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }), + makeMailbox({ id: 'archive-role', name: 'Archive', role: 'archive' }), + makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }), + makeMailbox({ id: 'trash-role', name: 'Trash', role: 'trash' }), + makeMailbox({ id: 'old-inbox', name: 'old inbox' }), + makeMailbox({ id: 'archive-2025', name: '2025 Archive' }), + makeMailbox({ id: 'sent-acct', name: 'Sent to Accounting' }), + makeMailbox({ id: 'trashy', name: 'Trashy Podcasts' }), + ]; + + const rootIds = buildMailboxTree(mailboxes).map(n => n.id); + + expect(rootIds).toContain('old-inbox'); + expect(rootIds).toContain('archive-2025'); + expect(rootIds).toContain('sent-acct'); + expect(rootIds).toContain('trashy'); + }); + + it('keeps short root-level folders that are a substring of a role folder name', () => { + // The old bidirectional match also ate folders *shorter* than the role name. + const mailboxes = [ + makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }), + makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }), + makeMailbox({ id: 'in', name: 'In' }), + makeMailbox({ id: 'draft', name: 'Draft' }), + ]; + + const rootIds = buildMailboxTree(mailboxes).map(n => n.id); + + expect(rootIds).toContain('in'); + expect(rootIds).toContain('draft'); + }); + + it('still drops a root-level folder whose name exactly matches a role folder', () => { + const mailboxes = [ + makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }), + makeMailbox({ id: 'archive-role', name: 'Archive', role: 'archive' }), + makeMailbox({ id: 'archive-dup', name: 'archive' }), + ]; + + const rootIds = buildMailboxTree(mailboxes).map(n => n.id); + + expect(rootIds).toContain('archive-role'); + expect(rootIds).not.toContain('archive-dup'); + }); + + it('scopes exact-name deduplication to the same account', () => { + const mailboxes = [ + makeMailbox({ id: 'inbox-a', name: 'Inbox', role: 'inbox', accountId: 'a' }), + makeMailbox({ id: 'archive-a', name: 'Archive', role: 'archive', accountId: 'a' }), + // Same name, different account - not a duplicate + makeMailbox({ id: 'archive-b', name: 'Archive', accountId: 'b' }), + ]; + + const rootIds = buildMailboxTree(mailboxes).map(n => n.id); + + expect(rootIds).toContain('archive-b'); + }); +}); + describe('mailbox orphan behavior (missing parent)', () => { it('should expose orphan-to-root behavior when parent is missing', () => { // Simulates what happens if JMAP response is incomplete (e.g., truncated at 500 objects) diff --git a/lib/utils.ts b/lib/utils.ts index 3f3ecd9fc..8dfa66470 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -292,12 +292,12 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] { // Check if this root-level mailbox is a duplicate of a role-based mailbox in the SAME account const accountKey = mb.accountId || ''; const accountRoles = rolesByAccount.get(accountKey) || []; - const lowerName = mb.name.toLowerCase(); - const matchedRole = accountRoles.find(roleMb => { - const roleLowerName = roleMb.name.toLowerCase(); - // Check for common duplicates: "Sent Mail" vs "Sent", etc. - return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName); - }); + // Only an exact name collision counts as a duplicate. Substring matching + // silently hid legitimate user folders whose name merely contained a role + // folder's name - "Old Inbox", "2025 Archive", "Sent to Accounting". + // (GitHub #771) + const lowerName = mb.name.trim().toLowerCase(); + const matchedRole = accountRoles.find(roleMb => roleMb.name.trim().toLowerCase() === lowerName); const isDuplicate = !!matchedRole; // Only keep if not a duplicate