mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 08:06:59 +00:00
fix: search the shared accounts' folders too with "All folders" #1082
"All folders" sent its Email/query only to the account of the open folder, so mail in a shared (group) account the same login reaches never showed up: "zephyr" found the 3 own messages and missed the 5 in the team inbox, although that inbox sits right there in the sidebar. The search now asks the own account and every shared account with folders in the sidebar, like the webmail's `searchAcrossAccounts`: - one request carries Email/query, Email/get and Thread/get for each account (`queryEmailPagesAcrossAccounts`), and the pages are merged under the list order - each row is stamped with its JMAP account (`Email.jmapAccountId`), so opening a hit opens it in that account and read, star, tag, archive, delete, spam and move act there, filing into that account's own Archive, Trash or Junk; one undo restores rows in every account - "load more" continues each account from its own rows on screen rather than one shared offset, which would skip messages of the account with more hits - an account that fails leaves the others' hits on screen with a notice - a push for any account re-runs the search - list keys, thread grouping and conversation sizes are scoped by account, as JMAP ids are only unique per account - deleting a hit judges Trash and Junk by the hit's own folders, not the open folder, both for the move and for the permanent-delete confirm A search scoped to one folder still asks only that folder's account.
This commit is contained in:
@@ -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<RootStackParam
|
||||
threadId: email.threadId,
|
||||
subject: email.subject,
|
||||
jmapAccountId: mailboxAccountId(all, currentMailboxId),
|
||||
// A row of an "All folders" list or a tag view names its own
|
||||
// account instead, and pages over that account's rows.
|
||||
...viewerParamsForRow(email),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValueOnce(4);
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, 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: [
|
||||
|
||||
@@ -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<string, unknown>; threads?: boolean },
|
||||
): Promise<AccountPage[]> {
|
||||
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<ReturnType<typeof jmapClient.request>>;
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
+12
-1
@@ -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<Email, 'jmapAccountId'>, 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 {
|
||||
|
||||
@@ -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 <View style={styles.separator} />;
|
||||
}
|
||||
|
||||
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
|
||||
</View>
|
||||
)}
|
||||
|
||||
{unreachedAccounts.length > 0 && (
|
||||
<View style={[styles.emptyFolderBanner, { borderColor: c.error }]}>
|
||||
<Text style={styles.emptyFolderHint} numberOfLines={2}>
|
||||
{t('unified_mailbox.accounts_failed', `${unreachedAccounts.length} account(s) could not be loaded`, { count: unreachedAccounts.length })}
|
||||
{`: ${unreachedAccounts[0]}`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Email list */}
|
||||
{loading && emails.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
|
||||
@@ -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<typeof import('./fake-jmap-server').createFakeJmap> }));
|
||||
|
||||
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<void>) => {
|
||||
if (onlineRun) await onlineRun();
|
||||
else for (const op of ops) await runOp(op);
|
||||
return { queued: false };
|
||||
};
|
||||
return {
|
||||
applyOrQueueBatch,
|
||||
applyOrQueue: async (op: Op, onlineRun?: () => Promise<void>) => applyOrQueueBatch([op], onlineRun),
|
||||
useOutboxStore: { getState: () => ({ setAccount: vi.fn(async () => undefined), flush: vi.fn(async () => undefined) }) },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../settings-store', () => {
|
||||
const settings: Record<string, unknown> = {};
|
||||
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<string, unknown> }).__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<FakeEmail> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<t
|
||||
const mockGetSharedMailboxes = emailApi.getSharedMailboxes as ReturnType<typeof vi.fn>;
|
||||
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
|
||||
const mockQueryEmailPage = emailApi.queryEmailPage as ReturnType<typeof vi.fn>;
|
||||
const mockQueryAcross = emailApi.queryEmailPagesAcrossAccounts as ReturnType<typeof vi.fn>;
|
||||
const mockGetThreads = emailApi.getThreads as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
|
||||
@@ -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<string, unknown>,
|
||||
) => 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Email, 'id' | 'threadId' | 'mailboxIds' | 'keywords' | 'receivedAt' | 'subject'>
|
||||
& Partial<Email>;
|
||||
|
||||
type Filter = Record<string, unknown>;
|
||||
|
||||
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<string, FakeEmail[]>) {
|
||||
const requests: JMAPMethodCall[][] = [];
|
||||
/** JMAP account id → error type every call for it is answered with. */
|
||||
const failing = new Map<string, string>();
|
||||
|
||||
const request = async (calls: JMAPMethodCall[]) => {
|
||||
requests.push(calls);
|
||||
const responses: Array<[string, Record<string, any>, string]> = [];
|
||||
const byCallId = new Map<string, [string, Record<string, any>, string]>();
|
||||
for (const [name, args, callId] of calls) {
|
||||
const accountId = args.accountId as string;
|
||||
let response: [string, Record<string, any>, 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<string, null> = {};
|
||||
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
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<string, boolean>) };
|
||||
else if (path === 'keywords') email.keywords = { ...(value as Record<string, boolean>) };
|
||||
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<string, boolean>;
|
||||
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 };
|
||||
}
|
||||
+509
-33
@@ -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<string, boolean>;
|
||||
originalKeywords?: Record<string, boolean>;
|
||||
/**
|
||||
* 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<string, number>;
|
||||
/**
|
||||
* Accounts an "All folders" list could not reach, by JMAP account id → the
|
||||
* error. The other accounts' messages still show.
|
||||
*/
|
||||
accountErrors: Record<string, string>;
|
||||
|
||||
// ── 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<EmailState, 'searchQuery' | 'filters'>): 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<string | undefined> {
|
||||
const out: Array<string | undefined> = [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<string, { accountId?: string; emails: Email[] }>();
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
// 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<string, number>,
|
||||
filter: Record<string, unknown> | undefined,
|
||||
limit: number,
|
||||
): Promise<SpanningPage> {
|
||||
const primary = jmapClient.accountId;
|
||||
const threads = !useSettingsStore.getState().disableThreading;
|
||||
const run = async (accounts: Array<string | undefined>) => 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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
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<string, number> = {};
|
||||
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<EmailState>()(
|
||||
? 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<EmailState>()(
|
||||
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
await applyOrQueue({
|
||||
kind: 'keywords',
|
||||
emailId,
|
||||
accountId: currentAccountId(state),
|
||||
accountId: rowAccountId(state, email),
|
||||
patch,
|
||||
});
|
||||
set({
|
||||
@@ -1072,7 +1289,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
),
|
||||
...(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<EmailState>()(
|
||||
await applyOrQueue({
|
||||
kind: 'keywords',
|
||||
emailId,
|
||||
accountId: currentAccountId(state),
|
||||
accountId: rowAccountId(state, email),
|
||||
patch,
|
||||
});
|
||||
set({
|
||||
@@ -1094,7 +1311,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
? { 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<EmailState>()(
|
||||
await applyOrQueue({
|
||||
kind: 'keywords',
|
||||
emailId,
|
||||
accountId: currentAccountId(state),
|
||||
accountId: rowAccountId(state, email),
|
||||
patch,
|
||||
});
|
||||
set({
|
||||
@@ -1115,11 +1332,15 @@ export const useEmailStore = create<EmailState>()(
|
||||
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<EmailState>()(
|
||||
|
||||
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<EmailState>()(
|
||||
|
||||
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<EmailState>()(
|
||||
|
||||
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<EmailState>()(
|
||||
|
||||
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<EmailState>()(
|
||||
|
||||
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<EmailState>()(
|
||||
|
||||
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<EmailState>()(
|
||||
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
? { 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<EmailState>()(
|
||||
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<string, { accountId?: string; items: UndoEntry['items'] }>();
|
||||
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<EmailState>()(
|
||||
// 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<EmailState>()(
|
||||
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<void> {
|
||||
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<unknown>> = [];
|
||||
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<void> {
|
||||
// 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<ReturnType<typeof queryEmailPage>>;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user