diff --git a/App.tsx b/App.tsx index d7ec84ef..f53bd60c 100644 --- a/App.tsx +++ b/App.tsx @@ -46,7 +46,7 @@ import { useAccountStore } from './src/stores/account-store'; import { useAuthStore } from './src/stores/auth-store'; import { useCalendarStore } from './src/stores/calendar-store'; import { useContactsStore } from './src/stores/contacts-store'; -import { useEmailStore } from './src/stores/email-store'; +import { useEmailStore, viewerParamsForRow } from './src/stores/email-store'; import { mailboxAccountId } from './src/lib/mailbox-tree'; import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useSettingsStore } from './src/stores/settings-store'; @@ -200,6 +200,9 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps diff --git a/src/api/__tests__/email.test.ts b/src/api/__tests__/email.test.ts index 5a64b4d0..101f27ad 100644 --- a/src/api/__tests__/email.test.ts +++ b/src/api/__tests__/email.test.ts @@ -19,6 +19,7 @@ import { getSharedMailboxes, queryEmails, queryEmailPage, + queryEmailPagesAcrossAccounts, getEmails, getFullEmail, getThread, @@ -252,6 +253,60 @@ describe('email operations', () => { }); }); + it('asks several accounts for a folder-less page in one request (#1082)', async () => { + (jmapClient.getMaxCallsInRequest as ReturnType).mockReturnValueOnce(16); + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/query', { ids: ['e1'], total: 3 }, '0:q'], + ['Email/get', { list: [{ id: 'e1', threadId: 't1' }] }, '0:g'], + ['Thread/get', { list: [{ id: 't1', emailIds: ['e1'] }] }, '0:t'], + ['error', { type: 'forbidden' }, '1:q'], + ['error', { type: 'invalidResultReference' }, '1:g'], + ['error', { type: 'invalidResultReference' }, '1:t'], + ], + }); + const sort = [{ property: 'receivedAt', isAscending: false }]; + + const pages = await queryEmailPagesAcrossAccounts( + [{ position: 2, sort }, { accountId: 'grp-1', position: 0, sort }], + { limit: 25, filter: { text: 'zephyr*' }, threads: true }, + ); + + expect(mockRequest).toHaveBeenCalledTimes(1); + const calls = mockRequest.mock.calls[0][0]; + expect(calls.map((c: unknown[]) => [c[0], (c[1] as { accountId: string }).accountId, c[2]])).toEqual([ + ['Email/query', 'acc-1', '0:q'], ['Email/get', 'acc-1', '0:g'], ['Thread/get', 'acc-1', '0:t'], + ['Email/query', 'grp-1', '1:q'], ['Email/get', 'grp-1', '1:g'], ['Thread/get', 'grp-1', '1:t'], + ]); + expect(calls[0][1]).toMatchObject({ filter: { text: 'zephyr*' }, position: 2, limit: 25, sort }); + expect(calls[4][1]['#ids']).toEqual({ resultOf: '1:q', name: 'Email/query', path: '/ids' }); + expect(pages[0]).toEqual({ + accountId: undefined, ok: true, total: 3, + list: [{ id: 'e1', threadId: 't1' }], threads: [{ id: 't1', emailIds: ['e1'] }], + }); + expect(pages[1]).toMatchObject({ accountId: 'grp-1', ok: false, error: { type: 'forbidden' } }); + }); + + it('splits the accounts over requests by maxCallsInRequest', async () => { + (jmapClient.getMaxCallsInRequest as ReturnType).mockReturnValueOnce(4); + mockRequest.mockImplementation(async (calls: Array<[string, Record, string]>) => ({ + methodResponses: calls.map(([name, , id]) => (name === 'Email/query' + ? [name, { ids: [], total: 0 }, id] + : [name, { list: [] }, id])), + })); + const sort = [{ property: 'receivedAt', isAscending: false }]; + + const pages = await queryEmailPagesAcrossAccounts( + [{ position: 0, sort }, { accountId: 'g1', position: 0, sort }, { accountId: 'g2', position: 0, sort }], + { limit: 10 }, + ); + + // Two calls per account without threads: two accounts fit in one request. + expect(mockRequest.mock.calls.map((c) => c[0].length)).toEqual([4, 2]); + expect(pages.map((p) => p.ok)).toEqual([true, true, true]); + mockRequest.mockReset(); + }); + it('leaves Thread/get out unless asked, and targets a shared account', async () => { mockRequest.mockResolvedValue({ methodResponses: [ diff --git a/src/api/email.ts b/src/api/email.ts index 99091251..408b6d7d 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -449,6 +449,96 @@ export async function queryEmailPage( }; } +/** One account's part of a list that spans accounts. */ +export interface AccountPageTarget { + /** Shared/group JMAP account; undefined = the user's own. */ + accountId?: string; + position: number; + sort: Array<{ property: string; isAscending: boolean; keyword?: string }>; +} + +export type AccountPage = + | { accountId?: string; ok: true; total: number; list: Email[]; threads: Thread[] } + | { accountId?: string; ok: false; error: Error }; + +/** + * A folder-less ("All folders") page from each of several accounts the + * session reaches, in as few requests as `maxCallsInRequest` allows: per + * account its Email/query, the Email/get of the ids and, with `threads`, the + * Thread/get of their threads, chained with result references. Results come + * back in `targets` order; an account whose calls fail carries its error + * while the others still return their page (webmail `fanOutAccountQuery`). + */ +export async function queryEmailPagesAcrossAccounts( + targets: AccountPageTarget[], + options: { limit: number; filter?: Record; threads?: boolean }, +): Promise { + const callsPerTarget = options.threads ? 3 : 2; + const perRequest = Math.max(1, Math.floor(jmapClient.getMaxCallsInRequest() / callsPerTarget)); + const limit = Math.min(options.limit, maxInGet()); + const out: AccountPage[] = new Array(targets.length); + const indexed = targets.map((target, index) => ({ target, index })); + await Promise.all(batched(indexed, perRequest).map(async (chunk) => { + const calls: JMAPMethodCall[] = []; + for (const { target, index } of chunk) { + const args = emailQueryArgs(undefined, { + position: target.position, + limit, + sort: target.sort, + filter: options.filter, + accountId: target.accountId, + }); + calls.push(['Email/query', args, `${index}:q`]); + calls.push(['Email/get', { + accountId: args.accountId, + '#ids': { resultOf: `${index}:q`, name: 'Email/query', path: '/ids' }, + properties: EMAIL_LIST_PROPERTIES, + }, `${index}:g`]); + if (options.threads) { + calls.push(['Thread/get', { + accountId: args.accountId, + '#ids': { resultOf: `${index}:g`, name: 'Email/get', path: '/list/*/threadId' }, + }, `${index}:t`]); + } + } + let res: Awaited>; + try { + res = await jmapClient.request(calls); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + for (const { target, index } of chunk) out[index] = { accountId: target.accountId, ok: false, error }; + return; + } + for (const { target, index } of chunk) { + try { + const query = requireMethodResult(res, `${index}:q`, 'Email/query'); + const got = requireMethodResult(res, `${index}:g`, 'Email/get'); + let threads: Thread[] = []; + if (options.threads) { + try { + threads = (requireMethodResult(res, `${index}:t`, 'Thread/get').list as Thread[]) ?? []; + } catch { /* thread sizes only decorate the rows */ } + } + const byId = new Map(((got.list as Email[]) ?? []).map((e) => [e.id, e])); + out[index] = { + accountId: target.accountId, + ok: true, + total: (query.total as number) ?? 0, + list: ((query.ids as string[]) ?? []).flatMap((id) => byId.get(id) ?? []), + threads, + }; + } catch (err) { + out[index] = { + accountId: target.accountId, + ok: false, + error: err instanceof Error ? err : new Error(String(err)), + }; + } + } + })); + return out; +} + export interface EmailQueryChangesResult { oldQueryState: string; newQueryState: string; diff --git a/src/api/types.ts b/src/api/types.ts index bfc1366a..829cabe4 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -92,6 +92,13 @@ export interface Email { attachments?: Attachment[]; blobId?: string; bodyStructure?: BodyPart; + /** + * Client-side stamp, never sent by the server: the JMAP account a row of a + * list that spans accounts (an "All folders" search) was fetched from, the + * user's own account included. Rows of a single folder carry none; they + * belong to the folder's account. + */ + jmapAccountId?: string; } export interface MailboxRights { diff --git a/src/lib/__tests__/thread-utils.test.ts b/src/lib/__tests__/thread-utils.test.ts index 71ed4569..fa173ce1 100644 --- a/src/lib/__tests__/thread-utils.test.ts +++ b/src/lib/__tests__/thread-utils.test.ts @@ -5,6 +5,7 @@ import { getEmailTagIds, getThreadTagIds, tagIdFromKeyword, + threadKeyOf, } from '../thread-utils'; import type { Email } from '../../api/types'; @@ -55,6 +56,17 @@ describe('collapseThreads', () => { expect(collapseThreads([oldest, other, newest], true).map((e) => e.id)).toEqual(['a', 'c', 'b']); }); + it("keeps two accounts' threads with the same id apart (#1082)", () => { + // Stalwart hands out per-account counters, so an "All folders" list can + // hold an own and a team thread that share an id. + const own = { ...email('a', 't1', '2026-01-01T00:00:00Z'), jmapAccountId: 'c' }; + const team = { ...email('a', 't1', '2026-01-02T00:00:00Z'), jmapAccountId: 'team' }; + expect(collapseThreads([team, own], false)).toEqual([team, own]); + expect(threadKeyOf(own, false)).toBe('c:t1'); + expect(threadKeyOf(team, true)).toBe('team:a'); + expect(threadKeyOf(email('a', 't1', '2026-01-01T00:00:00Z'), false)).toBe('t1'); + }); + it('puts threads with a pinned message first', () => { const pinned = email('d', 't3', '2025-12-01T00:00:00Z', { $pinned: true }); expect(collapseThreads([other, oldest, pinned], false).map((e) => e.id)).toEqual(['d', 'c', 'a']); diff --git a/src/lib/thread-utils.ts b/src/lib/thread-utils.ts index 0995496c..aa7807c1 100644 --- a/src/lib/thread-utils.ts +++ b/src/lib/thread-utils.ts @@ -46,9 +46,20 @@ export function tagIdFromKeyword(keyword: string): string | null { return null; } +/** + * A message or thread id scoped by the account stamp of a row from a list + * that spans accounts: JMAP ids are only unique per account (Stalwart hands + * out small per-account counters), so keys built from bare ids would merge + * two accounts' rows. Unstamped rows keep the bare id (webmail + * `threadKeyFor`). + */ +export function accountScopedId(email: Pick, id: string): string { + return email.jmapAccountId ? `${email.jmapAccountId}:${id}` : id; +} + /** Key a message groups under: its thread, or itself when threading is off. */ export function threadKeyOf(email: Email, disableThreading: boolean): string { - return disableThreading ? email.id : email.threadId || email.id; + return accountScopedId(email, disableThreading ? email.id : email.threadId || email.id); } function time(value: string | undefined): number { diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 1664fb45..5d65d9bd 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -26,7 +26,10 @@ import { } from '../components/email/ListAttachmentChips'; import type { LoadListAttachments } from '../lib/list-attachments'; import { useNetworkStore } from '../stores/network-store'; -import { useEmailStore, effectiveFolderScope, type EmailFilters } from '../stores/email-store'; +import { + useEmailStore, effectiveFolderScope, spansAccounts, accountIdOfRow, deleteDestroysAcrossAccounts, + type EmailFilters, +} from '../stores/email-store'; import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store'; import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store'; import { useLocaleStore } from '../stores/locale-store'; @@ -41,7 +44,7 @@ import { } from '../lib/mailbox-tree'; import { localizeMailboxName } from '../lib/mailbox-label'; import { - collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, + collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, } from '../lib/thread-utils'; import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm'; import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context'; @@ -301,7 +304,8 @@ function EmailRowSeparator() { return ; } -const emailKeyExtractor = (item: Email) => item.id; +// Rows of a list spanning accounts can share an id (#1082). +const emailKeyExtractor = (item: Email) => accountScopedId(item, item.id); interface EmailListScreenProps { onEmailPress?: (email: Email) => void; @@ -325,6 +329,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const currentMailboxId = useEmailStore((s) => s.currentMailboxId); const storeSearchQuery = useEmailStore((s) => s.searchQuery); const filters = useEmailStore((s) => s.filters); + const accountErrors = useEmailStore((s) => s.accountErrors); const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); const selectMailbox = useEmailStore((s) => s.selectMailbox); const loadMoreEmails = useEmailStore((s) => s.loadMoreEmails); @@ -406,7 +411,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const threadCountFor = React.useCallback((e: Email): number => { if (disableThreading) return 1; const local = threadGroups.get(threadKeyOf(e, false))?.length ?? 1; - const server = e.threadId ? serverThreadCounts[e.threadId] : undefined; + const server = e.threadId ? serverThreadCounts[accountScopedId(e, e.threadId)] : undefined; return Math.max(local, server ?? 1); }, [disableThreading, threadGroups, serverThreadCounts]); @@ -533,8 +538,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL if (openingDraftId) return; setOpeningDraftId(email.id); try { - const full = await getFullEmail(email.id, currentOwnerAccountId); - navigation.navigate('Compose', { draft: draftContextFromEmail(full, currentOwnerAccountId) }); + const owner = accountIdOfRow(email); + const full = await getFullEmail(email.id, owner); + navigation.navigate('Compose', { draft: draftContextFromEmail(full, owner) }); } catch (err) { Alert.alert( t('email_list.error', 'Error'), @@ -543,7 +549,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL } finally { setOpeningDraftId(null); } - }, [openingDraftId, currentOwnerAccountId, navigation, t]); + }, [openingDraftId, navigation, t]); const openDraftRef = React.useRef(openDraft); React.useEffect(() => { openDraftRef.current = openDraft; }, [openDraft]); const currentRoleRef = React.useRef(currentRole); @@ -578,7 +584,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL ); return; } - const permanent = isPermanentDelete({ + // Rows of a list spanning accounts are judged by their own folders. + const permanent = deleteDestroysAcrossAccounts(ids) ?? isPermanentDelete({ inTrash: currentMailboxId === trashMailboxId, inJunk, deleteAction, @@ -743,7 +750,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL return; } const ids = selectedMessageIds; - const permanent = isPermanentDelete({ + const permanent = deleteDestroysAcrossAccounts(ids) ?? isPermanentDelete({ inTrash: currentMailboxId === trash.id, inJunk, deleteAction, @@ -843,6 +850,10 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL (filters.folder && filters.folder !== 'current' ? 1 : 0); const hasActiveSearchOrFilter = Boolean(storeSearchQuery) || activeFilterCount > 0; const folderScope = effectiveFolderScope(storeSearchQuery, filters); + // Accounts an "All folders" list could not reach (#1082). + const unreachedAccounts = spansAccounts({ searchQuery: storeSearchQuery, filters }) + ? Object.values(accountErrors) + : []; const scopeFolderName = React.useMemo(() => { if (folderScope === 'all' || folderScope === 'current') return null; const m = mailboxes.find((mb) => mb.id === folderScope); @@ -1281,6 +1292,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL )} + {unreachedAccounts.length > 0 && ( + + + {t('unified_mailbox.accounts_failed', `${unreachedAccounts.length} account(s) could not be loaded`, { count: unreachedAccounts.length })} + {`: ${unreachedAccounts[0]}`} + + + )} + {/* Email list */} {loading && emails.length === 0 ? ( diff --git a/src/stores/__tests__/email-store-all-folders.test.ts b/src/stores/__tests__/email-store-all-folders.test.ts new file mode 100644 index 00000000..5a03d676 --- /dev/null +++ b/src/stores/__tests__/email-store-all-folders.test.ts @@ -0,0 +1,407 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createFakeJmap, type FakeEmail } from './fake-jmap-server'; + +// #1082: "All folders" used to send one Email/query to the account of the +// open folder, so the 5 "zephyr" messages in the team (group) inbox never +// showed up next to the 3 in the user's own account. It now asks every +// account whose folders are in the sidebar, in one request, and stamps each +// row with its account so opening and acting on it reach the right one. + +const server = vi.hoisted(() => ({ current: null as null | ReturnType })); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + isConnected: true, + accountId: 'c', + username: 'me@example.com', + serverUrl: 'https://mail.example.com', + // No hasKeyword sort option: the list sort stays plain receivedAt and no + // polarity probe goes out. + currentSession: { + apiUrl: 'https://mail.example.com/jmap/', + accounts: { + c: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { emailQuerySortOptions: ['receivedAt'] } } }, + team: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { emailQuerySortOptions: ['receivedAt'] } } }, + }, + }, + getMaxObjectsInGet: () => 500, + getMaxObjectsInSet: () => 500, + getMaxCallsInRequest: () => 16, + getSharedMailAccounts: () => [{ id: 'team', name: 'Team' }], + request: vi.fn((calls) => server.current!.request(calls)), + }, +})); + +vi.mock('../locale-store', () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + useLocaleStore: { getState: () => ({ locale: 'en', t: (_k: string, f?: string) => f ?? _k }) }, +})); + +// Online, nothing queued: run the store's online path, or each op's primitive. +vi.mock('../outbox-store', async () => { + const api = await import('../../api/email'); + type Op = { kind: string; emailId: string; accountId?: string; patch?: never; mailboxIds?: never }; + const runOp = async (op: Op) => { + if (op.kind === 'keywords') return api.patchKeywordsForEmails([op.emailId], op.patch!, op.accountId); + if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds!, op.accountId); + if (op.kind === 'destroy') return api.destroyEmails([op.emailId], op.accountId); + }; + const applyOrQueueBatch = async (ops: Op[], onlineRun?: () => Promise) => { + if (onlineRun) await onlineRun(); + else for (const op of ops) await runOp(op); + return { queued: false }; + }; + return { + applyOrQueueBatch, + applyOrQueue: async (op: Op, onlineRun?: () => Promise) => applyOrQueueBatch([op], onlineRun), + useOutboxStore: { getState: () => ({ setAccount: vi.fn(async () => undefined), flush: vi.fn(async () => undefined) }) }, + }; +}); + +vi.mock('../settings-store', () => { + const settings: Record = {}; + return { + useSettingsStore: { + getState: () => ({ + ...settings, + updateSetting: (key: string, value: unknown) => { settings[key] = value; }, + }), + }, + __settings: settings, + }; +}); + +vi.mock('../offline-cache-store', () => ({ + useOfflineCacheStore: { + getState: () => ({ + hydrated: true, + hydrate: vi.fn(), + setAccount: vi.fn(async () => undefined), + totalCount: () => 0, + getEmailsInMailbox: vi.fn(async () => []), + has: () => false, + get: vi.fn(async () => null), + put: vi.fn(async () => undefined), + patch: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + }), + }, +})); + +import { generateAccountId } from '../../lib/account-utils'; +import * as settingsModule from '../settings-store'; +import { + useEmailStore, viewerParamsForRow, deleteDestroysAcrossAccounts, accountIdOfRow, +} from '../email-store'; +import type { Email, Mailbox } from '../../api/types'; + +const settings = (settingsModule as unknown as { __settings: Record }).__settings; + +function mb(id: string, role: string | null, shared?: { raw: string }): Mailbox { + return { + id, + name: role ?? id, + role, + totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: {} as Mailbox['myRights'], + ...(shared + ? { originalId: shared.raw, accountId: 'team', accountName: 'Team', isShared: true } + : { accountId: 'c', isShared: false }), + }; +} + +const MAILBOXES: Mailbox[] = [ + mb('inbox', 'inbox'), mb('archive', 'archive'), mb('trash', 'trash'), mb('junk', 'junk'), + mb('team:t-inbox', 'inbox', { raw: 't-inbox' }), + mb('team:t-archive', 'archive', { raw: 't-archive' }), + mb('team:t-trash', 'trash', { raw: 't-trash' }), + mb('team:t-junk', 'junk', { raw: 't-junk' }), +]; + +function mail(id: string, folder: string, day: number, subject: string, extra: Partial = {}): Email { + return { + id, + size: 1, + hasAttachment: false, + threadId: `t-${id}`, + mailboxIds: { [folder]: true }, + keywords: {}, + receivedAt: `2026-09-${String(day).padStart(2, '0')}T10:00:00Z`, + subject, + ...extra, + }; +} + +function seed() { + return createFakeJmap({ + c: [ + mail('o1', 'inbox', 20, 'zephyr launch'), + mail('o2', 'archive', 14, 'zephyr notes'), + mail('o3', 'inbox', 9, 'zephyr retro'), + mail('o4', 'inbox', 21, 'lunch'), + ], + team: [ + mail('m1', 't-inbox', 22, 'zephyr ticket 1'), + mail('m2', 't-inbox', 18, 'zephyr ticket 2'), + mail('m3', 't-inbox', 15, 'zephyr ticket 3'), + mail('m4', 't-inbox', 12, 'zephyr ticket 4'), + mail('m5', 't-trash', 8, 'zephyr ticket 5'), + ], + }); +} + +const ids = () => useEmailStore.getState().emails.map((e) => e.id); + +async function search(query: string) { + useEmailStore.getState().setSearchQuery(query); + await vi.waitFor(() => expect(useEmailStore.getState().loading).toBe(false)); +} + +beforeEach(() => { + server.current = seed(); + for (const key of Object.keys(settings)) delete settings[key]; + Object.assign(settings, { + emailsPerPage: 25, + mailSortAscending: false, + disableThreading: false, + deleteAction: 'trash', + permanentlyDeleteJunk: false, + archiveMode: 'single', + messageListOrder: [], + messageListOrderScope: 'inbox', + }); + useEmailStore.getState().reset(); + useEmailStore.setState({ + activeAccountId: generateAccountId('me@example.com', 'https://mail.example.com'), + mailboxes: MAILBOXES, + currentMailboxId: 'inbox', + pendingUndo: null, + }); +}); + +describe('"All folders" search across the own and the team account (#1082)', () => { + it('finds the team inbox matches too, in one request', async () => { + await search('zephyr'); + + expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3', 'm5']); + expect(useEmailStore.getState().totalEmails).toBe(8); + expect(server.current!.requests).toHaveLength(1); + const queries = server.current!.callsOf('Email/query'); + expect(queries.map(([, args]) => args.accountId)).toEqual(['c', 'team']); + for (const [, args] of queries) expect(args.filter).toEqual({ text: 'zephyr*' }); + expect(server.current!.callsOf('Email/get').map(([, args]) => args.accountId)).toEqual(['c', 'team']); + }); + + it('stamps every row with its account and scopes its conversation size', async () => { + await search('zephyr'); + + const rows = useEmailStore.getState().emails; + expect(rows.find((e) => e.id === 'o1')?.jmapAccountId).toBe('c'); + expect(rows.find((e) => e.id === 'm1')?.jmapAccountId).toBe('team'); + expect(useEmailStore.getState().threadCounts).toMatchObject({ 'c:t-o1': 1, 'team:t-m1': 1 }); + expect(accountIdOfRow(rows.find((e) => e.id === 'm1')!)).toBe('team'); + expect(accountIdOfRow(rows.find((e) => e.id === 'o1')!)).toBeUndefined(); + }); + + it('keeps a search scoped to one folder in that folder\'s account', async () => { + useEmailStore.setState({ filters: { folder: 'team:t-inbox' } }); + await search('zephyr'); + + const queries = server.current!.callsOf('Email/query'); + expect(queries).toHaveLength(1); + expect(queries[0][1]).toMatchObject({ accountId: 'team', filter: { inMailbox: 't-inbox', text: 'zephyr*' } }); + expect(ids()).toEqual(['m1', 'm2', 'm3', 'm4']); + expect(useEmailStore.getState().emails[0].jmapAccountId).toBeUndefined(); + }); + + it('asks only the own account when no shared folders are in the sidebar', async () => { + useEmailStore.setState({ mailboxes: MAILBOXES.filter((m) => !m.isShared) }); + await search('zephyr'); + + expect(server.current!.callsOf('Email/query').map(([, args]) => args.accountId)).toEqual(['c']); + expect(ids()).toEqual(['o1', 'o2', 'o3']); + }); + + it('continues each account from its own rows on load more, skipping none', async () => { + settings.emailsPerPage = 2; + await search('zephyr'); + expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']); + + await useEmailStore.getState().loadMoreEmails(); + const page2 = server.current!.requests[1].filter(([n]) => n === 'Email/query'); + expect(page2.map(([, a]) => [a.accountId, a.position])).toEqual([['c', 2], ['team', 2]]); + expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']); + + await useEmailStore.getState().loadMoreEmails(); + const page3 = server.current!.requests[2].filter(([n]) => n === 'Email/query'); + expect(page3.map(([, a]) => [a.accountId, a.position])).toEqual([['c', 3], ['team', 4]]); + expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3', 'm5']); + + // Everything is loaded: no further request. + await useEmailStore.getState().loadMoreEmails(); + expect(server.current!.requests).toHaveLength(3); + }); + + it('shows the reachable account\'s hits when the other one fails', async () => { + server.current!.failing.set('team', 'forbidden'); + await search('zephyr'); + + expect(ids()).toEqual(['o1', 'o2', 'o3']); + expect(useEmailStore.getState().accountErrors).toEqual({ team: 'forbidden' }); + expect(useEmailStore.getState().error).toBeNull(); + }); + + it('reports an error when no account answers', async () => { + server.current!.failing.set('team', 'forbidden'); + server.current!.failing.set('c', 'serverFail'); + await search('zephyr'); + + expect(ids()).toEqual([]); + expect(useEmailStore.getState().error).toBe('serverFail'); + }); + + it('keeps two accounts\' rows with the same ids apart', 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; + expect(rows.map((e) => [e.id, e.jmapAccountId])).toEqual([['x', 'team'], ['x', 'c']]); + }); + + it('re-runs the search when only the team account reports a change', async () => { + await search('zephyr'); + server.current!.accounts.team.push(mail('m6', 't-inbox', 23, 'zephyr ticket 6')); + + await useEmailStore.getState().handleStateChange({ + '@type': 'StateChange', changed: { team: { Email: 's2' } }, + } as never); + + expect(ids()[0]).toBe('m6'); + }); +}); + +describe('opening and acting on "All folders" rows (#1082)', () => { + it('opens a team row in the team account, paging over the team rows', async () => { + await search('zephyr'); + const row = useEmailStore.getState().emails.find((e) => e.id === 'm2')!; + + expect(viewerParamsForRow(row)).toEqual({ jmapAccountId: 'team', emailIds: ['m1', 'm2', 'm3', 'm4', 'm5'] }); + }); + + it('opens an own row in the own account while a team folder is open', async () => { + useEmailStore.setState({ currentMailboxId: 'team:t-inbox' }); + await search('zephyr'); + const own = useEmailStore.getState().emails.find((e) => e.id === 'o1')!; + + // The key is present, so it overrides the open folder's account. + const params = viewerParamsForRow(own); + expect('jmapAccountId' in params).toBe(true); + expect(params).toEqual({ jmapAccountId: undefined, emailIds: ['o1', 'o2', 'o3'] }); + }); + + it("acts on the viewer's message in the account it names, not a same-id row", async () => { + server.current = createFakeJmap({ + c: [mail('x', 'inbox', 20, 'zephyr own')], + team: [mail('x', 't-inbox', 21, 'zephyr team')], + }); + await search('zephyr'); + const team = useEmailStore.getState().emails.find((e) => e.jmapAccountId === 'team')!; + + await useEmailStore.getState().setKeywordForEmails(['x'], '$flagged', true, { email: team, accountId: 'team' }); + + expect(server.current!.callsOf('Email/set').map(([, a]) => a.accountId)).toEqual(['team']); + expect(server.current!.accounts.c[0].keywords).toEqual({}); + }); + + it("marks the viewer's message read in the account it names", async () => { + server.current = createFakeJmap({ + c: [mail('x', 'inbox', 20, 'zephyr own')], + team: [mail('x', 't-inbox', 21, 'zephyr team')], + }); + await search('zephyr'); + + // The own message, although the team row with the same id comes first. + await useEmailStore.getState().markRead('x', undefined); + await useEmailStore.getState().markRead('x', 'team'); + + expect(server.current!.callsOf('Email/set').map(([, a]) => a.accountId)).toEqual(['c', 'team']); + }); + + it('marks a mixed selection read with one Email/set per account', async () => { + await search('zephyr'); + await useEmailStore.getState().setKeywordForEmails(['o1', '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']]]); + expect(server.current!.accounts.team.find((e) => e.id === 'm1')!.keywords).toEqual({ $seen: true }); + }); + + it('stars a team row in the team account', async () => { + await search('zephyr'); + await useEmailStore.getState().toggleStar('m3', true); + + expect(server.current!.callsOf('Email/set')[0][1]).toEqual({ + accountId: 'team', update: { m3: { 'keywords/$flagged': true } }, + }); + }); + + 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']); + + const sets = server.current!.callsOf('Email/set'); + expect(sets.map(([, a]) => [a.accountId, a.update])).toEqual([ + ['team', { m1: { mailboxIds: { 't-archive': true } } }], + ['c', { o1: { mailboxIds: { archive: true } } }], + ]); + expect(ids()).not.toContain('o1'); + expect(ids()).not.toContain('m1'); + + await useEmailStore.getState().undoLast(); + expect(server.current!.accounts.c.find((e) => e.id === 'o1')!.mailboxIds).toEqual({ inbox: true }); + expect(server.current!.accounts.team.find((e) => e.id === 'm1')!.mailboxIds).toEqual({ 't-inbox': true }); + expect(ids()).toContain('o1'); + expect(ids()).toContain('m1'); + }); + + 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'); + + 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); + + await useEmailStore.getState().deleteEmailsBatch(['m1', '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 } } }], + ]); + expect(server.current!.accounts.team.map((e) => e.id)).not.toContain('m5'); + }); + + it('files spam in each row\'s own Junk', async () => { + await search('zephyr'); + await useEmailStore.getState().markSpam(['m2']); + + expect(server.current!.callsOf('Email/set')[0][1]).toEqual({ + accountId: 'team', + update: { m2: { mailboxIds: { 't-junk': true }, 'keywords/$junk': true, 'keywords/$notjunk': null } }, + }); + }); + + it('leaves the rows of a single folder on the folder\'s account', async () => { + const own = mail('o4', 'inbox', 21, 'lunch'); + useEmailStore.setState({ emails: [own], totalEmails: 1 }); + expect(viewerParamsForRow(own)).toEqual({}); + expect(deleteDestroysAcrossAccounts(['o4'])).toBeNull(); + }); +}); diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index f8770064..0570f874 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -12,6 +12,9 @@ vi.mock('../../api/email', () => ({ // A list page: Email/query, Email/get and Thread/get in one request. // beforeEach scripts it from the queryEmails / getEmailsWithState mocks. queryEmailPage: vi.fn(), + // "All folders": one page per account; beforeEach runs each through + // queryEmailPage. + queryEmailPagesAcrossAccounts: vi.fn(), getEmailQueryChanges: vi.fn(async () => null), getEmails: vi.fn(), getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })), @@ -156,6 +159,7 @@ const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType; const mockQueryEmails = emailApi.queryEmails as ReturnType; const mockQueryEmailPage = emailApi.queryEmailPage as ReturnType; +const mockQueryAcross = emailApi.queryEmailPagesAcrossAccounts as ReturnType; const mockGetThreads = emailApi.getThreads as ReturnType; const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType; const mockGetEmails = emailApi.getEmails as ReturnType; @@ -183,6 +187,15 @@ beforeEach(() => { : { list: [], state: 'em-state-0' }; return { ...query, list: got.list, state: got.state, threads: [] }; }); + mockQueryAcross.mockImplementation(async ( + targets: Array<{ accountId?: string; position: number; sort: unknown }>, + opts: Record, + ) => Promise.all(targets.map(async (target) => { + const page = await emailApi.queryEmailPage(undefined, { + ...opts, position: target.position, sort: target.sort as never, accountId: target.accountId, + }); + return { accountId: target.accountId, ok: true, total: page.total, list: page.list, threads: page.threads }; + }))); }); describe('email-store', () => { @@ -614,7 +627,8 @@ describe('email-store', () => { await useEmailStore.getState().refreshEmails(); const state = useEmailStore.getState(); - expect(state.emails).toEqual([base[1]]); + // An "All folders" hit carries the account it came from (#1082). + expect(state.emails).toEqual([{ ...base[1], jmapAccountId: 'acc-1' }]); // Base-view cache must survive the search untouched. expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base); expect(state.queryState).toBe('q-base'); @@ -1127,10 +1141,12 @@ describe('email-store', () => { mockQueryEmailPage.mockResolvedValue(page); useEmailStore.getState().setSearchQuery('invoice'); - await vi.waitFor(() => expect(useEmailStore.getState().threadCounts).toEqual({ t1: 3, t2: 1 })); + // "All folders" rows and their threads are scoped by account (#1082). + await vi.waitFor(() => expect(useEmailStore.getState().threadCounts).toEqual({ 'acc-1:t1': 3, 'acc-1:t2': 1 })); + expect(mockQueryAcross).toHaveBeenCalledTimes(1); + expect(mockQueryAcross.mock.calls[0][1]).toMatchObject({ filter: { text: 'invoice*' }, threads: true }); expect(mockQueryEmailPage).toHaveBeenCalledTimes(1); - expect(mockQueryEmailPage.mock.calls[0][1]).toMatchObject({ filter: { text: 'invoice*' }, threads: true }); expect(mockGetThreads).not.toHaveBeenCalled(); }); diff --git a/src/stores/__tests__/fake-jmap-server.ts b/src/stores/__tests__/fake-jmap-server.ts new file mode 100644 index 00000000..1060e8a4 --- /dev/null +++ b/src/stores/__tests__/fake-jmap-server.ts @@ -0,0 +1,134 @@ +// A small in-memory JMAP server for store tests that assert the wire: it +// answers Email/query (text / inMailbox / hasKeyword / notKeyword filters, +// newest first, position + limit), Email/get and Thread/get (including +// result references), and applies Email/set updates and destroys. Every +// request is recorded so a test can check which method calls went out, and +// in how many requests. + +import type { Email, JMAPMethodCall } from '../../api/types'; + +export type FakeEmail = Pick + & Partial; + +type Filter = Record; + +function matches(email: FakeEmail, filter: Filter | undefined): boolean { + if (!filter) return true; + if ('operator' in filter) { + const results = (filter.conditions as Filter[]).map((c) => matches(email, c)); + if (filter.operator === 'AND') return results.every(Boolean); + if (filter.operator === 'OR') return results.some(Boolean); + return !results.some(Boolean); + } + for (const [key, value] of Object.entries(filter)) { + if (key === 'text') { + const needle = String(value).replace(/\*/g, '').toLowerCase(); + if (!`${email.subject ?? ''} ${email.preview ?? ''}`.toLowerCase().includes(needle)) return false; + } else if (key === 'inMailbox') { + if (!email.mailboxIds[value as string]) return false; + } else if (key === 'hasKeyword') { + if (!email.keywords[value as string]) return false; + } else if (key === 'notKeyword') { + if (email.keywords[value as string]) return false; + } else { + throw new Error(`fake server: unsupported filter ${key}`); + } + } + return true; +} + +function unescapePointer(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +export function createFakeJmap(accounts: Record) { + const requests: JMAPMethodCall[][] = []; + /** JMAP account id → error type every call for it is answered with. */ + const failing = new Map(); + + const request = async (calls: JMAPMethodCall[]) => { + requests.push(calls); + const responses: Array<[string, Record, string]> = []; + const byCallId = new Map, string]>(); + for (const [name, args, callId] of calls) { + const accountId = args.accountId as string; + let response: [string, Record, string]; + const store = accounts[accountId]; + if (failing.has(accountId)) { + response = ['error', { type: failing.get(accountId) }, callId]; + } else if (!store) { + response = ['error', { type: 'accountNotFound' }, callId]; + } else { + const ref = args['#ids'] as { resultOf: string; path: string } | undefined; + let ids = args.ids as string[] | undefined; + if (ref) { + const source = byCallId.get(ref.resultOf); + if (!source || source[0] === 'error') ids = undefined; + else if (ref.path === '/ids') ids = source[1].ids; + else if (ref.path === '/list/*/threadId') ids = (source[1].list as Email[]).map((e) => e.threadId); + else throw new Error(`fake server: unsupported path ${ref.path}`); + } + if (ref && !ids) { + response = ['error', { type: 'invalidResultReference' }, callId]; + } else if (name === 'Email/query') { + const all = store + .filter((e) => matches(e, args.filter as Filter)) + .sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)); + const position = (args.position as number) ?? 0; + const limit = (args.limit as number) ?? 50; + response = ['Email/query', { + accountId, + ids: all.slice(position, position + limit).map((e) => e.id), + total: all.length, + position, + queryState: `q-${accountId}`, + }, callId]; + } else if (name === 'Email/get') { + const list = (ids ?? store.map((e) => e.id)) + .map((id) => store.find((e) => e.id === id)) + .filter((e): e is FakeEmail => !!e) + .map((e) => ({ ...e, keywords: { ...e.keywords }, mailboxIds: { ...e.mailboxIds } })); + response = ['Email/get', { accountId, list, state: `s-${accountId}`, notFound: [] }, callId]; + } else if (name === 'Thread/get') { + const list = [...new Set(ids ?? [])].map((id) => ({ + id, + emailIds: store.filter((e) => e.threadId === id).map((e) => e.id), + })); + response = ['Thread/get', { accountId, list, state: 't', notFound: [] }, callId]; + } else if (name === 'Email/set') { + const updated: Record = {}; + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + const email = store.find((e) => e.id === id); + if (!email) continue; + for (const [path, value] of Object.entries(patch)) { + if (path === 'mailboxIds') email.mailboxIds = { ...(value as Record) }; + else if (path === 'keywords') email.keywords = { ...(value as Record) }; + else { + const [prop, key] = [path.slice(0, path.indexOf('/')), unescapePointer(path.slice(path.indexOf('/') + 1))]; + const target = (prop === 'mailboxIds' ? email.mailboxIds : email.keywords) as Record; + if (value) target[key] = true; + else delete target[key]; + } + } + updated[id] = null; + } + const destroyed = ((args.destroy ?? []) as string[]).filter((id) => store.some((e) => e.id === id)); + accounts[accountId] = store.filter((e) => !destroyed.includes(e.id)); + response = ['Email/set', { accountId, updated, destroyed, newState: 'n' }, callId]; + } else { + response = ['error', { type: 'unknownMethod' }, callId]; + } + } + byCallId.set(callId, response); + responses.push(response); + } + return { methodResponses: responses, sessionState: 'session' }; + }; + + /** Every method call sent so far, flattened across requests. */ + const calls = () => requests.flat(); + /** The calls of `name` sent so far. */ + const callsOf = (name: string) => calls().filter(([n]) => n === name); + + return { request, requests, calls, callsOf, failing, accounts }; +} diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 32a2e58c..cf331704 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -11,6 +11,7 @@ import { getMailboxesByIds, getMailboxChanges, queryEmailPage, + queryEmailPagesAcrossAccounts, getEmailQueryChanges, getEmails as fetchEmails, getEmailsWithState, @@ -34,9 +35,12 @@ import { } from '../api/email'; import { useNetworkStore } from './network-store'; import { JMAPMethodError } from '../api/jmap-result'; -import { mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox } from '../lib/mailbox-tree'; +import { + mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox, findTrashMailbox, ownMailboxes, +} from '../lib/mailbox-tree'; import { toWildcardQuery } from '../lib/search-utils'; -import { orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order'; +import { collapseThreads, accountScopedId } from '../lib/thread-utils'; +import { compareEmails, orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order'; import { buildListSort, markKeywordSortUnsupported } from '../lib/keyword-sort-polarity'; import { generateAccountId } from '../lib/account-utils'; import { applyKeywordPatch, revertKeywordPatch, type KeywordPatch } from '../lib/keyword-patch'; @@ -151,9 +155,11 @@ function actionTarget( emailId: string, viewed?: ViewedEmail, ): { email: Email | undefined; listed: boolean } { - const listed = !viewed || viewed.accountId === currentAccountId(state); - const row = listed ? state.emails.find((e) => e.id === emailId) : undefined; - return { email: row ?? viewed?.email, listed }; + // 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)); + return { email: row ?? viewed?.email, listed: !viewed || !!row }; } // actionTarget for the selection actions: the viewer only acts on its one message. @@ -283,6 +289,11 @@ export interface UndoEntry { email: Email; originalMailboxIds: Record; originalKeywords?: Record; + /** + * The item's own JMAP account when one action covered several accounts + * (a list that spans accounts); `accountId` above applies otherwise. + */ + accountId?: string; }>; } @@ -348,6 +359,11 @@ export interface EmailState { * folders. Cleared when the folder or account changes. */ threadCounts: Record; + /** + * Accounts an "All folders" list could not reach, by JMAP account id → the + * error. The other accounts' messages still show. + */ + accountErrors: Record; // ── Actions ──────────────────────────────────────────────────── setActiveAccount: (accountId: string | null) => void; @@ -502,6 +518,176 @@ function isUnsupportedSort(err: unknown): boolean { return err instanceof JMAPMethodError && err.type === 'unsupportedSort'; } +// ── Lists that span accounts ─────────────────────────────────────────── +// "All folders" covers every folder of the user's own account AND of the +// shared (group) accounts whose folders are in the sidebar (#1082, webmail +// `searchAcrossAccounts`). Each account is asked for its own page, the pages +// are merged under the list order and every row is stamped with its JMAP +// account (`Email.jmapAccountId`), so opening and acting on a row reach the +// account it lives in. A search scoped to one folder stays in its account. + +/** Whether the list on screen spans the own and the shared accounts. */ +export function spansAccounts(state: Pick): boolean { + return effectiveFolderScope(state.searchQuery, state.filters) === 'all' && !state.filters.keyword; +} + +// The JMAP accounts such a list covers; undefined = the user's own. +function listAccounts(mailboxes: Mailbox[]): Array { + const out: Array = [undefined]; + for (const m of mailboxes) { + if (m.isShared && m.accountId && m.accountId !== jmapClient.accountId && !out.includes(m.accountId)) { + out.push(m.accountId); + } + } + return out; +} + +// The JMAP account a list row lives under (undefined = the user's own): its +// stamp in a list that spans accounts, else the folder on screen. +function rowAccountId(state: EmailState, email: Email | undefined): string | undefined { + if (email?.jmapAccountId) { + return email.jmapAccountId === jmapClient.accountId ? undefined : email.jmapAccountId; + } + return currentAccountId(state); +} + +// The folders of one JMAP account (undefined = the user's own). +function accountMailboxes(mailboxes: Mailbox[], accountId: string | undefined): Mailbox[] { + return accountId + ? mailboxes.filter((m) => m.isShared && m.accountId === accountId) + : ownMailboxes(mailboxes); +} + +// Rows grouped by the account they live in, in first-seen order. +function groupByAccount(state: EmailState, emails: Email[]): Array<{ accountId?: string; emails: Email[] }> { + const groups = new Map(); + for (const e of emails) { + const accountId = rowAccountId(state, e); + const group = groups.get(accountId ?? ''); + if (group) group.emails.push(e); + else groups.set(accountId ?? '', { accountId, emails: [e] }); + } + 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)); +} + +// Client-side mirror of the list sort, for merging the accounts' pages. +function listComparator(state: EmailState): (a: Email, b: Email) => number { + const order = orderFor(state); + const oldestFirst = useSettingsStore.getState().mailSortAscending + && !order.some((l) => l.criterion === 'receivedAt'); + const levels: SortLevel[] = oldestFirst ? [...order, { criterion: 'receivedAt', direction: 'asc' }] : order; + return compareEmails(levels, { pinnedFirst: true }); +} + +interface SpanningPage { + /** Stamped rows of every account that answered, merged under the list order. */ + list: Email[]; + /** Sum of the answering accounts' totals. */ + total: number; + /** Their conversations, ids scoped like `accountScopedId`. */ + threads: Thread[]; + /** JMAP account id → error, for the accounts that did not answer. */ + errors: Record; +} + +// One page from every account the list spans, each account starting at its +// own position (`loaded[stamp]`: its rows already on screen), so "load more" +// never skips one account's messages because another had more. An account +// refusing the keyword sort is retried once without it; a failing account +// lands in `errors`. Throws only when no account answered. +async function fetchSpanningPage( + state: EmailState, + loaded: Record, + filter: Record | undefined, + limit: number, +): Promise { + const primary = jmapClient.accountId; + const threads = !useSettingsStore.getState().disableThreading; + const run = async (accounts: Array) => queryEmailPagesAcrossAccounts( + await Promise.all(accounts.map(async (accountId) => ({ + accountId, + position: loaded[accountId ?? primary] ?? 0, + sort: await resolveSort(state, accountId), + }))), + { limit, filter, threads }, + ); + let pages = await run(listAccounts(state.mailboxes)); + const refused = pages.filter((p) => !p.ok && isUnsupportedSort(p.error)); + if (refused.length > 0) { + for (const p of refused) markKeywordSortUnsupported(p.accountId ?? primary); + const retried = await run(refused.map((p) => p.accountId)); + pages = pages.map((p) => retried.find((r) => r.accountId === p.accountId) ?? p); + } + + const page: SpanningPage = { list: [], total: 0, threads: [], errors: {} }; + let firstError: Error | undefined; + for (const p of pages) { + const stamp = p.accountId ?? primary; + if (!p.ok) { + page.errors[stamp] = p.error.message; + if (!firstError) firstError = p.error; + continue; + } + page.total += p.total; + for (const e of p.list) page.list.push({ ...e, jmapAccountId: stamp }); + for (const th of p.threads) page.threads.push({ ...th, id: `${stamp}:${th.id}` }); + } + if (firstError && Object.keys(page.errors).length === pages.length) throw firstError; + page.list.sort(listComparator(state)); + return page; +} + +// Whether deleting a row of a list that spans accounts destroys it instead of +// moving it to its account's Trash: the rule `deleteEmail` applies to the +// folder on screen, per row — in that Trash already, the "permanent" delete +// action, or in Junk with "permanently delete junk". +function destroysOnDelete(state: EmailState, email: Email): boolean { + const settings = useSettingsStore.getState(); + if (settings.deleteAction === 'permanent') return true; + const mailboxes = accountMailboxes(state.mailboxes, rowAccountId(state, email)); + const trash = findTrashMailbox(mailboxes); + if (trash && email.mailboxIds?.[trash.originalId ?? trash.id]) return true; + const junk = findJunkMailbox(mailboxes); + return !!(settings.permanentlyDeleteJunk && junk && email.mailboxIds?.[junk.originalId ?? junk.id]); +} + +/** + * For rows of a list that spans accounts: whether deleting them destroys any + * outright, so the caller asks first. Null when none of them comes from such + * a list; the folder on screen decides for those. + */ +export function deleteDestroysAcrossAccounts(emailIds: string[]): boolean | null { + const state = useEmailStore.getState(); + const rows = spanningRows(state, emailIds); + if (rows.length === 0) return null; + return rows.some((e) => destroysOnDelete(state, e)); +} + +/** The JMAP account a row of the list on screen lives under; undefined = the user's own. */ +export function accountIdOfRow(email: Email): string | undefined { + return rowAccountId(useEmailStore.getState(), email); +} + +/** + * 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 + * that account to page over. Empty for the rows of a single folder. + */ +export function viewerParamsForRow(email: Email): { jmapAccountId?: string; emailIds?: string[] } { + if (!email.jmapAccountId) return {}; + const state = useEmailStore.getState(); + const rows = state.emails.filter((e) => e.jmapAccountId === email.jmapAccountId); + return { + jmapAccountId: rowAccountId(state, email), + emailIds: collapseThreads(rows, useSettingsStore.getState().disableThreading).map((e) => e.id), + }; +} + // Splice rows the user just read/unstarred back into a freshly re-queried // Unread/Starred view at their previous position (webmail `mergeRetainedRows`). function mergeRetainedRows(previous: Email[], fresh: Email[], retainedIds: string[]): Email[] { @@ -631,6 +817,7 @@ export const useEmailStore = create()( pendingUndo: null, retainedIds: [], threadCounts: {}, + accountErrors: {}, // Swap which account's data is currently visible. The previous account's // view is tucked into accountSnapshots so a return-trip can restore it @@ -836,6 +1023,31 @@ export const useEmailStore = create()( const scope = queryScope(state, refFor(state.mailboxes, currentMailboxId)); const filter = buildJmapFilter(searchQuery, filters); const limit = useSettingsStore.getState().emailsPerPage; + if (spansAccounts(state)) { + // 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; + } + const page = await fetchSpanningPage(state, loaded, filter, limit); + const now = get(); + if ( + 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))); + set({ + emails: [...now.emails, ...page.list.filter((e) => !shown.has(accountScopedId(e, e.id)))] + .sort(listComparator(now)), + // An account that failed this time keeps counting what it showed, + // so load-more doesn't keep asking for it. + totalEmails: page.total + Object.keys(page.errors).reduce((n, id) => n + (loaded[id] ?? 0), 0), + threadCounts: withThreadCounts(now.threadCounts, page.threads), + accountErrors: page.errors, + loading: false, + }); + return; + } const { list, total, threads } = await queryEmailPage(scope.mailboxId, { position, limit, @@ -935,13 +1147,15 @@ export const useEmailStore = create()( ? refFor(state.mailboxes, state.currentMailboxId).accountId ?? primaryId : primaryId; const known = new Set([primaryId, ...jmapClient.getSharedMailAccounts().map((a) => a.id)]); + // An "All folders" list shows every account's mail (#1082). + const spanning = spansAccounts(state); let mailboxChanged = false; let emailChanged = false; for (const [accountId, accountChanges] of Object.entries(change.changed ?? {})) { if (!known.has(accountId) || !accountChanges) continue; if ('Mailbox' in accountChanges) mailboxChanged = true; - if (accountId !== currentAccountId) continue; + if (accountId !== currentAccountId && !spanning) continue; if ('Email' in accountChanges || 'EmailDelivery' in accountChanges) emailChanged = true; } if (!mailboxChanged && !emailChanged) return; @@ -1033,7 +1247,10 @@ export const useEmailStore = create()( markRead: async (emailId, accountId) => { const state = get(); - const email = state.emails.find((e) => e.id === emailId); + // 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); // 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 }; @@ -1044,7 +1261,7 @@ export const useEmailStore = create()( await patchKeywordsForEmails([emailId], patch, accountId); return; } - const owner = accountId ?? currentAccountId(state); + const owner = accountId ?? rowAccountId(state, email); await applyOrQueue({ kind: 'keywords', emailId, accountId: owner, patch }); set({ emails: get().emails.map((e) => @@ -1063,7 +1280,7 @@ export const useEmailStore = create()( await applyOrQueue({ kind: 'keywords', emailId, - accountId: currentAccountId(state), + accountId: rowAccountId(state, email), patch, }); set({ @@ -1072,7 +1289,7 @@ export const useEmailStore = create()( ), ...(state.filters.isUnread === false ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), }); - patchCache(emailId, { keywords: patch }, currentAccountId(state)); + patchCache(emailId, { keywords: patch }, rowAccountId(state, email)); }, toggleStar: async (emailId, starred) => { @@ -1083,7 +1300,7 @@ export const useEmailStore = create()( await applyOrQueue({ kind: 'keywords', emailId, - accountId: currentAccountId(state), + accountId: rowAccountId(state, email), patch, }); set({ @@ -1094,7 +1311,7 @@ export const useEmailStore = create()( ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), }); - patchCache(emailId, { keywords: patch }, currentAccountId(state)); + patchCache(emailId, { keywords: patch }, rowAccountId(state, email)); }, togglePin: async (emailId, pinned) => { @@ -1107,7 +1324,7 @@ export const useEmailStore = create()( await applyOrQueue({ kind: 'keywords', emailId, - accountId: currentAccountId(state), + accountId: rowAccountId(state, email), patch, }); set({ @@ -1115,11 +1332,15 @@ export const useEmailStore = create()( e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, ), }); - patchCache(emailId, { keywords: patch }, currentAccountId(state)); + patchCache(emailId, { keywords: patch }, rowAccountId(state, email)); }, markSpam: async (emailIds, viewed) => { 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, emailIds); + if (spanning.length > 0) return fileAcrossAccounts('spam', spanning); const { targets, listed } = actionTargets(state, emailIds, viewed); if (targets.length === 0) return; const scoped = actionMailboxes(state, viewed); @@ -1165,6 +1386,10 @@ export const useEmailStore = create()( unmarkSpam: async (emailIds, viewed) => { 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, emailIds); + if (spanning.length > 0) return fileAcrossAccounts('notSpam', spanning); const { targets, listed } = actionTargets(state, emailIds, viewed); if (targets.length === 0) return; const scoped = actionMailboxes(state, viewed); @@ -1206,6 +1431,10 @@ export const useEmailStore = create()( moveToMailbox: async (emailId, fromMailboxId, toMailboxId, viewed) => { 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); + if (spanning.length > 0) return fileAcrossAccounts('move', spanning, toMailboxId); const { email, listed } = actionTarget(state, emailId, viewed); const from = refFor(state.mailboxes, fromMailboxId); const to = refFor(state.mailboxes, toMailboxId); @@ -1253,6 +1482,10 @@ export const useEmailStore = create()( archiveEmail: async (emailId, viewed) => { 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); + 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); @@ -1312,6 +1545,10 @@ export const useEmailStore = create()( deleteEmail: async (emailId, trashMailboxId, currentMailboxId, viewed) => { 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); + if (spanning.length > 0) return fileAcrossAccounts('delete', spanning); const { email, listed } = actionTarget(state, emailId, viewed); const original = email ? { ...email.mailboxIds } : null; const settings = useSettingsStore.getState(); @@ -1386,6 +1623,9 @@ export const useEmailStore = create()( archiveEmailsBatch: async (emailIds) => { const state = get(); + // A list spanning accounts files each row in its own account (#1082). + const spanning = spanningRows(state, emailIds); + if (spanning.length > 0) return fileAcrossAccounts('archive', spanning); const scoped = mailboxesForSiblingOf(state.mailboxes, state.currentMailboxId); const archiveMailbox = findArchiveMailbox(scoped); if (!archiveMailbox) return; @@ -1437,6 +1677,9 @@ export const useEmailStore = create()( moveEmailsToMailbox: async (emailIds, toMailboxId) => { const { emails, 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); @@ -1491,6 +1734,9 @@ export const useEmailStore = create()( deleteEmailsBatch: async (emailIds, trashMailboxId, currentMailboxId) => { const { emails, 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); const settings = useSettingsStore.getState(); const trash = refFor(mailboxes, trashMailboxId); const source = refFor(mailboxes, currentMailboxId); @@ -1586,17 +1832,23 @@ export const useEmailStore = create()( const state = get(); const { targets, listed } = actionTargets(state, emailIds, viewed); if (targets.length === 0) return; - const owner = viewed ? viewed.accountId : currentAccountId(state); + // 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( - ids.map((id): OutboxOp => ({ + groups.flatMap(({ accountId, emails }) => emails.map((e): OutboxOp => ({ kind: 'keywords', - emailId: id, - accountId: owner, + emailId: e.id, + accountId, patch, - })), - () => patchKeywordsForEmails(ids, patch, owner), + }))), + async () => { + for (const { accountId, emails } of groups) { + await patchKeywordsForEmails(emails.map((e) => e.id), patch, accountId); + } + }, ); const touched = new Set(listed ? ids : []); set({ @@ -1609,7 +1861,9 @@ export const useEmailStore = create()( ? { retainedIds: retain(get().retainedIds, ids) } : {}), }); - for (const id of ids) patchCache(id, { keywords: patch }, owner); + for (const { accountId, emails } of groups) { + for (const e of emails) patchCache(e.id, { keywords: patch }, accountId); + } }, undoLast: async () => { @@ -1617,43 +1871,67 @@ export const useEmailStore = create()( if (!entry) return; set({ pendingUndo: null }); + // An action on a list spanning accounts is undone in each item's account. + const accountOf = (it: UndoEntry['items'][number]) => it.accountId ?? entry.accountId; + const byAccount = new Map(); + for (const it of entry.items) { + const accountId = accountOf(it); + const group = byAccount.get(accountId ?? ''); + if (group) group.items.push(it); + else byAccount.set(accountId ?? '', { accountId, items: [it] }); + } try { await applyOrQueueBatch( entry.items.map((it): OutboxOp => ({ kind: 'mailboxes', emailId: it.email.id, - accountId: entry.accountId, + accountId: accountOf(it), mailboxIds: it.originalMailboxIds, })), - () => restoreEmailMailboxes( - entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })), - entry.accountId, - ), + async () => { + for (const { accountId, items } of byAccount.values()) { + await restoreEmailMailboxes( + items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })), + accountId, + ); + } + }, ); // Spam / not-spam also flipped `$junk`/`$notjunk` (and maybe `$seen`): // put those keywords back as they were, leaving the rest alone. const keywordPatch = entry.keywordPatch; const keywordUndo = keywordPatch - ? entry.items.map((it) => ({ id: it.email.id, patch: revertKeywordPatch(keywordPatch, it.originalKeywords) })) + ? entry.items.map((it) => ({ + id: it.email.id, + accountId: accountOf(it), + patch: revertKeywordPatch(keywordPatch, it.originalKeywords), + })) : []; if (keywordUndo.length > 0) { await applyOrQueueBatch( keywordUndo.map((u): OutboxOp => ({ kind: 'keywords', emailId: u.id, - accountId: entry.accountId, + accountId: u.accountId, patch: u.patch, })), - () => patchKeywordsPerEmail(keywordUndo, entry.accountId), + async () => { + for (const { accountId } of byAccount.values()) { + await patchKeywordsPerEmail( + keywordUndo.filter((u) => u.accountId === accountId).map(({ id, patch }) => ({ id, patch })), + accountId, + ); + } + }, ); } - const undoById = new Map(keywordUndo.map((u) => [u.id, u.patch])); + const undoById = new Map(keywordUndo.map((u) => [`${u.accountId ?? ''}:${u.id}`, u.patch])); for (const it of entry.items) { - const patch = undoById.get(it.email.id); + const patch = undoById.get(`${accountOf(it) ?? ''}:${it.email.id}`); patchCache(it.email.id, { mailboxIds: it.originalMailboxIds, ...(patch ? { keywords: patch } : {}), - }, entry.accountId); + }, accountOf(it)); } } catch (err) { set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') }); @@ -1665,7 +1943,21 @@ export const useEmailStore = create()( // ordering, but local re-insertion gives the user instant feedback. Only // into a list of the same account: mailbox ids repeat across accounts. const { currentMailboxId, emails, mailboxes } = get(); - if (currentMailboxId && entry.accountId === currentAccountId(get())) { + const spanned = entry.items.filter((it) => it.email.jmapAccountId); + 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 restored = spanned + .filter((it) => !shown.has(accountScopedId(it.email, it.email.id))) + .map((it) => ({ + ...it.email, + mailboxIds: it.originalMailboxIds, + ...(it.originalKeywords ? { keywords: it.originalKeywords } : {}), + })); + set({ emails: [...restored, ...emails].sort(listComparator(get())) }); + } + } else if (currentMailboxId && entry.accountId === currentAccountId(get())) { const currentRawId = rawMailboxId(mailboxes, currentMailboxId); const restored = entry.items .filter((it) => it.originalMailboxIds[currentRawId]) @@ -1711,6 +2003,7 @@ export const useEmailStore = create()( filters: {}, retainedIds: [], threadCounts: {}, + accountErrors: {}, }), }), { @@ -1823,6 +2116,176 @@ async function crossAccountMove(targets: Email[], from: MailboxRef, to: MailboxR } } +type SpanningAction = 'archive' | 'delete' | 'spam' | 'notSpam' | 'move'; + +// Archive / delete / spam / move rows of a list that spans accounts (#1082). +// Each row is filed into a folder of its own account (a group message can't +// go to the user's Archive or Trash) and leaves every folder it is in: the +// list is not one folder it could be taken out of. `move` goes to the picked +// folder, copying across accounts when it is another account's. One undo +// covers all accounts. +async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMailboxId?: string): Promise { + const get = useEmailStore.getState; + const set = useEmailStore.setState; + const state = get(); + const settings = useSettingsStore.getState(); + const to = toMailboxId ? refFor(state.mailboxes, toMailboxId) : undefined; + const ops: OutboxOp[] = []; + const runs: Array<() => Promise> = []; + const cacheUpdates: Array<() => void> = []; + const items: UndoEntry['items'] = []; + const gone: Email[] = []; + const copies: Array<{ accountId?: string; emails: Email[] }> = []; + let missing: string | null = null; + let refreshFolders = false; + const keywordPatch: KeywordPatch | undefined = + action === 'spam' ? { $junk: true, $notjunk: null, ...(settings.deleteAction === 'trash-and-read' ? { $seen: true } : {}) } + : action === 'notSpam' ? { $junk: null, $notjunk: true } + : undefined; + + // Replace each row's folders with `destId`, keeping undo and cache in step. + const fileInto = (accountId: string | undefined, rows: Email[], destId: string, patch?: KeywordPatch) => { + const mailboxIds = { [destId]: true }; + for (const e of rows) { + ops.push({ kind: 'mailboxes', emailId: e.id, accountId, mailboxIds }); + if (patch) ops.push({ kind: 'keywords', emailId: e.id, accountId, patch }); + items.push({ + email: e, + originalMailboxIds: { ...e.mailboxIds }, + ...(keywordPatch ? { originalKeywords: { ...e.keywords } } : {}), + accountId, + }); + cacheUpdates.push(() => patchCache(e.id, { mailboxIds, ...(patch ? { keywords: patch } : {}) }, accountId)); + gone.push(e); + } + }; + + for (const { accountId, emails } of groupByAccount(state, targets)) { + const mailboxes = accountMailboxes(state.mailboxes, accountId); + const ids = (rows: Email[]) => rows.map((e) => e.id); + if (action === 'archive') { + const archive = findArchiveMailbox(mailboxes); + if (!archive) { + missing = t('email_list.no_archive_folder', 'Could not find an Archive folder on the server.'); + continue; + } + const archiveId = archive.originalId ?? archive.id; + const rows = emails.filter((e) => !e.mailboxIds?.[archiveId]); + const mode = settings.archiveMode; + for (const e of rows) { + ops.push({ kind: 'archive', emailId: e.id, accountId, archiveMailboxId: archiveId, mode, receivedAt: e.receivedAt }); + items.push({ email: e, originalMailboxIds: { ...e.mailboxIds }, accountId }); + cacheUpdates.push(() => patchCache(e.id, { mailboxIds: { [archiveId]: true } }, accountId)); + gone.push(e); + } + if (rows.length > 0) { + runs.push(() => apiArchiveEmails( + rows.map((e) => ({ id: e.id, receivedAt: e.receivedAt })), + archiveId, + mode, + toRawMailboxes(mailboxes), + accountId, + )); + if (mode !== 'single') refreshFolders = true; + } + } else if (action === 'delete') { + const trash = findTrashMailbox(mailboxes); + const destroy = emails.filter((e) => destroysOnDelete(state, e)); + const toTrash = emails.filter((e) => !destroy.includes(e)); + if (destroy.length > 0) { + for (const e of destroy) ops.push({ kind: 'destroy', emailId: e.id, accountId }); + runs.push(() => apiDestroyEmails(ids(destroy), accountId)); + cacheUpdates.push(() => dropFromCache(ids(destroy), accountId)); + gone.push(...destroy); + } + if (toTrash.length > 0 && !trash) { + missing = t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'); + } else if (toTrash.length > 0 && trash) { + const trashId = trash.originalId ?? trash.id; + // "Move to Trash and mark as read" (#323). + const unread = settings.deleteAction === 'trash-and-read' ? toTrash.filter((e) => !e.keywords?.$seen) : []; + const read = new Set(unread); + for (const e of toTrash) fileInto(accountId, [e], trashId, read.has(e) ? { $seen: true } : undefined); + runs.push(() => restoreEmailMailboxes(toTrash.map((e) => ({ id: e.id, mailboxIds: { [trashId]: true } })), accountId)); + if (unread.length > 0) runs.push(() => patchKeywordsForEmails(ids(unread), { $seen: true }, accountId)); + } + } else if (action === 'spam' || action === 'notSpam') { + const dest = action === 'spam' ? findJunkMailbox(mailboxes) : mailboxes.find((m) => m.role === 'inbox'); + if (!dest) { + if (action === 'spam') missing = t('email_list.no_junk_folder', 'Could not find a Spam/Junk folder on the server.'); + continue; + } + const destId = dest.originalId ?? dest.id; + fileInto(accountId, emails, destId, keywordPatch); + runs.push(() => (action === 'spam' + ? apiMarkAsSpam(ids(emails), destId, accountId, { markRead: settings.deleteAction === 'trash-and-read' }) + : apiUndoSpam(ids(emails), destId, accountId))); + } else if (to) { + if (to.accountId !== accountId) { + copies.push({ accountId, emails }); + continue; + } + const rows = emails.filter((e) => { + const current = Object.keys(e.mailboxIds ?? {}).filter((id) => e.mailboxIds[id]); + return !(current.length === 1 && current[0] === to.id); + }); + if (rows.length === 0) continue; + fileInto(accountId, rows, to.id); + runs.push(() => restoreEmailMailboxes(rows.map((e) => ({ id: e.id, mailboxIds: { [to.id]: true } })), accountId)); + } + } + + if (ops.length > 0) { + const { queued } = await applyOrQueueBatch(ops, async () => { + for (const run of runs) await run(); + }); + // Year/month archiving may have created folders. + if (refreshFolders && !queued) void get().fetchMailboxes(); + } + for (const copy of copies) { + try { + await crossAccountMove(copy.emails, { accountId: copy.accountId, id: '' }, to!); + gone.push(...copy.emails); + dropFromCache(copy.emails.map((e) => e.id), copy.accountId); + } catch (err) { + missing = err instanceof Error ? err.message : t('notifications.move_failed', 'Move failed'); + } + } + for (const update of cacheUpdates) update(); + + const goneKeys = new Set(gone.map((e) => accountScopedId(e, e.id))); + const count = items.length; + const targetName = toMailboxId ? mailboxPath(state.mailboxes, toMailboxId) : undefined; + const label = action === 'archive' + ? (count === 1 ? t('notifications.email_archived', 'Email archived') : t('email_list.emails_archived_count', `${count} emails archived`, { count })) + : action === 'delete' + ? (count === 1 ? t('email_list.moved_to_trash', 'Email moved to Trash') : t('email_list.moved_to_trash_count', `${count} emails moved to Trash`, { count })) + : action === 'spam' + ? (count === 1 ? t('email_list.marked_as_spam', 'Marked as spam') : t('email_list.marked_as_spam_count', `${count} emails marked as spam`, { count })) + : action === 'notSpam' + ? (count === 1 ? t('email_list.marked_not_spam', 'Marked as not spam') : t('email_list.marked_not_spam_count', `${count} emails marked as not spam`, { count })) + : targetName + ? (count === 1 + ? t('notifications.moved_to_mailbox', `Email moved to ${targetName}`, { mailbox: targetName }) + : 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))), + ...(count > 0 + ? { + pendingUndo: { + kind: action === 'notSpam' ? 'spam' : action, + label, + createdAt: Date.now(), + ...(keywordPatch ? { keywordPatch } : {}), + items, + }, + } + : {}), + ...(missing ? { error: missing } : {}), + }); +} + // ── Refresh implementations (wrapped by coalesceRefresh above) ───────── function retain(current: string[], ids: string[]): string[] { @@ -2155,6 +2618,19 @@ async function refreshEmailsImpl(): Promise { // query), or when the server returned cannotCalculateChanges above. // One request carries the query, its messages and (for the // conversation badges) their threads. + if (spansAccounts(state)) { + // "All folders": the own and every shared account, one request (#1082). + const page = await fetchSpanningPage(state, {}, filter, limit); + if (viewChanged()) return; + set({ + emails: mergeRetainedRows(get().emails, page.list, get().retainedIds), + totalEmails: page.total, + threadCounts: withThreadCounts(get().threadCounts, page.threads), + accountErrors: page.errors, + loading: false, + }); + return; + } const threads = !useSettingsStore.getState().disableThreading; let queryRes: Awaited>; try {