fix: mark read and star a list selection in one request

Bulk mark read/unread and star/unstar from the list selection ran the
single-message action once per message, so a large selection sent one
`Email/set` per message against Stalwart's limit of four concurrent
requests. They now go through `setKeywordForEmails`, which sends one
`keywords/$seen` or `keywords/$flagged` patch for the whole selection
(chunked by `maxObjectsInSet`). It also keeps rows that leave an open
Unread or Starred view until the view is re-opened, as the single
actions do.
This commit is contained in:
Linus Rath
2026-09-24 18:44:29 +02:00
parent 50418971f4
commit cb2f03bc09
3 changed files with 55 additions and 11 deletions
+3 -9
View File
@@ -665,20 +665,14 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e));
const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e));
// One Email/set for the whole selection, not a request per message.
const handleBulkMarkReadToggle = async () => {
const ids = selectedMessageIds;
if (allSelectedAreRead) {
await Promise.all(ids.map((id) => markUnread(id)));
} else {
await Promise.all(ids.map((id) => markRead(id)));
}
await setKeywordForEmails(selectedMessageIds, '$seen', !allSelectedAreRead);
clearSelection();
};
const handleBulkStar = async () => {
const ids = selectedMessageIds;
const next = !allSelectedAreStarred;
await Promise.all(ids.map((id) => toggleStar(id, next)));
await setKeywordForEmails(selectedMessageIds, '$flagged', !allSelectedAreStarred);
clearSelection();
};
+39
View File
@@ -811,6 +811,45 @@ describe('email-store', () => {
});
});
describe('bulk keyword actions', () => {
const three = () => [
{ id: 'e1', keywords: {} } as any,
{ id: 'e2', keywords: { $flagged: true } } as any,
{ id: 'e3', keywords: { $seen: true, $flagged: true } } as any,
];
it('marks a whole selection read with one patch instead of a request per message', async () => {
useEmailStore.setState({ emails: three() });
await useEmailStore.getState().setKeywordForEmails(['e1', 'e2', 'e3'], '$seen', true);
expect(mockPatchKeywords).toHaveBeenCalledTimes(1);
expect(mockPatchKeywords).toHaveBeenCalledWith(['e1', 'e2', 'e3'], { $seen: true }, undefined);
expect(useEmailStore.getState().emails.map((e) => e.keywords)).toEqual([
{ $seen: true },
{ $flagged: true, $seen: true },
{ $seen: true, $flagged: true },
]);
});
it('keeps rows that leave the open Unread or Starred view, like the single actions', async () => {
useEmailStore.setState({ emails: three(), filters: { isUnread: true } });
await useEmailStore.getState().setKeywordForEmails(['e1', 'e2'], '$seen', true);
expect(useEmailStore.getState().retainedIds).toEqual(['e1', 'e2']);
useEmailStore.setState({ emails: three(), filters: { isStarred: true }, retainedIds: [] });
await useEmailStore.getState().setKeywordForEmails(['e2', 'e3'], '$flagged', true);
expect(useEmailStore.getState().retainedIds).toEqual([]);
await useEmailStore.getState().setKeywordForEmails(['e2', 'e3'], '$flagged', false);
expect(mockPatchKeywords).toHaveBeenLastCalledWith(['e2', 'e3'], { $flagged: null }, undefined);
expect(useEmailStore.getState().retainedIds).toEqual(['e2', 'e3']);
useEmailStore.setState({ emails: three(), filters: { isUnread: false }, retainedIds: [] });
await useEmailStore.getState().setKeywordForEmails(['e3'], '$seen', false);
expect(useEmailStore.getState().retainedIds).toEqual(['e3']);
});
});
describe('pin and spam keywords', () => {
it('togglePin writes $pinned, not $important', async () => {
useEmailStore.setState({ emails: [{ id: 'e1', keywords: {} } as any] });
+13 -2
View File
@@ -329,6 +329,7 @@ export interface EmailState {
archiveEmailsBatch: (emailIds: string[]) => Promise<void>;
moveEmailsToMailbox: (emailIds: string[], toMailboxId: string) => Promise<void>;
deleteEmailsBatch: (emailIds: string[], trashMailboxId: string, currentMailboxId: string) => Promise<void>;
/** Set or clear one keyword (tag, `$seen`, `$flagged`) on a selection in one `Email/set`. */
setKeywordForEmails: (emailIds: string[], token: string, on: boolean) => Promise<void>;
undoLast: () => Promise<void>;
clearUndo: () => void;
@@ -1539,8 +1540,9 @@ export const useEmailStore = create<EmailState>()(
emails: get().emails.map((e) =>
touched.has(e.id) ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
),
// Untagging inside that tag's view keeps the rows until it's re-opened.
...(!on && state.filters.keyword === token
// Reading inside Unread, unstarring inside Starred or untagging inside
// that tag's view keeps the rows until it's re-opened.
...(leavesView(state.filters, token, on)
? { retainedIds: retain(get().retainedIds, ids) }
: {}),
});
@@ -1754,6 +1756,15 @@ function retain(current: string[], ids: string[]): string[] {
return [...set];
}
// Whether setting `token` to `on` makes a row stop matching the open Unread,
// Starred or tag view (the rows are then retained, like markRead/toggleStar).
function leavesView(filters: EmailFilters, token: string, on: boolean): boolean {
if (!on && filters.keyword === token) return true;
if (token === '$seen') return filters.isUnread === on;
if (token === '$flagged') return filters.isStarred !== undefined && filters.isStarred !== on;
return false;
}
/** "Parent / Child" path of a folder for toasts, like the webmail (1.5.0). */
function mailboxPath(mailboxes: Mailbox[], mailboxId: string): string | undefined {
const byId = new Map(mailboxes.map((m) => [m.id, m]));