From 95fee1812612a3b985d04eacf6e75e48083f3875 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Mon, 27 Apr 2026 06:58:02 -0500 Subject: [PATCH] fix(server): match IMAP \Noselect attribute case-insensitively (#20043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `ImapGetAllFoldersService.isMailboxSelectable` checked `mailbox.flags?.has('\\Noselect')`, which is case-sensitive. Per [RFC 3501 §6.3.8](https://www.rfc-editor.org/rfc/rfc3501#section-6.3.8), IMAP attribute names are case-insensitive — different servers spell the flag differently: | Server | Spelling | |---|---| | Dovecot | `\Noselect` | | Stalwart | `\NoSelect` | | Cyrus | `\Noselect` | | RFC examples | `\NOSELECT` | The previous check only caught Dovecot's spelling. On other servers, virtual namespace placeholders (e.g. Stalwart's `Shared Folders` parent) passed through `isMailboxSelectable` and got persisted as folders. When `MessagingMessageListFetchJob` later ran, it issued `SELECT "Shared Folders"`, the server correctly rejected it with `NO [NONEXISTENT] Mailbox does not exist.`, and the entire message-list fetch failed for the channel. ## Reproduction 1. Connect an IMAP account whose server advertises a `\NoSelect` (or `\NOSELECT`) namespace placeholder. Stalwart Mail v0.15.x exhibits this with shared mailboxes: ``` * LIST (\NoSelect) "/" "Shared Folders" ``` 2. The folder discovery job persists it as a syncable folder. 3. `MessagingMessageListFetchJob` runs and fails: ``` IMAP: Error fetching message list: D0 SELECT "Shared Folders" responseStatus: NO serverResponseCode: NONEXISTENT ``` ## Fix Iterate the flag set and lowercase-compare against `'\\noselect'`. This matches every legal spelling without changing behavior for compliant `\Noselect` clients. ```diff private isMailboxSelectable(mailbox: ListResponse): boolean { - return !mailbox.flags?.has('\\Noselect'); + if (!mailbox.flags) return true; + for (const flag of mailbox.flags) { + if (flag.toLowerCase() === '\\noselect') return false; + } + return true; } ``` ## Test plan - [x] Existing `\Noselect` test cases (`should not issue STATUS against a \Noselect folder`, parent-reference preservation, Sent-folder exclusion) still pass — the lowercase comparison subsumes them. - [x] New parameterized test covers `\Noselect`, `\NoSelect`, `\NOSELECT`, `\noselect` spellings against a Stalwart-style `Shared Folders` namespace placeholder, asserting Twenty does **not** issue `STATUS` on the placeholder and does **not** include it in the discovered folder set. --------- Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> --- .../imap-get-all-folders.service.spec.ts | 41 +++++++++++++++++++ .../services/imap-get-all-folders.service.ts | 16 +++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/services/__tests__/imap-get-all-folders.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/services/__tests__/imap-get-all-folders.service.spec.ts index f9cb244224..285cf6695e 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/services/__tests__/imap-get-all-folders.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/services/__tests__/imap-get-all-folders.service.spec.ts @@ -190,5 +190,46 @@ describe('ImapGetAllFoldersService', () => { ); expect(result.find((f) => f.isSentFolder)).toBeUndefined(); }); + + it.each([['\\Noselect'], ['\\NoSelect'], ['\\NOSELECT'], ['\\noselect']])( + 'should treat %s as non-selectable (RFC 3501 attribute names are case-insensitive)', + async (flagSpelling) => { + const mailboxList = [ + createMockMailbox({ path: 'INBOX' }), + createMockMailbox({ + path: 'Shared Folders', + flags: new Set([flagSpelling]), + }), + createMockMailbox({ + path: 'Shared Folders/team/INBOX', + name: 'INBOX', + parentPath: 'Shared Folders/team', + }), + ]; + + mockImapClient.list.mockResolvedValue(mailboxList); + mockImapClient.status.mockImplementation(async (path: string) => { + if (path === 'Shared Folders') { + throw new Error(`Mailbox doesn't exist: ${path}`); + } + + return { uidValidity: BigInt(1) } as any; + }); + + const result = await service.getAllMessageFolders( + CONNECTED_ACCOUNT, + MESSAGE_CHANNEL, + ); + + expect(mockImapClient.status).not.toHaveBeenCalledWith( + 'Shared Folders', + expect.anything(), + ); + + const paths = result.map((f) => f.externalId?.split(':')[0]); + + expect(paths).not.toContain('Shared Folders'); + }, + ); }); }); diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/services/imap-get-all-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/services/imap-get-all-folders.service.ts index 669948b68d..975f7d34b5 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/services/imap-get-all-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/services/imap-get-all-folders.service.ts @@ -138,7 +138,21 @@ export class ImapGetAllFoldersService implements MessageFolderDriver { } private isMailboxSelectable(mailbox: ListResponse): boolean { - return !mailbox.flags?.has('\\Noselect'); + // Per RFC 3501, IMAP attribute names are case-insensitive. Different + // servers vary the spelling (Dovecot: \Noselect, Stalwart: \NoSelect), + // so we compare lowercased to avoid attempting SELECT on a virtual + // namespace placeholder, which the server would reject as NONEXISTENT. + if (!mailbox.flags) { + return true; + } + + for (const flag of mailbox.flags) { + if (flag.toLowerCase() === '\\noselect') { + return false; + } + } + + return true; } private isValidMailbox(