From 67cdb4e7926ee55362c03e322bb760567a302ad4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:38:26 +0100 Subject: [PATCH] fix: leave a message unread when the server refuses to move it to trash across accounts --- .../__tests__/email-store-all-folders.test.ts | 19 +++++++++++++++++++ src/stores/email-store.ts | 10 ++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/stores/__tests__/email-store-all-folders.test.ts b/src/stores/__tests__/email-store-all-folders.test.ts index 5a4d2fc3..7f4cb3cd 100644 --- a/src/stores/__tests__/email-store-all-folders.test.ts +++ b/src/stores/__tests__/email-store-all-folders.test.ts @@ -481,6 +481,25 @@ describe('opening and acting on "All folders" rows (#1082)', () => { expect(useEmailStore.getState().pendingUndo?.items.map((it) => it.email.id).sort()).toEqual(['m1', 'o1']); }); + // "Move to Trash and mark as read": a message the server refused to move + // stays in its folder, so it must not be marked read either. + it('marks only the moved rows read when the server refuses one of them', async () => { + settings.deleteAction = 'trash-and-read'; + await search('zephyr'); + server.current!.refusing.set('team', new Set(['m2'])); + + await useEmailStore.getState() + .deleteEmailsBatch(['team:m1', 'team:m2'], 'trash', 'inbox') + .catch(() => undefined); + + const seenPatches = server.current!.callsOf('Email/set') + .flatMap(([, a]) => Object.entries((a.update ?? {}) as Record>)) + .filter(([, patch]) => Object.keys(patch).some((k) => k.startsWith('keywords'))) + .map(([id]) => id); + expect(seenPatches).toEqual(['m1']); + expect(server.current!.accounts.team.find((e) => e.id === 'm1')!.keywords).toEqual({ $seen: true }); + }); + it('files the rest when one account refuses a whole move', async () => { await search('zephyr'); server.current!.failing.set('team', 'forbidden'); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index a989779e..d0149b6d 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -2400,6 +2400,8 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa const items: UndoEntry['items'] = []; const gone: Array<{ email: Email; key: string }> = []; const keyOf = (accountId: string | undefined, id: string) => `${accountId ?? ''}:${id}`; + // Rows a server refused to file (see the runs below), by `keyOf`. + const refused = new Set(); const copies: Array<{ accountId?: string; emails: Email[] }> = []; let missing: string | null = null; let refreshFolders = false; @@ -2489,7 +2491,12 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa }); if (unread.length > 0) { runs.push({ - run: () => patchKeywordsForEmails(ids(unread), { $seen: true }, accountId), + // Runs after the move: a row the server refused to move stays + // where it is, unread, as in the single-account delete. + run: async () => { + const moved = ids(unread).filter((id) => !refused.has(keyOf(accountId, id))); + if (moved.length > 0) await patchKeywordsForEmails(moved, { $seen: true }, accountId); + }, accountId, ids: ids(unread), keeps: false, @@ -2536,7 +2543,6 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa // account may refuse what another takes (a read-only share): the refused // rows stay, the others are filed, and the first refusal is reported once // the list shows the rest. Before, one refused message kept every row. - const refused = new Set(); const unpatched = new Set(); let refusal: unknown = null; if (ops.length > 0) {