fix(server): match IMAP \Noselect attribute case-insensitively (#20043)

## 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>
This commit is contained in:
Roland Rodriguez
2026-04-27 06:58:02 -05:00
committed by GitHub
parent 5b8804ff06
commit 95fee18126
2 changed files with 56 additions and 1 deletions
@@ -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');
},
);
});
});
@@ -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(