From 613451dfefe1e8b0a3f41b2ce8172f089a2c515f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:32:23 +0100 Subject: [PATCH] fix: keep loading older mail when a saved or refreshed list has no total from the server #1364 --- .../__tests__/email-snapshot-account.test.ts | 25 +++++++++++++++++ .../__tests__/email-store-delta-sync.test.ts | 27 +++++++++++++++++++ stores/email-store.ts | 18 ++++++++++--- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/stores/__tests__/email-snapshot-account.test.ts b/stores/__tests__/email-snapshot-account.test.ts index a81b8542e..a7d2c4e4f 100644 --- a/stores/__tests__/email-snapshot-account.test.ts +++ b/stores/__tests__/email-snapshot-account.test.ts @@ -88,6 +88,31 @@ describe('email boot snapshot folder', { timeout: 30_000 }, () => { expect(state.emailListSync).toEqual(emailListSync); }); + // A snapshot that says "no more mail" beside rows its total does not cover + // kept the inbox at one page across reloads: the delta catch-up after boot + // never re-reads the total (#1364). + it('re-reads a page whose saved total does not cover its rows', async () => { + const rows = [{ id: 'e1' }, { id: 'e2' }]; + const emailListSync = { state: 's1', accountId: 'acc', mailboxId: 'm-inbox' }; + const state = await boot('/', { + mailboxes: FOLDERS, selectedMailbox: 'm-inbox', emails: rows, totalEmails: 0, hasMoreEmails: false, emailListSync, + }); + expect(state.emails).toHaveLength(2); + const { useEmailStore } = await import('@/stores/email-store'); + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ emailsPerPage: 2 }); + const client = { + getAccountId: () => 'acc', + getEmailChanges: vi.fn(async () => ({ oldState: 's1', newState: 's1', hasMoreChanges: false, created: [], updated: [], destroyed: [], updatedProperties: null })), + getEmails: vi.fn(async () => ({ emails: rows, hasMore: true, total: 10853, state: 's1' })), + getThreadEmailCounts: vi.fn(async () => ({})), + }; + await useEmailStore.getState().fetchEmails(client as never, 'm-inbox', { background: true }); + expect(client.getEmails).toHaveBeenCalled(); + expect(useEmailStore.getState().totalEmails).toBe(10853); + expect(useEmailStore.getState().hasMoreEmails).toBe(true); + }); + it('keeps the snapshot folder when the account has no inbox of its own', async () => { const state = await boot('/', { mailboxes: FOLDERS.slice(1), selectedMailbox: 'm-trash', emails: TRASH_ROWS }); expect(state.selectedMailbox).toBe('m-trash'); diff --git a/stores/__tests__/email-store-delta-sync.test.ts b/stores/__tests__/email-store-delta-sync.test.ts index ac1fa1aab..0e65166b4 100644 --- a/stores/__tests__/email-store-delta-sync.test.ts +++ b/stores/__tests__/email-store-delta-sync.test.ts @@ -178,6 +178,33 @@ describe('Email/changes delta sync', () => { expect(useEmailStore.getState().emailListSync).toEqual({ state: 's2', accountId: 'acc', mailboxId: 'inbox' }); }); + // A total of 0 beside the rows it was read with is a total the server did + // not give; it says nothing about mail past the page (#1364). + it('keeps paging when the refresh after new mail comes back without a total', async () => { + useEmailStore.setState({ totalEmails: 10, hasMoreEmails: true }); + const client = makeClient({ + getEmailChanges: vi.fn(async () => delta({ created: ['new'] })), + getSomeEmails: vi.fn(async () => [makeEmail('new')]), + getEmails: vi.fn(async () => ({ emails: [makeEmail('new'), makeEmail('a'), makeEmail('b')], hasMore: true, total: 0, state: 's2' })), + }); + + await useEmailStore.getState().handleStateChange(push({ acc: { Email: 's2' } }), client); + + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['new', 'a', 'b']); + expect(useEmailStore.getState().hasMoreEmails).toBe(true); + }); + + it('keeps paging when a delta is applied to a list whose total is not known', async () => { + useEmailStore.setState({ totalEmails: 0, hasMoreEmails: true }); + const client = makeClient({ getEmailChanges: vi.fn(async () => delta({ destroyed: ['c'] })) }); + + await useEmailStore.getState().handleStateChange(push({ acc: { Email: 's2' } }), client); + + expect(client.getEmails).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a', 'b']); + expect(useEmailStore.getState().hasMoreEmails).toBe(true); + }); + it('falls back when the server cannot compute the delta', async () => { const client = makeClient({ getEmailChanges: vi.fn(async () => null) }); await useEmailStore.getState().handleStateChange(push({ acc: { Email: 's2' } }), client); diff --git a/stores/email-store.ts b/stores/email-store.ts index 9745a30dd..fb5583209 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -952,10 +952,12 @@ async function applyEmailDeltaNow(client: IJMAPClient, newState: string | null, const threadEmailsCache = new Map(state.threadEmailsCache); for (const tid of changedThreadIds) threadEmailsCache.delete(tid); const totalEmails = Math.max(state.totalEmails - (current.length - next.length), next.length); + // A total below the rows shown is one the server did not give (#1364). + const totalKnown = state.totalEmails >= current.length; return { emails: next, totalEmails, - hasMoreEmails: state.hasMoreEmails && next.length < totalEmails, + hasMoreEmails: state.hasMoreEmails && (!totalKnown || next.length < totalEmails), threadEmailsCache, emailListSync: state.emailListSync?.mailboxId === sync.mailboxId ? { ...sync, state: delta.newState } : state.emailListSync, }; @@ -6506,7 +6508,11 @@ export const useEmailStore = create((set, get) => ({ // hasMore should reflect whether there are still more emails beyond // what we have loaded, using the fresh total from the server. The // Unread section's rows are not part of that total. - const hasMore = merged.length - sectionRows.length < effectiveTotal; + // A total below the page it came with is one the server did not give: + // then only a full page tells that more may follow (#1364). + const hasMore = (result.total || 0) >= result.emails.length + ? merged.length - sectionRows.length < effectiveTotal + : result.hasMore; // Invalidate thread email caches for threads whose composition changed // so expanded threads pick up new/removed emails. @@ -8002,7 +8008,13 @@ if (typeof window !== 'undefined') { // by it, so without it an "unread first" page shows by date // until the live fetch lands and re-sorts it. ...(Array.isArray(snap.listOrder) ? { listOrder: sanitizeSortLevels(snap.listOrder) } : {}), - ...(isEmailListSync(snap.emailListSync, snapMailbox) ? { emailListSync: snap.emailListSync } : {}), + // Without the baseline the live load re-reads the page and its + // total: a saved total that doesn't cover the rows is wrong, and + // a catch-up from the baseline would keep it, and "no more + // mail" with it, across reloads (#1364). + ...(isEmailListSync(snap.emailListSync, snapMailbox) && !(snap.totalEmails < snap.emails.length) + ? { emailListSync: snap.emailListSync } + : {}), } : {}), });