From d2066dad07abffac25df7fe52a34eb011d532b94 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:23:31 +0200 Subject: [PATCH] fix: act on the exact row picked in "All folders" and tag lists #1082 An "All folders" search or a tag view holds rows of the own and the shared accounts, and Stalwart hands out ids per account, so an own and a team message can share an id. The list still named its rows by bare id: selecting one of such a pair selected both, a batch action hit both, and a swipe or single-row action took whichever came first, in the other account. The list now names every row by `rowKeyOf` (its id, scoped by its account in a list spanning accounts; the bare id in a folder, so nothing changes there) end to end: - FlatList keys, row presses, swipes, selection and select-all - thread expansion of a selection (`expandThreadSelection`) - every store action the list calls: read, unread, star, pin, the per-account keyword batch, move, archive, delete and spam, and the rows they drop or keep in view - the viewer's pages, which now come only from the rows of the opened message's account (`listRowsOfAccount`), and its mark-read, which goes through the store when the list holds that account's row A key whose row is no longer loaded is ignored rather than guessed onto the open folder's account. The unified inbox already keys its rows by account and needed no change. --- src/lib/__tests__/thread-utils.test.ts | 13 ++ src/lib/__tests__/viewer-pages.test.ts | 17 +- src/lib/thread-utils.ts | 41 ++-- src/lib/viewer-pages.ts | 12 +- src/screens/EmailListScreen.tsx | 33 ++-- src/screens/EmailThreadScreen.tsx | 15 +- .../__tests__/email-store-all-folders.test.ts | 107 +++++++++-- src/stores/email-store.ts | 179 +++++++++++------- 8 files changed, 286 insertions(+), 131 deletions(-) diff --git a/src/lib/__tests__/thread-utils.test.ts b/src/lib/__tests__/thread-utils.test.ts index fa173ce1..13ff3814 100644 --- a/src/lib/__tests__/thread-utils.test.ts +++ b/src/lib/__tests__/thread-utils.test.ts @@ -6,6 +6,7 @@ import { getThreadTagIds, tagIdFromKeyword, threadKeyOf, + rowKeyOf, } from '../thread-utils'; import type { Email } from '../../api/types'; @@ -90,4 +91,16 @@ describe('expandThreadSelection', () => { expect(expandThreadSelection(['b'], emails, true)).toEqual(['b']); expect(expandThreadSelection(['zzz'], emails, false)).toEqual(['zzz']); }); + + it('works on row keys, so two accounts\' same ids stay apart (#1082)', () => { + const own = [email('a', 't1', '2026-01-01T00:00:00Z'), email('b', 't1', '2026-01-03T00:00:00Z')] + .map((e) => ({ ...e, jmapAccountId: 'c' })); + const team = [email('a', 't1', '2026-01-02T00:00:00Z')].map((e) => ({ ...e, jmapAccountId: 'team' })); + const rows = [...own, ...team]; + + expect(rowKeyOf(team[0])).toBe('team:a'); + expect(rowKeyOf(emails[0])).toBe('a'); + expect(expandThreadSelection(['c:b'], rows, false)).toEqual(['c:a', 'c:b']); + expect(expandThreadSelection(['team:a'], rows, false)).toEqual(['team:a']); + }); }); diff --git a/src/lib/__tests__/viewer-pages.test.ts b/src/lib/__tests__/viewer-pages.test.ts index 5e3ae8ac..bad52442 100644 --- a/src/lib/__tests__/viewer-pages.test.ts +++ b/src/lib/__tests__/viewer-pages.test.ts @@ -11,14 +11,14 @@ const LIST = [row('n1'), row('q2', 'tq'), row('q1', 'tq'), row('o3')]; describe('viewerPages', () => { it('collapses the open folder to one page per thread, the opened message standing in', () => { const pages = viewerPages({ - emailId: 'q1', threadId: 'tq', list: LIST, listIsMessageAccount: true, threading: true, + emailId: 'q1', threadId: 'tq', list: LIST, threading: true, }); expect(pages.map((e) => e.id)).toEqual(['n1', 'q1', 'o3']); }); it('pages over every row when threading is off', () => { const pages = viewerPages({ - emailId: 'q1', threadId: 'tq', list: LIST, listIsMessageAccount: true, threading: false, + emailId: 'q1', threadId: 'tq', list: LIST, threading: false, }); expect(pages.map((e) => e.id)).toEqual(['n1', 'q2', 'q1', 'o3']); }); @@ -26,7 +26,7 @@ describe('viewerPages', () => { it('does not follow the list once taken, so new mail cannot shift the page (B5)', () => { const list = [...LIST]; const pages = viewerPages({ - emailId: 'q2', threadId: 'tq', list, listIsMessageAccount: true, threading: false, + emailId: 'q2', threadId: 'tq', list, threading: false, }); list.unshift(row('new1'), row('new2')); expect(pages.map((e) => e.id)).toEqual(['n1', 'q2', 'q1', 'o3']); @@ -36,27 +36,28 @@ describe('viewerPages', () => { it('pages over the ids another list handed over, using the store row when there is one', () => { const pages = viewerPages({ emailId: 'u1', threadId: 't-u1', emailIds: ['u1', 'o3'], - list: LIST, listIsMessageAccount: true, threading: true, + list: LIST, threading: true, }); expect(pages).toEqual([{ id: 'u1', threadId: 't-u1' }, LIST[3]]); }); it('is a single page for a message the list does not hold', () => { const pages = viewerPages({ - emailId: 'm9', threadId: 't9', list: LIST, listIsMessageAccount: true, threading: true, + emailId: 'm9', threadId: 't9', list: LIST, threading: true, }); expect(pages).toEqual([{ id: 'm9', threadId: 't9' }]); }); - it('never takes another account\'s rows, even under the same id (B3)', () => { + it('pages over nothing of a list of another account (B3)', () => { + // The caller hands over only the rows of the message's account. const opened = viewerPages({ - emailId: 'q1', threadId: 'tq', list: LIST, listIsMessageAccount: false, threading: true, + emailId: 'q1', threadId: 'tq', list: [], threading: true, }); expect(opened).toEqual([{ id: 'q1', threadId: 'tq' }]); const handed = viewerPages({ emailId: 'o3', threadId: 't-o3', emailIds: ['o3'], - list: LIST, listIsMessageAccount: false, threading: true, + list: [], threading: true, }); expect(handed).toEqual([{ id: 'o3', threadId: 't-o3' }]); }); diff --git a/src/lib/thread-utils.ts b/src/lib/thread-utils.ts index aa7807c1..ad65ced3 100644 --- a/src/lib/thread-utils.ts +++ b/src/lib/thread-utils.ts @@ -57,6 +57,15 @@ export function accountScopedId(email: Pick, id: string) return email.jmapAccountId ? `${email.jmapAccountId}:${id}` : id; } +/** + * A list row's identity: its id, scoped by its account in a list spanning + * accounts, where an own and a shared message can share an id. What the list + * keys, selects and hands to the store's actions (#1082). + */ +export function rowKeyOf(email: Pick): string { + return accountScopedId(email, email.id); +} + /** Key a message groups under: its thread, or itself when threading is off. */ export function threadKeyOf(email: Email, disableThreading: boolean): string { return accountScopedId(email, disableThreading ? email.id : email.threadId || email.id); @@ -121,33 +130,35 @@ export function groupByThread(emails: Email[], disableThreading: boolean): Map, + keys: Iterable, emails: Email[], disableThreading: boolean, ): string[] { - const wanted = new Set(ids); + const wanted = new Set(keys); if (disableThreading) return [...wanted]; - const byId = new Map(emails.map((e) => [e.id, e])); - const keys = new Set(); - for (const id of wanted) { - const e = byId.get(id); - if (e) keys.add(threadKeyOf(e, false)); + const byKey = new Map(emails.map((e) => [rowKeyOf(e), e])); + const threads = new Set(); + for (const key of wanted) { + const e = byKey.get(key); + if (e) threads.add(threadKeyOf(e, false)); } const out: string[] = []; const seen = new Set(); for (const e of emails) { - if (keys.has(threadKeyOf(e, false)) && !seen.has(e.id)) { - seen.add(e.id); - out.push(e.id); + const key = rowKeyOf(e); + if (threads.has(threadKeyOf(e, false)) && !seen.has(key)) { + seen.add(key); + out.push(key); } } - for (const id of wanted) { - if (!seen.has(id)) { seen.add(id); out.push(id); } + for (const key of wanted) { + if (!seen.has(key)) { seen.add(key); out.push(key); } } return out; } diff --git a/src/lib/viewer-pages.ts b/src/lib/viewer-pages.ts index 2a144969..c35aa192 100644 --- a/src/lib/viewer-pages.ts +++ b/src/lib/viewer-pages.ts @@ -6,10 +6,12 @@ export interface ViewerPagesInput { threadId: string; /** Ids handed over by a list other than the open folder (unified inbox, contact activity). */ emailIds?: string[]; - /** The store's loaded list: the open folder's rows. */ + /** + * The store's loaded rows that live in the message's account: none when the + * open folder is another account's, and only that account's rows of a list + * spanning accounts. Ids are only unique per account (B3, #1082). + */ list: Email[]; - /** Whether that list is the message's account: ids are only unique per account. */ - listIsMessageAccount: boolean; threading: boolean; } @@ -26,9 +28,7 @@ export interface ViewerPagesInput { * toolbar acts on the tapped message. */ export function viewerPages(input: ViewerPagesInput): Email[] { - const { emailId, threadId, emailIds, threading } = input; - // Another account's rows say nothing about this message, whatever their ids. - const list = input.listIsMessageAccount ? input.list : []; + const { emailId, threadId, emailIds, threading, list } = input; if (emailIds && emailIds.length > 0) { const byId = new Map(list.map((e) => [e.id, e])); return emailIds.map((id) => byId.get(id) ?? ({ id, threadId } as Email)); diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 9276bee8..377c57b0 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -44,7 +44,7 @@ import { } from '../lib/mailbox-tree'; import { localizeMailboxName } from '../lib/mailbox-label'; import { - collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, + collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, rowKeyOf, } from '../lib/thread-utils'; import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm'; import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context'; @@ -144,8 +144,11 @@ const EmailRow = React.memo(function EmailRow({ }); }, [tagIds, keywordDefs, c]); - const handlePress = React.useCallback(() => onPress(item.id), [onPress, item.id]); - const handleLongPress = React.useCallback(() => onLongPress(item.id), [onLongPress, item.id]); + // The row's key (`rowKeyOf`): ids repeat across the accounts of a list + // spanning accounts (#1082). + const key = rowKeyOf(item); + const handlePress = React.useCallback(() => onPress(key), [onPress, key]); + const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]); return ( ({ unread, starred, pinned, inJunk }), [unread, starred, pinned, inJunk], ); + const key = rowKeyOf(item); const onAction = React.useCallback( - (action: SwipeAction) => onSwipe(item.id, action), - [onSwipe, item.id], + (action: SwipeAction) => onSwipe(key, action), + [onSwipe, key], ); return ( accountScopedId(item, item.id); +const emailKeyExtractor = rowKeyOf; interface EmailListScreenProps { onEmailPress?: (email: Email) => void; @@ -511,7 +515,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const [selectedIds, setSelectedIds] = React.useState>(new Set()); const selectionMode = selectedIds.size > 0; const allSelected = - visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(e.id)); + visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(rowKeyOf(e))); // Refs so row press handlers stay referentially stable across renders. // FlatList rows then skip re-render when the parent re-renders for unrelated @@ -560,7 +564,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL if (selectionModeRef.current) { toggleSelect(id); } else { - const email = emailsRef.current.find((e) => e.id === id); + const email = emailsRef.current.find((e) => rowKeyOf(e) === id); if (!email) return; if (isDraftEmail(email, currentRoleRef.current)) void openDraftRef.current(email); else onEmailPressRef.current?.(email); @@ -599,7 +603,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const handleSwipeAction = React.useCallback((id: string, action: SwipeAction) => { if (action === 'none') return; - const email = emailsRef.current.find((e) => e.id === id); + const email = emailsRef.current.find((e) => rowKeyOf(e) === id); if (!email || !currentMailboxId) return; switch (action) { case 'archive': @@ -682,7 +686,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL disableAvatarImages={inJunk && !showAvatarsInJunk} answered={flags?.answered ?? false} forwarded={flags?.forwarded ?? false} - selected={selectedIds.has(item.id)} + selected={selectedIds.has(rowKeyOf(item))} selectionMode={selectionMode} onPress={handleRowPress} onLongPress={toggleSelect} @@ -707,9 +711,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const toggleSelectAllVisible = React.useCallback(() => { setSelectedIds((prev) => { - const allCurrent = visibleEmails.length > 0 && visibleEmails.every((e) => prev.has(e.id)); + const allCurrent = visibleEmails.length > 0 && visibleEmails.every((e) => prev.has(rowKeyOf(e))); if (allCurrent) return new Set(); - return new Set(visibleEmails.map((e) => e.id)); + return new Set(visibleEmails.map(rowKeyOf)); }); }, [visibleEmails]); @@ -718,7 +722,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL setSelectedIds(new Set()); }, [currentMailboxId]); - // `selectedIds` holds the representative row ids; every action expands to + // `selectedIds` holds the representative rows' keys (`rowKeyOf`, unique + // across the accounts of a list spanning accounts); every action expands to // all loaded messages of the selected conversations (webmail // `toggleThreadSelection`), so "3 selected" conversations never means // "3 messages touched". @@ -728,7 +733,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL ); const selectedEmails = React.useMemo(() => { const wanted = new Set(selectedMessageIds); - return emails.filter((e) => wanted.has(e.id)); + return emails.filter((e) => wanted.has(rowKeyOf(e))); }, [emails, selectedMessageIds]); const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e)); const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e)); diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index c9f7b046..2e46efa2 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -18,7 +18,7 @@ import { MoveSheet } from '../components/MoveSheet'; import { ThreadMessageCard, ThreadCardPlaceholder } from '../components/email/ThreadMessageCard'; import { QuickReplyBox } from '../components/email/QuickReplyBox'; import { AddressActionSheet } from '../components/email/AddressActionSheet'; -import { useEmailStore } from '../stores/email-store'; +import { useEmailStore, listRowsOfAccount } from '../stores/email-store'; import { toast } from '../stores/toast-store'; import { useSettingsStore, @@ -126,8 +126,10 @@ function EmailViewer({ route, navigation }: Props) { emailId: route.params.emailId, threadId: route.params.threadId, emailIds: route.params.emailIds, - list: useEmailStore.getState().emails, - listIsMessageAccount: listAccountId === ownerAccountId, + // Only the list rows of the message's account: none when the open + // folder is another's, that account's rows of an "All folders" list or + // a tag view, whose ids repeat across accounts (#1082). + list: listRowsOfAccount(ownerAccountId), threading: !disableThreading, }).map((page) => (page.receivedAt ? page : peekRow(page.id, ownerAccountId) ?? page))); @@ -310,10 +312,11 @@ function EmailViewer({ route, navigation }: Props) { }, [ownerAccountId]); // Mark read through the store (its list row and offline queue) only when - // the list holds the message's account; the store would otherwise read the - // id as a row of the open folder's account (B3), so go to the server. + // the list holds the message's account, or its row in an "All folders" + // list or a tag view (#1082); the store would otherwise read the id as a + // row of the open folder's account (B3), so go to the server. const markSeen = React.useCallback( - (id: string) => (listAccountId === ownerAccountId + (id: string) => (listAccountId === ownerAccountId || listRowsOfAccount(ownerAccountId).some((e) => e.id === id) ? markRead(id, ownerAccountId) : patchKeywordsForEmails([id], { $seen: true }, ownerAccountId)), [listAccountId, ownerAccountId, markRead], diff --git a/src/stores/__tests__/email-store-all-folders.test.ts b/src/stores/__tests__/email-store-all-folders.test.ts index 5abecd7a..639dcb56 100644 --- a/src/stores/__tests__/email-store-all-folders.test.ts +++ b/src/stores/__tests__/email-store-all-folders.test.ts @@ -91,8 +91,9 @@ vi.mock('../offline-cache-store', () => ({ import { generateAccountId } from '../../lib/account-utils'; import * as settingsModule from '../settings-store'; import { - useEmailStore, viewerParamsForRow, deleteDestroysAcrossAccounts, accountIdOfRow, + useEmailStore, viewerParamsForRow, deleteDestroysAcrossAccounts, accountIdOfRow, listRowsOfAccount, } from '../email-store'; +import { expandThreadSelection, rowKeyOf } from '../../lib/thread-utils'; import { useTagCountsStore } from '../tag-counts-store'; import type { Email, Mailbox } from '../../api/types'; @@ -333,7 +334,7 @@ describe('opening and acting on "All folders" rows (#1082)', () => { it('marks a mixed selection read with one Email/set per account', async () => { await search('zephyr'); - await useEmailStore.getState().setKeywordForEmails(['o1', 'm1'], '$seen', true); + await useEmailStore.getState().setKeywordForEmails(['c:o1', 'team:m1'], '$seen', true); const sets = server.current!.callsOf('Email/set'); expect(sets.map(([, a]) => [a.accountId, Object.keys(a.update as object)])).toEqual([['team', ['m1']], ['c', ['o1']]]); @@ -342,7 +343,7 @@ describe('opening and acting on "All folders" rows (#1082)', () => { it('stars a team row in the team account', async () => { await search('zephyr'); - await useEmailStore.getState().toggleStar('m3', true); + await useEmailStore.getState().toggleStar('team:m3', true); expect(server.current!.callsOf('Email/set')[0][1]).toEqual({ accountId: 'team', update: { m3: { 'keywords/$flagged': true } }, @@ -351,7 +352,7 @@ describe('opening and acting on "All folders" rows (#1082)', () => { it('archives each row into its own account\'s Archive, and undo puts them back', async () => { await search('zephyr'); - await useEmailStore.getState().archiveEmailsBatch(['o1', 'm1']); + await useEmailStore.getState().archiveEmailsBatch(['c:o1', 'team:m1']); const sets = server.current!.callsOf('Email/set'); expect(sets.map(([, a]) => [a.accountId, a.update])).toEqual([ @@ -371,17 +372,17 @@ describe('opening and acting on "All folders" rows (#1082)', () => { it('moves a hit to Trash out of the folder it is in, not the open one', async () => { await search('zephyr'); // o2 sits in Archive while the Inbox is open. - await useEmailStore.getState().deleteEmail('o2', 'trash', 'inbox'); + await useEmailStore.getState().deleteEmail('c:o2', 'trash', 'inbox'); expect(server.current!.accounts.c.find((e) => e.id === 'o2')!.mailboxIds).toEqual({ trash: true }); }); it('confirms before deleting a hit that already sits in its account\'s Trash', async () => { await search('zephyr'); - expect(deleteDestroysAcrossAccounts(['m1'])).toBe(false); - expect(deleteDestroysAcrossAccounts(['m1', 'm5'])).toBe(true); + expect(deleteDestroysAcrossAccounts(['team:m1'])).toBe(false); + expect(deleteDestroysAcrossAccounts(['team:m1', 'team:m5'])).toBe(true); - await useEmailStore.getState().deleteEmailsBatch(['m1', 'm5'], 'trash', 'inbox'); + await useEmailStore.getState().deleteEmailsBatch(['team:m1', 'team:m5'], 'trash', 'inbox'); expect(server.current!.callsOf('Email/set').map(([, a]) => [a.accountId, a.update ?? a.destroy])).toEqual([ ['team', ['m5']], ['team', { m1: { mailboxIds: { 't-trash': true } } }], @@ -391,7 +392,7 @@ describe('opening and acting on "All folders" rows (#1082)', () => { it('files spam in each row\'s own Junk', async () => { await search('zephyr'); - await useEmailStore.getState().markSpam(['m2']); + await useEmailStore.getState().markSpam(['team:m2']); expect(server.current!.callsOf('Email/set')[0][1]).toEqual({ accountId: 'team', @@ -410,6 +411,90 @@ describe('opening and acting on "All folders" rows (#1082)', () => { // #1038: a tag is set on messages in the own and in the team account alike, // but the tag view only asked the account of the open folder: 2 own and 2 // team messages tagged Red showed 2 or the other 2, depending on the folder. +// Stalwart hands out per-account counters, so the own and the team account +// both hold an "x" in a thread "t-x". The list names its rows by `rowKeyOf` +// (`c:x`, `team:x`), so selection, swipe and batch actions reach exactly the +// row the user picked, in its own account. +describe('same-id rows of two accounts (#1082)', () => { + beforeEach(async () => { + server.current = createFakeJmap({ + c: [mail('x', 'inbox', 20, 'zephyr own')], + team: [mail('x', 't-inbox', 21, 'zephyr team')], + }); + await search('zephyr'); + }); + + const rows = () => useEmailStore.getState().emails.map((e) => [e.jmapAccountId, e.id, !!e.keywords.$seen]); + + it('keeps both rows apart in selection and conversation', () => { + const emails = useEmailStore.getState().emails; + expect(emails.map(rowKeyOf)).toEqual(['team:x', 'c:x']); + // Selecting the team row selects it alone, even with threading on. + expect(expandThreadSelection(['team:x'], emails, false)).toEqual(['team:x']); + }); + + it('select all + mark read reads both, each in its own account', async () => { + const emails = useEmailStore.getState().emails; + const selected = expandThreadSelection(emails.map(rowKeyOf), emails, false); + await useEmailStore.getState().setKeywordForEmails(selected, '$seen', true); + + expect(server.current!.callsOf('Email/set').map(([, a]) => [a.accountId, a.update])).toEqual([ + ['team', { x: { 'keywords/$seen': true } }], + ['c', { x: { 'keywords/$seen': true } }], + ]); + expect(rows()).toEqual([['team', 'x', true], ['c', 'x', true]]); + }); + + it('marks only the selected row read', async () => { + await useEmailStore.getState().setKeywordForEmails(['c:x'], '$seen', true); + + expect(server.current!.callsOf('Email/set').map(([, a]) => a.accountId)).toEqual(['c']); + expect(rows()).toEqual([['team', 'x', false], ['c', 'x', true]]); + }); + + it('swipe-archives the team row alone', async () => { + await useEmailStore.getState().archiveEmail('team:x'); + + expect(server.current!.callsOf('Email/set').map(([, a]) => [a.accountId, a.update])).toEqual([ + ['team', { x: { mailboxIds: { 't-archive': true } } }], + ]); + expect(server.current!.accounts.c[0].mailboxIds).toEqual({ inbox: true }); + expect(rows()).toEqual([['c', 'x', false]]); + }); + + it('swipe-reads the own row alone', async () => { + await useEmailStore.getState().markRead('c:x'); + + expect(server.current!.callsOf('Email/set').map(([, a]) => a.accountId)).toEqual(['c']); + expect(rows()).toEqual([['team', 'x', false], ['c', 'x', true]]); + }); + + it('batch-deletes the own row alone', async () => { + expect(deleteDestroysAcrossAccounts(['c:x'])).toBe(false); + await useEmailStore.getState().deleteEmailsBatch(['c:x'], 'trash', 'inbox'); + + expect(server.current!.callsOf('Email/set').map(([, a]) => [a.accountId, a.update])).toEqual([ + ['c', { x: { mailboxIds: { trash: true } } }], + ]); + expect(server.current!.accounts.team[0].mailboxIds).toEqual({ 't-inbox': true }); + expect(rows()).toEqual([['team', 'x', false]]); + }); + + it('does nothing for a row that is no longer loaded, rather than guess its account', async () => { + useEmailStore.setState({ emails: [] }); + await useEmailStore.getState().markRead('team:x'); + await useEmailStore.getState().moveToMailbox('team:x', 'inbox', 'archive'); + await useEmailStore.getState().deleteEmail('team:x', 'trash', 'inbox'); + + expect(server.current!.callsOf('Email/set')).toEqual([]); + }); + + it('hands the viewer only the rows of the opened message\'s account', () => { + expect(listRowsOfAccount('team').map(rowKeyOf)).toEqual(['team:x']); + expect(listRowsOfAccount(undefined).map(rowKeyOf)).toEqual(['c:x']); + }); +}); + describe('tag view across the own and the team account (#1038)', () => { const RED = '$label:red'; @@ -467,13 +552,13 @@ describe('tag view across the own and the team account (#1038)', () => { it('untags a team row in the team account and keeps it in view', async () => { await openTag('inbox'); - await useEmailStore.getState().setKeywordForEmails(['m2'], RED, false); + await useEmailStore.getState().setKeywordForEmails(['team:m2'], RED, false); expect(server.current!.callsOf('Email/set')[0][1]).toEqual({ accountId: 'team', update: { m2: { 'keywords/$label:red': null } }, }); expect(ids()).toContain('m2'); - expect(useEmailStore.getState().retainedIds).toEqual(['m2']); + expect(useEmailStore.getState().retainedIds).toEqual(['team:m2']); }); it('pages each account on from its own rows', async () => { diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 19489c5f..47d51270 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -42,7 +42,7 @@ import { mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox, findTrashMailbox, ownMailboxes, } from '../lib/mailbox-tree'; import { toWildcardQuery } from '../lib/search-utils'; -import { collapseThreads, accountScopedId } from '../lib/thread-utils'; +import { collapseThreads, rowKeyOf } from '../lib/thread-utils'; import { compareEmails, levelKeyword, orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order'; import { buildListSort, markKeywordSortUnsupported } from '../lib/keyword-sort-polarity'; import { generateAccountId } from '../lib/account-utils'; @@ -162,7 +162,10 @@ function actionTarget( // A row is the viewer's message only in the message's account: a folder's // rows are in the folder's, a row of a list spanning accounts in its own // (#1082). - const row = state.emails.find((e) => e.id === emailId && (!viewed || rowAccountId(state, e) === viewed.accountId)); + // The list names its row by `rowKeyOf`, unique across accounts. + const row = viewed + ? state.emails.find((e) => e.id === emailId && rowAccountId(state, e) === viewed.accountId) + : state.emails.find((e) => rowKeyOf(e) === emailId); return { email: row ?? viewed?.email, listed: !viewed || !!row }; } @@ -172,7 +175,7 @@ function actionTargets( emailIds: string[], viewed?: ViewedEmail, ): { targets: Email[]; listed: boolean } { - if (!viewed) return { targets: state.emails.filter((e) => emailIds.includes(e.id)), listed: true }; + if (!viewed) return { targets: rowsByKey(state, emailIds), listed: true }; const { email, listed } = actionTarget(state, viewed.email.id, viewed); return { targets: email ? [email] : [], listed }; } @@ -351,10 +354,10 @@ export interface EmailState { filters: EmailFilters; pendingUndo: UndoEntry | null; /** - * Rows the user just read/unstarred/untagged while an Unread/Starred/tag - * view was open. They stay in the list until the view is re-opened even - * though the server query no longer matches them (webmail 1.9.0 - * `retainedInViewIds`). + * Rows (by `rowKeyOf`) the user just read/unstarred/untagged while an + * Unread/Starred/tag view was open. They stay in the list until the view + * is re-opened even though the server query no longer matches them + * (webmail 1.9.0 `retainedInViewIds`). */ retainedIds: string[]; /** @@ -585,9 +588,27 @@ function groupByAccount(state: EmailState, emails: Email[]): Array<{ accountId?: return [...groups.values()]; } -// The rows among `ids` that come from a list spanning accounts. -function spanningRows(state: EmailState, ids: string[]): Email[] { - return state.emails.filter((e) => !!e.jmapAccountId && ids.includes(e.id)); +// The loaded rows the list names by `rowKeyOf`: unique across the accounts +// of a list spanning accounts, and the bare id for a folder's rows. +function rowsByKey(state: EmailState, keys: string[]): Email[] { + const wanted = new Set(keys); + return state.emails.filter((e) => wanted.has(rowKeyOf(e))); +} + +// The JMAP id inside a row key (ids never contain ':', RFC 8620 ยง1.2). +function idOfRowKey(key: string): string { + return key.slice(key.lastIndexOf(':') + 1); +} + +// A key naming a row of a list spanning accounts that is no longer loaded: +// its account can't be told any more, so there is nothing to act on. +function isGoneSpanningRow(key: string, row: Email | undefined): boolean { + return !row && idOfRowKey(key) !== key; +} + +// The rows among `keys` that come from a list spanning accounts. +function spanningRows(state: EmailState, keys: string[]): Email[] { + return rowsByKey(state, keys).filter((e) => !!e.jmapAccountId); } // Client-side mirror of the list sort, for merging the accounts' pages. @@ -688,6 +709,16 @@ export function accountIdOfRow(email: Email): string | undefined { return rowAccountId(useEmailStore.getState(), email); } +/** + * The loaded rows that live in one JMAP account (undefined = the user's own): + * the open folder's rows when it is that account's, that account's rows of a + * list spanning accounts. Ids are unique among them. + */ +export function listRowsOfAccount(accountId: string | undefined): Email[] { + const state = useEmailStore.getState(); + return state.emails.filter((e) => rowAccountId(state, e) === accountId); +} + /** * Viewer route params for a row of a list that spans accounts: its own * account (undefined = the user's), whatever folder is open, and the rows of @@ -707,11 +738,11 @@ export function viewerParamsForRow(email: Email): { jmapAccountId?: string; emai // Unread/Starred view at their previous position (webmail `mergeRetainedRows`). function mergeRetainedRows(previous: Email[], fresh: Email[], retainedIds: string[]): Email[] { if (retainedIds.length === 0) return fresh; - const freshIds = new Set(fresh.map((e) => e.id)); + const freshKeys = new Set(fresh.map(rowKeyOf)); const retained = new Set(retainedIds); const out = [...fresh]; previous.forEach((e, index) => { - if (!retained.has(e.id) || freshIds.has(e.id)) return; + if (!retained.has(rowKeyOf(e)) || freshKeys.has(rowKeyOf(e))) return; out.splice(Math.min(index, out.length), 0, e); }); return out; @@ -1036,7 +1067,7 @@ export const useEmailStore = create()( // Unread view, unstarred in Starred, untagged in a tag view) are no longer // part of the server's result; counting them would skip as many messages. const retained = new Set(state.retainedIds); - const position = emails.filter((e) => !retained.has(e.id)).length; + const position = emails.filter((e) => !retained.has(rowKeyOf(e))).length; if (!currentMailboxId || loading || position >= totalEmails) return; if (!jmapClientServesActiveAccount(activeAccountId)) return; @@ -1049,7 +1080,7 @@ export const useEmailStore = create()( // Each account continues from its own rows on screen (#1082). const loaded: Record = {}; for (const e of emails) { - if (e.jmapAccountId && !retained.has(e.id)) loaded[e.jmapAccountId] = (loaded[e.jmapAccountId] ?? 0) + 1; + if (e.jmapAccountId && !retained.has(rowKeyOf(e))) loaded[e.jmapAccountId] = (loaded[e.jmapAccountId] ?? 0) + 1; } const page = await fetchSpanningPage(state, loaded, filter, limit); const now = get(); @@ -1057,9 +1088,9 @@ export const useEmailStore = create()( now.activeAccountId !== activeAccountId || now.currentMailboxId !== currentMailboxId || now.searchQuery !== searchQuery || now.filters !== filters ) return; - const shown = new Set(now.emails.map((e) => accountScopedId(e, e.id))); + const shown = new Set(now.emails.map(rowKeyOf)); set({ - emails: [...now.emails, ...page.list.filter((e) => !shown.has(accountScopedId(e, e.id)))] + emails: [...now.emails, ...page.list.filter((e) => !shown.has(rowKeyOf(e)))] .sort(listComparator(now)), // An account that failed this time keeps counting what it showed, // so load-more doesn't keep asking for it. @@ -1263,10 +1294,13 @@ export const useEmailStore = create()( markRead: async (emailId, accountId) => { const state = get(); - // Ids repeat across the accounts of a list spanning accounts: take the - // row of the account asked for when there is one (#1082). - const email = state.emails.find((e) => e.id === emailId && rowAccountId(state, e) === accountId) - ?? state.emails.find((e) => e.id === emailId); + // The list names its row by `rowKeyOf`; the viewer by id and account, as + // ids repeat across the accounts of a list spanning accounts (#1082). + const email = state.emails.find((e) => rowKeyOf(e) === emailId) + ?? state.emails.find((e) => e.id === emailId && rowAccountId(state, e) === accountId); + if (isGoneSpanningRow(emailId, email)) return; + const id = email?.id ?? emailId; + const key = email ? rowKeyOf(email) : emailId; // Only `$seen` goes to the server: the message may not be in the list at // all, and a whole keyword map would erase its stars and tags. const patch = { $seen: true }; @@ -1274,81 +1308,81 @@ export const useEmailStore = create()( // JMAP account and isn't in the active list/cache or the (account-scoped) // offline queue โ€” mark it read directly against its owning account. if (accountId && !email) { - await patchKeywordsForEmails([emailId], patch, accountId); + await patchKeywordsForEmails([id], patch, accountId); return; } const owner = accountId ?? rowAccountId(state, email); - await applyOrQueue({ kind: 'keywords', emailId, accountId: owner, patch }); + await applyOrQueue({ kind: 'keywords', emailId: id, accountId: owner, patch }); set({ emails: get().emails.map((e) => - e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, + rowKeyOf(e) === key ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, ), - ...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), + ...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [key]) } : {}), }); - patchCache(emailId, { keywords: patch }, owner); + patchCache(id, { keywords: patch }, owner); }, markUnread: async (emailId) => { const state = get(); - const email = state.emails.find((e) => e.id === emailId); + const email = state.emails.find((e) => rowKeyOf(e) === emailId); if (!email) return; const patch = { $seen: null }; await applyOrQueue({ kind: 'keywords', - emailId, + emailId: email.id, accountId: rowAccountId(state, email), patch, }); set({ emails: get().emails.map((e) => - e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, + rowKeyOf(e) === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, ), ...(state.filters.isUnread === false ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), }); - patchCache(emailId, { keywords: patch }, rowAccountId(state, email)); + patchCache(email.id, { keywords: patch }, rowAccountId(state, email)); }, toggleStar: async (emailId, starred) => { const state = get(); - const email = state.emails.find((e) => e.id === emailId); + const email = state.emails.find((e) => rowKeyOf(e) === emailId); if (!email) return; const patch = { $flagged: starred ? true : null }; await applyOrQueue({ kind: 'keywords', - emailId, + emailId: email.id, accountId: rowAccountId(state, email), patch, }); set({ emails: get().emails.map((e) => - e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, + rowKeyOf(e) === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, ), ...(state.filters.isStarred !== undefined && state.filters.isStarred !== starred ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), }); - patchCache(emailId, { keywords: patch }, rowAccountId(state, email)); + patchCache(email.id, { keywords: patch }, rowAccountId(state, email)); }, togglePin: async (emailId, pinned) => { const state = get(); - const email = state.emails.find((e) => e.id === emailId); + const email = state.emails.find((e) => rowKeyOf(e) === emailId); if (!email) return; // `$pinned` is what the webmail reads and writes; a pin set as // `$important` was invisible to it (and vice versa). const patch = { $pinned: pinned ? true : null }; await applyOrQueue({ kind: 'keywords', - emailId, + emailId: email.id, accountId: rowAccountId(state, email), patch, }); set({ emails: get().emails.map((e) => - e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, + rowKeyOf(e) === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, ), }); - patchCache(emailId, { keywords: patch }, rowAccountId(state, email)); + patchCache(email.id, { keywords: patch }, rowAccountId(state, email)); }, markSpam: async (emailIds, viewed) => { @@ -1383,9 +1417,9 @@ export const useEmailStore = create()( () => apiMarkAsSpam(targets.map((e) => e.id), junk.id, junk.accountId, { markRead }), ); - const removed = new Set(listed ? targets.map((e) => e.id) : []); + const removed = new Set(listed ? targets.map(rowKeyOf) : []); set({ - emails: get().emails.filter((e) => !removed.has(e.id)), + emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))), pendingUndo: { kind: 'spam', label: targets.length === 1 @@ -1428,9 +1462,9 @@ export const useEmailStore = create()( () => apiUndoSpam(targets.map((e) => e.id), inbox.id, inbox.accountId), ); - const removed = new Set(listed ? targets.map((e) => e.id) : []); + const removed = new Set(listed ? targets.map(rowKeyOf) : []); set({ - emails: get().emails.filter((e) => !removed.has(e.id)), + emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))), pendingUndo: { kind: 'spam', label: targets.length === 1 @@ -1449,9 +1483,12 @@ export const useEmailStore = create()( const state = get(); // A list spanning accounts files each row in its own account (#1082); // the viewer names its message's account itself. - const spanning = viewed ? [] : spanningRows(state, [emailId]).slice(0, 1); + const spanning = viewed ? [] : spanningRows(state, [emailId]); if (spanning.length > 0) return fileAcrossAccounts('move', spanning, toMailboxId); const { email, listed } = actionTarget(state, emailId, viewed); + if (isGoneSpanningRow(emailId, email)) return; + // The list row it is, by `rowKeyOf`: ids repeat across accounts. + const rowKey = email ? rowKeyOf(email) : emailId; const from = refFor(state.mailboxes, fromMailboxId); const to = refFor(state.mailboxes, toMailboxId); assertViewedAccount(viewed, from, to); @@ -1466,7 +1503,7 @@ export const useEmailStore = create()( set({ error: err instanceof Error ? err.message : t('notifications.move_failed', 'Move failed') }); return; } - set({ emails: get().emails.filter((e) => e.id !== emailId) }); + if (listed) set({ emails: get().emails.filter((e) => rowKeyOf(e) !== rowKey) }); dropFromCache([emailId], from.accountId); return; } @@ -1477,7 +1514,7 @@ export const useEmailStore = create()( { kind: 'mailboxes', emailId, accountId: from.accountId, mailboxIds: target }, () => moveEmail(emailId, from.id, to.id, from.accountId), ); - if (listed) set({ emails: get().emails.filter((e) => e.id !== emailId) }); + if (listed) set({ emails: get().emails.filter((e) => rowKeyOf(e) !== rowKey) }); patchCache(emailId, { mailboxIds: target }, from.accountId); if (email && original) { @@ -1500,12 +1537,13 @@ export const useEmailStore = create()( const state = get(); // A list spanning accounts files each row in its own account (#1082); // the viewer names its message's account itself. - const spanning = viewed ? [] : spanningRows(state, [emailId]).slice(0, 1); + const spanning = viewed ? [] : spanningRows(state, [emailId]); if (spanning.length > 0) return fileAcrossAccounts('archive', spanning); // The viewer's copy stands in for a message the list doesn't hold // (unified inbox, notification, deep link). const { email, listed } = actionTarget(state, emailId, viewed); if (!email) return; + const rowKey = rowKeyOf(email); // Archive into the *same account's* Archive folder โ€” a shared mailbox's // messages can't be filed into the user's own. @@ -1540,7 +1578,7 @@ export const useEmailStore = create()( ); set({ - ...(listed ? { emails: get().emails.filter((e) => e.id !== emailId) } : {}), + ...(listed ? { emails: get().emails.filter((e) => rowKeyOf(e) !== rowKey) } : {}), pendingUndo: { kind: 'archive', label: t('notifications.email_archived', 'Email archived'), @@ -1563,9 +1601,11 @@ export const useEmailStore = create()( const state = get(); // A list spanning accounts files each row in its own account (#1082); // the viewer names its message's account itself. - const spanning = viewed ? [] : spanningRows(state, [emailId]).slice(0, 1); + const spanning = viewed ? [] : spanningRows(state, [emailId]); if (spanning.length > 0) return fileAcrossAccounts('delete', spanning); const { email, listed } = actionTarget(state, emailId, viewed); + if (isGoneSpanningRow(emailId, email)) return; + const rowKey = email ? rowKeyOf(email) : emailId; const original = email ? { ...email.mailboxIds } : null; const settings = useSettingsStore.getState(); const trash = refFor(state.mailboxes, trashMailboxId); @@ -1616,7 +1656,7 @@ export const useEmailStore = create()( patchCache(emailId, { mailboxIds: target }, source.accountId); } } - if (listed) set({ emails: get().emails.filter((e) => e.id !== emailId) }); + if (listed) set({ emails: get().emails.filter((e) => rowKeyOf(e) !== rowKey) }); // Permanent destroy can't be undone - skip the snackbar so we don't // promise an undo we can't deliver. @@ -1646,9 +1686,7 @@ export const useEmailStore = create()( const archiveMailbox = findArchiveMailbox(scoped); if (!archiveMailbox) return; const archive = refFor(state.mailboxes, archiveMailbox.id); - const targets = state.emails.filter( - (e) => emailIds.includes(e.id) && !e.mailboxIds?.[archive.id], - ); + const targets = rowsByKey(state, emailIds).filter((e) => !e.mailboxIds?.[archive.id]); if (targets.length === 0) return; const mode = useSettingsStore.getState().archiveMode; @@ -1673,9 +1711,9 @@ export const useEmailStore = create()( ), ); - const removed = new Set(targets.map((e) => e.id)); + const removed = new Set(targets.map(rowKeyOf)); set({ - emails: get().emails.filter((e) => !removed.has(e.id)), + emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))), pendingUndo: { kind: 'archive', label: targets.length === 1 @@ -1692,14 +1730,14 @@ export const useEmailStore = create()( }, moveEmailsToMailbox: async (emailIds, toMailboxId) => { - const { emails, currentMailboxId, mailboxes } = get(); + const { currentMailboxId, mailboxes } = get(); // A list spanning accounts files each row in its own account (#1082). const spanning = spanningRows(get(), emailIds); if (spanning.length > 0) return fileAcrossAccounts('move', spanning, toMailboxId); if (!currentMailboxId || toMailboxId === currentMailboxId) return; const source = refFor(mailboxes, currentMailboxId); const to = refFor(mailboxes, toMailboxId); - const targets = emails.filter((e) => emailIds.includes(e.id)); + const targets = rowsByKey(get(), emailIds); if (targets.length === 0) return; // See moveToMailbox: one Email/set can't span two accounts โ€” copy+delete. if (source.accountId !== to.accountId) { @@ -1709,8 +1747,8 @@ export const useEmailStore = create()( set({ error: err instanceof Error ? err.message : t('notifications.move_failed', 'Move failed') }); return; } - const moved = new Set(targets.map((e) => e.id)); - set({ emails: get().emails.filter((e) => !moved.has(e.id)) }); + const moved = new Set(targets.map(rowKeyOf)); + set({ emails: get().emails.filter((e) => !moved.has(rowKeyOf(e))) }); dropFromCache(targets.map((e) => e.id), source.accountId); return; } @@ -1727,13 +1765,13 @@ export const useEmailStore = create()( () => apiMoveEmails(targets.map((e) => e.id), source.id, to.id, source.accountId), ); - const removed = new Set(targets.map((e) => e.id)); + const removed = new Set(targets.map(rowKeyOf)); for (const e of targets) { patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, to.id) }, source.accountId); } const targetName = mailboxPath(mailboxes, toMailboxId); set({ - emails: get().emails.filter((e) => !removed.has(e.id)), + emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))), pendingUndo: { kind: 'move', label: targetName @@ -1749,7 +1787,7 @@ export const useEmailStore = create()( }, deleteEmailsBatch: async (emailIds, trashMailboxId, currentMailboxId) => { - const { emails, mailboxes } = get(); + const { mailboxes } = get(); // A list spanning accounts files each row in its own account (#1082). const spanning = spanningRows(get(), emailIds); if (spanning.length > 0) return fileAcrossAccounts('delete', spanning); @@ -1760,7 +1798,7 @@ export const useEmailStore = create()( .find((m) => m.role === 'junk' || m.role === 'spam'); const junkId = junkMailbox ? rawMailboxId(mailboxes, junkMailbox.id) : null; const inTrash = currentMailboxId === trashMailboxId; - const targets = emails.filter((e) => emailIds.includes(e.id)); + const targets = rowsByKey(get(), emailIds); if (targets.length === 0) return; // Split into permanent-destroy vs move-to-trash following the same policy @@ -1825,8 +1863,8 @@ export const useEmailStore = create()( }, source.accountId); } - const removed = new Set(targets.map((e) => e.id)); - set({ emails: get().emails.filter((e) => !removed.has(e.id)) }); + const removed = new Set(targets.map(rowKeyOf)); + set({ emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))) }); // Only the moved-to-trash items are recoverable; destroyed ones are gone. if (toTrash.length > 0) { @@ -1851,7 +1889,6 @@ export const useEmailStore = create()( // One Email/set per account the selection spans (#1082); the viewer's // message goes to the account it names. const groups = viewed ? [{ accountId: viewed.accountId, emails: targets }] : groupByAccount(state, targets); - const ids = targets.map((e) => e.id); const patch = { [token]: on ? true : null }; await applyOrQueueBatch( groups.flatMap(({ accountId, emails }) => emails.map((e): OutboxOp => ({ @@ -1866,15 +1903,15 @@ export const useEmailStore = create()( } }, ); - const touched = new Set(listed ? ids : []); + const touched = new Set(listed ? targets.map(rowKeyOf) : []); set({ emails: get().emails.map((e) => - touched.has(e.id) ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, + touched.has(rowKeyOf(e)) ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, ), // Reading inside Unread, unstarring inside Starred or untagging inside // that tag's view keeps the rows until it's re-opened. ...(listed && leavesView(state.filters, token, on) - ? { retainedIds: retain(get().retainedIds, ids) } + ? { retainedIds: retain(get().retainedIds, [...touched]) } : {}), }); for (const { accountId, emails } of groups) { @@ -1963,9 +2000,9 @@ export const useEmailStore = create()( if (spanned.length > 0) { // Rows of a list spanning accounts go back while that list is shown. if (spansAccounts(get())) { - const shown = new Set(emails.map((e) => accountScopedId(e, e.id))); + const shown = new Set(emails.map(rowKeyOf)); const restored = spanned - .filter((it) => !shown.has(accountScopedId(it.email, it.email.id))) + .filter((it) => !shown.has(rowKeyOf(it.email))) .map((it) => ({ ...it.email, mailboxIds: it.originalMailboxIds, @@ -2286,7 +2323,7 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa } for (const update of cacheUpdates) update(); - const goneKeys = new Set(gone.map((e) => accountScopedId(e, e.id))); + const goneKeys = new Set(gone.map(rowKeyOf)); const count = items.length; const targetName = toMailboxId ? mailboxPath(state.mailboxes, toMailboxId) : undefined; const label = action === 'archive' @@ -2303,7 +2340,7 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa : t('email_list.emails_moved_to', `${count} emails moved to ${targetName}`, { count, mailbox: targetName })) : t('notifications.emails_moved', 'Emails moved'); set({ - emails: get().emails.filter((e) => !goneKeys.has(accountScopedId(e, e.id))), + emails: get().emails.filter((e) => !goneKeys.has(rowKeyOf(e))), ...(count > 0 ? { pendingUndo: {