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 {