From b63b22764569c402a5bc6adbd1dfc88fdf4a469f Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:00:37 +0200 Subject: [PATCH] fix(email): route unified/cross virtual ids through the fan-out in fetchEmails (#791) fetchEmails sent a unified/cross view's client-only virtual mailbox id (__cross_all__ / __unified_*) straight to getEmails as `inMailbox`. The server rejects it (Stalwart: "filter.inMailbox = '__cross_all__'") and the foreground catch wiped the list to "no messages found" - notably after actions (mark read, delete, move) in the aggregated views. Mirror the loadMoreEmails / refreshCurrentMailbox guard: when the target id is virtual, fan out via fetchCrossViewEmails / fetchUnifiedEmails (search-aware) at page 0 instead of querying the virtual id. If the view state isn't resolved yet, stop loading without sending the id or wiping the list. Real folders are unaffected. +5 unit tests (email-store-fetch-unified): cross/unified fan-out, explicit virtual-id arg, unresolved-view no-op, and the real-folder path still using getEmails. tsc + eslint clean; all store suites green (389). --- .../email-store-fetch-unified.test.ts | 162 ++++++++++++++++++ stores/email-store.ts | 43 +++++ 2 files changed, 205 insertions(+) create mode 100644 stores/__tests__/email-store-fetch-unified.test.ts diff --git a/stores/__tests__/email-store-fetch-unified.test.ts b/stores/__tests__/email-store-fetch-unified.test.ts new file mode 100644 index 000000000..21cd22f44 --- /dev/null +++ b/stores/__tests__/email-store-fetch-unified.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * #791: fetchEmails is also called with a unified/cross view's VIRTUAL mailbox + * id (e.g. __cross_all__) - notably after actions in the aggregated views. The + * single-mailbox path sent that virtual id to getEmails as `inMailbox`; the + * server rejects it and the (foreground) catch wipes the list to "no messages". + * These tests pin that fetchEmails fans out via the unified loaders and never + * sends the virtual id, so the list stays intact. + */ + +const { buildMock, fetchCrossViewMock, fetchUnifiedMock } = vi.hoisted(() => ({ + buildMock: vi.fn(async () => [] as unknown[]), + fetchCrossViewMock: vi.fn(), + fetchUnifiedMock: vi.fn(), +})); + +vi.mock('@/lib/unified-mailbox', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + buildUnifiedAccountClients: buildMock, + fetchCrossViewEmails: fetchCrossViewMock, + fetchUnifiedEmails: fetchUnifiedMock, + }; +}); + +import { useEmailStore } from '../email-store'; +import { useSettingsStore } from '../settings-store'; +import { CROSS_ALL, UNIFIED_INBOX } from '@/lib/jmap/types'; +import type { Email } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +const makeEmail = (id: string, keywords: Record = {}): Email => + ({ + id, + threadId: `t-${id}`, + mailboxIds: { inbox: true }, + keywords, + from: [{ email: 'a@example.com' }], + to: [{ email: 'b@example.com' }], + subject: `mail ${id}`, + receivedAt: '2026-08-13T10:00:00Z', + preview: '', + hasAttachment: false, + size: 1, + }) as unknown as Email; + +// A client whose getEmails returns an empty page for the virtual id - the +// pre-fix bug. If fetchEmails ever calls it in a unified view, the list wipes. +const makeVirtualIdClient = () => + ({ + getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0 })), + }) as unknown as IJMAPClient & { getEmails: ReturnType }; + +describe('fetchEmails in unified / cross-account views (#791)', () => { + beforeEach(() => { + vi.clearAllMocks(); + buildMock.mockResolvedValue([]); + useSettingsStore.setState({ emailsPerPage: 25 }); + useEmailStore.setState({ + emails: [], + totalEmails: 0, + searchQuery: '', + isUnifiedView: false, + unifiedRole: null, + crossView: null, + }); + }); + + it('cross-account "All Mail" fans out and keeps the list instead of emptying it', async () => { + fetchCrossViewMock.mockResolvedValue({ + emails: [makeEmail('a'), makeEmail('b', { $seen: true })], + hasMore: false, + total: 2, + errors: new Map(), + }); + useEmailStore.setState({ + selectedMailbox: CROSS_ALL, + isUnifiedView: true, + crossView: 'all', + emails: [makeEmail('a'), makeEmail('b')], + totalEmails: 2, + }); + + const client = makeVirtualIdClient(); + await useEmailStore.getState().fetchEmails(client); + + const emails = useEmailStore.getState().emails; + expect(emails.map((e) => e.id)).toEqual(['a', 'b']); // not emptied + expect(emails.find((e) => e.id === 'b')?.keywords?.$seen).toBe(true); + expect(fetchCrossViewMock).toHaveBeenCalledTimes(1); + expect(client.getEmails).not.toHaveBeenCalled(); // never sent __cross_all__ + }); + + it('unified-role view fans out via the unified loader, not the virtual id', async () => { + fetchUnifiedMock.mockResolvedValue({ + emails: [makeEmail('a'), makeEmail('b')], + hasMore: false, + total: 2, + errors: new Map(), + }); + useEmailStore.setState({ + selectedMailbox: UNIFIED_INBOX, + isUnifiedView: true, + unifiedRole: 'inbox', + crossView: null, + emails: [makeEmail('a')], + totalEmails: 1, + }); + + const client = makeVirtualIdClient(); + await useEmailStore.getState().fetchEmails(client); + + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a', 'b']); + expect(fetchUnifiedMock).toHaveBeenCalledTimes(1); + expect(client.getEmails).not.toHaveBeenCalled(); + }); + + it('an explicit virtual mailboxId argument is also routed (not sent to getEmails)', async () => { + fetchCrossViewMock.mockResolvedValue({ emails: [makeEmail('a')], hasMore: false, total: 1, errors: new Map() }); + useEmailStore.setState({ isUnifiedView: true, crossView: 'all', selectedMailbox: 'inbox' }); + + const client = makeVirtualIdClient(); + await useEmailStore.getState().fetchEmails(client, CROSS_ALL); + + expect(fetchCrossViewMock).toHaveBeenCalledTimes(1); + expect(client.getEmails).not.toHaveBeenCalled(); + }); + + it('a virtual id with no resolved view leaves the list untouched (no query, no wipe)', async () => { + useEmailStore.setState({ + selectedMailbox: CROSS_ALL, + isUnifiedView: true, + crossView: null, + unifiedRole: null, + emails: [makeEmail('a'), makeEmail('b')], + totalEmails: 2, + }); + + const client = makeVirtualIdClient(); + await useEmailStore.getState().fetchEmails(client); + + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a', 'b']); // not wiped + expect(fetchCrossViewMock).not.toHaveBeenCalled(); + expect(fetchUnifiedMock).not.toHaveBeenCalled(); + expect(client.getEmails).not.toHaveBeenCalled(); + }); + + it('a real folder still uses the single-mailbox getEmails path', async () => { + const client = ({ + getEmails: vi.fn(async () => ({ emails: [makeEmail('r')], hasMore: false, total: 1 })), + }) as unknown as IJMAPClient & { getEmails: ReturnType }; + useEmailStore.setState({ selectedMailbox: 'inbox', isUnifiedView: false, crossView: null, unifiedRole: null }); + + await useEmailStore.getState().fetchEmails(client, 'inbox'); + + expect(client.getEmails).toHaveBeenCalledTimes(1); + expect(fetchCrossViewMock).not.toHaveBeenCalled(); + expect(fetchUnifiedMock).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 9fa6a17f4..dc6cb260d 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1160,6 +1160,49 @@ export const useEmailStore = create((set, get) => ({ await get().fetchScheduledEmails(client); return; } + + // Unified / cross-account views use client-only virtual mailbox ids + // (`__cross_*`, `__unified_*`) that no JMAP server accepts as `inMailbox`. + // Route them through the fan-out loaders (mirrors loadMoreEmails / + // refreshCurrentMailbox) instead of sending the virtual id to getEmails, + // which the server rejects and the catch below then empties the list (#791). + if (isCrossViewId(targetMailboxId) || isUnifiedMailboxId(targetMailboxId)) { + const { crossView, unifiedRole, searchQuery, searchFilters } = get(); + // View state not resolved yet: don't send a virtual id and don't wipe + // the current list - just stop the loading state. + if (!crossView && !unifiedRole) { + set({ isLoading: false }); + return; + } + const emailsPerPage = useSettingsStore.getState().emailsPerPage; + const includeGroup = useSettingsStore.getState().includeGroupInUnified; + const built = await buildUnifiedAccountClients({ includeGroup }); + const hasFilters = !isFilterEmpty(searchFilters); + const result = crossView + ? (hasFilters + ? await advancedSearchCrossViewEmails(built, crossView, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0) + : searchQuery + ? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0) + : await fetchCrossViewEmails(built, crossView, emailsPerPage, 0)) + : (hasFilters + ? await advancedSearchUnifiedEmails(built, unifiedRole!, (mb) => buildJMAPFilter(searchQuery, searchFilters, mb), emailsPerPage, 0) + : searchQuery + ? await searchUnifiedEmails(built, unifiedRole!, searchQuery, emailsPerPage, 0) + : await fetchUnifiedEmails(built, unifiedRole!, emailsPerPage, 0)); + const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails); + set({ + emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId), + hasMoreEmails: result.hasMore, + totalEmails: result.total, + threadEmailsCache: new Map(), + expandedThreadIds: new Set(), + isLoadingThread: null, + isLoading: false, + unifiedErrors: result.errors, + }); + return; + } + const effectiveClient = resolveActionClient(client); // Find the mailbox to get its accountId (for shared folder support)