diff --git a/src/lib/__tests__/mailbox-tree.test.ts b/src/lib/__tests__/mailbox-tree.test.ts index cb2e2031..d491c9e4 100644 --- a/src/lib/__tests__/mailbox-tree.test.ts +++ b/src/lib/__tests__/mailbox-tree.test.ts @@ -140,3 +140,42 @@ describe('account scoping helpers', () => { expect(mailboxesForSiblingOf(all, null).map((m) => m.id)).toEqual(['inbox', 'trash']); }); }); + +describe('role-folder deduplication (#771)', () => { + it('keeps user folders whose name merely contains a role folder name', () => { + const tree = buildMailboxTree([ + own('inbox', 'Inbox', { role: 'inbox' }), + own('sent', 'Sent', { role: 'sent' }), + own('archive', 'Archive', { role: 'archive' }), + own('old-inbox', 'Old Inbox'), + own('arch-2025', '2025 Archive'), + own('sent-acc', 'Sent to Accounting'), + own('in', 'In'), + ]); + expect(tree.map((n) => n.id)).toEqual([ + 'inbox', 'sent', 'archive', 'arch-2025', 'in', 'old-inbox', 'sent-acc', + ]); + }); + + it('drops only an exact (trimmed, case-insensitive) duplicate of a role folder', () => { + const tree = buildMailboxTree([ + own('sent', 'Sent', { role: 'sent' }), + own('sent-dup', ' sent '), + own('sent-mail', 'Sent Mail'), + ]); + expect(tree.map((n) => n.id)).toEqual(['sent', 'sent-mail']); + }); + + it('never drops a nested folder or a duplicate that has children', () => { + const tree = buildMailboxTree([ + own('inbox', 'Inbox', { role: 'inbox' }), + own('sent', 'Sent', { role: 'sent' }), + own('proj', 'Projects'), + own('proj-sent', 'Sent', { parentId: 'proj' }), + own('inbox-dup', 'Inbox'), + own('inbox-dup-child', 'Child', { parentId: 'inbox-dup' }), + ]); + expect(tree.map((n) => n.id)).toEqual(['inbox', 'sent', 'inbox-dup', 'proj']); + expect(tree.find((n) => n.id === 'proj')?.children.map((n) => n.id)).toEqual(['proj-sent']); + }); +}); diff --git a/src/lib/mailbox-tree.ts b/src/lib/mailbox-tree.ts index d7ea5800..3fbdc95b 100644 --- a/src/lib/mailbox-tree.ts +++ b/src/lib/mailbox-tree.ts @@ -44,8 +44,12 @@ const ROLE_PRIORITY: Record = { }; // Drop root-level folders whose name collides with an existing role mailbox -// (e.g. "Sent Mail" when a role=sent mailbox already exists). Mirrors the -// webmail's `deduplicateMailboxes`; kept minimal (single account only). +// (e.g. a plain "Sent" folder when a role=sent mailbox already exists). +// Mirrors the webmail's `deduplicateMailboxes`; kept minimal (single account +// only). Only an *exact* (trimmed, case-insensitive) name collision counts: +// 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). function deduplicate(mailboxes: Mailbox[]): Mailbox[] { const roles = mailboxes.filter((m) => m.role); const referencedParentIds = new Set(); @@ -56,12 +60,11 @@ function deduplicate(mailboxes: Mailbox[]): Mailbox[] { const result: Mailbox[] = []; for (const m of mailboxes) { if (m.role) { result.push(m); continue; } + // Never deduplicate nested folders — removing one would orphan its + // children to the root (GitHub #118). if (m.parentId) { result.push(m); continue; } - const lower = m.name.toLowerCase(); - const dup = roles.find((r) => { - const rn = r.name.toLowerCase(); - return lower.includes(rn) || rn.includes(lower); - }); + const lower = m.name.trim().toLowerCase(); + const dup = roles.find((r) => r.name.trim().toLowerCase() === lower); if (!dup || referencedParentIds.has(m.id)) result.push(m); } return result;