fix: use canonical INBOX in Sieve filter paths #313

This commit is contained in:
Linus Rath
2026-05-22 11:51:25 +02:00
parent 66b2036e37
commit 1c02970ae1
2 changed files with 29 additions and 9 deletions
+4 -1
View File
@@ -78,7 +78,10 @@ export function FilterRuleModal({
const pathMap = new Map<string, string>(); const pathMap = new Map<string, string>();
const buildPaths = (nodes: MailboxNode[], parentPath = "") => { const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
for (const node of nodes) { for (const node of nodes) {
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name; // Sieve fileinto expects the IMAP-canonical "INBOX" for the inbox,
// not the localized JMAP display name (e.g. "Entrada" in pt-BR).
const segment = node.role === "inbox" ? "INBOX" : node.name;
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
pathMap.set(node.id, fullPath); pathMap.set(node.id, fullPath);
if (node.children.length > 0) buildPaths(node.children, fullPath); if (node.children.length > 0) buildPaths(node.children, fullPath);
} }
+25 -8
View File
@@ -33,7 +33,8 @@ function buildMailboxPathMap(tree: MailboxNode[]): Map<string, string> {
const pathMap = new Map<string, string>(); const pathMap = new Map<string, string>();
const walk = (nodes: MailboxNode[], parentPath = '') => { const walk = (nodes: MailboxNode[], parentPath = '') => {
for (const node of nodes) { for (const node of nodes) {
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name; const segment = node.role === 'inbox' ? 'INBOX' : node.name;
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
pathMap.set(node.id, fullPath); pathMap.set(node.id, fullPath);
if (node.children.length > 0) walk(node.children, fullPath); if (node.children.length > 0) walk(node.children, fullPath);
} }
@@ -47,7 +48,7 @@ describe('mailbox path building for sieve fileinto', () => {
const mailboxes = [makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' })]; const mailboxes = [makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' })];
const tree = buildMailboxTree(mailboxes); const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree); const paths = buildMailboxPathMap(tree);
expect(paths.get('inbox')).toBe('Inbox'); expect(paths.get('inbox')).toBe('INBOX');
}); });
it('should produce correct path for a single-level subfolder', () => { it('should produce correct path for a single-level subfolder', () => {
@@ -57,7 +58,7 @@ describe('mailbox path building for sieve fileinto', () => {
]; ];
const tree = buildMailboxTree(mailboxes); const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree); const paths = buildMailboxPathMap(tree);
expect(paths.get('sub1')).toBe('Inbox/Projects'); expect(paths.get('sub1')).toBe('INBOX/Projects');
}); });
it('should produce correct path for deeply nested subfolders', () => { it('should produce correct path for deeply nested subfolders', () => {
@@ -68,7 +69,7 @@ describe('mailbox path building for sieve fileinto', () => {
]; ];
const tree = buildMailboxTree(mailboxes); const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree); const paths = buildMailboxPathMap(tree);
expect(paths.get('sub2')).toBe('Inbox/Test/Test2'); expect(paths.get('sub2')).toBe('INBOX/Test/Test2');
}); });
it('should handle multiple root-level folders', () => { it('should handle multiple root-level folders', () => {
@@ -79,7 +80,7 @@ describe('mailbox path building for sieve fileinto', () => {
]; ];
const tree = buildMailboxTree(mailboxes); const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree); const paths = buildMailboxPathMap(tree);
expect(paths.get('inbox')).toBe('Inbox'); expect(paths.get('inbox')).toBe('INBOX');
expect(paths.get('archive')).toBe('Archive'); expect(paths.get('archive')).toBe('Archive');
expect(paths.get('sub1')).toBe('Archive/Work'); expect(paths.get('sub1')).toBe('Archive/Work');
}); });
@@ -99,9 +100,25 @@ describe('mailbox path building for sieve fileinto', () => {
expect(paths.has(node.id)).toBe(true); expect(paths.has(node.id)).toBe(true);
} }
expect(paths.get('inbox')).toBe('Inbox'); expect(paths.get('inbox')).toBe('INBOX');
expect(paths.get('sub1')).toBe('Inbox/Projects'); expect(paths.get('sub1')).toBe('INBOX/Projects');
expect(paths.get('sub2')).toBe('Inbox/Projects/Active'); expect(paths.get('sub2')).toBe('INBOX/Projects/Active');
});
it('uses canonical INBOX even when JMAP returns a localized inbox name', () => {
// Stalwart returns localized display names for the inbox based on the
// user's locale (e.g. "Entrada" for pt-BR). Sieve fileinto must still
// target the IMAP-canonical "INBOX" so the message is filed correctly.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Entrada', role: 'inbox' }),
makeMailbox({ id: 'host', name: 'Host', parentId: 'inbox' }),
makeMailbox({ id: 'eveo', name: 'EVEO', parentId: 'host' }),
];
const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree);
expect(paths.get('inbox')).toBe('INBOX');
expect(paths.get('host')).toBe('INBOX/Host');
expect(paths.get('eveo')).toBe('INBOX/Host/EVEO');
}); });
it('should preserve depth info in flattened tree', () => { it('should preserve depth info in flattened tree', () => {