From ac234f288ea140a9b898106dd370989565779db8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 18 May 2026 20:05:03 +0200 Subject: [PATCH] Refactor email store to support incremental sync --- src/api/email.ts | 184 +++++++- src/stores/__tests__/email-store.test.ts | 43 +- src/stores/auth-store.ts | 82 +++- src/stores/email-store.ts | 564 ++++++++++++++++++++--- 4 files changed, 769 insertions(+), 104 deletions(-) diff --git a/src/api/email.ts b/src/api/email.ts index 362bdc66..2624748b 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -22,6 +22,55 @@ export async function getMailboxes(): Promise { return res.methodResponses[0][1].list; } +// Variant that also returns the JMAP `state` token so callers can later issue +// Mailbox/changes(sinceState=…) to fetch only what changed. +export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['Mailbox/get', { accountId }, '0']], + ); + const body = res.methodResponses[0][1]; + return { list: body.list as Mailbox[], state: body.state as string }; +} + +export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> { + if (ids.length === 0) return { list: [], state: '' }; + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['Mailbox/get', { accountId, ids }, '0']], + ); + const body = res.methodResponses[0][1]; + return { list: body.list as Mailbox[], state: body.state as string }; +} + +export interface MailboxChangesResult { + oldState: string; + newState: string; + hasMoreChanges: boolean; + created: string[]; + updated: string[]; + destroyed: string[]; +} + +// Returns null when the server can't compute the diff (typically +// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get. +export async function getMailboxChanges(sinceState: string): Promise { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['Mailbox/changes', { accountId, sinceState }, '0']], + ); + const [name, body] = res.methodResponses[0]; + if (name === 'error') return null; + return { + oldState: body.oldState as string, + newState: body.newState as string, + hasMoreChanges: Boolean(body.hasMoreChanges), + created: (body.created as string[]) ?? [], + updated: (body.updated as string[]) ?? [], + destroyed: (body.destroyed as string[]) ?? [], + }; +} + export async function createMailbox( data: { name: string; parentId?: string | null }, ): Promise { @@ -83,6 +132,22 @@ export async function deleteMailbox(id: string): Promise { } } +function buildMailboxQueryFilter( + mailboxId: string, + userFilter: Record | undefined, +): Record { + const inMailbox = { inMailbox: mailboxId }; + // JMAP filters are either a FilterCondition or a FilterOperator (operator + + // conditions) — never both. Spreading a FilterOperator next to `inMailbox` + // produces a hybrid object that servers reduce to the FilterCondition, + // silently dropping the operator's conditions (e.g. the "unread" toggle). + if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox; + if ('operator' in userFilter) { + return { operator: 'AND', conditions: [inMailbox, userFilter] }; + } + return { ...inMailbox, ...userFilter }; +} + export async function queryEmails( mailboxId: string, options?: { @@ -91,20 +156,9 @@ export async function queryEmails( sort?: Array<{ property: string; isAscending: boolean }>; filter?: Record; }, -): Promise<{ ids: string[]; total: number }> { +): Promise<{ ids: string[]; total: number; queryState?: string }> { const accountId = jmapClient.accountId; - const inMailbox = { inMailbox: mailboxId }; - const userFilter = options?.filter; - // JMAP filters are either a FilterCondition or a FilterOperator (operator + - // conditions) — never both. Spreading a FilterOperator next to `inMailbox` - // produces a hybrid object that servers reduce to the FilterCondition, - // silently dropping the operator's conditions (e.g. the "unread" toggle). - const filter = - !userFilter || Object.keys(userFilter).length === 0 - ? inMailbox - : 'operator' in userFilter - ? { operator: 'AND', conditions: [inMailbox, userFilter] } - : { ...inMailbox, ...userFilter }; + const filter = buildMailboxQueryFilter(mailboxId, options?.filter); const res = await jmapClient.request([ ['Email/query', { accountId, @@ -115,9 +169,56 @@ export async function queryEmails( calculateTotal: true, }, '0'], ]); + const body = res.methodResponses[0][1]; return { - ids: res.methodResponses[0][1].ids, - total: res.methodResponses[0][1].total, + ids: body.ids, + total: body.total, + queryState: body.queryState as string | undefined, + }; +} + +export interface EmailQueryChangesResult { + oldQueryState: string; + newQueryState: string; + total: number; + removed: string[]; + added: Array<{ id: string; index: number }>; +} + +// Run Email/queryChanges for the standard "by receivedAt desc, in this mailbox" +// query. Returns null when the server replies with `cannotCalculateChanges` +// (or any other error) — caller should fall back to a fresh Email/query. +export async function getEmailQueryChanges( + mailboxId: string, + sinceQueryState: string, + options?: { + sort?: Array<{ property: string; isAscending: boolean }>; + filter?: Record; + upToId?: string; + maxChanges?: number; + }, +): Promise { + const accountId = jmapClient.accountId; + const filter = buildMailboxQueryFilter(mailboxId, options?.filter); + const args: Record = { + accountId, + filter, + sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }], + sinceQueryState, + calculateTotal: true, + }; + if (options?.upToId) args.upToId = options.upToId; + if (options?.maxChanges) args.maxChanges = options.maxChanges; + + const res = await jmapClient.request([['Email/queryChanges', args, '0']]); + const [name, body] = res.methodResponses[0]; + if (name === 'error') return null; + return { + oldQueryState: body.oldQueryState as string, + newQueryState: body.newQueryState as string, + total: (body.total as number) ?? 0, + removed: (body.removed as string[]) ?? [], + added: (body.added as Array<{ id: string; index: number }>) ?? [], }; } @@ -129,6 +230,59 @@ export async function getEmails(ids: string[]): Promise { return res.methodResponses[0][1].list; } +// Returns the Email/get response with the JMAP `state` token. Used by the +// store so we can later issue Email/changes(sinceState=…) for incremental +// updates instead of re-fetching the full list. +export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> { + if (ids.length === 0) { + // Email/get with an empty id list still returns a state token; useful for + // priming the store after an empty mailbox query. + const accountId = jmapClient.accountId; + const res = await jmapClient.request([ + ['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'], + ]); + const body = res.methodResponses[0][1]; + return { list: [], state: body.state as string }; + } + const accountId = jmapClient.accountId; + const res = await jmapClient.request([ + ['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'], + ]); + const body = res.methodResponses[0][1]; + return { list: body.list as Email[], state: body.state as string }; +} + +export interface EmailChangesResult { + oldState: string; + newState: string; + hasMoreChanges: boolean; + created: string[]; + updated: string[]; + destroyed: string[]; +} + +// Returns null when the server replies with `cannotCalculateChanges` or any +// other error response; caller should treat that as "rebuild from scratch". +export async function getEmailChanges( + sinceState: string, + maxChanges?: number, +): Promise { + const accountId = jmapClient.accountId; + const args: Record = { accountId, sinceState }; + if (maxChanges) args.maxChanges = maxChanges; + const res = await jmapClient.request([['Email/changes', args, '0']]); + const [name, body] = res.methodResponses[0]; + if (name === 'error') return null; + return { + oldState: body.oldState as string, + newState: body.newState as string, + hasMoreChanges: Boolean(body.hasMoreChanges), + created: (body.created as string[]) ?? [], + updated: (body.updated as string[]) ?? [], + destroyed: (body.destroyed as string[]) ?? [], + }; +} + export async function getFullEmail(id: string): Promise { const accountId = jmapClient.accountId; const res = await jmapClient.request([ diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 5f202a43..098b425d 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -2,8 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../../api/email', () => ({ getMailboxes: vi.fn(), + // New helpers used by the incremental-sync path. Default behavior: behave + // like a first-ever load — no prior state, full re-query expected. + getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })), + getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })), + getMailboxChanges: vi.fn(async () => null), queryEmails: vi.fn(), + getEmailQueryChanges: vi.fn(async () => null), getEmails: vi.fn(), + getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })), + getEmailChanges: vi.fn(async () => null), getFullEmail: vi.fn(), setEmailKeywords: vi.fn(), moveEmail: vi.fn(), @@ -20,6 +28,23 @@ vi.mock('../settings-store', () => ({ useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) }, })); +// offline-cache-store is touched by selectMailbox (cache-seed fallback) and +// getEmailDetail (best-effort body refresh). Stub it as an empty cache so +// tests don't need to set up AsyncStorage. +vi.mock('../offline-cache-store', () => ({ + useOfflineCacheStore: { + getState: () => ({ + hydrated: true, + hydrate: vi.fn(), + totalCount: () => 0, + getEmailsInMailbox: vi.fn(async () => []), + has: () => false, + get: vi.fn(async () => null), + put: vi.fn(async () => undefined), + }), + }, +})); + // email-store now early-returns from fetch actions when jmapClient.isConnected // is false (cold-start guard). The tests exercise those actions, so present // a connected stub. @@ -28,15 +53,18 @@ vi.mock('../../api/jmap-client', () => ({ isConnected: true, accountId: 'acc-1', currentSession: { apiUrl: 'https://mail.example.com/jmap/' }, + // refreshEmails / loadMoreEmails chunk by this value when fetching ids. + getMaxObjectsInGet: () => 500, }, })); import * as emailApi from '../../api/email'; import { useEmailStore } from '../email-store'; -const mockGetMailboxes = emailApi.getMailboxes as ReturnType; +const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType; const mockQueryEmails = emailApi.queryEmails as ReturnType; const mockGetEmails = emailApi.getEmails as ReturnType; +const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType; const mockGetFullEmail = emailApi.getFullEmail as ReturnType; const mockSetKeywords = emailApi.setEmailKeywords as ReturnType; const mockMoveEmail = emailApi.moveEmail as ReturnType; @@ -55,7 +83,7 @@ describe('email-store', () => { { id: 'mb-1', name: 'Inbox', role: 'inbox' }, { id: 'mb-2', name: 'Sent', role: 'sent' }, ]; - mockGetMailboxes.mockResolvedValue(mailboxes); + mockGetMailboxesWithState.mockResolvedValue({ list: mailboxes, state: 'mb-state-1' }); await useEmailStore.getState().fetchMailboxes(); @@ -63,7 +91,7 @@ describe('email-store', () => { }); it('should set error on failure', async () => { - mockGetMailboxes.mockRejectedValue(new Error('Network error')); + mockGetMailboxesWithState.mockRejectedValue(new Error('Network error')); await useEmailStore.getState().fetchMailboxes(); @@ -73,12 +101,12 @@ describe('email-store', () => { describe('selectMailbox', () => { it('should query and fetch emails for mailbox', async () => { - mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2 }); + mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2, queryState: 'q-1' }); const emails = [ { id: 'e1', subject: 'Email 1' }, { id: 'e2', subject: 'Email 2' }, ]; - mockGetEmails.mockResolvedValue(emails); + mockGetEmailsWithState.mockResolvedValue({ list: emails, state: 'em-state-1' }); await useEmailStore.getState().selectMailbox('mb-1'); @@ -90,11 +118,14 @@ describe('email-store', () => { }); it('should handle empty mailbox', async () => { - mockQueryEmails.mockResolvedValue({ ids: [], total: 0 }); + mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q-empty' }); await useEmailStore.getState().selectMailbox('mb-empty'); expect(useEmailStore.getState().emails).toEqual([]); + // First-time load with no ids hits getEmailsWithState only to prime + // emailState (with an empty ids array). It should NOT call legacy + // getEmails — that path is reserved for pagination/search. expect(mockGetEmails).not.toHaveBeenCalled(); }); }); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 86823a95..4cc02735 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -46,8 +46,28 @@ export interface AuthState { clearError: () => void; } -function resetFeatureStores(): void { - useEmailStore.getState().reset(); +// Wipe ALL cached feature data for ALL accounts. Used for logoutAll where +// the user is signing out of everything — we don't want stale snapshots +// lingering on disk for accounts that no longer exist. +function clearAllFeatureStores(): void { + useEmailStore.getState().clearAllAccounts(); + useContactsStore.getState().reset(); + useCalendarStore.getState().reset(); +} + +// Drop the named account from the email cache, then reset the (per-session, +// not yet per-account) contacts and calendar stores. Used by logout when +// signing one account out while others remain. +function clearAccountFeatureStores(accountId: string | null): void { + if (accountId) { + useEmailStore.getState().removeAccount(accountId); + } else { + useEmailStore.getState().clearAllAccounts(); + } + // Contacts and calendar stores aren't yet keyed by account — the safe + // thing on logout is still to wipe them so the next account doesn't see + // the previous user's data. Per-account caching for those stores is a + // follow-up. useContactsStore.getState().reset(); useCalendarStore.getState().reset(); } @@ -108,11 +128,13 @@ export const useAuthStore = create((set, get) => ({ login: async (serverUrl, username, password, opts) => { set({ isLoading: true, error: null }); try { - // Adding an additional account - snapshot/reset feature stores so the - // new account starts with a clean slate. + // Adding an additional account - snapshot the current account away so + // its cache survives, then reset the JMAP client for the new login. + // Contacts/calendar are still single-bucket, so wipe those. if (opts?.addAccount && get().isAuthenticated) { jmapClient.reset(); - resetFeatureStores(); + useContactsStore.getState().reset(); + useCalendarStore.getState().reset(); } const session = await jmapClient.connect(serverUrl, username, password); @@ -129,6 +151,9 @@ export const useAuthStore = create((set, get) => ({ hasError: false, }); accountStore.setActiveAccount(accountId); + // Swap the email store's active view to the new account so the rest of + // this function (and refetchFeatureStores) writes to the right bucket. + useEmailStore.getState().setActiveAccount(accountId); applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId); } catch (err) { @@ -164,7 +189,7 @@ export const useAuthStore = create((set, get) => ({ } jmapClient.reset(); - resetFeatureStores(); + clearAccountFeatureStores(currentId); // Switch to next remaining account, if any const remaining = accountStore.accounts; @@ -198,7 +223,7 @@ export const useAuthStore = create((set, get) => ({ await teardownPushNotifications().catch(() => undefined); await jmapClient.clearAllCredentials(ids); jmapClient.reset(); - resetFeatureStores(); + clearAllFeatureStores(); for (const id of ids) accountStore.removeAccount(id); @@ -225,19 +250,31 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }); - // Load the new account's session BEFORE clearing jmapClient or feature - // stores. The previous order (reset → resetFeatureStores → loadAccount) - // left jmapClient with no accountId during the loadAccount await, and - // any fetch fired by a screen effect reacting to the emptied stores - // (e.g. EmailListScreen retrying fetchMailboxes when mailboxes goes to - // []) threw "Not authenticated - call connect() first". loadAccount - // overwrites credentials/session/_accountId itself, so the pre-reset - // is unnecessary. + // Swap the email-store view to the new account *before* the network + // round-trip. The previous account's data is tucked into its snapshot; + // the new account's data (if previously cached) is restored to the + // top-level fields so the EmailListScreen immediately shows the new + // account's last-known mail instead of flashing empty. The network + // refresh below applies incremental updates on top. + useEmailStore.getState().setActiveAccount(accountId); + + // Contacts and calendar stores aren't yet per-account, so they still + // need a reset to avoid showing the previous account's data. + useContactsStore.getState().reset(); + useCalendarStore.getState().reset(); + + // Load the new account's session. loadAccount overwrites + // credentials/session/_accountId itself, so we don't need to reset + // jmapClient first. If it fails, restore the previous active account + // so we don't leave the user stranded on a half-switched state. + const previousActive = get().activeAccountId; try { const ok = await jmapClient.loadAccount(accountId); if (!ok) { // Credentials missing - evict stale entry and surface error accountStore.removeAccount(accountId); + useEmailStore.getState().removeAccount(accountId); + if (previousActive) useEmailStore.getState().setActiveAccount(previousActive); set({ isLoading: false, error: 'Session expired for this account' }); return; } @@ -245,11 +282,14 @@ export const useAuthStore = create((set, get) => ({ if (err instanceof AuthenticationError) { await jmapClient.clearAccountCredentials(accountId).catch(() => undefined); accountStore.removeAccount(accountId); + useEmailStore.getState().removeAccount(accountId); + if (previousActive) useEmailStore.getState().setActiveAccount(previousActive); set({ isLoading: false, error: 'Session expired for this account' }); return; } // NetworkError or anything else - keep the previous active account // intact instead of stranding the user on a half-switched state. + if (previousActive) useEmailStore.getState().setActiveAccount(previousActive); set({ isLoading: false, error: err instanceof Error ? err.message : 'Failed to switch account', @@ -257,10 +297,6 @@ export const useAuthStore = create((set, get) => ({ return; } - // Session is in place under the new account. Safe now to wipe the - // previous account's cached feature data; the refetch below repopulates. - resetFeatureStores(); - accountStore.setActiveAccount(accountId); accountStore.updateAccount(accountId, { isConnected: true, @@ -320,11 +356,18 @@ export const useAuthStore = create((set, get) => ({ return false; } + // Point the email store at the target account before any await — so + // the EmailListScreen, which re-renders the moment the persisted state + // hydrates, sees the right account's cached emails instead of stale + // data from a previous session. + useEmailStore.getState().setActiveAccount(target.id); + try { const ok = await jmapClient.loadAccount(target.id); if (!ok) { // No stored credentials (or corrupt) — genuine logout. accountStore.removeAccount(target.id); + useEmailStore.getState().removeAccount(target.id); set({ isLoading: false, hasRestoredSession: true }); return false; } @@ -358,6 +401,7 @@ export const useAuthStore = create((set, get) => ({ // Server reachable but credentials rejected — drop them. await jmapClient.clearAccountCredentials(target.id).catch(() => undefined); accountStore.removeAccount(target.id); + useEmailStore.getState().removeAccount(target.id); set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' }); return false; } diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index a7b25ea4..112b7322 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -5,8 +5,14 @@ import type { Email, Mailbox, StateChange } from '../api/types'; import { jmapClient } from '../api/jmap-client'; import { getMailboxes as fetchMailboxes, + getMailboxesWithState, + getMailboxesByIds, + getMailboxChanges, queryEmails, + getEmailQueryChanges, getEmails as fetchEmails, + getEmailsWithState, + getEmailChanges, getFullEmail, setEmailKeywords, moveEmail, @@ -43,17 +49,56 @@ export interface UndoEntry { items: Array<{ email: Email; originalMailboxIds: Record }>; } -export interface EmailState { +// Cached emails for one mailbox (the base view: no search query, no filters). +// `queryState` is the JMAP queryState for the matching Email/query, used to +// drive Email/queryChanges on the next refresh. +interface MailboxSnapshot { + emails: Email[]; + total: number; + queryState?: string; +} + +// Everything we cache for one account so switching accounts can restore the +// previous view instantly instead of going through a network round-trip. +interface AccountSnapshot { mailboxes: Mailbox[]; + mailboxState?: string; // JMAP Mailbox state (drives Mailbox/changes) + emailState?: string; // JMAP Email state (drives Email/changes) currentMailboxId: string | null; + mailboxSnapshots: Record; +} + +export interface EmailState { + // ── Per-account persisted caches ────────────────────────────── + // accountSnapshots is the source of truth for accounts the user is *not* + // currently viewing. The active account's data lives in the top-level + // fields below (`mailboxes`, `mailboxSnapshots`, `mailboxState`, `emailState`, + // `currentMailboxId`, `emails`, `totalEmails`, `queryState`) so consumers + // keep reading the same shape they always have. + accountSnapshots: Record; + activeAccountId: string | null; + + // ── Active view (the currently-shown account/mailbox) ───────── + mailboxes: Mailbox[]; + mailboxState?: string; + emailState?: string; + currentMailboxId: string | null; + mailboxSnapshots: Record; emails: Email[]; totalEmails: number; + queryState?: string; // queryState for the currently-shown mailbox + + // ── UI state (not persisted, not per-account) ───────────────── loading: boolean; error: string | null; searchQuery: string; filters: EmailFilters; pendingUndo: UndoEntry | null; + // ── Actions ──────────────────────────────────────────────────── + setActiveAccount: (accountId: string | null) => void; + removeAccount: (accountId: string) => void; + clearAllAccounts: () => void; fetchMailboxes: () => Promise; selectMailbox: (mailboxId: string) => Promise; loadMoreEmails: () => Promise; @@ -115,19 +160,207 @@ function buildJmapFilter( return { operator: 'AND', conditions }; } +// True when the user has no search/filters active. Only in this case do we +// touch the per-mailbox snapshot cache or use Email/queryChanges — once a +// filter is in play, the queryState belongs to a different query and the +// cached list no longer represents what's on screen. +function isBaseView(searchQuery: string, filters: EmailFilters): boolean { + return !searchQuery.trim() && Object.keys(filters).length === 0; +} + +function snapshotFromActive(state: EmailState): AccountSnapshot { + // Persist the currently-visible mailbox into its snapshot before tucking + // the whole account away. + let mailboxSnapshots = state.mailboxSnapshots; + if (state.currentMailboxId && isBaseView(state.searchQuery, state.filters)) { + mailboxSnapshots = { + ...mailboxSnapshots, + [state.currentMailboxId]: { + emails: state.emails, + total: state.totalEmails, + queryState: state.queryState, + }, + }; + } + return { + mailboxes: state.mailboxes, + mailboxState: state.mailboxState, + emailState: state.emailState, + currentMailboxId: state.currentMailboxId, + mailboxSnapshots, + }; +} + +function viewFromSnapshot(snap: AccountSnapshot | null): { + mailboxes: Mailbox[]; + mailboxState?: string; + emailState?: string; + currentMailboxId: string | null; + mailboxSnapshots: Record; + emails: Email[]; + totalEmails: number; + queryState?: string; +} { + if (!snap) { + return { + mailboxes: [], + mailboxState: undefined, + emailState: undefined, + currentMailboxId: null, + mailboxSnapshots: {}, + emails: [], + totalEmails: 0, + queryState: undefined, + }; + } + const mailboxSnap = snap.currentMailboxId + ? snap.mailboxSnapshots[snap.currentMailboxId] + : undefined; + return { + mailboxes: snap.mailboxes, + mailboxState: snap.mailboxState, + emailState: snap.emailState, + currentMailboxId: snap.currentMailboxId, + mailboxSnapshots: snap.mailboxSnapshots, + emails: mailboxSnap?.emails ?? [], + totalEmails: mailboxSnap?.total ?? 0, + queryState: mailboxSnap?.queryState, + }; +} + +// JMAP's maxObjectsInGet bounds how many ids we can pull in one Email/get. +// Chunk to that ceiling (with a small safety fallback) so large change sets +// don't trip 429/413 responses. +async function fetchEmailsChunked(ids: string[]): Promise { + if (ids.length === 0) return []; + const cap = Math.max(1, jmapClient.getMaxObjectsInGet()); + const chunk = Math.min(cap, 200); + if (ids.length <= chunk) return fetchEmails(ids); + const out: Email[] = []; + for (let i = 0; i < ids.length; i += chunk) { + const slice = await fetchEmails(ids.slice(i, i + chunk)); + out.push(...slice); + } + return out; +} + +// Merge a fresh batch of emails into an existing list keyed by id. New entries +// replace stale ones (keywords/mailboxIds may have changed); destroyed ids are +// dropped. Order is preserved according to the supplied id order — pass the +// authoritative id list from Email/query when re-syncing. +function applyEmailDiff( + current: Email[], + orderedIds: string[], + fetched: Email[], + destroyed: Set, +): Email[] { + const byId = new Map(); + for (const e of current) byId.set(e.id, e); + for (const e of fetched) byId.set(e.id, e); + const out: Email[] = []; + for (const id of orderedIds) { + if (destroyed.has(id)) continue; + const e = byId.get(id); + if (e) out.push(e); + } + return out; +} + export const useEmailStore = create()( persist( (set, get) => ({ + accountSnapshots: {}, + activeAccountId: null, + mailboxes: [], + mailboxState: undefined, + emailState: undefined, currentMailboxId: null, + mailboxSnapshots: {}, emails: [], totalEmails: 0, + queryState: undefined, + loading: false, error: null, searchQuery: '', filters: {}, pendingUndo: null, + // Swap which account's data is currently visible. The previous account's + // view is tucked into accountSnapshots so a return-trip can restore it + // without a network call; the new account's view is pulled from its + // snapshot (or empty defaults if we've never seen it). Callers (auth-store) + // run the network refresh afterwards. + setActiveAccount: (accountId) => { + const state = get(); + if (state.activeAccountId === accountId) return; + + const nextSnapshots = { ...state.accountSnapshots }; + if (state.activeAccountId) { + nextSnapshots[state.activeAccountId] = snapshotFromActive(state); + } + const incoming = accountId ? nextSnapshots[accountId] ?? null : null; + const view = viewFromSnapshot(incoming); + + set({ + accountSnapshots: nextSnapshots, + activeAccountId: accountId, + ...view, + // UI state is reset on switch — search/filters and pending undo belong + // to the previous account's intent. + searchQuery: '', + filters: {}, + pendingUndo: null, + error: null, + loading: false, + }); + }, + + removeAccount: (accountId) => { + const state = get(); + const { [accountId]: _drop, ...rest } = state.accountSnapshots; + if (state.activeAccountId === accountId) { + set({ + accountSnapshots: rest, + activeAccountId: null, + mailboxes: [], + mailboxState: undefined, + emailState: undefined, + currentMailboxId: null, + mailboxSnapshots: {}, + emails: [], + totalEmails: 0, + queryState: undefined, + searchQuery: '', + filters: {}, + pendingUndo: null, + }); + } else { + set({ accountSnapshots: rest }); + } + }, + + clearAllAccounts: () => { + set({ + accountSnapshots: {}, + activeAccountId: null, + mailboxes: [], + mailboxState: undefined, + emailState: undefined, + currentMailboxId: null, + mailboxSnapshots: {}, + emails: [], + totalEmails: 0, + queryState: undefined, + searchQuery: '', + filters: {}, + pendingUndo: null, + error: null, + loading: false, + }); + }, + fetchMailboxes: async () => { // Skip silently when there's no live session. Screens fire this from // mount-time useEffects, and on cold start App.tsx renders MainTabs @@ -136,48 +369,118 @@ export const useEmailStore = create()( // surface as a user-visible error before the real fetch (kicked off by // refetchFeatureStores() once the session is live) replaces it. if (!jmapClient.isConnected) return; + + const prevState = get().mailboxState; try { - const mailboxes = await fetchMailboxes(); - set({ mailboxes }); + // Incremental path: ask for just what changed since last time. Fall + // through to a full refetch when the server can't compute the diff or + // we have no previous state to compare against. + if (prevState) { + const changes = await getMailboxChanges(prevState); + if (changes) { + // No changes at all — keep the cached list, just bump the state. + if ( + changes.created.length === 0 && + changes.updated.length === 0 && + changes.destroyed.length === 0 + ) { + set({ mailboxState: changes.newState }); + return; + } + const toFetch = [...changes.created, ...changes.updated]; + const fetched = toFetch.length > 0 + ? (await getMailboxesByIds(toFetch)).list + : []; + const destroyed = new Set(changes.destroyed); + const byId = new Map(); + for (const m of get().mailboxes) byId.set(m.id, m); + for (const m of fetched) byId.set(m.id, m); + for (const id of destroyed) byId.delete(id); + set({ + mailboxes: Array.from(byId.values()), + mailboxState: changes.hasMoreChanges ? prevState : changes.newState, + }); + // hasMoreChanges = there are still pending changes past the + // server's response cap. Run the same path again to drain. + if (changes.hasMoreChanges) { + void get().fetchMailboxes(); + } + return; + } + // changes === null → cannotCalculateChanges. Fall through to full. + } + + const { list, state } = await getMailboxesWithState(); + set({ mailboxes: list, mailboxState: state }); } catch (err) { console.warn('[email-store] fetchMailboxes failed:', err); - set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' }); + // Don't overwrite the cached list on a transient failure — the user + // can still navigate folders. Only surface the error when we have no + // mailboxes at all to show. + if (get().mailboxes.length === 0) { + set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' }); + } } }, selectMailbox: async (mailboxId) => { - // Reset search/filters when switching mailbox - matches webmail behavior. - // Switching mailboxes invalidates any pending undo: the snackbar would be - // stale and the restored email would re-appear in a different view. + const state = get(); + // Tuck the previously-visible mailbox into its snapshot so a return-trip + // can restore it without a network call. Only do this for the base view — + // a filter or search makes the visible list unrepresentative of the + // cached "no-filter" snapshot. + let mailboxSnapshots = state.mailboxSnapshots; + if ( + state.currentMailboxId && + state.currentMailboxId !== mailboxId && + isBaseView(state.searchQuery, state.filters) + ) { + mailboxSnapshots = { + ...mailboxSnapshots, + [state.currentMailboxId]: { + emails: state.emails, + total: state.totalEmails, + queryState: state.queryState, + }, + }; + } + + const incoming = mailboxSnapshots[mailboxId]; + // Swap to the new mailbox's cached view immediately. If there's no + // snapshot, fall through to the offline cache as a second-best seed; + // if that's also empty we render the empty-state, not a spinner over + // a blank list — better than the previous flash to "Loading…". + let seededEmails: Email[] = incoming?.emails ?? []; + let seededTotal = incoming?.total ?? 0; + let seededQueryState = incoming?.queryState; + + if (seededEmails.length === 0) { + const cacheStore = useOfflineCacheStore.getState(); + if (!cacheStore.hydrated) await cacheStore.hydrate(); + if (cacheStore.totalCount() > 0) { + try { + const limit = useSettingsStore.getState().emailsPerPage; + seededEmails = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50)); + seededTotal = seededEmails.length; + } catch (err) { + console.warn('[email-store] cache seed failed:', err); + } + } + } + set({ currentMailboxId: mailboxId, + emails: seededEmails, + totalEmails: seededTotal, + queryState: seededQueryState, + mailboxSnapshots, loading: true, error: null, - emails: [], - totalEmails: 0, searchQuery: '', filters: {}, pendingUndo: null, }); - // Seed from the offline cache immediately so changing folders shows - // something even before the network responds — and is the fallback - // when offline. The network result will replace this once it arrives. - const cacheStore = useOfflineCacheStore.getState(); - if (!cacheStore.hydrated) await cacheStore.hydrate(); - const limit = useSettingsStore.getState().emailsPerPage; - let cached: Email[] = []; - if (cacheStore.totalCount() > 0) { - try { - cached = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50)); - if (cached.length > 0 && get().currentMailboxId === mailboxId) { - set({ emails: cached, totalEmails: cached.length }); - } - } catch (err) { - console.warn('[email-store] cache seed failed:', err); - } - } - // Stop here if there's no live session yet — the cached seed already // gave the user something to look at, and the refetch driven by // restoreSession() / switchAccount will run the network half once the @@ -187,25 +490,7 @@ export const useEmailStore = create()( return; } - try { - const filter = buildJmapFilter(mailboxId, '', {}); - const { ids, total } = await queryEmails(mailboxId, { limit, filter }); - const emails = ids.length > 0 ? await fetchEmails(ids) : []; - // Race guard: don't overwrite if the user already moved to a different - // mailbox while this request was in flight. - if (get().currentMailboxId !== mailboxId) return; - set({ emails, totalEmails: total, loading: false }); - } catch (err) { - console.warn('[email-store] selectMailbox failed:', err); - if (get().currentMailboxId !== mailboxId) return; - // Keep whatever the cache seeded with; only surface the error when the - // cache is empty. With cached emails visible the OfflineBanner already - // tells the user the data is stale. - set({ - loading: false, - error: cached.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'), - }); - } + await get().refreshEmails(); }, loadMoreEmails: async () => { @@ -222,37 +507,174 @@ export const useEmailStore = create()( limit, filter, }); - const newEmails = ids.length > 0 ? await fetchEmails(ids) : []; - set({ emails: [...emails, ...newEmails], loading: false }); + const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids) : []; + const merged = [...emails, ...newEmails]; + const updates: Partial = { emails: merged, loading: false }; + if (isBaseView(searchQuery, filters)) { + updates.mailboxSnapshots = { + ...get().mailboxSnapshots, + [currentMailboxId]: { + emails: merged, + total: get().totalEmails, + queryState: get().queryState, + }, + }; + } + set(updates); } catch (err) { set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load more' }); } }, refreshEmails: async () => { - const { currentMailboxId, searchQuery, filters, emails: existing } = get(); + const state = get(); + const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState } = state; if (!currentMailboxId) return; if (!jmapClient.isConnected) return; - // Keep existing emails visible while refreshing - swap atomically once - // the new list is ready so pull-to-refresh doesn't flash an empty list. set({ loading: true, error: null }); + + const filter = buildJmapFilter(currentMailboxId, searchQuery, filters); + const limit = useSettingsStore.getState().emailsPerPage; + const baseView = isBaseView(searchQuery, filters); + try { - const filter = buildJmapFilter(currentMailboxId, searchQuery, filters); - const limit = useSettingsStore.getState().emailsPerPage; - const { ids, total } = await queryEmails(currentMailboxId, { limit, filter }); - const emails = ids.length > 0 ? await fetchEmails(ids) : []; - set({ emails, totalEmails: total, loading: false }); + // Incremental sync path: requires the base unfiltered view AND a known + // queryState (so Email/queryChanges has something to diff against). + // Anything else — search, filter active, first-ever load — falls + // through to a full re-query. + if (baseView && queryState) { + const queryChanges = await getEmailQueryChanges(currentMailboxId, queryState, { + filter: undefined, + }); + if (queryChanges) { + // What's in the visible window now: drop removed ids, then apply + // added (id, index) entries. Newly added ids need bodies fetched. + const removed = new Set(queryChanges.removed); + const addedIds = queryChanges.added.map((a) => a.id); + + // Email/changes catches updates to messages already in our list + // (e.g. another device toggled $seen) that queryChanges wouldn't + // report. Skipped when we have no emailState yet — first refresh + // after a cold start primes it from the Email/get below. + let updatedIds: string[] = []; + let destroyedExtra: string[] = []; + let nextEmailState: string | undefined = emailState; + if (emailState) { + const ec = await getEmailChanges(emailState); + if (ec) { + updatedIds = ec.updated; + destroyedExtra = ec.destroyed; + nextEmailState = ec.newState; + } else { + // cannotCalculateChanges → forget the state and rely on the + // next full re-sync to repopulate it. + nextEmailState = undefined; + } + } + + // Fetch only what we don't already have. `addedIds` are new to the + // window; `updatedIds` may already be in `existing` but their + // keywords/mailboxIds need refreshing. + const existingById = new Map(existing.map((e) => [e.id, e])); + const idsToFetch = [ + ...addedIds.filter((id) => !existingById.has(id)), + ...updatedIds.filter((id) => existingById.has(id)), + ]; + let fetchState: string | undefined; + let fetched: Email[] = []; + if (idsToFetch.length > 0) { + const res = await getEmailsWithState(idsToFetch); + fetched = res.list; + fetchState = res.state; + } + + // Rebuild the visible window order: start with existing emails, + // drop removed/destroyed, then splice added at their indices. + const allDestroyed = new Set([...destroyedExtra, ...removed]); + const kept = existing.filter((e) => !allDestroyed.has(e.id)); + // Map updated entries onto kept array + const fetchedById = new Map(fetched.map((e) => [e.id, e])); + const updatedKept = kept.map((e) => fetchedById.get(e.id) ?? e); + + // Insert added entries at the indices the server gave us. Sort + // ascending by index so each splice lands at the right offset. + const sortedAdded = [...queryChanges.added].sort((a, b) => a.index - b.index); + const out = [...updatedKept]; + for (const entry of sortedAdded) { + const email = fetchedById.get(entry.id); + if (!email) continue; + const idx = Math.min(entry.index, out.length); + out.splice(idx, 0, email); + } + // Cap the visible list to the user's page size — Email/queryChanges + // can push entries past the original window if many were added. + const trimmed = out.slice(0, Math.max(limit, out.length)); + + const nextQueryState = queryChanges.newQueryState; + const nextTotal = queryChanges.total; + + if (get().currentMailboxId !== currentMailboxId) return; + + set({ + emails: trimmed, + totalEmails: nextTotal, + queryState: nextQueryState, + emailState: nextEmailState ?? fetchState ?? emailState, + loading: false, + mailboxSnapshots: { + ...get().mailboxSnapshots, + [currentMailboxId]: { + emails: trimmed, + total: nextTotal, + queryState: nextQueryState, + }, + }, + }); + return; + } + // queryChanges === null → cannotCalculateChanges. Drop our queryState + // and fall through to a full re-query, which will repopulate it. + } + + // Full re-query path. Used when there's no prior queryState, when the + // user has search/filters active (queryState only tracks the base + // query), or when the server returned cannotCalculateChanges above. + const queryRes = await queryEmails(currentMailboxId, { limit, filter }); + const fetched = queryRes.ids.length > 0 + ? await getEmailsWithState(queryRes.ids) + : { list: [], state: undefined as string | undefined }; + + if (get().currentMailboxId !== currentMailboxId) return; + + const updates: Partial = { + emails: fetched.list, + totalEmails: queryRes.total, + loading: false, + }; + if (baseView) { + updates.queryState = queryRes.queryState; + updates.emailState = fetched.state; + updates.mailboxSnapshots = { + ...get().mailboxSnapshots, + [currentMailboxId]: { + emails: fetched.list, + total: queryRes.total, + queryState: queryRes.queryState, + }, + }; + } + set(updates); } catch (err) { console.warn('[email-store] refreshEmails failed:', err); - // If the list was empty before the refresh attempt, fall back to the - // offline cache so the user still sees something. Otherwise keep what - // they had visible and let the OfflineBanner explain. + if (get().currentMailboxId !== currentMailboxId) return; + // Keep whatever's visible; only surface the error when the list is + // empty. With cached emails on screen the OfflineBanner already + // tells the user the data is stale. if (existing.length === 0) { try { const cacheStore = useOfflineCacheStore.getState(); if (!cacheStore.hydrated) await cacheStore.hydrate(); if (cacheStore.totalCount() > 0) { - const limit = useSettingsStore.getState().emailsPerPage; const cached = await cacheStore.getEmailsInMailbox( currentMailboxId, Math.max(limit, 50), @@ -266,7 +688,10 @@ export const useEmailStore = create()( console.warn('[email-store] refresh cache fallback failed:', cacheErr); } } - set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' }); + set({ + loading: false, + error: existing.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'), + }); } }, @@ -527,9 +952,13 @@ export const useEmailStore = create()( reset: () => set({ mailboxes: [], + mailboxState: undefined, + emailState: undefined, currentMailboxId: null, + mailboxSnapshots: {}, emails: [], totalEmails: 0, + queryState: undefined, loading: false, error: null, searchQuery: '', @@ -537,16 +966,23 @@ export const useEmailStore = create()( }), }), { - // Persist the mail list so the UI can render instantly on re-open, - // before the JMAP session has finished restoring. auth-store triggers - // a background refresh once the session is ready. + // Persist the per-account caches and the active view so the UI can + // render instantly on re-open / account switch, before the JMAP + // session has finished restoring. auth-store triggers a background + // refresh once the session is ready. name: 'email-cache', storage: createJSONStorage(() => AsyncStorage), partialize: (state) => ({ + accountSnapshots: state.accountSnapshots, + activeAccountId: state.activeAccountId, mailboxes: state.mailboxes, + mailboxState: state.mailboxState, + emailState: state.emailState, currentMailboxId: state.currentMailboxId, + mailboxSnapshots: state.mailboxSnapshots, emails: state.emails, totalEmails: state.totalEmails, + queryState: state.queryState, }), }, ),