fix: leave a message unread when the server refuses to move it to trash across accounts

This commit is contained in:
Linus Rath
2026-10-29 04:38:26 +01:00
parent 918571c3c6
commit 67cdb4e792
2 changed files with 27 additions and 2 deletions
@@ -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<string, Record<string, unknown>>))
.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');
+8 -2
View File
@@ -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<string>();
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<string>();
const unpatched = new Set<string>();
let refusal: unknown = null;
if (ops.length > 0) {