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.
This commit is contained in:
Linus Rath
2026-09-24 18:56:37 +02:00
parent 29169c3426
commit e37621be5e
3 changed files with 281 additions and 11 deletions
@@ -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> = {}): 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<string, Email[]>, 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<string, AccountSnapshot> = {}): 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<string, Email[]> = {};
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<string, Email[]> = {};
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>): 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);
});
});
+82
View File
@@ -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<string, AccountSnapshot>;
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<string, MailboxSnapshot> = {};
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<string, AccountSnapshot> = {};
for (const [id, account] of Object.entries(accountSnapshots)) {
if (id !== activeAccountId) others[id] = bound(account);
}
return { ...view, accountSnapshots: others, activeAccountId };
}
+13 -11
View File
@@ -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<EmailState>()(
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) };
},
},
),
);