fix: only offer archiving when the archive folder can take the mail #578

The list resolved the Archive folder with a loose name match ("Archives",
"Archived" or any name containing "archive"), while the store's archive
actions only accept the `archive` role or a folder named exactly
"Archive". With a role-less "Archives" folder the swipe and bulk archive
were offered but silently did nothing.

`findArchiveMailbox` now uses the webmail's rules (role first, then the
exact name, case-insensitive) and both store archive actions resolve
through it, so the list offers archiving only when it will run and
otherwise shows the existing "no Archive folder" message.
This commit is contained in:
Linus Rath
2026-09-24 19:36:41 +02:00
parent 30aea63a66
commit 9f75e70218
3 changed files with 38 additions and 15 deletions
+26
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
buildMailboxTree,
findArchiveMailbox,
flattenVisible,
mailboxAccountId,
mailboxesForSiblingOf,
@@ -205,3 +206,28 @@ describe('role-folder deduplication (#771)', () => {
expect(tree.find((n) => n.id === 'proj')?.children.map((n) => n.id)).toEqual(['proj-sent']);
});
});
describe('findArchiveMailbox (#578)', () => {
it('prefers the archive role over a folder named Archive', () => {
const found = findArchiveMailbox([
own('named', 'Archive'),
own('role', 'Old mail', { role: 'archive' }),
]);
expect(found?.id).toBe('role');
});
it('falls back to a role-less folder named exactly "Archive", any case', () => {
expect(findArchiveMailbox([own('inbox', 'Inbox', { role: 'inbox' }), own('a', 'ARCHIVE')])?.id)
.toBe('a');
});
it('does not treat "Archives" or folders merely containing "archive" as the Archive', () => {
// The store's archive actions only file into a folder this resolver
// returns; a looser match here offered an archive that did nothing.
expect(findArchiveMailbox([
own('plural', 'Archives'),
own('past', 'Archived'),
own('proj', 'Project archive 2019'),
])).toBeUndefined();
});
});
+9 -8
View File
@@ -272,15 +272,16 @@ export function findTrashMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
});
}
/**
* The Archive folder: the `archive` role, else a role-less folder named
* exactly "Archive" (any case), as the webmail matches it. Whatever offers
* the archive action must resolve through this, like the store's archive
* actions do: a looser list-only match used to offer archiving into e.g.
* "Archives", which the store then silently ignored. (#578)
*/
export function findArchiveMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
const roleMatch = mailboxes.find((m) => m.role === 'archive');
if (roleMatch) return roleMatch;
const names = ['archive', 'archives', 'archived'];
return mailboxes.find((m) => {
const lower = m.name.toLowerCase();
return names.includes(lower) || names.some((n) => lower.includes(n));
});
return mailboxes.find((m) => m.role === 'archive')
?? mailboxes.find((m) => m.name.toLowerCase() === 'archive');
}
export function findJunkMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
+3 -7
View File
@@ -34,7 +34,7 @@ import {
} from '../api/email';
import { useNetworkStore } from './network-store';
import { JMAPMethodError } from '../api/jmap-result';
import { mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox } from '../lib/mailbox-tree';
import { mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox } from '../lib/mailbox-tree';
import { toWildcardQuery } from '../lib/search-utils';
import { orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order';
import { buildListSort, markKeywordSortUnsupported } from '../lib/keyword-sort-polarity';
@@ -1251,9 +1251,7 @@ export const useEmailStore = create<EmailState>()(
// Archive into the *same account's* Archive folder — a shared mailbox's
// messages can't be filed into the user's own.
const scoped = actionMailboxes(state, viewed);
const archiveMailbox = scoped.find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
const archiveMailbox = findArchiveMailbox(scoped);
if (!archiveMailbox) return;
const archive = refFor(state.mailboxes, archiveMailbox.id);
if (email.mailboxIds?.[archive.id]) return;
@@ -1379,9 +1377,7 @@ export const useEmailStore = create<EmailState>()(
archiveEmailsBatch: async (emailIds) => {
const state = get();
const scoped = mailboxesForSiblingOf(state.mailboxes, state.currentMailboxId);
const archiveMailbox = scoped.find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
const archiveMailbox = findArchiveMailbox(scoped);
if (!archiveMailbox) return;
const archive = refFor(state.mailboxes, archiveMailbox.id);
const targets = state.emails.filter(