From e37621be5edfd2ec04f014ffe2498838655c152f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:46:08 +0200 Subject: [PATCH] fix: cap the cached mail list so android can still read it back The whole email cache is one AsyncStorage row, and Android can't read a row over ~2 MB. The row held every window the user had paged through, in every folder of every account, stored the open folder twice (as `emails` and in `mailboxSnapshots`), kept a stale copy of the active account in `accountSnapshots`, and rows seeded from the offline body cache carried whole message bodies. Once it outgrew the limit the next cold start could not load it. - the active view is persisted the way an account switch tucks it away (`snapshotFromActive`), so the open folder is stored once, and `merge` rebuilds `emails` from it with `viewFromSnapshot` - `boundEmailCache` keeps at most 100 rows per folder (the largest page size, so the next refresh can still patch the window) and 500 overall, the open folder first; a window is cut to a prefix, which keeps its queryState valid, and a folder past the budget is dropped - body parts are stripped from persisted rows and the active account's stale entry is left out of `accountSnapshots` 500 typical rows serialise to about 0.3 MB. --- .../__tests__/email-cache-persist.test.ts | 186 ++++++++++++++++++ src/stores/email-cache-persist.ts | 82 ++++++++ src/stores/email-store.ts | 24 +-- 3 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 src/stores/__tests__/email-cache-persist.test.ts create mode 100644 src/stores/email-cache-persist.ts diff --git a/src/stores/__tests__/email-cache-persist.test.ts b/src/stores/__tests__/email-cache-persist.test.ts new file mode 100644 index 00000000..aa4795a2 --- /dev/null +++ b/src/stores/__tests__/email-cache-persist.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import type { Email } from '../../api/types'; +import { + boundEmailCache, + PERSISTED_ROWS_PER_FOLDER, + PERSISTED_ROWS_TOTAL, + type PersistedEmailCache, +} from '../email-cache-persist'; +import { useEmailStore, type AccountSnapshot, type EmailState } from '../email-store'; + +function row(id: string, extra: Partial = {}): Email { + return { + id, + threadId: `t-${id}`, + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 4096, + receivedAt: '2026-09-01T10:00:00Z', + from: [{ name: 'Jane Doe', email: 'jane.doe@example.com' }], + to: [{ name: 'Max Mustermann', email: 'max@example.org' }], + subject: `Quarterly report ${id}`, + preview: 'Hi Max, attached is the quarterly report. Let me know if the numbers look right to you. '.repeat(3), + hasAttachment: false, + ...extra, + }; +} + +function rows(prefix: string, n: number): Email[] { + return Array.from({ length: n }, (_, i) => row(`${prefix}${i}`)); +} + +function account(snaps: Record, currentMailboxId: string | null = null): AccountSnapshot { + return { + mailboxes: [], + emailStates: {}, + currentMailboxId, + mailboxSnapshots: Object.fromEntries( + Object.entries(snaps).map(([id, emails]) => [id, { emails, total: 1000, queryState: `q-${id}` }]), + ), + }; +} + +function cache(active: AccountSnapshot, others: Record = {}): PersistedEmailCache { + return { ...active, accountSnapshots: others, activeAccountId: 'me' }; +} + +const rowCount = (a: AccountSnapshot) => + Object.values(a.mailboxSnapshots).reduce((n, s) => n + s.emails.length, 0); + +describe('boundEmailCache', () => { + it('keeps at most a page-sized prefix per folder, with its queryState and total', () => { + const inbox = rows('i', 250); + const out = boundEmailCache(cache(account({ inbox }, 'inbox'))); + const snap = out.mailboxSnapshots.inbox; + expect(snap.emails).toHaveLength(PERSISTED_ROWS_PER_FOLDER); + expect(snap.emails.map((e) => e.id)).toEqual(inbox.slice(0, PERSISTED_ROWS_PER_FOLDER).map((e) => e.id)); + expect(snap.total).toBe(1000); + expect(snap.queryState).toBe('q-inbox'); + }); + + it('fills the current folder first and drops folders past the overall budget', () => { + const folders: Record = {}; + for (let i = 0; i < 8; i++) folders[`f${i}`] = rows(`f${i}-`, 100); + folders.empty = []; + const active = account({ ...folders, current: rows('c', 100) }, 'current'); + const other = account({ inbox: rows('o', 100) }, 'inbox'); + + const out = boundEmailCache(cache(active, { them: other })); + + expect(out.mailboxSnapshots.current.emails).toHaveLength(100); + expect(out.mailboxSnapshots.empty).toEqual({ emails: [], total: 1000, queryState: 'q-empty' }); + expect(rowCount(out) + rowCount(out.accountSnapshots.them)).toBe(PERSISTED_ROWS_TOTAL); + expect(Object.keys(out.mailboxSnapshots)).toHaveLength(6); + expect(out.accountSnapshots.them.mailboxSnapshots).toEqual({}); + }); + + it('leaves out the stale copy of the active account', () => { + const out = boundEmailCache( + cache(account({ inbox: rows('i', 3) }, 'inbox'), { me: account({ inbox: rows('old', 3) }), them: account({}) }), + ); + expect(Object.keys(out.accountSnapshots)).toEqual(['them']); + }); + + it('drops message bodies from rows seeded out of the offline cache', () => { + const full = row('full', { + bodyValues: { 1: { value: 'x'.repeat(100_000) } }, + htmlBody: [{ partId: '1', type: 'text/html' } as never], + textBody: [], + attachments: [], + headers: [{ name: 'Subject', value: 'hi' }], + bodyStructure: { partId: '1', type: 'text/html' } as never, + }); + const [persisted] = boundEmailCache(cache(account({ inbox: [full] }, 'inbox'))).mailboxSnapshots.inbox.emails; + expect(Object.keys(persisted).sort()).toEqual(Object.keys(row('x')).sort()); + }); + + it('keeps a full cache well under the ~2 MB Android can read back', () => { + const folders: Record = {}; + for (let i = 0; i < 10; i++) folders[`f${i}`] = rows(`f${i}-`, 300); + const json = JSON.stringify(boundEmailCache(cache(account(folders, 'f0')))); + expect(json.length).toBeLessThan(1_000_000); + }); +}); + +describe('email-store persistence', () => { + const { partialize, merge } = useEmailStore.persist.getOptions(); + + function viewState(overrides: Partial): EmailState { + return { + ...useEmailStore.getInitialState(), + activeAccountId: 'me', + currentMailboxId: 'inbox', + ...overrides, + }; + } + + it('stores the open folder once, from the live list', () => { + const live = rows('live', 3); + const persisted = partialize!(viewState({ + emails: live, + totalEmails: 42, + queryState: 'q2', + mailboxSnapshots: { inbox: { emails: rows('old', 3), total: 40, queryState: 'q1' } }, + })) as PersistedEmailCache; + + expect(persisted).not.toHaveProperty('emails'); + expect(persisted.mailboxSnapshots.inbox).toEqual({ emails: live, total: 42, queryState: 'q2' }); + expect(JSON.stringify(persisted).match(/"id":"live0"/g)).toHaveLength(1); + }); + + it('keeps the base view, not search results, for the open folder', () => { + const base = { emails: rows('base', 2), total: 2, queryState: 'q1' }; + const persisted = partialize!(viewState({ + searchQuery: 'invoice', + emails: rows('hit', 1), + mailboxSnapshots: { inbox: base }, + })) as PersistedEmailCache; + + expect(persisted.mailboxSnapshots.inbox).toEqual(base); + }); + + it('restores the open folder into the list on hydration', () => { + const live = rows('live', 3); + const persisted = JSON.parse(JSON.stringify(partialize!(viewState({ + emails: live, + totalEmails: 42, + queryState: 'q2', + mailboxSnapshots: {}, + })))); + + const restored = merge!(persisted, useEmailStore.getInitialState()); + + expect(restored.currentMailboxId).toBe('inbox'); + expect(restored.emails.map((e) => e.id)).toEqual(live.map((e) => e.id)); + expect(restored.totalEmails).toBe(42); + expect(restored.queryState).toBe('q2'); + expect(restored.mailboxSnapshots.inbox.emails).toHaveLength(3); + expect(restored.loading).toBe(false); + }); + + it('restores a row written before the list was stored once', () => { + const snap = { emails: rows('base', 2), total: 2, queryState: 'q1' }; + const legacy = { + accountSnapshots: {}, + activeAccountId: 'me', + mailboxes: [], + emailStates: {}, + currentMailboxId: 'inbox', + mailboxSnapshots: { inbox: snap }, + emails: rows('hit', 1), + totalEmails: 1, + queryState: undefined, + }; + + const restored = merge!(legacy, useEmailStore.getInitialState()); + + expect(restored.emails).toEqual(snap.emails); + expect(restored.totalEmails).toBe(2); + expect(restored.queryState).toBe('q1'); + }); + + it('keeps the initial state when nothing was stored', () => { + const initial = useEmailStore.getInitialState(); + expect(merge!(undefined, initial)).toBe(initial); + }); +}); diff --git a/src/stores/email-cache-persist.ts b/src/stores/email-cache-persist.ts new file mode 100644 index 00000000..5aeb1141 --- /dev/null +++ b/src/stores/email-cache-persist.ts @@ -0,0 +1,82 @@ +// Bounds for the email-store's persisted `email-cache` row. +// +// The whole cache is one AsyncStorage row, and Android can't read back a row +// over ~2 MB (CursorWindow). A list row is roughly 0.5-1 KB, so a few hundred +// of them keep the row well under that. Before this the row held every +// window the user had paged through, in every folder and account, and rows +// seeded from the offline body cache carried their full bodies. + +import type { Email } from '../api/types'; +import type { AccountSnapshot, MailboxSnapshot } from './email-store'; + +/** + * Rows kept per folder. Matches the largest "emails per page" choice, so a + * restored window still covers a full first page and the next refresh can + * patch it with Email/queryChanges. + */ +export const PERSISTED_ROWS_PER_FOLDER = 100; +/** Rows kept across all folders and accounts. */ +export const PERSISTED_ROWS_TOTAL = 500; + +/** + * What the email-store persists: the active account's view in the shape an + * account switch tucks it away in (its current folder lives in + * `mailboxSnapshots` once, not also as `emails`), plus the other accounts. + */ +export interface PersistedEmailCache extends AccountSnapshot { + accountSnapshots: Record; + activeAccountId: string | null; +} + +// A list needs the envelope only; a row seeded from the offline body cache +// also carries the message body. +function listRow(email: Email): Email { + const { + bodyValues: _bodyValues, + htmlBody: _htmlBody, + textBody: _textBody, + bodyStructure: _bodyStructure, + attachments: _attachments, + headers: _headers, + ...row + } = email; + return row; +} + +/** + * Caps the cache at {@link PERSISTED_ROWS_PER_FOLDER} rows per folder and + * {@link PERSISTED_ROWS_TOTAL} overall. The active account's current folder + * is filled first, then its other folders, then the other accounts'. A + * window is cut to a prefix, which keeps its queryState valid; a folder the + * budget can't reach is dropped and loads from the server when opened. The + * active account's entry in `accountSnapshots` is a stale copy of the view + * and is left out. + */ +export function boundEmailCache(cache: PersistedEmailCache): PersistedEmailCache { + let budget = PERSISTED_ROWS_TOTAL; + const bound = (account: AccountSnapshot): AccountSnapshot => { + const ids = Object.keys(account.mailboxSnapshots); + const current = account.currentMailboxId; + if (current && ids.includes(current)) { + ids.splice(ids.indexOf(current), 1); + ids.unshift(current); + } + const mailboxSnapshots: Record = {}; + for (const id of ids) { + const snap = account.mailboxSnapshots[id]; + const rows = Math.min(snap.emails.length, PERSISTED_ROWS_PER_FOLDER, budget); + if (rows === 0 && snap.emails.length > 0) continue; + budget -= rows; + mailboxSnapshots[id] = { ...snap, emails: snap.emails.slice(0, rows).map(listRow) }; + } + return { ...account, mailboxSnapshots }; + }; + + const { accountSnapshots, activeAccountId, ...active } = cache; + const view = bound(active); + const others: Record = {}; + for (const [id, account] of Object.entries(accountSnapshots)) { + if (id !== activeAccountId) others[id] = bound(account); + } + return { ...view, accountSnapshots: others, activeAccountId }; +} diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 6cf90464..977289a0 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { createPersistStorage } from './persist-storage'; +import { boundEmailCache, type PersistedEmailCache } from './email-cache-persist'; import type { Email, Mailbox, StateChange } from '../api/types'; import { jmapClient } from '../api/jmap-client'; import { @@ -244,7 +245,7 @@ export interface UndoEntry { // 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 { +export interface MailboxSnapshot { emails: Email[]; total: number; queryState?: string; @@ -252,7 +253,7 @@ interface MailboxSnapshot { // 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 { +export interface AccountSnapshot { mailboxes: Mailbox[]; mailboxState?: string; // JMAP Mailbox state (drives Mailbox/changes) // JMAP Email state per JMAP account (drives Email/changes). Keyed by @@ -1711,18 +1712,19 @@ export const useEmailStore = create()( queryState: undefined, } as unknown as EmailState; }, - partialize: (state) => ({ + // The active view is stored the way an account switch tucks it away, + // so its folder is in the row once and `merge` rebuilds `emails` from + // it. Bounded so Android can still read the row back. + partialize: (state): PersistedEmailCache => boundEmailCache({ accountSnapshots: state.accountSnapshots, activeAccountId: state.activeAccountId, - mailboxes: state.mailboxes, - mailboxState: state.mailboxState, - emailStates: state.emailStates, - currentMailboxId: state.currentMailboxId, - mailboxSnapshots: state.mailboxSnapshots, - emails: state.emails, - totalEmails: state.totalEmails, - queryState: state.queryState, + ...snapshotFromActive(state), }), + merge: (persisted, current) => { + if (!persisted) return current; + const cache = persisted as PersistedEmailCache; + return { ...current, ...cache, ...viewFromSnapshot(cache) }; + }, }, ), );