From 726e0877952f9fdc8350db320313e8c140cde70d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 05:09:49 +0200 Subject: [PATCH] fix: keep offline edits when the app cannot read an account's server record The app records which server filled each account's caches and checks it with every new session (a330d77). A read of that record that failed - a storage error, such as a busy database - answered "none recorded", so the session counted as the first sight of the account: its queued offline edits were dropped, with a notice that the mail server had changed when it had not, and the record was written anew. Had the server been replaced, that record hid the change from every later check. A read that fails now fails the check: nothing is dropped or written, and the next session checks again. As with any failed check, signing in goes on. --- src/lib/server-change.ts | 6 ++- src/lib/server-fingerprint.ts | 13 ++++--- src/stores/__tests__/server-change.test.ts | 45 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/lib/server-change.ts b/src/lib/server-change.ts index a554e87e..3744d661 100644 --- a/src/lib/server-change.ts +++ b/src/lib/server-change.ts @@ -151,8 +151,10 @@ async function forgetUnconfirmedServerState(accountId: string): Promise { * (username@host) and drop what was derived from the server when it changed. * On the first sight of an account only what can't be confirmed to belong * to its server goes (forgetUnconfirmedServerState). The fingerprint is - * written after that, so an interrupted drop is repeated next time. Resolves - * to true when caches were dropped. + * written after that, so an interrupted drop is repeated next time. A + * recorded fingerprint that cannot be read rejects before anything goes or + * is written, and the next session checks again. Resolves to true when + * caches were dropped. */ export async function reconcileServerFingerprint(accountId: string, session: JMAPSession): Promise { const next = sessionFingerprint(session); diff --git a/src/lib/server-fingerprint.ts b/src/lib/server-fingerprint.ts index c130f710..bb670fc1 100644 --- a/src/lib/server-fingerprint.ts +++ b/src/lib/server-fingerprint.ts @@ -48,13 +48,14 @@ export function sessionFingerprint(session: JMAPSession): string { return sha256Hex(new TextEncoder().encode(JSON.stringify(parts))).slice(0, 16); } -/** The fingerprint recorded for a local account (username@host), if any. */ +/** + * The fingerprint recorded for a local account (username@host), or null when + * none is. A read that fails throws rather than answer null: that would be + * the first sight of the account, which drops its queued changes (see + * reconcileServerFingerprint), on a server that may never have changed. + */ export async function readServerFingerprint(accountId: string): Promise { - try { - return await AsyncStorage.getItem(storageKey(accountId)); - } catch { - return null; - } + return AsyncStorage.getItem(storageKey(accountId)); } /** The primary JMAP account id recorded with the fingerprint, if any. */ diff --git a/src/stores/__tests__/server-change.test.ts b/src/stores/__tests__/server-change.test.ts index cbc85791..67cde321 100644 --- a/src/stores/__tests__/server-change.test.ts +++ b/src/stores/__tests__/server-change.test.ts @@ -390,4 +390,49 @@ describe('reconcileServerFingerprint', () => { expect(await AsyncStorage.getItem(`push:notifiedIds:v1:${ACCOUNT}`)).toBeNull(); expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0'))); }); + + // A read that fails is no answer. Taken for no fingerprint, it made the + // session the first sight of the account: the queued changes went on a + // server that had not changed, and the fingerprint written then hid one + // that had. + describe('when the recorded fingerprint cannot be read', () => { + const checkWithFailingRead = async (current: JMAPSession) => { + const getItem = vi.mocked(AsyncStorage.getItem); + const read = getItem.getMockImplementation()!; + getItem.mockImplementation(async (key: string) => { + if (key === `server-fingerprint:v1:${ACCOUNT}`) throw new Error('SQLITE_BUSY'); + return read(key); + }); + try { + await expect(reconcileServerFingerprint(ACCOUNT, current)).rejects.toThrow('SQLITE_BUSY'); + } finally { + getItem.mockImplementation(read); + } + }; + + it('keeps the queued changes, and checks again with the next session (Stalwart 1.0)', async () => { + await seed(ACCOUNT); + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('1.0')), 'f'); + + await checkWithFailingRead(session('1.0')); + + expect(useOutboxStore.getState().entries).toHaveLength(2); + expect(useOutboxStore.getState().failed).toHaveLength(1); + expect(useToastStore.getState().toasts).toEqual([]); + expect(await reconcileServerFingerprint(ACCOUNT, session('1.0'))).toBe(false); + expect(useOutboxStore.getState().entries).toHaveLength(2); + }); + + it('records nothing, so the next session still drops what the old server left (0.16.23 to 1.0)', async () => { + await seed(ACCOUNT); + + await checkWithFailingRead(session('1.0')); + + expect(useEmailStore.getState().mailboxState).toBe('s1iaq'); + expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('0.16.23'))); + expect(await reconcileServerFingerprint(ACCOUNT, session('1.0'))).toBe(true); + expect(useEmailStore.getState().mailboxState).toBeUndefined(); + expect(useOutboxStore.getState().entries).toEqual([]); + }); + }); });